work work hard work

This commit is contained in:
StellaOps Bot
2025-12-18 00:47:24 +02:00
parent dee252940b
commit b4235c134c
189 changed files with 9627 additions and 3258 deletions

View File

@@ -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
};
}
}

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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
};
}

View File

@@ -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;
}
}

View File

@@ -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>