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:
master
2025-12-23 14:06:48 +02:00
parent ef933db0d8
commit 84d97fd22c
51 changed files with 4353 additions and 747 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) { }
}

View File

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

View File

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

View File

@@ -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());