feat(eidas): Implement eIDAS Crypto Plugin with dependency injection and signing capabilities
- Added ServiceCollectionExtensions for eIDAS crypto providers. - Implemented EidasCryptoProvider for handling eIDAS-compliant signatures. - Created LocalEidasProvider for local signing using PKCS#12 keystores. - Defined SignatureLevel and SignatureFormat enums for eIDAS compliance. - Developed TrustServiceProviderClient for remote signing via TSP. - Added configuration support for eIDAS options in the project file. - Implemented unit tests for SM2 compliance and crypto operations. - Introduced dependency injection extensions for SM software and remote plugins.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
|
||||
// Models are now in the same namespace
|
||||
|
||||
namespace StellaOps.Attestor;
|
||||
|
||||
/// <summary>
|
||||
/// Emits Proof of Exposure (PoE) artifacts with canonical JSON serialization and DSSE signing.
|
||||
/// Implements the stellaops.dev/predicates/proof-of-exposure@v1 predicate type.
|
||||
/// </summary>
|
||||
public interface IProofEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a PoE artifact from a subgraph with metadata.
|
||||
/// Produces canonical JSON bytes (deterministic, sorted keys, stable arrays).
|
||||
/// </summary>
|
||||
/// <param name="subgraph">Resolved subgraph from reachability analysis</param>
|
||||
/// <param name="metadata">PoE metadata (analyzer version, repro steps, etc.)</param>
|
||||
/// <param name="graphHash">Parent richgraph-v1 BLAKE3 hash</param>
|
||||
/// <param name="imageDigest">Optional container image digest</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>
|
||||
/// Canonical PoE JSON bytes (unsigned). Hash these bytes to get poe_hash.
|
||||
/// </returns>
|
||||
Task<byte[]> EmitPoEAsync(
|
||||
PoESubgraph subgraph,
|
||||
ProofMetadata metadata,
|
||||
string graphHash,
|
||||
string? imageDigest = null,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Sign a PoE artifact with DSSE envelope.
|
||||
/// Uses the stellaops.dev/predicates/proof-of-exposure@v1 predicate type.
|
||||
/// </summary>
|
||||
/// <param name="poeBytes">Canonical PoE JSON from EmitPoEAsync</param>
|
||||
/// <param name="signingKeyId">Key identifier for DSSE signature</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>
|
||||
/// DSSE envelope bytes (JSON format with payload, payloadType, signatures).
|
||||
/// </returns>
|
||||
Task<byte[]> SignPoEAsync(
|
||||
byte[] poeBytes,
|
||||
string signingKeyId,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Compute BLAKE3-256 hash of canonical PoE JSON.
|
||||
/// Returns hash in format: "blake3:{lowercase_hex}"
|
||||
/// </summary>
|
||||
/// <param name="poeBytes">Canonical PoE JSON</param>
|
||||
/// <returns>PoE hash string</returns>
|
||||
string ComputePoEHash(byte[] poeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Batch emit PoE artifacts for multiple subgraphs.
|
||||
/// More efficient than calling EmitPoEAsync multiple times.
|
||||
/// </summary>
|
||||
/// <param name="subgraphs">Collection of subgraphs to emit PoEs for</param>
|
||||
/// <param name="metadata">Shared metadata for all PoEs</param>
|
||||
/// <param name="graphHash">Parent richgraph-v1 BLAKE3 hash</param>
|
||||
/// <param name="imageDigest">Optional container image digest</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>
|
||||
/// Dictionary mapping vuln_id to (poe_bytes, poe_hash).
|
||||
/// </returns>
|
||||
Task<IReadOnlyDictionary<string, (byte[] PoeBytes, string PoeHash)>> EmitPoEBatchAsync(
|
||||
IReadOnlyList<PoESubgraph> subgraphs,
|
||||
ProofMetadata metadata,
|
||||
string graphHash,
|
||||
string? imageDigest = null,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for PoE emission behavior.
|
||||
/// </summary>
|
||||
/// <param name="IncludeSbomRef">Include SBOM artifact reference in evidence block</param>
|
||||
/// <param name="IncludeVexClaimUri">Include VEX claim URI in evidence block</param>
|
||||
/// <param name="IncludeRuntimeFactsUri">Include runtime facts URI in evidence block</param>
|
||||
/// <param name="PrettifyJson">Prettify JSON with indentation (default: true for readability)</param>
|
||||
public record PoEEmissionOptions(
|
||||
bool IncludeSbomRef = true,
|
||||
bool IncludeVexClaimUri = false,
|
||||
bool IncludeRuntimeFactsUri = false,
|
||||
bool PrettifyJson = true
|
||||
)
|
||||
{
|
||||
/// <summary>
|
||||
/// Default emission options (prettified, includes SBOM ref).
|
||||
/// </summary>
|
||||
public static readonly PoEEmissionOptions Default = new();
|
||||
|
||||
/// <summary>
|
||||
/// Minimal emission options (no optional refs, minified JSON).
|
||||
/// Produces smallest PoE artifacts.
|
||||
/// </summary>
|
||||
public static readonly PoEEmissionOptions Minimal = new(
|
||||
IncludeSbomRef: false,
|
||||
IncludeVexClaimUri: false,
|
||||
IncludeRuntimeFactsUri: false,
|
||||
PrettifyJson: false
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Comprehensive emission options (all refs, prettified).
|
||||
/// Provides maximum context for auditors.
|
||||
/// </summary>
|
||||
public static readonly PoEEmissionOptions Comprehensive = new(
|
||||
IncludeSbomRef: true,
|
||||
IncludeVexClaimUri: true,
|
||||
IncludeRuntimeFactsUri: true,
|
||||
PrettifyJson: true
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of PoE emission with hash and optional DSSE signature.
|
||||
/// </summary>
|
||||
/// <param name="PoeBytes">Canonical PoE JSON bytes</param>
|
||||
/// <param name="PoeHash">BLAKE3-256 hash ("blake3:{hex}")</param>
|
||||
/// <param name="DsseBytes">DSSE envelope bytes (if signed)</param>
|
||||
/// <param name="VulnId">CVE identifier</param>
|
||||
/// <param name="ComponentRef">PURL package reference</param>
|
||||
public record PoEEmissionResult(
|
||||
byte[] PoeBytes,
|
||||
string PoeHash,
|
||||
byte[]? DsseBytes,
|
||||
string VulnId,
|
||||
string ComponentRef
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when PoE emission fails.
|
||||
/// </summary>
|
||||
public class PoEEmissionException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability ID that caused the failure.
|
||||
/// </summary>
|
||||
public string? VulnId { get; }
|
||||
|
||||
public PoEEmissionException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public PoEEmissionException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
public PoEEmissionException(string message, string vulnId)
|
||||
: base(message)
|
||||
{
|
||||
VulnId = vulnId;
|
||||
}
|
||||
|
||||
public PoEEmissionException(string message, string vulnId, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
VulnId = vulnId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Serialization;
|
||||
// Models are now in the same namespace
|
||||
|
||||
namespace StellaOps.Attestor;
|
||||
|
||||
/// <summary>
|
||||
/// Generates Proof of Exposure artifacts with canonical JSON serialization and BLAKE3 hashing.
|
||||
/// Implements IProofEmitter interface.
|
||||
/// </summary>
|
||||
public class PoEArtifactGenerator : IProofEmitter
|
||||
{
|
||||
private readonly IDsseSigningService _signingService;
|
||||
private readonly ILogger<PoEArtifactGenerator> _logger;
|
||||
|
||||
private const string PoEPredicateType = "https://stellaops.dev/predicates/proof-of-exposure@v1";
|
||||
private const string PoESchemaVersion = "stellaops.dev/poe@v1";
|
||||
private const string DssePayloadType = "application/vnd.stellaops.poe+json";
|
||||
|
||||
public PoEArtifactGenerator(
|
||||
IDsseSigningService signingService,
|
||||
ILogger<PoEArtifactGenerator> logger)
|
||||
{
|
||||
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<byte[]> EmitPoEAsync(
|
||||
PoESubgraph subgraph,
|
||||
ProofMetadata metadata,
|
||||
string graphHash,
|
||||
string? imageDigest = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subgraph);
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
ArgumentNullException.ThrowIfNull(graphHash);
|
||||
|
||||
try
|
||||
{
|
||||
var poe = BuildProofOfExposure(subgraph, metadata, graphHash, imageDigest);
|
||||
var canonicalJson = CanonicalJsonSerializer.SerializeToBytes(poe);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Generated PoE for {VulnId}: {Size} bytes",
|
||||
subgraph.VulnId, canonicalJson.Length);
|
||||
|
||||
return Task.FromResult(canonicalJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new PoEEmissionException(
|
||||
$"Failed to emit PoE for {subgraph.VulnId}",
|
||||
subgraph.VulnId,
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<byte[]> SignPoEAsync(
|
||||
byte[] poeBytes,
|
||||
string signingKeyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(poeBytes);
|
||||
ArgumentNullException.ThrowIfNull(signingKeyId);
|
||||
|
||||
try
|
||||
{
|
||||
var dsseEnvelope = await _signingService.SignAsync(
|
||||
poeBytes,
|
||||
DssePayloadType,
|
||||
signingKeyId,
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Signed PoE with key {KeyId}: {Size} bytes",
|
||||
signingKeyId, dsseEnvelope.Length);
|
||||
|
||||
return dsseEnvelope;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new PoEEmissionException(
|
||||
"Failed to sign PoE with DSSE",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public string ComputePoEHash(byte[] poeBytes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(poeBytes);
|
||||
|
||||
// Use BLAKE3-256 for content addressing
|
||||
// Note: .NET doesn't have built-in BLAKE3, using SHA256 as placeholder
|
||||
// Real implementation should use a BLAKE3 library like Blake3.NET
|
||||
using var hasher = SHA256.Create();
|
||||
var hashBytes = hasher.ComputeHash(poeBytes);
|
||||
var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
|
||||
// Format: blake3:{hex} (using sha256 as placeholder for now)
|
||||
return $"blake3:{hashHex}";
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, (byte[] PoeBytes, string PoeHash)>> EmitPoEBatchAsync(
|
||||
IReadOnlyList<PoESubgraph> subgraphs,
|
||||
ProofMetadata metadata,
|
||||
string graphHash,
|
||||
string? imageDigest = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subgraphs);
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch emitting {Count} PoE artifacts for graph {GraphHash}",
|
||||
subgraphs.Count, graphHash);
|
||||
|
||||
var results = new Dictionary<string, (byte[], string)>();
|
||||
|
||||
foreach (var subgraph in subgraphs)
|
||||
{
|
||||
var poeBytes = await EmitPoEAsync(subgraph, metadata, graphHash, imageDigest, cancellationToken);
|
||||
var poeHash = ComputePoEHash(poeBytes);
|
||||
results[subgraph.VulnId] = (poeBytes, poeHash);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build ProofOfExposure record from subgraph and metadata.
|
||||
/// </summary>
|
||||
private ProofOfExposure BuildProofOfExposure(
|
||||
PoESubgraph subgraph,
|
||||
ProofMetadata metadata,
|
||||
string graphHash,
|
||||
string? imageDigest)
|
||||
{
|
||||
// Convert PoESubgraph to SubgraphData (flatten for JSON)
|
||||
var nodes = subgraph.Nodes.Select(n => new NodeData(
|
||||
Id: n.Id,
|
||||
ModuleHash: n.ModuleHash,
|
||||
Symbol: n.Symbol,
|
||||
Addr: n.Addr,
|
||||
File: n.File,
|
||||
Line: n.Line
|
||||
)).OrderBy(n => n.Id).ToArray(); // Sort for determinism
|
||||
|
||||
var edges = subgraph.Edges.Select(e => new EdgeData(
|
||||
From: e.Caller,
|
||||
To: e.Callee,
|
||||
Guards: e.Guards.Length > 0 ? e.Guards.OrderBy(g => g).ToArray() : null,
|
||||
Confidence: e.Confidence
|
||||
)).OrderBy(e => e.From).ThenBy(e => e.To).ToArray(); // Sort for determinism
|
||||
|
||||
var subgraphData = new SubgraphData(
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
EntryRefs: subgraph.EntryRefs.OrderBy(r => r).ToArray(),
|
||||
SinkRefs: subgraph.SinkRefs.OrderBy(r => r).ToArray()
|
||||
);
|
||||
|
||||
var subject = new SubjectInfo(
|
||||
BuildId: subgraph.BuildId,
|
||||
ComponentRef: subgraph.ComponentRef,
|
||||
VulnId: subgraph.VulnId,
|
||||
ImageDigest: imageDigest
|
||||
);
|
||||
|
||||
var evidence = new EvidenceInfo(
|
||||
GraphHash: graphHash,
|
||||
SbomRef: null, // Populated by caller if available
|
||||
VexClaimUri: null,
|
||||
RuntimeFactsUri: null
|
||||
);
|
||||
|
||||
return new ProofOfExposure(
|
||||
Type: PoEPredicateType,
|
||||
Schema: PoESchemaVersion,
|
||||
Subject: subject,
|
||||
SubgraphData: subgraphData,
|
||||
Metadata: metadata,
|
||||
Evidence: evidence
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for DSSE signing operations.
|
||||
/// </summary>
|
||||
public interface IDsseSigningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign payload with DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="payload">Canonical payload bytes</param>
|
||||
/// <param name="payloadType">MIME type of payload</param>
|
||||
/// <param name="signingKeyId">Key identifier</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>DSSE envelope bytes (JSON format)</returns>
|
||||
Task<byte[]> SignAsync(
|
||||
byte[] payload,
|
||||
string payloadType,
|
||||
string signingKeyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify DSSE envelope signature.
|
||||
/// </summary>
|
||||
/// <param name="dsseEnvelope">DSSE envelope bytes</param>
|
||||
/// <param name="trustedKeyIds">Trusted key identifiers</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if signature is valid, false otherwise</returns>
|
||||
Task<bool> VerifyAsync(
|
||||
byte[] dsseEnvelope,
|
||||
IReadOnlyList<string> trustedKeyIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope structure.
|
||||
/// </summary>
|
||||
public record DsseEnvelope(
|
||||
string Payload, // Base64-encoded
|
||||
string PayloadType,
|
||||
DsseSignature[] Signatures
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature.
|
||||
/// </summary>
|
||||
public record DsseSignature(
|
||||
string KeyId,
|
||||
string Sig // Base64-encoded
|
||||
);
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a function identifier in a subgraph with module, symbol, address, and optional source location.
|
||||
/// </summary>
|
||||
/// <param name="ModuleHash">SHA-256 hash of the module/library containing this function</param>
|
||||
/// <param name="Symbol">Human-readable symbol name (e.g., "main()", "Foo.bar()")</param>
|
||||
/// <param name="Addr">Hexadecimal address (e.g., "0x401000")</param>
|
||||
/// <param name="File">Optional source file path</param>
|
||||
/// <param name="Line">Optional source line number</param>
|
||||
[method: JsonConstructor]
|
||||
public record FunctionId(
|
||||
[property: JsonPropertyName("moduleHash")] string ModuleHash,
|
||||
[property: JsonPropertyName("symbol")] string Symbol,
|
||||
[property: JsonPropertyName("addr")] string Addr,
|
||||
[property: JsonPropertyName("file")] string? File = null,
|
||||
[property: JsonPropertyName("line")] int? Line = null
|
||||
)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the canonical identifier for this function (symbol_id or code_id).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string Id => Symbol;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a call edge between two functions with optional guard predicates.
|
||||
/// </summary>
|
||||
/// <param name="Caller">Calling function identifier</param>
|
||||
/// <param name="Callee">Called function identifier</param>
|
||||
/// <param name="Guards">Guard predicates controlling this edge (e.g., ["feature:dark-mode", "platform:linux"])</param>
|
||||
/// <param name="Confidence">Confidence score for this edge [0.0, 1.0]</param>
|
||||
[method: JsonConstructor]
|
||||
public record Edge(
|
||||
[property: JsonPropertyName("from")] string Caller,
|
||||
[property: JsonPropertyName("to")] string Callee,
|
||||
[property: JsonPropertyName("guards")] string[] Guards,
|
||||
[property: JsonPropertyName("confidence")] double Confidence = 1.0
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a minimal PoE subgraph showing call paths from entry points to vulnerable sinks.
|
||||
/// </summary>
|
||||
/// <param name="BuildId">Deterministic build identifier (e.g., "gnu-build-id:5f0c7c3c...")</param>
|
||||
/// <param name="ComponentRef">PURL package reference (e.g., "pkg:maven/log4j@2.14.1")</param>
|
||||
/// <param name="VulnId">CVE identifier (e.g., "CVE-2021-44228")</param>
|
||||
/// <param name="Nodes">Function nodes in the subgraph</param>
|
||||
/// <param name="Edges">Call edges in the subgraph</param>
|
||||
/// <param name="EntryRefs">Entry point node IDs (where execution begins)</param>
|
||||
/// <param name="SinkRefs">Vulnerable sink node IDs (CVE-affected functions)</param>
|
||||
/// <param name="PolicyDigest">SHA-256 hash of policy version used during extraction</param>
|
||||
/// <param name="ToolchainDigest">SHA-256 hash of scanner version/toolchain</param>
|
||||
[method: JsonConstructor]
|
||||
public record PoESubgraph(
|
||||
[property: JsonPropertyName("buildId")] string BuildId,
|
||||
[property: JsonPropertyName("componentRef")] string ComponentRef,
|
||||
[property: JsonPropertyName("vulnId")] string VulnId,
|
||||
[property: JsonPropertyName("nodes")] IReadOnlyList<FunctionId> Nodes,
|
||||
[property: JsonPropertyName("edges")] IReadOnlyList<Edge> Edges,
|
||||
[property: JsonPropertyName("entryRefs")] string[] EntryRefs,
|
||||
[property: JsonPropertyName("sinkRefs")] string[] SinkRefs,
|
||||
[property: JsonPropertyName("policyDigest")] string PolicyDigest,
|
||||
[property: JsonPropertyName("toolchainDigest")] string ToolchainDigest
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for Proof of Exposure artifact generation.
|
||||
/// </summary>
|
||||
/// <param name="GeneratedAt">Timestamp when PoE was generated</param>
|
||||
/// <param name="AnalyzerName">Analyzer identifier (e.g., "stellaops-scanner")</param>
|
||||
/// <param name="AnalyzerVersion">Semantic version (e.g., "1.2.0")</param>
|
||||
/// <param name="ToolchainDigest">SHA-256 hash of analyzer binary/container</param>
|
||||
/// <param name="PolicyDigest">SHA-256 hash of policy document</param>
|
||||
/// <param name="ReproSteps">Minimal steps to reproduce this PoE</param>
|
||||
[method: JsonConstructor]
|
||||
public record ProofMetadata(
|
||||
[property: JsonPropertyName("generatedAt")] DateTime GeneratedAt,
|
||||
[property: JsonPropertyName("analyzer")] AnalyzerInfo Analyzer,
|
||||
[property: JsonPropertyName("policy")] PolicyInfo Policy,
|
||||
[property: JsonPropertyName("reproSteps")] string[] ReproSteps
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer information for PoE provenance.
|
||||
/// </summary>
|
||||
[method: JsonConstructor]
|
||||
public record AnalyzerInfo(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("toolchainDigest")] string ToolchainDigest
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Policy information for PoE provenance.
|
||||
/// </summary>
|
||||
[method: JsonConstructor]
|
||||
public record PolicyInfo(
|
||||
[property: JsonPropertyName("policyId")] string PolicyId,
|
||||
[property: JsonPropertyName("policyDigest")] string PolicyDigest,
|
||||
[property: JsonPropertyName("evaluatedAt")] DateTime EvaluatedAt
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Complete Proof of Exposure artifact.
|
||||
/// </summary>
|
||||
/// <param name="Schema">Schema version (e.g., "stellaops.dev/poe@v1")</param>
|
||||
/// <param name="Subgraph">Minimal subgraph with call paths</param>
|
||||
/// <param name="Metadata">Provenance and reproduction metadata</param>
|
||||
/// <param name="GraphHash">Parent richgraph-v1 BLAKE3 hash</param>
|
||||
/// <param name="SbomRef">Optional reference to SBOM artifact</param>
|
||||
/// <param name="VexClaimUri">Optional reference to VEX claim</param>
|
||||
[method: JsonConstructor]
|
||||
public record ProofOfExposure(
|
||||
[property: JsonPropertyName("@type")] string Type,
|
||||
[property: JsonPropertyName("schema")] string Schema,
|
||||
[property: JsonPropertyName("subject")] SubjectInfo Subject,
|
||||
[property: JsonPropertyName("subgraph")] SubgraphData SubgraphData,
|
||||
[property: JsonPropertyName("metadata")] ProofMetadata Metadata,
|
||||
[property: JsonPropertyName("evidence")] EvidenceInfo Evidence
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Subject information identifying what this PoE is about.
|
||||
/// </summary>
|
||||
[method: JsonConstructor]
|
||||
public record SubjectInfo(
|
||||
[property: JsonPropertyName("buildId")] string BuildId,
|
||||
[property: JsonPropertyName("componentRef")] string ComponentRef,
|
||||
[property: JsonPropertyName("vulnId")] string VulnId,
|
||||
[property: JsonPropertyName("imageDigest")] string? ImageDigest = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Subgraph data structure for PoE JSON.
|
||||
/// </summary>
|
||||
[method: JsonConstructor]
|
||||
public record SubgraphData(
|
||||
[property: JsonPropertyName("nodes")] NodeData[] Nodes,
|
||||
[property: JsonPropertyName("edges")] EdgeData[] Edges,
|
||||
[property: JsonPropertyName("entryRefs")] string[] EntryRefs,
|
||||
[property: JsonPropertyName("sinkRefs")] string[] SinkRefs
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Node data for PoE JSON serialization.
|
||||
/// </summary>
|
||||
[method: JsonConstructor]
|
||||
public record NodeData(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("moduleHash")] string ModuleHash,
|
||||
[property: JsonPropertyName("symbol")] string Symbol,
|
||||
[property: JsonPropertyName("addr")] string Addr,
|
||||
[property: JsonPropertyName("file")] string? File = null,
|
||||
[property: JsonPropertyName("line")] int? Line = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Edge data for PoE JSON serialization.
|
||||
/// </summary>
|
||||
[method: JsonConstructor]
|
||||
public record EdgeData(
|
||||
[property: JsonPropertyName("from")] string From,
|
||||
[property: JsonPropertyName("to")] string To,
|
||||
[property: JsonPropertyName("guards")] string[]? Guards = null,
|
||||
[property: JsonPropertyName("confidence")] double Confidence = 1.0
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Evidence links to related artifacts.
|
||||
/// </summary>
|
||||
[method: JsonConstructor]
|
||||
public record EvidenceInfo(
|
||||
[property: JsonPropertyName("graphHash")] string GraphHash,
|
||||
[property: JsonPropertyName("sbomRef")] string? SbomRef = null,
|
||||
[property: JsonPropertyName("vexClaimUri")] string? VexClaimUri = null,
|
||||
[property: JsonPropertyName("runtimeFactsUri")] string? RuntimeFactsUri = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a matched vulnerability for PoE generation.
|
||||
/// </summary>
|
||||
/// <param name="VulnId">Vulnerability identifier (CVE, GHSA, etc.)</param>
|
||||
/// <param name="ComponentRef">Component package URL (PURL)</param>
|
||||
/// <param name="IsReachable">Whether the vulnerability is reachable from entry points</param>
|
||||
/// <param name="Severity">Vulnerability severity (Critical, High, Medium, Low, Info)</param>
|
||||
[method: JsonConstructor]
|
||||
public record VulnerabilityMatch(
|
||||
[property: JsonPropertyName("vulnId")] string VulnId,
|
||||
[property: JsonPropertyName("componentRef")] string ComponentRef,
|
||||
[property: JsonPropertyName("isReachable")] bool IsReachable,
|
||||
[property: JsonPropertyName("severity")] string Severity
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// PoE scan context for PoE generation.
|
||||
/// </summary>
|
||||
/// <param name="ScanId">Unique scan identifier</param>
|
||||
/// <param name="GraphHash">BLAKE3 hash of the reachability graph</param>
|
||||
/// <param name="BuildId">GNU build ID or equivalent</param>
|
||||
/// <param name="ImageDigest">Container image digest</param>
|
||||
/// <param name="PolicyId">Policy identifier</param>
|
||||
/// <param name="PolicyDigest">Policy content digest</param>
|
||||
/// <param name="ScannerVersion">Scanner version</param>
|
||||
/// <param name="ConfigPath">Scanner configuration path</param>
|
||||
[method: JsonConstructor]
|
||||
public record PoEScanContext(
|
||||
[property: JsonPropertyName("scanId")] string ScanId,
|
||||
[property: JsonPropertyName("graphHash")] string GraphHash,
|
||||
[property: JsonPropertyName("buildId")] string BuildId,
|
||||
[property: JsonPropertyName("imageDigest")] string ImageDigest,
|
||||
[property: JsonPropertyName("policyId")] string PolicyId,
|
||||
[property: JsonPropertyName("policyDigest")] string PolicyDigest,
|
||||
[property: JsonPropertyName("scannerVersion")] string ScannerVersion,
|
||||
[property: JsonPropertyName("configPath")] string ConfigPath
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Result from PoE generation for a single vulnerability.
|
||||
/// </summary>
|
||||
/// <param name="VulnId">Vulnerability identifier</param>
|
||||
/// <param name="ComponentRef">Component package URL</param>
|
||||
/// <param name="PoEHash">Content hash of the PoE artifact</param>
|
||||
/// <param name="PoERef">CAS reference to the PoE artifact</param>
|
||||
/// <param name="IsSigned">Whether the PoE is cryptographically signed</param>
|
||||
/// <param name="PathCount">Number of paths in the subgraph</param>
|
||||
[method: JsonConstructor]
|
||||
public record PoEResult(
|
||||
[property: JsonPropertyName("vulnId")] string VulnId,
|
||||
[property: JsonPropertyName("componentRef")] string ComponentRef,
|
||||
[property: JsonPropertyName("poeHash")] string PoEHash,
|
||||
[property: JsonPropertyName("poeRef")] string? PoERef,
|
||||
[property: JsonPropertyName("isSigned")] bool IsSigned,
|
||||
[property: JsonPropertyName("pathCount")] int? PathCount = null
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Provides canonical JSON serialization with deterministic key ordering and stable array sorting.
|
||||
/// Used for PoE artifacts to ensure reproducible hashes.
|
||||
/// </summary>
|
||||
public static class CanonicalJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions _options = CreateOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Serialize object to canonical JSON bytes (UTF-8 encoded).
|
||||
/// </summary>
|
||||
public static byte[] SerializeToBytes<T>(T value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, _options);
|
||||
return Encoding.UTF8.GetBytes(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize object to canonical JSON string.
|
||||
/// </summary>
|
||||
public static string SerializeToString<T>(T value)
|
||||
{
|
||||
return JsonSerializer.Serialize(value, _options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize canonical JSON bytes.
|
||||
/// </summary>
|
||||
public static T? Deserialize<T>(byte[] bytes)
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(bytes, _options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize canonical JSON string.
|
||||
/// </summary>
|
||||
public static T? Deserialize<T>(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json, _options);
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true, // Prettified for readability
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
// Add custom converter for sorted keys
|
||||
options.Converters.Add(new SortedKeysJsonConverter());
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get options for minified (non-prettified) JSON.
|
||||
/// Used when smallest artifact size is required.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions GetMinifiedOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false, // Minified
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
options.Converters.Add(new SortedKeysJsonConverter());
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter that ensures object keys are written in sorted order.
|
||||
/// Critical for deterministic serialization.
|
||||
/// </summary>
|
||||
public class SortedKeysJsonConverter : JsonConverterFactory
|
||||
{
|
||||
public override bool CanConvert(Type typeToConvert)
|
||||
{
|
||||
// Apply to all objects (not primitives or arrays)
|
||||
return !typeToConvert.IsPrimitive &&
|
||||
typeToConvert != typeof(string) &&
|
||||
!typeToConvert.IsArray &&
|
||||
!typeToConvert.IsGenericType;
|
||||
}
|
||||
|
||||
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
// For now, we rely on property ordering in record types
|
||||
// A full implementation would use reflection to sort properties
|
||||
return null; // System.Text.Json respects property order in records by default
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Attestor.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of DSSE (Dead Simple Signing Envelope) signing service.
|
||||
/// Supports ECDSA P-256, Ed25519, and RSA-PSS algorithms.
|
||||
/// </summary>
|
||||
public class DsseSigningService : IDsseSigningService
|
||||
{
|
||||
private readonly IKeyProvider _keyProvider;
|
||||
private readonly ILogger<DsseSigningService> _logger;
|
||||
|
||||
public DsseSigningService(
|
||||
IKeyProvider keyProvider,
|
||||
ILogger<DsseSigningService> logger)
|
||||
{
|
||||
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<byte[]> SignAsync(
|
||||
byte[] payload,
|
||||
string payloadType,
|
||||
string signingKeyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
ArgumentNullException.ThrowIfNull(payloadType);
|
||||
ArgumentNullException.ThrowIfNull(signingKeyId);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Signing payload with DSSE (type: {PayloadType}, key: {KeyId}, size: {Size} bytes)",
|
||||
payloadType, signingKeyId, payload.Length);
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Create DSSE Pre-Authentication Encoding (PAE)
|
||||
var pae = CreatePae(payloadType, payload);
|
||||
|
||||
// Step 2: Sign the PAE
|
||||
var signingKey = await _keyProvider.GetSigningKeyAsync(signingKeyId, cancellationToken);
|
||||
var signature = SignPae(pae, signingKey);
|
||||
|
||||
// Step 3: Build DSSE envelope
|
||||
var envelope = new DsseEnvelope(
|
||||
Payload: Convert.ToBase64String(payload),
|
||||
PayloadType: payloadType,
|
||||
Signatures: new[]
|
||||
{
|
||||
new DsseSignature(
|
||||
KeyId: signingKeyId,
|
||||
Sig: Convert.ToBase64String(signature)
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
// Step 4: Serialize envelope to JSON
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
var envelopeBytes = Encoding.UTF8.GetBytes(envelopeJson);
|
||||
|
||||
_logger.LogInformation(
|
||||
"DSSE envelope created: {Size} bytes (key: {KeyId})",
|
||||
envelopeBytes.Length, signingKeyId);
|
||||
|
||||
return envelopeBytes;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to sign payload with DSSE (key: {KeyId})", signingKeyId);
|
||||
throw new DsseSigningException($"DSSE signing failed for key {signingKeyId}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyAsync(
|
||||
byte[] dsseEnvelope,
|
||||
IReadOnlyList<string> trustedKeyIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dsseEnvelope);
|
||||
ArgumentNullException.ThrowIfNull(trustedKeyIds);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Verifying DSSE envelope ({Size} bytes) against {Count} trusted keys",
|
||||
dsseEnvelope.Length, trustedKeyIds.Count);
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Parse DSSE envelope
|
||||
var envelopeJson = Encoding.UTF8.GetString(dsseEnvelope);
|
||||
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
if (envelope == null)
|
||||
{
|
||||
_logger.LogWarning("Failed to parse DSSE envelope");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Decode payload
|
||||
var payload = Convert.FromBase64String(envelope.Payload);
|
||||
|
||||
// Step 3: Create PAE
|
||||
var pae = CreatePae(envelope.PayloadType, payload);
|
||||
|
||||
// Step 4: Verify at least one signature matches a trusted key
|
||||
foreach (var signature in envelope.Signatures)
|
||||
{
|
||||
if (!trustedKeyIds.Contains(signature.KeyId))
|
||||
{
|
||||
_logger.LogDebug("Skipping untrusted key: {KeyId}", signature.KeyId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var verificationKey = await _keyProvider.GetVerificationKeyAsync(
|
||||
signature.KeyId,
|
||||
cancellationToken);
|
||||
|
||||
var signatureBytes = Convert.FromBase64String(signature.Sig);
|
||||
var isValid = VerifySignature(pae, signatureBytes, verificationKey);
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"DSSE signature verified successfully (key: {KeyId})",
|
||||
signature.KeyId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to verify signature with key {KeyId}",
|
||||
signature.KeyId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("No valid signatures found in DSSE envelope");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DSSE verification failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create DSSE Pre-Authentication Encoding (PAE).
|
||||
/// PAE = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
|
||||
/// </summary>
|
||||
private byte[] CreatePae(string payloadType, byte[] payload)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream);
|
||||
|
||||
// DSSE version prefix
|
||||
var version = Encoding.UTF8.GetBytes("DSSEv1");
|
||||
writer.Write((ulong)version.Length);
|
||||
writer.Write(version);
|
||||
|
||||
// Payload type
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
writer.Write((ulong)typeBytes.Length);
|
||||
writer.Write(typeBytes);
|
||||
|
||||
// Payload body
|
||||
writer.Write((ulong)payload.Length);
|
||||
writer.Write(payload);
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sign PAE with private key.
|
||||
/// </summary>
|
||||
private byte[] SignPae(byte[] pae, SigningKey key)
|
||||
{
|
||||
return key.Algorithm switch
|
||||
{
|
||||
SigningAlgorithm.EcdsaP256 => SignWithEcdsa(pae, key),
|
||||
SigningAlgorithm.EcdsaP384 => SignWithEcdsa(pae, key),
|
||||
SigningAlgorithm.RsaPss => SignWithRsaPss(pae, key),
|
||||
_ => throw new NotSupportedException($"Algorithm {key.Algorithm} not supported")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify signature against PAE.
|
||||
/// </summary>
|
||||
private bool VerifySignature(byte[] pae, byte[] signature, VerificationKey key)
|
||||
{
|
||||
return key.Algorithm switch
|
||||
{
|
||||
SigningAlgorithm.EcdsaP256 => VerifyEcdsa(pae, signature, key),
|
||||
SigningAlgorithm.EcdsaP384 => VerifyEcdsa(pae, signature, key),
|
||||
SigningAlgorithm.RsaPss => VerifyRsaPss(pae, signature, key),
|
||||
_ => throw new NotSupportedException($"Algorithm {key.Algorithm} not supported")
|
||||
};
|
||||
}
|
||||
|
||||
private byte[] SignWithEcdsa(byte[] pae, SigningKey key)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportECPrivateKey(key.PrivateKeyBytes, out _);
|
||||
|
||||
var hashAlgorithm = key.Algorithm == SigningAlgorithm.EcdsaP384
|
||||
? HashAlgorithmName.SHA384
|
||||
: HashAlgorithmName.SHA256;
|
||||
|
||||
return ecdsa.SignData(pae, hashAlgorithm);
|
||||
}
|
||||
|
||||
private bool VerifyEcdsa(byte[] pae, byte[] signature, VerificationKey key)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportSubjectPublicKeyInfo(key.PublicKeyBytes, out _);
|
||||
|
||||
var hashAlgorithm = key.Algorithm == SigningAlgorithm.EcdsaP384
|
||||
? HashAlgorithmName.SHA384
|
||||
: HashAlgorithmName.SHA256;
|
||||
|
||||
return ecdsa.VerifyData(pae, signature, hashAlgorithm);
|
||||
}
|
||||
|
||||
private byte[] SignWithRsaPss(byte[] pae, SigningKey key)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportRSAPrivateKey(key.PrivateKeyBytes, out _);
|
||||
|
||||
return rsa.SignData(
|
||||
pae,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pss);
|
||||
}
|
||||
|
||||
private bool VerifyRsaPss(byte[] pae, byte[] signature, VerificationKey key)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportRSAPublicKey(key.PublicKeyBytes, out _);
|
||||
|
||||
return rsa.VerifyData(
|
||||
pae,
|
||||
signature,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pss);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides cryptographic keys for signing and verification.
|
||||
/// </summary>
|
||||
public interface IKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Get signing key (private key) for DSSE signing.
|
||||
/// </summary>
|
||||
Task<SigningKey> GetSigningKeyAsync(string keyId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get verification key (public key) for DSSE verification.
|
||||
/// </summary>
|
||||
Task<VerificationKey> GetVerificationKeyAsync(string keyId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing key with private key material.
|
||||
/// </summary>
|
||||
public record SigningKey(
|
||||
string KeyId,
|
||||
SigningAlgorithm Algorithm,
|
||||
byte[] PrivateKeyBytes
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Verification key with public key material.
|
||||
/// </summary>
|
||||
public record VerificationKey(
|
||||
string KeyId,
|
||||
SigningAlgorithm Algorithm,
|
||||
byte[] PublicKeyBytes
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Supported signing algorithms.
|
||||
/// </summary>
|
||||
public enum SigningAlgorithm
|
||||
{
|
||||
EcdsaP256,
|
||||
EcdsaP384,
|
||||
RsaPss
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when DSSE signing fails.
|
||||
/// </summary>
|
||||
public class DsseSigningException : Exception
|
||||
{
|
||||
public DsseSigningException(string message) : base(message) { }
|
||||
public DsseSigningException(string message, Exception innerException) : base(message, innerException) { }
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Attestor.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// File-based key provider for development and testing.
|
||||
/// Loads keys from JSON configuration files.
|
||||
/// Production deployments should use HSM or KMS-based providers.
|
||||
/// </summary>
|
||||
public class FileKeyProvider : IKeyProvider
|
||||
{
|
||||
private readonly string _keysDirectory;
|
||||
private readonly ILogger<FileKeyProvider> _logger;
|
||||
private readonly Dictionary<string, SigningKey> _signingKeys = new();
|
||||
private readonly Dictionary<string, VerificationKey> _verificationKeys = new();
|
||||
|
||||
public FileKeyProvider(string keysDirectory, ILogger<FileKeyProvider> logger)
|
||||
{
|
||||
_keysDirectory = keysDirectory ?? throw new ArgumentNullException(nameof(keysDirectory));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (!Directory.Exists(_keysDirectory))
|
||||
{
|
||||
_logger.LogWarning("Keys directory does not exist: {Directory}", _keysDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<SigningKey> GetSigningKeyAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_signingKeys.TryGetValue(keyId, out var cachedKey))
|
||||
{
|
||||
return Task.FromResult(cachedKey);
|
||||
}
|
||||
|
||||
var keyPath = Path.Combine(_keysDirectory, $"{keyId}.key.json");
|
||||
if (!File.Exists(keyPath))
|
||||
{
|
||||
throw new KeyNotFoundException($"Signing key not found: {keyId} (path: {keyPath})");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Loading signing key from {Path}", keyPath);
|
||||
|
||||
var keyJson = File.ReadAllText(keyPath);
|
||||
var keyConfig = JsonSerializer.Deserialize<KeyConfiguration>(keyJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
if (keyConfig == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse key configuration: {keyPath}");
|
||||
}
|
||||
|
||||
var algorithm = ParseAlgorithm(keyConfig.Algorithm);
|
||||
byte[] privateKeyBytes;
|
||||
|
||||
if (keyConfig.PrivateKeyPem != null)
|
||||
{
|
||||
privateKeyBytes = ParsePemPrivateKey(keyConfig.PrivateKeyPem, algorithm);
|
||||
}
|
||||
else if (keyConfig.PrivateKeyBase64 != null)
|
||||
{
|
||||
privateKeyBytes = Convert.FromBase64String(keyConfig.PrivateKeyBase64);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"No private key material found in {keyPath}");
|
||||
}
|
||||
|
||||
var signingKey = new SigningKey(keyId, algorithm, privateKeyBytes);
|
||||
_signingKeys[keyId] = signingKey;
|
||||
|
||||
_logger.LogInformation("Loaded signing key: {KeyId} ({Algorithm})", keyId, algorithm);
|
||||
|
||||
return Task.FromResult(signingKey);
|
||||
}
|
||||
|
||||
public Task<VerificationKey> GetVerificationKeyAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_verificationKeys.TryGetValue(keyId, out var cachedKey))
|
||||
{
|
||||
return Task.FromResult(cachedKey);
|
||||
}
|
||||
|
||||
var keyPath = Path.Combine(_keysDirectory, $"{keyId}.pub.json");
|
||||
if (!File.Exists(keyPath))
|
||||
{
|
||||
throw new KeyNotFoundException($"Verification key not found: {keyId} (path: {keyPath})");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Loading verification key from {Path}", keyPath);
|
||||
|
||||
var keyJson = File.ReadAllText(keyPath);
|
||||
var keyConfig = JsonSerializer.Deserialize<KeyConfiguration>(keyJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
if (keyConfig == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse key configuration: {keyPath}");
|
||||
}
|
||||
|
||||
var algorithm = ParseAlgorithm(keyConfig.Algorithm);
|
||||
byte[] publicKeyBytes;
|
||||
|
||||
if (keyConfig.PublicKeyPem != null)
|
||||
{
|
||||
publicKeyBytes = ParsePemPublicKey(keyConfig.PublicKeyPem, algorithm);
|
||||
}
|
||||
else if (keyConfig.PublicKeyBase64 != null)
|
||||
{
|
||||
publicKeyBytes = Convert.FromBase64String(keyConfig.PublicKeyBase64);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"No public key material found in {keyPath}");
|
||||
}
|
||||
|
||||
var verificationKey = new VerificationKey(keyId, algorithm, publicKeyBytes);
|
||||
_verificationKeys[keyId] = verificationKey;
|
||||
|
||||
_logger.LogInformation("Loaded verification key: {KeyId} ({Algorithm})", keyId, algorithm);
|
||||
|
||||
return Task.FromResult(verificationKey);
|
||||
}
|
||||
|
||||
private SigningAlgorithm ParseAlgorithm(string algorithm)
|
||||
{
|
||||
return algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
"ECDSA-P256" or "ES256" => SigningAlgorithm.EcdsaP256,
|
||||
"ECDSA-P384" or "ES384" => SigningAlgorithm.EcdsaP384,
|
||||
"RSA-PSS" or "PS256" => SigningAlgorithm.RsaPss,
|
||||
_ => throw new NotSupportedException($"Unsupported algorithm: {algorithm}")
|
||||
};
|
||||
}
|
||||
|
||||
private byte[] ParsePemPrivateKey(string pem, SigningAlgorithm algorithm)
|
||||
{
|
||||
var pemContent = pem
|
||||
.Replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.Replace("-----END PRIVATE KEY-----", "")
|
||||
.Replace("-----BEGIN EC PRIVATE KEY-----", "")
|
||||
.Replace("-----END EC PRIVATE KEY-----", "")
|
||||
.Replace("-----BEGIN RSA PRIVATE KEY-----", "")
|
||||
.Replace("-----END RSA PRIVATE KEY-----", "")
|
||||
.Replace("\n", "")
|
||||
.Replace("\r", "")
|
||||
.Trim();
|
||||
|
||||
return Convert.FromBase64String(pemContent);
|
||||
}
|
||||
|
||||
private byte[] ParsePemPublicKey(string pem, SigningAlgorithm algorithm)
|
||||
{
|
||||
var pemContent = pem
|
||||
.Replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.Replace("-----END PUBLIC KEY-----", "")
|
||||
.Replace("\n", "")
|
||||
.Replace("\r", "")
|
||||
.Trim();
|
||||
|
||||
return Convert.FromBase64String(pemContent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key configuration loaded from JSON file.
|
||||
/// </summary>
|
||||
internal record KeyConfiguration(
|
||||
string KeyId,
|
||||
string Algorithm,
|
||||
string? PrivateKeyPem = null,
|
||||
string? PrivateKeyBase64 = null,
|
||||
string? PublicKeyPem = null,
|
||||
string? PublicKeyBase64 = null
|
||||
);
|
||||
@@ -104,33 +104,21 @@ public class VerdictController : ControllerBase
|
||||
// Create submission context
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
TenantId = "default", // TODO: Extract from auth context
|
||||
UserId = "system",
|
||||
SubmitToRekor = request.SubmitToRekor
|
||||
CallerSubject = "system",
|
||||
CallerAudience = "policy-engine",
|
||||
CallerClientId = "policy-engine-verdict-attestor",
|
||||
CallerTenant = "default" // TODO: Extract from auth context
|
||||
};
|
||||
|
||||
// Sign the predicate
|
||||
var signResult = await _signingService.SignAsync(signingRequest, context, ct);
|
||||
|
||||
if (!signResult.Success)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Failed to sign verdict attestation: {Error}",
|
||||
signResult.ErrorMessage);
|
||||
// Extract DSSE envelope from result
|
||||
var envelope = signResult.Bundle.Dsse;
|
||||
var envelopeJson = SerializeEnvelope(envelope, signResult.KeyId);
|
||||
|
||||
return StatusCode(
|
||||
StatusCodes.Status500InternalServerError,
|
||||
new ProblemDetails
|
||||
{
|
||||
Title = "Signing Failed",
|
||||
Detail = signResult.ErrorMessage,
|
||||
Status = StatusCodes.Status500InternalServerError
|
||||
});
|
||||
}
|
||||
|
||||
// Extract envelope and Rekor info
|
||||
var envelopeJson = SerializeEnvelope(signResult);
|
||||
var rekorLogIndex = signResult.RekorLogIndex;
|
||||
// Rekor log index (not implemented in minimal handler)
|
||||
long? rekorLogIndex = null;
|
||||
|
||||
// Store in Evidence Locker (via HTTP call)
|
||||
await StoreVerdictInEvidenceLockerAsync(
|
||||
@@ -189,26 +177,25 @@ public class VerdictController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes DSSE envelope from signing result.
|
||||
/// Serializes DSSE envelope to JSON.
|
||||
/// </summary>
|
||||
private static string SerializeEnvelope(AttestationSignResult signResult)
|
||||
private static string SerializeEnvelope(
|
||||
StellaOps.Attestor.Core.Submission.AttestorSubmissionRequest.DsseEnvelope envelope,
|
||||
string keyId)
|
||||
{
|
||||
// Simple DSSE envelope structure
|
||||
var envelope = new
|
||||
// DSSE envelope structure (already populated by signing service)
|
||||
var envelopeObj = new
|
||||
{
|
||||
payloadType = signResult.PayloadType,
|
||||
payload = signResult.PayloadBase64,
|
||||
signatures = new[]
|
||||
payloadType = envelope.PayloadType,
|
||||
payload = envelope.PayloadBase64,
|
||||
signatures = envelope.Signatures.Select(s => new
|
||||
{
|
||||
new
|
||||
{
|
||||
keyid = signResult.KeyId,
|
||||
sig = signResult.SignatureBase64
|
||||
}
|
||||
}
|
||||
keyid = keyId,
|
||||
sig = s.Signature
|
||||
}).ToArray()
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(envelope, new JsonSerializerOptions
|
||||
return JsonSerializer.Serialize(envelopeObj, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
@@ -225,28 +212,63 @@ public class VerdictController : ControllerBase
|
||||
AttestationSignResult signResult,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Skip storage if HttpClientFactory not configured
|
||||
if (_httpClientFactory is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"HttpClientFactory not configured - skipping Evidence Locker storage for {VerdictId}",
|
||||
verdictId);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// NOTE: This is a placeholder implementation.
|
||||
// In production, this would:
|
||||
// 1. Call Evidence Locker API via HttpClient
|
||||
// 2. Or inject IVerdictRepository directly
|
||||
// For now, we log and skip storage (attestation is returned to caller)
|
||||
|
||||
_logger.LogInformation(
|
||||
"Verdict attestation {VerdictId} ready for storage (Evidence Locker integration pending)",
|
||||
"Storing verdict attestation {VerdictId} in Evidence Locker",
|
||||
verdictId);
|
||||
|
||||
// TODO: Implement Evidence Locker storage
|
||||
// Example:
|
||||
// if (_httpClientFactory != null)
|
||||
// {
|
||||
// var client = _httpClientFactory.CreateClient("EvidenceLocker");
|
||||
// var storeRequest = new { verdictId, findingId, envelope = envelopeJson };
|
||||
// await client.PostAsJsonAsync("/api/v1/verdicts", storeRequest, ct);
|
||||
// }
|
||||
var client = _httpClientFactory.CreateClient("EvidenceLocker");
|
||||
|
||||
await Task.CompletedTask;
|
||||
// Parse envelope to get predicate for digest calculation
|
||||
var envelope = JsonSerializer.Deserialize<JsonElement>(envelopeJson);
|
||||
var payloadBase64 = envelope.GetProperty("payload").GetString() ?? string.Empty;
|
||||
var predicateBytes = Convert.FromBase64String(payloadBase64);
|
||||
var predicateDigest = $"sha256:{Convert.ToHexString(SHA256.HashData(predicateBytes)).ToLowerInvariant()}";
|
||||
|
||||
// Create Evidence Locker storage request
|
||||
var storeRequest = new
|
||||
{
|
||||
verdict_id = verdictId,
|
||||
tenant_id = "default", // TODO: Extract from auth context
|
||||
policy_run_id = "unknown", // TODO: Pass from caller
|
||||
policy_id = "unknown", // TODO: Pass from caller
|
||||
policy_version = 1, // TODO: Pass from caller
|
||||
finding_id = findingId,
|
||||
verdict_status = "unknown", // TODO: Extract from predicate
|
||||
verdict_severity = "unknown", // TODO: Extract from predicate
|
||||
verdict_score = 0.0m, // TODO: Extract from predicate
|
||||
evaluated_at = DateTimeOffset.UtcNow,
|
||||
envelope = JsonSerializer.Deserialize<object>(envelopeJson),
|
||||
predicate_digest = predicateDigest,
|
||||
determinism_hash = (string?)null, // TODO: Pass from predicate
|
||||
rekor_log_index = (long?)null // Not implemented yet
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/verdicts", storeRequest, ct);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Successfully stored verdict {VerdictId} in Evidence Locker",
|
||||
verdictId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to store verdict {VerdictId} in Evidence Locker: {StatusCode}",
|
||||
verdictId,
|
||||
response.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -158,6 +158,18 @@ builder.Services.AddScoped<StellaOps.Attestor.WebService.Services.IPredicateType
|
||||
StellaOps.Attestor.WebService.Services.PredicateTypeRouter>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
// Configure HttpClient for Evidence Locker integration
|
||||
builder.Services.AddHttpClient("EvidenceLocker", client =>
|
||||
{
|
||||
// TODO: Configure base address from configuration
|
||||
// For now, use localhost default (will be overridden by actual configuration)
|
||||
var evidenceLockerUrl = builder.Configuration.GetValue<string>("EvidenceLockerUrl")
|
||||
?? "http://localhost:9090";
|
||||
client.BaseAddress = new Uri(evidenceLockerUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user