work work hard work
This commit is contained in:
@@ -1,25 +1,15 @@
|
||||
// =============================================================================
|
||||
// IEvidenceReconciler.cs
|
||||
// Main orchestrator for the 5-step evidence reconciliation algorithm
|
||||
// =============================================================================
|
||||
|
||||
using System.Diagnostics;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
|
||||
using StellaOps.AirGap.Importer.Reconciliation.Signing;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Reconciliation;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates the 5-step deterministic evidence reconciliation algorithm.
|
||||
/// Orchestrates the deterministic evidence reconciliation algorithm (advisory A5).
|
||||
/// </summary>
|
||||
public interface IEvidenceReconciler
|
||||
{
|
||||
/// <summary>
|
||||
/// Reconciles evidence from an input directory into a deterministic evidence graph.
|
||||
/// </summary>
|
||||
/// <param name="inputDirectory">Directory containing SBOMs, attestations, and VEX documents.</param>
|
||||
/// <param name="outputDirectory">Directory for output files.</param>
|
||||
/// <param name="options">Reconciliation options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The reconciled evidence graph.</returns>
|
||||
Task<EvidenceGraph> ReconcileAsync(
|
||||
string inputDirectory,
|
||||
string outputDirectory,
|
||||
@@ -35,54 +25,65 @@ public sealed record ReconciliationOptions
|
||||
public static readonly ReconciliationOptions Default = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether to sign the output with DSSE.
|
||||
/// When null, a deterministic epoch timestamp is used for output stability.
|
||||
/// </summary>
|
||||
public DateTimeOffset? GeneratedAtUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to sign the output with DSSE (implemented in later tasks).
|
||||
/// </summary>
|
||||
public bool SignOutput { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID for DSSE signing.
|
||||
/// Optional key ID for DSSE signing (implemented in later tasks).
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON normalization options.
|
||||
/// Private key PEM path used for DSSE signing when <see cref="SignOutput"/> is enabled.
|
||||
/// </summary>
|
||||
public string? SigningPrivateKeyPemPath { get; init; }
|
||||
|
||||
public NormalizationOptions Normalization { get; init; } = NormalizationOptions.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Lattice configuration for precedence rules.
|
||||
/// </summary>
|
||||
public LatticeConfiguration Lattice { get; init; } = LatticeConfiguration.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify attestation signatures.
|
||||
/// </summary>
|
||||
public bool VerifySignatures { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify Rekor inclusion proofs.
|
||||
/// </summary>
|
||||
public bool VerifyRekorProofs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust roots used for DSSE signature verification.
|
||||
/// </summary>
|
||||
public TrustRootConfig? TrustRoots { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor public key path used to verify checkpoint signatures when <see cref="VerifyRekorProofs"/> is enabled.
|
||||
/// </summary>
|
||||
public string? RekorPublicKeyPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the evidence reconciler.
|
||||
/// Implements the 5-step algorithm from advisory §5.
|
||||
/// </summary>
|
||||
public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
{
|
||||
private readonly EvidenceDirectoryDiscovery _discovery;
|
||||
private readonly SourcePrecedenceLattice _lattice;
|
||||
private static readonly DateTimeOffset DeterministicEpoch = DateTimeOffset.UnixEpoch;
|
||||
|
||||
private readonly SbomCollector _sbomCollector;
|
||||
private readonly AttestationCollector _attestationCollector;
|
||||
private readonly EvidenceGraphSerializer _serializer;
|
||||
private readonly EvidenceGraphDsseSigner _dsseSigner;
|
||||
|
||||
public EvidenceReconciler(
|
||||
EvidenceDirectoryDiscovery? discovery = null,
|
||||
SourcePrecedenceLattice? lattice = null,
|
||||
SbomCollector? sbomCollector = null,
|
||||
AttestationCollector? attestationCollector = null,
|
||||
EvidenceGraphSerializer? serializer = null)
|
||||
{
|
||||
_discovery = discovery ?? new EvidenceDirectoryDiscovery();
|
||||
_lattice = lattice ?? new SourcePrecedenceLattice();
|
||||
_sbomCollector = sbomCollector ?? new SbomCollector();
|
||||
_attestationCollector = attestationCollector ?? new AttestationCollector(dsseVerifier: new DsseVerifier());
|
||||
_serializer = serializer ?? new EvidenceGraphSerializer();
|
||||
_dsseSigner = new EvidenceGraphDsseSigner(_serializer);
|
||||
}
|
||||
|
||||
public async Task<EvidenceGraph> ReconcileAsync(
|
||||
@@ -95,129 +96,67 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory);
|
||||
|
||||
options ??= ReconciliationOptions.Default;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// ========================================
|
||||
// Step 1: Index artifacts by immutable digest
|
||||
// ========================================
|
||||
var index = await IndexArtifactsAsync(inputDirectory, ct);
|
||||
var index = new ArtifactIndex();
|
||||
|
||||
// ========================================
|
||||
// Step 2: Collect evidence for each artifact
|
||||
// ========================================
|
||||
var collectedIndex = await CollectEvidenceAsync(index, inputDirectory, options, ct);
|
||||
// Step 2: Evidence collection (SBOM + attestations). VEX parsing is not yet implemented.
|
||||
await _sbomCollector.CollectAsync(Path.Combine(inputDirectory, "sboms"), index, ct).ConfigureAwait(false);
|
||||
|
||||
// ========================================
|
||||
// Step 3: Normalize all documents
|
||||
// ========================================
|
||||
// Normalization is applied during evidence collection
|
||||
|
||||
// ========================================
|
||||
// Step 4: Apply lattice precedence rules
|
||||
// ========================================
|
||||
var mergedStatements = ApplyLatticeRules(collectedIndex);
|
||||
|
||||
// ========================================
|
||||
// Step 5: Emit evidence graph
|
||||
// ========================================
|
||||
var graph = BuildGraph(collectedIndex, mergedStatements, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
// Write output files
|
||||
await _serializer.WriteAsync(graph, outputDirectory, ct);
|
||||
|
||||
// Optionally sign with DSSE
|
||||
if (options.SignOutput && !string.IsNullOrEmpty(options.SigningKeyId))
|
||||
var attestationOptions = new AttestationCollectionOptions
|
||||
{
|
||||
await SignOutputAsync(outputDirectory, options.SigningKeyId, ct);
|
||||
MarkAsUnverified = !options.VerifySignatures,
|
||||
VerifySignatures = options.VerifySignatures,
|
||||
VerifyRekorProofs = options.VerifyRekorProofs,
|
||||
RekorPublicKeyPath = options.RekorPublicKeyPath,
|
||||
TrustRoots = options.TrustRoots
|
||||
};
|
||||
|
||||
await _attestationCollector.CollectAsync(
|
||||
Path.Combine(inputDirectory, "attestations"),
|
||||
index,
|
||||
attestationOptions,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Step 4: Lattice merge (currently no VEX ingestion; returns empty).
|
||||
var mergedStatements = new Dictionary<string, VexStatement>(StringComparer.Ordinal);
|
||||
|
||||
// Step 5: Graph emission.
|
||||
var graph = BuildGraph(index, mergedStatements, generatedAtUtc: options.GeneratedAtUtc ?? DeterministicEpoch);
|
||||
await _serializer.WriteAsync(graph, outputDirectory, ct).ConfigureAwait(false);
|
||||
|
||||
if (options.SignOutput)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.SigningPrivateKeyPemPath))
|
||||
{
|
||||
throw new InvalidOperationException("SignOutput requires SigningPrivateKeyPemPath.");
|
||||
}
|
||||
|
||||
await _dsseSigner.WriteEvidenceGraphEnvelopeAsync(
|
||||
graph,
|
||||
outputDirectory,
|
||||
options.SigningPrivateKeyPemPath,
|
||||
options.SigningKeyId,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
return graph;
|
||||
}
|
||||
|
||||
private async Task<ArtifactIndex> IndexArtifactsAsync(string inputDirectory, CancellationToken ct)
|
||||
{
|
||||
// Use the discovery service to find all artifacts
|
||||
var discoveredFiles = await _discovery.DiscoverAsync(inputDirectory, ct);
|
||||
var index = new ArtifactIndex();
|
||||
|
||||
foreach (var file in discoveredFiles)
|
||||
{
|
||||
// Create entry for each discovered file
|
||||
var entry = ArtifactEntry.Empty(file.ContentHash, file.Path);
|
||||
index.AddOrUpdate(entry);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private async Task<ArtifactIndex> CollectEvidenceAsync(
|
||||
private static EvidenceGraph BuildGraph(
|
||||
ArtifactIndex index,
|
||||
string inputDirectory,
|
||||
ReconciliationOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// In a full implementation, this would:
|
||||
// 1. Parse SBOM files (CycloneDX, SPDX)
|
||||
// 2. Parse attestation files (DSSE envelopes)
|
||||
// 3. Parse VEX files (OpenVEX)
|
||||
// 4. Validate signatures if enabled
|
||||
// 5. Verify Rekor proofs if enabled
|
||||
|
||||
// For now, return the index with discovered files
|
||||
await Task.CompletedTask;
|
||||
return index;
|
||||
}
|
||||
|
||||
private Dictionary<string, VexStatement> ApplyLatticeRules(ArtifactIndex index)
|
||||
{
|
||||
var mergedStatements = new Dictionary<string, VexStatement>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (digest, entry) in index.GetAll())
|
||||
{
|
||||
// Group VEX statements by vulnerability ID
|
||||
var groupedByVuln = entry.VexDocuments
|
||||
.GroupBy(v => v.VulnerabilityId, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var group in groupedByVuln)
|
||||
{
|
||||
// Convert VexReference to VexStatement
|
||||
var statements = group.Select(v => new VexStatement
|
||||
{
|
||||
VulnerabilityId = v.VulnerabilityId,
|
||||
ProductId = digest,
|
||||
Status = ParseVexStatus(v.Status),
|
||||
Source = ParseSourcePrecedence(v.Source),
|
||||
Justification = v.Justification,
|
||||
DocumentRef = v.Path
|
||||
}).ToList();
|
||||
|
||||
if (statements.Count > 0)
|
||||
{
|
||||
// Merge using lattice rules
|
||||
var merged = _lattice.Merge(statements);
|
||||
var key = $"{digest}:{merged.VulnerabilityId}";
|
||||
mergedStatements[key] = merged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergedStatements;
|
||||
}
|
||||
|
||||
private EvidenceGraph BuildGraph(
|
||||
ArtifactIndex index,
|
||||
Dictionary<string, VexStatement> mergedStatements,
|
||||
long elapsedMs)
|
||||
IReadOnlyDictionary<string, VexStatement> mergedStatements,
|
||||
DateTimeOffset generatedAtUtc)
|
||||
{
|
||||
var nodes = new List<EvidenceNode>();
|
||||
var edges = new List<EvidenceEdge>();
|
||||
|
||||
int sbomCount = 0, attestationCount = 0, vexCount = 0;
|
||||
var sbomCount = 0;
|
||||
var attestationCount = 0;
|
||||
|
||||
foreach (var (digest, entry) in index.GetAll())
|
||||
{
|
||||
// Create node for artifact
|
||||
var node = new EvidenceNode
|
||||
{
|
||||
Id = digest,
|
||||
@@ -226,16 +165,16 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
Name = entry.Name,
|
||||
Sboms = entry.Sboms.Select(s => new SbomNodeRef
|
||||
{
|
||||
Format = s.Format,
|
||||
Path = s.Path,
|
||||
Format = s.Format.ToString(),
|
||||
Path = s.FilePath,
|
||||
ContentHash = s.ContentHash
|
||||
}).ToList(),
|
||||
Attestations = entry.Attestations.Select(a => new AttestationNodeRef
|
||||
{
|
||||
PredicateType = a.PredicateType,
|
||||
Path = a.Path,
|
||||
SignatureValid = a.SignatureValid,
|
||||
RekorVerified = a.RekorVerified
|
||||
Path = a.FilePath,
|
||||
SignatureValid = a.SignatureVerified,
|
||||
RekorVerified = a.TlogVerified
|
||||
}).ToList(),
|
||||
VexStatements = mergedStatements
|
||||
.Where(kv => kv.Key.StartsWith(digest + ":", StringComparison.Ordinal))
|
||||
@@ -251,9 +190,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
nodes.Add(node);
|
||||
sbomCount += entry.Sboms.Count;
|
||||
attestationCount += entry.Attestations.Count;
|
||||
vexCount += entry.VexDocuments.Count;
|
||||
|
||||
// Create edges from artifacts to SBOMs
|
||||
foreach (var sbom in entry.Sboms)
|
||||
{
|
||||
edges.Add(new EvidenceEdge
|
||||
@@ -264,13 +201,12 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
});
|
||||
}
|
||||
|
||||
// Create edges from artifacts to attestations
|
||||
foreach (var att in entry.Attestations)
|
||||
foreach (var attestation in entry.Attestations)
|
||||
{
|
||||
edges.Add(new EvidenceEdge
|
||||
{
|
||||
Source = digest,
|
||||
Target = att.Path,
|
||||
Target = attestation.ContentHash,
|
||||
Relationship = "attested-by"
|
||||
});
|
||||
}
|
||||
@@ -278,7 +214,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
|
||||
return new EvidenceGraph
|
||||
{
|
||||
GeneratedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
GeneratedAt = generatedAtUtc.ToString("O"),
|
||||
Nodes = nodes,
|
||||
Edges = edges,
|
||||
Metadata = new EvidenceGraphMetadata
|
||||
@@ -287,39 +223,9 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
SbomCount = sbomCount,
|
||||
AttestationCount = attestationCount,
|
||||
VexStatementCount = mergedStatements.Count,
|
||||
ConflictCount = 0, // TODO: Track conflicts during merge
|
||||
ReconciliationDurationMs = elapsedMs
|
||||
ConflictCount = 0,
|
||||
ReconciliationDurationMs = 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task SignOutputAsync(string outputDirectory, string keyId, CancellationToken ct)
|
||||
{
|
||||
// Placeholder for DSSE signing integration
|
||||
// Would use the Signer module to create a DSSE envelope
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static VexStatus ParseVexStatus(string status)
|
||||
{
|
||||
return status.ToLowerInvariant() switch
|
||||
{
|
||||
"affected" => VexStatus.Affected,
|
||||
"not_affected" or "notaffected" => VexStatus.NotAffected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" or "underinvestigation" => VexStatus.UnderInvestigation,
|
||||
_ => VexStatus.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static SourcePrecedence ParseSourcePrecedence(string source)
|
||||
{
|
||||
return source.ToLowerInvariant() switch
|
||||
{
|
||||
"vendor" => SourcePrecedence.Vendor,
|
||||
"maintainer" => SourcePrecedence.Maintainer,
|
||||
"third-party" or "thirdparty" => SourcePrecedence.ThirdParty,
|
||||
_ => SourcePrecedence.Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,9 +124,19 @@ public sealed class AttestationCollector
|
||||
bool tlogVerified = false;
|
||||
string? rekorUuid = null;
|
||||
|
||||
if (options.TrustRoots is not null && _dsseVerifier is not null)
|
||||
if (options.VerifySignatures && options.TrustRoots is not null && _dsseVerifier is not null)
|
||||
{
|
||||
var verifyResult = _dsseVerifier.Verify(envelope, options.TrustRoots, _logger);
|
||||
var validationEnvelope = new StellaOps.AirGap.Importer.Validation.DsseEnvelope(
|
||||
envelope.PayloadType,
|
||||
envelope.Payload,
|
||||
envelope.Signatures
|
||||
.Where(sig => !string.IsNullOrWhiteSpace(sig.KeyId))
|
||||
.Select(sig => new StellaOps.AirGap.Importer.Validation.DsseSignature(
|
||||
sig.KeyId!.Trim(),
|
||||
sig.Sig))
|
||||
.ToList());
|
||||
|
||||
var verifyResult = _dsseVerifier.Verify(validationEnvelope, options.TrustRoots, _logger);
|
||||
signatureVerified = verifyResult.IsValid;
|
||||
|
||||
if (signatureVerified)
|
||||
@@ -139,7 +149,7 @@ public sealed class AttestationCollector
|
||||
_logger.LogWarning(
|
||||
"DSSE signature verification failed for attestation: {File}, reason={Reason}",
|
||||
relativePath,
|
||||
verifyResult.ErrorCode);
|
||||
verifyResult.Reason);
|
||||
}
|
||||
}
|
||||
else if (options.MarkAsUnverified)
|
||||
@@ -149,6 +159,53 @@ public sealed class AttestationCollector
|
||||
tlogVerified = false;
|
||||
}
|
||||
|
||||
// Verify Rekor inclusion proof (T8 integration)
|
||||
if (options.VerifyRekorProofs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.RekorPublicKeyPath))
|
||||
{
|
||||
result.FailedFiles.Add((filePath, "Rekor public key path not configured for VerifyRekorProofs."));
|
||||
}
|
||||
else
|
||||
{
|
||||
var receiptPath = ResolveRekorReceiptPath(filePath);
|
||||
if (receiptPath is null)
|
||||
{
|
||||
result.FailedFiles.Add((filePath, "Rekor receipt file not found for attestation."));
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var dsseSha256 = ParseSha256Digest(contentHash);
|
||||
var verify = await RekorOfflineReceiptVerifier.VerifyAsync(
|
||||
receiptPath,
|
||||
dsseSha256,
|
||||
options.RekorPublicKeyPath,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (verify.Verified)
|
||||
{
|
||||
tlogVerified = true;
|
||||
rekorUuid = verify.RekorUuid;
|
||||
_logger.LogDebug("Rekor inclusion verified for attestation: {File}", relativePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
tlogVerified = false;
|
||||
rekorUuid = null;
|
||||
result.FailedFiles.Add((filePath, $"Rekor verification failed: {verify.FailureReason}"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.FailedFiles.Add((filePath, $"Rekor verification exception: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all subject digests for this attestation
|
||||
var subjectDigests = statement.Subjects
|
||||
.Select(s => s.GetSha256Digest())
|
||||
@@ -258,6 +315,56 @@ public sealed class AttestationCollector
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static byte[] ParseSha256Digest(string sha256Digest)
|
||||
{
|
||||
if (!sha256Digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new FormatException("Expected sha256:<hex> digest.");
|
||||
}
|
||||
|
||||
return Convert.FromHexString(sha256Digest["sha256:".Length..]);
|
||||
}
|
||||
|
||||
private static string? ResolveRekorReceiptPath(string attestationFilePath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(attestationFilePath);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(attestationFilePath);
|
||||
var withoutExtension = Path.GetFileNameWithoutExtension(attestationFilePath);
|
||||
|
||||
var candidates = new List<string>
|
||||
{
|
||||
Path.Combine(directory, withoutExtension + ".rekor.json"),
|
||||
Path.Combine(directory, withoutExtension + ".rekor-receipt.json"),
|
||||
Path.Combine(directory, "rekor-receipt.json"),
|
||||
Path.Combine(directory, "offline-update.rekor.json")
|
||||
};
|
||||
|
||||
if (fileName.EndsWith(".dsse.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
candidates.Insert(0, Path.Combine(directory, fileName[..^".dsse.json".Length] + ".rekor.json"));
|
||||
}
|
||||
|
||||
if (fileName.EndsWith(".jsonl.dsig", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
candidates.Insert(0, Path.Combine(directory, fileName[..^".jsonl.dsig".Length] + ".rekor.json"));
|
||||
}
|
||||
|
||||
foreach (var candidate in candidates.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -282,6 +389,11 @@ public sealed record AttestationCollectionOptions
|
||||
/// </summary>
|
||||
public bool VerifyRekorProofs { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Rekor public key path used to verify checkpoint signatures when <see cref="VerifyRekorProofs"/> is enabled.
|
||||
/// </summary>
|
||||
public string? RekorPublicKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust roots configuration for DSSE signature verification.
|
||||
/// Required when VerifySignatures is true.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Org.BouncyCastle.Asn1;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.OpenSsl;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Reconciliation.Signing;
|
||||
|
||||
internal sealed class EvidenceGraphDsseSigner
|
||||
{
|
||||
internal const string EvidenceGraphPayloadType = "application/vnd.stellaops.evidence-graph+json";
|
||||
|
||||
private readonly EvidenceGraphSerializer serializer;
|
||||
|
||||
public EvidenceGraphDsseSigner(EvidenceGraphSerializer serializer)
|
||||
=> this.serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
|
||||
|
||||
public async Task<string> WriteEvidenceGraphEnvelopeAsync(
|
||||
EvidenceGraph graph,
|
||||
string outputDirectory,
|
||||
string signingPrivateKeyPemPath,
|
||||
string? signingKeyId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(signingPrivateKeyPemPath);
|
||||
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
var canonicalJson = serializer.Serialize(graph, pretty: false);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(canonicalJson);
|
||||
var pae = DssePreAuthenticationEncoding.Encode(EvidenceGraphPayloadType, payloadBytes);
|
||||
|
||||
var envelopeKey = await LoadEcdsaEnvelopeKeyAsync(signingPrivateKeyPemPath, signingKeyId, ct).ConfigureAwait(false);
|
||||
var signature = SignDeterministicEcdsa(pae, signingPrivateKeyPemPath, envelopeKey.AlgorithmId);
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
EvidenceGraphPayloadType,
|
||||
payloadBytes,
|
||||
signatures: [DsseSignature.FromBytes(signature, envelopeKey.KeyId)],
|
||||
payloadContentType: "application/json");
|
||||
|
||||
var serialized = DsseEnvelopeSerializer.Serialize(
|
||||
envelope,
|
||||
new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true,
|
||||
EmitExpandedJson = false,
|
||||
CompressionAlgorithm = DsseCompressionAlgorithm.None
|
||||
});
|
||||
|
||||
if (serialized.CompactJson is null)
|
||||
{
|
||||
throw new InvalidOperationException("DSSE envelope serialization did not emit compact JSON.");
|
||||
}
|
||||
|
||||
var dssePath = Path.Combine(outputDirectory, "evidence-graph.dsse.json");
|
||||
await File.WriteAllBytesAsync(dssePath, serialized.CompactJson, ct).ConfigureAwait(false);
|
||||
return dssePath;
|
||||
}
|
||||
|
||||
private static async Task<EnvelopeKey> LoadEcdsaEnvelopeKeyAsync(string pemPath, string? keyIdOverride, CancellationToken ct)
|
||||
{
|
||||
var pem = await File.ReadAllTextAsync(pemPath, ct).ConfigureAwait(false);
|
||||
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportFromPem(pem);
|
||||
|
||||
var algorithmId = ResolveEcdsaAlgorithmId(ecdsa.KeySize);
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
return EnvelopeKey.CreateEcdsaSigner(algorithmId, parameters, keyIdOverride);
|
||||
}
|
||||
|
||||
private static string ResolveEcdsaAlgorithmId(int keySizeBits) => keySizeBits switch
|
||||
{
|
||||
256 => "ES256",
|
||||
384 => "ES384",
|
||||
521 => "ES512",
|
||||
_ => throw new NotSupportedException($"Unsupported ECDSA key size {keySizeBits} bits.")
|
||||
};
|
||||
|
||||
private static byte[] SignDeterministicEcdsa(ReadOnlySpan<byte> message, string pemPath, string algorithmId)
|
||||
{
|
||||
var (digest, calculatorDigest) = CreateSignatureDigest(message, algorithmId);
|
||||
var privateKey = LoadEcPrivateKey(pemPath);
|
||||
|
||||
var signer = new ECDsaSigner(new HMacDsaKCalculator(calculatorDigest));
|
||||
signer.Init(true, privateKey);
|
||||
|
||||
var rs = signer.GenerateSignature(digest);
|
||||
var r = rs[0];
|
||||
var s = rs[1];
|
||||
var sequence = new DerSequence(new DerInteger(r), new DerInteger(s));
|
||||
return sequence.GetDerEncoded();
|
||||
}
|
||||
|
||||
private static (byte[] Digest, IDigest CalculatorDigest) CreateSignatureDigest(ReadOnlySpan<byte> message, string algorithmId)
|
||||
{
|
||||
return algorithmId?.ToUpperInvariant() switch
|
||||
{
|
||||
"ES256" => (SHA256.HashData(message), new Sha256Digest()),
|
||||
"ES384" => (SHA384.HashData(message), new Sha384Digest()),
|
||||
"ES512" => (SHA512.HashData(message), new Sha512Digest()),
|
||||
_ => throw new NotSupportedException($"Unsupported ECDSA algorithm '{algorithmId}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private static ECPrivateKeyParameters LoadEcPrivateKey(string pemPath)
|
||||
{
|
||||
using var reader = File.OpenText(pemPath);
|
||||
var pemReader = new PemReader(reader);
|
||||
var pemObject = pemReader.ReadObject();
|
||||
|
||||
return pemObject switch
|
||||
{
|
||||
AsymmetricCipherKeyPair pair when pair.Private is ECPrivateKeyParameters ecPrivate => ecPrivate,
|
||||
ECPrivateKeyParameters ecPrivate => ecPrivate,
|
||||
_ => throw new InvalidOperationException($"Unsupported private key content in '{pemPath}'.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DssePreAuthenticationEncoding
|
||||
{
|
||||
private const string Prefix = "DSSEv1";
|
||||
|
||||
public static byte[] Encode(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payloadType))
|
||||
{
|
||||
throw new ArgumentException("payloadType must be provided.", nameof(payloadType));
|
||||
}
|
||||
|
||||
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
|
||||
var header = $"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ";
|
||||
var headerBytes = Encoding.UTF8.GetBytes(header);
|
||||
|
||||
var buffer = new byte[headerBytes.Length + payload.Length];
|
||||
headerBytes.CopyTo(buffer.AsSpan());
|
||||
payload.CopyTo(buffer.AsSpan(headerBytes.Length));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\Attestor\\StellaOps.Attestor.Envelope\\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.Security;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Offline Rekor receipt verifier for air-gapped environments.
|
||||
/// Verifies checkpoint signature and Merkle inclusion (RFC 6962).
|
||||
/// </summary>
|
||||
public static class RekorOfflineReceiptVerifier
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static async Task<RekorOfflineReceiptVerificationResult> VerifyAsync(
|
||||
string receiptPath,
|
||||
ReadOnlyMemory<byte> dsseSha256,
|
||||
string rekorPublicKeyPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(receiptPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rekorPublicKeyPath);
|
||||
|
||||
if (!File.Exists(receiptPath))
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor receipt file not found.");
|
||||
}
|
||||
|
||||
if (!File.Exists(rekorPublicKeyPath))
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor public key file not found.");
|
||||
}
|
||||
|
||||
var receiptJson = await File.ReadAllTextAsync(receiptPath, cancellationToken).ConfigureAwait(false);
|
||||
RekorReceiptDocument? receipt;
|
||||
try
|
||||
{
|
||||
receipt = JsonSerializer.Deserialize<RekorReceiptDocument>(receiptJson, SerializerOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure($"Rekor receipt JSON invalid: {ex.Message}");
|
||||
}
|
||||
|
||||
if (receipt is null ||
|
||||
string.IsNullOrWhiteSpace(receipt.Uuid) ||
|
||||
receipt.LogIndex < 0 ||
|
||||
string.IsNullOrWhiteSpace(receipt.RootHash) ||
|
||||
receipt.Hashes is null ||
|
||||
receipt.Hashes.Count == 0 ||
|
||||
string.IsNullOrWhiteSpace(receipt.Checkpoint))
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor receipt is missing required fields.");
|
||||
}
|
||||
|
||||
if (dsseSha256.Length != 32)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("DSSE digest must be 32 bytes (sha256).");
|
||||
}
|
||||
|
||||
var publicKeyBytes = await LoadPublicKeyBytesAsync(rekorPublicKeyPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var receiptDirectory = Path.GetDirectoryName(Path.GetFullPath(receiptPath)) ?? Environment.CurrentDirectory;
|
||||
var checkpointText = await ResolveCheckpointAsync(receipt.Checkpoint, receiptDirectory, cancellationToken).ConfigureAwait(false);
|
||||
if (checkpointText is null)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint file not found.");
|
||||
}
|
||||
|
||||
var checkpoint = SigstoreCheckpoint.TryParse(checkpointText);
|
||||
if (checkpoint is null)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint format invalid.");
|
||||
}
|
||||
|
||||
if (checkpoint.Signatures.Count == 0)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint signature missing.");
|
||||
}
|
||||
|
||||
var signatureVerified = VerifyCheckpointSignature(checkpoint.BodyCanonicalUtf8, checkpoint.Signatures, publicKeyBytes);
|
||||
if (!signatureVerified)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint signature verification failed.");
|
||||
}
|
||||
|
||||
byte[] expectedRoot;
|
||||
try
|
||||
{
|
||||
expectedRoot = Convert.FromBase64String(checkpoint.RootHashBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint root hash is not valid base64.");
|
||||
}
|
||||
|
||||
if (expectedRoot.Length != 32)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint root hash must be 32 bytes (sha256).");
|
||||
}
|
||||
|
||||
var receiptRootBytes = TryParseHashBytes(receipt.RootHash);
|
||||
if (receiptRootBytes is null)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor receipt rootHash has invalid encoding.");
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(receiptRootBytes, expectedRoot))
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor receipt rootHash does not match checkpoint root hash.");
|
||||
}
|
||||
|
||||
var proofHashes = new List<byte[]>(capacity: receipt.Hashes.Count);
|
||||
foreach (var h in receipt.Hashes)
|
||||
{
|
||||
if (TryParseHashBytes(h) is not { } bytes)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Rekor receipt hashes contains an invalid hash value.");
|
||||
}
|
||||
|
||||
proofHashes.Add(bytes);
|
||||
}
|
||||
|
||||
var leafHash = Rfc6962Merkle.HashLeaf(dsseSha256.Span);
|
||||
|
||||
var computedRoot = Rfc6962Merkle.ComputeRootFromPath(
|
||||
leafHash,
|
||||
receipt.LogIndex,
|
||||
checkpoint.TreeSize,
|
||||
proofHashes);
|
||||
|
||||
if (computedRoot is null)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure("Failed to compute Rekor Merkle root from inclusion proof.");
|
||||
}
|
||||
|
||||
var computedRootHex = Convert.ToHexString(computedRoot).ToLowerInvariant();
|
||||
var expectedRootHex = Convert.ToHexString(expectedRoot).ToLowerInvariant();
|
||||
|
||||
var included = CryptographicOperations.FixedTimeEquals(computedRoot, expectedRoot);
|
||||
if (!included)
|
||||
{
|
||||
return RekorOfflineReceiptVerificationResult.Failure(
|
||||
"Rekor inclusion proof verification failed (computed root mismatch).",
|
||||
computedRootHex,
|
||||
expectedRootHex,
|
||||
checkpoint.TreeSize,
|
||||
checkpointSignatureVerified: true);
|
||||
}
|
||||
|
||||
return RekorOfflineReceiptVerificationResult.Success(
|
||||
receipt.Uuid.Trim(),
|
||||
receipt.LogIndex,
|
||||
computedRootHex,
|
||||
expectedRootHex,
|
||||
checkpoint.TreeSize,
|
||||
checkpointSignatureVerified: true);
|
||||
}
|
||||
|
||||
private static async Task<byte[]> LoadPublicKeyBytesAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
|
||||
var text = Encoding.UTF8.GetString(bytes);
|
||||
|
||||
const string Begin = "-----BEGIN PUBLIC KEY-----";
|
||||
const string End = "-----END PUBLIC KEY-----";
|
||||
|
||||
var begin = text.IndexOf(Begin, StringComparison.Ordinal);
|
||||
var end = text.IndexOf(End, StringComparison.Ordinal);
|
||||
if (begin >= 0 && end > begin)
|
||||
{
|
||||
var base64 = text
|
||||
.Substring(begin + Begin.Length, end - (begin + Begin.Length))
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", string.Empty, StringComparison.Ordinal)
|
||||
.Trim();
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
|
||||
// Note public key format: origin+keyid+base64(pubkey)
|
||||
var trimmed = text.Trim();
|
||||
if (trimmed.Contains('+', StringComparison.Ordinal) && trimmed.Count(static c => c == '+') >= 2)
|
||||
{
|
||||
var last = trimmed.Split('+')[^1];
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(last);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through to raw bytes
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveCheckpointAsync(string checkpointField, string receiptDirectory, CancellationToken ct)
|
||||
{
|
||||
var value = checkpointField.Trim();
|
||||
|
||||
// If the value looks like a path and exists, load it.
|
||||
var candidates = new List<string>();
|
||||
if (value.IndexOfAny(['/', '\\']) >= 0 || value.EndsWith(".sig", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
candidates.Add(Path.IsPathRooted(value) ? value : Path.Combine(receiptDirectory, value));
|
||||
}
|
||||
|
||||
candidates.Add(Path.Combine(receiptDirectory, "checkpoint.sig"));
|
||||
candidates.Add(Path.Combine(receiptDirectory, "tlog", "checkpoint.sig"));
|
||||
candidates.Add(Path.Combine(receiptDirectory, "evidence", "tlog", "checkpoint.sig"));
|
||||
|
||||
foreach (var candidate in candidates.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise treat as inline checkpoint content.
|
||||
return value.Length > 0 ? checkpointField : null;
|
||||
}
|
||||
|
||||
private static bool VerifyCheckpointSignature(ReadOnlySpan<byte> bodyUtf8, IReadOnlyList<byte[]> signatures, byte[] publicKey)
|
||||
{
|
||||
// Try ECDSA first (SPKI)
|
||||
if (TryVerifyEcdsaCheckpoint(bodyUtf8, signatures, publicKey))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ed25519 fallback (raw 32-byte key or SPKI parsed via BouncyCastle)
|
||||
if (TryVerifyEd25519Checkpoint(bodyUtf8, signatures, publicKey))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryVerifyEcdsaCheckpoint(ReadOnlySpan<byte> bodyUtf8, IReadOnlyList<byte[]> signatures, byte[] publicKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportSubjectPublicKeyInfo(publicKey, out _);
|
||||
|
||||
foreach (var sig in signatures)
|
||||
{
|
||||
if (ecdsa.VerifyData(bodyUtf8, sig, HashAlgorithmName.SHA256))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Some encoders store a raw (r||s) 64-byte signature.
|
||||
if (sig.Length == 64 && ecdsa.VerifyData(bodyUtf8, sig, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not an ECDSA key or signature format mismatch.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryVerifyEd25519Checkpoint(ReadOnlySpan<byte> bodyUtf8, IReadOnlyList<byte[]> signatures, byte[] publicKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
Ed25519PublicKeyParameters key;
|
||||
if (publicKey.Length == 32)
|
||||
{
|
||||
key = new Ed25519PublicKeyParameters(publicKey, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
var parsed = PublicKeyFactory.CreateKey(publicKey);
|
||||
if (parsed is not Ed25519PublicKeyParameters edKey)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
key = edKey;
|
||||
}
|
||||
|
||||
foreach (var sig in signatures)
|
||||
{
|
||||
var verifier = new Ed25519Signer();
|
||||
verifier.Init(false, key);
|
||||
var buffer = bodyUtf8.ToArray();
|
||||
verifier.BlockUpdate(buffer, 0, buffer.Length);
|
||||
if (verifier.VerifySignature(sig))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static byte[]? TryParseHashBytes(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trimmed = trimmed["sha256:".Length..];
|
||||
}
|
||||
|
||||
// Hex (most common)
|
||||
if (trimmed.Length % 2 == 0 && trimmed.All(static c => (c >= '0' && c <= '9') ||
|
||||
(c >= 'a' && c <= 'f') ||
|
||||
(c >= 'A' && c <= 'F')))
|
||||
{
|
||||
try
|
||||
{
|
||||
return Convert.FromHexString(trimmed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Base64
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(trimmed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RekorReceiptDocument(
|
||||
[property: JsonPropertyName("uuid")] string Uuid,
|
||||
[property: JsonPropertyName("logIndex")] long LogIndex,
|
||||
[property: JsonPropertyName("rootHash")] string RootHash,
|
||||
[property: JsonPropertyName("hashes")] IReadOnlyList<string> Hashes,
|
||||
[property: JsonPropertyName("checkpoint")] string Checkpoint);
|
||||
|
||||
private sealed class SigstoreCheckpoint
|
||||
{
|
||||
private SigstoreCheckpoint(
|
||||
string origin,
|
||||
long treeSize,
|
||||
string rootHashBase64,
|
||||
string? timestamp,
|
||||
IReadOnlyList<byte[]> signatures,
|
||||
byte[] bodyCanonicalUtf8)
|
||||
{
|
||||
Origin = origin;
|
||||
TreeSize = treeSize;
|
||||
RootHashBase64 = rootHashBase64;
|
||||
Timestamp = timestamp;
|
||||
Signatures = signatures;
|
||||
BodyCanonicalUtf8 = bodyCanonicalUtf8;
|
||||
}
|
||||
|
||||
public string Origin { get; }
|
||||
public long TreeSize { get; }
|
||||
public string RootHashBase64 { get; }
|
||||
public string? Timestamp { get; }
|
||||
public IReadOnlyList<byte[]> Signatures { get; }
|
||||
public byte[] BodyCanonicalUtf8 { get; }
|
||||
|
||||
public static SigstoreCheckpoint? TryParse(string checkpointContent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(checkpointContent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lines = checkpointContent
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Split('\n')
|
||||
.Select(static line => line.TrimEnd())
|
||||
.ToList();
|
||||
|
||||
// Extract signatures first (note format: "— origin base64sig", or "sig <base64>").
|
||||
var signatures = new List<byte[]>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("—", StringComparison.Ordinal) || trimmed.StartsWith("--", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var token = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(token) && TryDecodeBase64(token, out var sigBytes))
|
||||
{
|
||||
signatures.Add(sigBytes);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("sig ", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("signature ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var token = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(token) && TryDecodeBase64(token, out var sigBytes))
|
||||
{
|
||||
signatures.Add(sigBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body: first non-empty 3 lines (origin, size, root), optional 4th timestamp (digits).
|
||||
var bodyLines = lines
|
||||
.Select(static l => l.Trim())
|
||||
.Where(static l => l.Length > 0)
|
||||
.Where(static l => !LooksLikeSignatureLine(l))
|
||||
.ToList();
|
||||
|
||||
if (bodyLines.Count < 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var origin = bodyLines[0];
|
||||
if (!long.TryParse(bodyLines[1], out var treeSize) || treeSize <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var rootBase64 = bodyLines[2];
|
||||
// Validate base64 now; decode later for error messages.
|
||||
if (!TryDecodeBase64(rootBase64, out _))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? timestamp = null;
|
||||
if (bodyLines.Count >= 4 && bodyLines[3].All(static c => c >= '0' && c <= '9'))
|
||||
{
|
||||
timestamp = bodyLines[3];
|
||||
}
|
||||
|
||||
var canonical = new StringBuilder();
|
||||
canonical.Append(origin);
|
||||
canonical.Append('\n');
|
||||
canonical.Append(treeSize.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
canonical.Append('\n');
|
||||
canonical.Append(rootBase64);
|
||||
canonical.Append('\n');
|
||||
if (!string.IsNullOrWhiteSpace(timestamp))
|
||||
{
|
||||
canonical.Append(timestamp);
|
||||
canonical.Append('\n');
|
||||
}
|
||||
|
||||
return new SigstoreCheckpoint(
|
||||
origin,
|
||||
treeSize,
|
||||
rootBase64,
|
||||
timestamp,
|
||||
signatures,
|
||||
Encoding.UTF8.GetBytes(canonical.ToString()));
|
||||
}
|
||||
|
||||
private static bool LooksLikeSignatureLine(string trimmedLine)
|
||||
{
|
||||
if (trimmedLine.StartsWith("—", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trimmedLine.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trimmedLine.StartsWith("sig ", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmedLine.StartsWith("signature ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string token, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(token);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class Rfc6962Merkle
|
||||
{
|
||||
private const byte LeafPrefix = 0x00;
|
||||
private const byte NodePrefix = 0x01;
|
||||
|
||||
public static byte[] HashLeaf(ReadOnlySpan<byte> leafData)
|
||||
{
|
||||
var buffer = new byte[1 + leafData.Length];
|
||||
buffer[0] = LeafPrefix;
|
||||
leafData.CopyTo(buffer.AsSpan(1));
|
||||
return SHA256.HashData(buffer);
|
||||
}
|
||||
|
||||
public static byte[] HashInterior(ReadOnlySpan<byte> left, ReadOnlySpan<byte> right)
|
||||
{
|
||||
var buffer = new byte[1 + left.Length + right.Length];
|
||||
buffer[0] = NodePrefix;
|
||||
left.CopyTo(buffer.AsSpan(1));
|
||||
right.CopyTo(buffer.AsSpan(1 + left.Length));
|
||||
return SHA256.HashData(buffer);
|
||||
}
|
||||
|
||||
public static byte[]? ComputeRootFromPath(
|
||||
byte[] leafHash,
|
||||
long leafIndex,
|
||||
long treeSize,
|
||||
IReadOnlyList<byte[]> proofHashes)
|
||||
{
|
||||
if (leafIndex < 0 || treeSize <= 0 || leafIndex >= treeSize)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (proofHashes.Count == 0)
|
||||
{
|
||||
return treeSize == 1 ? leafHash : null;
|
||||
}
|
||||
|
||||
var currentHash = leafHash;
|
||||
var proofIndex = 0;
|
||||
var index = leafIndex;
|
||||
var size = treeSize;
|
||||
|
||||
while (size > 1)
|
||||
{
|
||||
if (proofIndex >= proofHashes.Count)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sibling = proofHashes[proofIndex++];
|
||||
|
||||
if (index % 2 == 0)
|
||||
{
|
||||
if (index + 1 < size)
|
||||
{
|
||||
currentHash = HashInterior(currentHash, sibling);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
currentHash = HashInterior(sibling, currentHash);
|
||||
}
|
||||
|
||||
index /= 2;
|
||||
size = (size + 1) / 2;
|
||||
}
|
||||
|
||||
return currentHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RekorOfflineReceiptVerificationResult
|
||||
{
|
||||
public required bool Verified { get; init; }
|
||||
public string? FailureReason { get; init; }
|
||||
public string? RekorUuid { get; init; }
|
||||
public long? LogIndex { get; init; }
|
||||
public string? ComputedRootHash { get; init; }
|
||||
public string? ExpectedRootHash { get; init; }
|
||||
public long? TreeSize { get; init; }
|
||||
public bool CheckpointSignatureVerified { get; init; }
|
||||
|
||||
public static RekorOfflineReceiptVerificationResult Success(
|
||||
string rekorUuid,
|
||||
long logIndex,
|
||||
string computedRootHash,
|
||||
string expectedRootHash,
|
||||
long treeSize,
|
||||
bool checkpointSignatureVerified) => new()
|
||||
{
|
||||
Verified = true,
|
||||
RekorUuid = rekorUuid,
|
||||
LogIndex = logIndex,
|
||||
ComputedRootHash = computedRootHash,
|
||||
ExpectedRootHash = expectedRootHash,
|
||||
TreeSize = treeSize,
|
||||
CheckpointSignatureVerified = checkpointSignatureVerified
|
||||
};
|
||||
|
||||
public static RekorOfflineReceiptVerificationResult Failure(
|
||||
string reason,
|
||||
string? computedRootHash = null,
|
||||
string? expectedRootHash = null,
|
||||
long? treeSize = null,
|
||||
bool checkpointSignatureVerified = false) => new()
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = reason,
|
||||
ComputedRootHash = computedRootHash,
|
||||
ExpectedRootHash = expectedRootHash,
|
||||
TreeSize = treeSize,
|
||||
CheckpointSignatureVerified = checkpointSignatureVerified
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AirGap.Importer.Reconciliation;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
|
||||
|
||||
public sealed class EvidenceReconcilerDsseSigningTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_WhenSignOutputEnabled_WritesDeterministicDsseEnvelopeWithValidSignature()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var pem = ecdsa.ExportPkcs8PrivateKeyPem();
|
||||
|
||||
var root = Path.Combine(Path.GetTempPath(), "stellaops-airgap-importer-tests", Guid.NewGuid().ToString("n"));
|
||||
var inputDir = Path.Combine(root, "input");
|
||||
var outputDir = Path.Combine(root, "output");
|
||||
|
||||
Directory.CreateDirectory(inputDir);
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
var keyPath = Path.Combine(root, "evidence-signing-key.pem");
|
||||
await File.WriteAllTextAsync(keyPath, pem, Encoding.UTF8);
|
||||
|
||||
var reconciler = new EvidenceReconciler();
|
||||
var options = new ReconciliationOptions
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UnixEpoch,
|
||||
SignOutput = true,
|
||||
SigningPrivateKeyPemPath = keyPath
|
||||
};
|
||||
|
||||
var graph1 = await reconciler.ReconcileAsync(inputDir, outputDir, options);
|
||||
var dssePath = Path.Combine(outputDir, "evidence-graph.dsse.json");
|
||||
var firstBytes = await File.ReadAllBytesAsync(dssePath);
|
||||
|
||||
var graph2 = await reconciler.ReconcileAsync(inputDir, outputDir, options);
|
||||
var secondBytes = await File.ReadAllBytesAsync(dssePath);
|
||||
|
||||
Assert.Equal(firstBytes, secondBytes);
|
||||
|
||||
using var json = JsonDocument.Parse(firstBytes);
|
||||
var rootElement = json.RootElement;
|
||||
|
||||
Assert.Equal("application/vnd.stellaops.evidence-graph+json", rootElement.GetProperty("payloadType").GetString());
|
||||
|
||||
var payloadBytes = Convert.FromBase64String(rootElement.GetProperty("payload").GetString()!);
|
||||
var signatureElement = rootElement.GetProperty("signatures")[0];
|
||||
var signatureBytes = Convert.FromBase64String(signatureElement.GetProperty("sig").GetString()!);
|
||||
|
||||
var expectedPayload = new EvidenceGraphSerializer().Serialize(graph1, pretty: false);
|
||||
Assert.Equal(expectedPayload, Encoding.UTF8.GetString(payloadBytes));
|
||||
|
||||
var pae = EncodeDssePreAuth("application/vnd.stellaops.evidence-graph+json", payloadBytes);
|
||||
Assert.True(ecdsa.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256));
|
||||
|
||||
var keyId = signatureElement.GetProperty("keyid").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(keyId));
|
||||
|
||||
Assert.Equal(new EvidenceGraphSerializer().Serialize(graph1, pretty: false), new EvidenceGraphSerializer().Serialize(graph2, pretty: false));
|
||||
}
|
||||
|
||||
private static byte[] EncodeDssePreAuth(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
|
||||
var header = $"DSSEv1 {payloadTypeByteCount} {payloadType} {payload.Length} ";
|
||||
var headerBytes = Encoding.UTF8.GetBytes(header);
|
||||
var buffer = new byte[headerBytes.Length + payload.Length];
|
||||
headerBytes.CopyTo(buffer.AsSpan());
|
||||
payload.CopyTo(buffer.AsSpan(headerBytes.Length));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\StellaOps.AirGap.Importer\\StellaOps.AirGap.Importer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user