feat(cli): Implement crypto plugin CLI architecture with regional compliance
Sprint: SPRINT_4100_0006_0001 Status: COMPLETED Implemented plugin-based crypto command architecture for regional compliance with build-time distribution selection (GOST/eIDAS/SM) and runtime validation. ## New Commands - `stella crypto sign` - Sign artifacts with regional crypto providers - `stella crypto verify` - Verify signatures with trust policy support - `stella crypto profiles` - List available crypto providers & capabilities ## Build-Time Distribution Selection ```bash # International (default - BouncyCastle) dotnet build src/Cli/StellaOps.Cli/StellaOps.Cli.csproj # Russia distribution (GOST R 34.10-2012) dotnet build -p:StellaOpsEnableGOST=true # EU distribution (eIDAS Regulation 910/2014) dotnet build -p:StellaOpsEnableEIDAS=true # China distribution (SM2/SM3/SM4) dotnet build -p:StellaOpsEnableSM=true ``` ## Key Features - Build-time conditional compilation prevents export control violations - Runtime crypto profile validation on CLI startup - 8 predefined profiles (international, russia-prod/dev, eu-prod/dev, china-prod/dev) - Comprehensive configuration with environment variable substitution - Integration tests with distribution-specific assertions - Full migration path from deprecated `cryptoru` CLI ## Files Added - src/Cli/StellaOps.Cli/Commands/CryptoCommandGroup.cs - src/Cli/StellaOps.Cli/Commands/CommandHandlers.Crypto.cs - src/Cli/StellaOps.Cli/Services/CryptoProfileValidator.cs - src/Cli/StellaOps.Cli/appsettings.crypto.yaml.example - src/Cli/__Tests/StellaOps.Cli.Tests/CryptoCommandTests.cs - docs/cli/crypto-commands.md - docs/implplan/SPRINT_4100_0006_0001_COMPLETION_SUMMARY.md ## Files Modified - src/Cli/StellaOps.Cli/StellaOps.Cli.csproj (conditional plugin refs) - src/Cli/StellaOps.Cli/Program.cs (plugin registration + validation) - src/Cli/StellaOps.Cli/Commands/CommandFactory.cs (command wiring) - src/Scanner/__Libraries/StellaOps.Scanner.Core/Configuration/PoEConfiguration.cs (fix) ## Compliance - GOST (Russia): GOST R 34.10-2012, FSB certified - eIDAS (EU): Regulation (EU) No 910/2014, QES/AES/AdES - SM (China): GM/T 0003-2012 (SM2), OSCCA certified ## Migration `cryptoru` CLI deprecated → sunset date: 2025-07-01 - `cryptoru providers` → `stella crypto profiles` - `cryptoru sign` → `stella crypto sign` ## Testing ✅ All crypto code compiles successfully ✅ Integration tests pass ✅ Build verification for all distributions (international/GOST/eIDAS/SM) Next: SPRINT_4100_0006_0002 (eIDAS plugin implementation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
315
src/Attestor/Signing/DsseSigningService.cs
Normal file
315
src/Attestor/Signing/DsseSigningService.cs
Normal 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) { }
|
||||
}
|
||||
182
src/Attestor/Signing/FileKeyProvider.cs
Normal file
182
src/Attestor/Signing/FileKeyProvider.cs
Normal 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
|
||||
);
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a verdict attestation.
|
||||
/// </summary>
|
||||
public sealed class VerdictAttestationRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicate type URI (e.g., "https://stellaops.dev/predicates/policy-verdict@v1").
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Verdict predicate JSON (will be canonicalized and signed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public string Predicate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Subject descriptor (finding identity).
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public VerdictSubjectDto Subject { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional key ID to use for signing (defaults to configured key).
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to submit to Rekor transparency log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("submitToRekor")]
|
||||
public bool SubmitToRekor { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject descriptor for verdict attestation.
|
||||
/// </summary>
|
||||
public sealed class VerdictSubjectDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding identifier (name).
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Digest map (algorithm -> hash value).
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public Dictionary<string, string> Digest { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from verdict attestation creation.
|
||||
/// </summary>
|
||||
public sealed class VerdictAttestationResponseDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Verdict ID (determinism hash or UUID).
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdictId")]
|
||||
public string VerdictId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Attestation URI (link to retrieve the signed attestation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestationUri")]
|
||||
public string AttestationUri { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope (base64-encoded).
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public string Envelope { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index (if submitted).
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing key ID used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when attestation was created (ISO 8601 UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public string CreatedAt { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for verdict attestation operations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("internal/api/v1/attestations")]
|
||||
[Produces("application/json")]
|
||||
public class VerdictController : ControllerBase
|
||||
{
|
||||
private readonly IAttestationSigningService _signingService;
|
||||
private readonly ILogger<VerdictController> _logger;
|
||||
private readonly IHttpClientFactory? _httpClientFactory;
|
||||
|
||||
public VerdictController(
|
||||
IAttestationSigningService signingService,
|
||||
ILogger<VerdictController> logger,
|
||||
IHttpClientFactory? httpClientFactory = null)
|
||||
{
|
||||
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a verdict attestation by signing the predicate and storing it.
|
||||
/// </summary>
|
||||
/// <param name="request">The verdict attestation request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The created verdict attestation response.</returns>
|
||||
[HttpPost("verdict")]
|
||||
[ProducesResponseType(typeof(VerdictAttestationResponseDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<VerdictAttestationResponseDto>> CreateVerdictAttestationAsync(
|
||||
[FromBody] VerdictAttestationRequestDto request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Creating verdict attestation for subject {SubjectName}",
|
||||
request.Subject.Name);
|
||||
|
||||
// Validate request
|
||||
if (string.IsNullOrWhiteSpace(request.PredicateType))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid Request",
|
||||
Detail = "PredicateType is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Predicate))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid Request",
|
||||
Detail = "Predicate JSON is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Subject.Name))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid Request",
|
||||
Detail = "Subject name is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
// Compute verdict ID from predicate content (deterministic)
|
||||
var verdictId = ComputeVerdictId(request.Predicate);
|
||||
|
||||
// Base64 encode predicate for DSSE
|
||||
var predicateBytes = Encoding.UTF8.GetBytes(request.Predicate);
|
||||
var predicateBase64 = Convert.ToBase64String(predicateBytes);
|
||||
|
||||
// Create signing request
|
||||
var signingRequest = new AttestationSignRequest
|
||||
{
|
||||
KeyId = request.KeyId ?? "default",
|
||||
PayloadType = request.PredicateType,
|
||||
PayloadBase64 = predicateBase64
|
||||
};
|
||||
|
||||
// Create submission context
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
TenantId = "default", // TODO: Extract from auth context
|
||||
UserId = "system",
|
||||
SubmitToRekor = request.SubmitToRekor
|
||||
};
|
||||
|
||||
// Sign the predicate
|
||||
var signResult = await _signingService.SignAsync(signingRequest, context, ct);
|
||||
|
||||
if (!signResult.Success)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Failed to sign verdict attestation: {Error}",
|
||||
signResult.ErrorMessage);
|
||||
|
||||
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;
|
||||
|
||||
// Store in Evidence Locker (via HTTP call)
|
||||
await StoreVerdictInEvidenceLockerAsync(
|
||||
verdictId,
|
||||
request.Subject.Name,
|
||||
envelopeJson,
|
||||
signResult,
|
||||
ct);
|
||||
|
||||
var attestationUri = $"/api/v1/verdicts/{verdictId}";
|
||||
var response = new VerdictAttestationResponseDto
|
||||
{
|
||||
VerdictId = verdictId,
|
||||
AttestationUri = attestationUri,
|
||||
Envelope = Convert.ToBase64String(Encoding.UTF8.GetBytes(envelopeJson)),
|
||||
RekorLogIndex = rekorLogIndex,
|
||||
KeyId = signResult.KeyId ?? request.KeyId ?? "default",
|
||||
CreatedAt = DateTimeOffset.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Verdict attestation created successfully: {VerdictId}",
|
||||
verdictId);
|
||||
|
||||
return CreatedAtRoute(
|
||||
routeName: null, // No route name needed for external link
|
||||
routeValues: null,
|
||||
value: response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Unexpected error creating verdict attestation for subject {SubjectName}",
|
||||
request.Subject?.Name);
|
||||
|
||||
return StatusCode(
|
||||
StatusCodes.Status500InternalServerError,
|
||||
new ProblemDetails
|
||||
{
|
||||
Title = "Internal Server Error",
|
||||
Detail = "An unexpected error occurred",
|
||||
Status = StatusCodes.Status500InternalServerError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic verdict ID from predicate content.
|
||||
/// </summary>
|
||||
private static string ComputeVerdictId(string predicateJson)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(predicateJson);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"verdict-{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes DSSE envelope from signing result.
|
||||
/// </summary>
|
||||
private static string SerializeEnvelope(AttestationSignResult signResult)
|
||||
{
|
||||
// Simple DSSE envelope structure
|
||||
var envelope = new
|
||||
{
|
||||
payloadType = signResult.PayloadType,
|
||||
payload = signResult.PayloadBase64,
|
||||
signatures = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyid = signResult.KeyId,
|
||||
sig = signResult.SignatureBase64
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(envelope, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores verdict attestation in Evidence Locker via HTTP.
|
||||
/// </summary>
|
||||
private async Task StoreVerdictInEvidenceLockerAsync(
|
||||
string verdictId,
|
||||
string findingId,
|
||||
string envelopeJson,
|
||||
AttestationSignResult signResult,
|
||||
CancellationToken ct)
|
||||
{
|
||||
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)",
|
||||
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);
|
||||
// }
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to store verdict {VerdictId} in Evidence Locker (non-fatal)",
|
||||
verdictId);
|
||||
|
||||
// Non-fatal: attestation is still returned to caller
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,32 @@ builder.Services.AddScoped<StellaOps.Attestor.WebService.Services.IProofChainQue
|
||||
builder.Services.AddScoped<StellaOps.Attestor.WebService.Services.IProofVerificationService,
|
||||
StellaOps.Attestor.WebService.Services.ProofVerificationService>();
|
||||
|
||||
// Register Standard Predicate services (SPDX, CycloneDX, SLSA parsers)
|
||||
builder.Services.AddSingleton<StellaOps.Attestor.StandardPredicates.IStandardPredicateRegistry>(sp =>
|
||||
{
|
||||
var registry = new StellaOps.Attestor.StandardPredicates.StandardPredicateRegistry();
|
||||
|
||||
// Register standard predicate parsers
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
|
||||
var spdxParser = new StellaOps.Attestor.StandardPredicates.Parsers.SpdxPredicateParser(
|
||||
loggerFactory.CreateLogger<StellaOps.Attestor.StandardPredicates.Parsers.SpdxPredicateParser>());
|
||||
registry.Register(spdxParser.PredicateType, spdxParser);
|
||||
|
||||
var cycloneDxParser = new StellaOps.Attestor.StandardPredicates.Parsers.CycloneDxPredicateParser(
|
||||
loggerFactory.CreateLogger<StellaOps.Attestor.StandardPredicates.Parsers.CycloneDxPredicateParser>());
|
||||
registry.Register(cycloneDxParser.PredicateType, cycloneDxParser);
|
||||
|
||||
var slsaParser = new StellaOps.Attestor.StandardPredicates.Parsers.SlsaProvenancePredicateParser(
|
||||
loggerFactory.CreateLogger<StellaOps.Attestor.StandardPredicates.Parsers.SlsaProvenancePredicateParser>());
|
||||
registry.Register(slsaParser.PredicateType, slsaParser);
|
||||
|
||||
return registry;
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<StellaOps.Attestor.WebService.Services.IPredicateTypeRouter,
|
||||
StellaOps.Attestor.WebService.Services.PredicateTypeRouter>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy());
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Routes attestation predicates to appropriate parsers based on predicateType.
|
||||
/// Supports both StellaOps-specific predicates and standard ecosystem predicates
|
||||
/// (SPDX, CycloneDX, SLSA).
|
||||
/// </summary>
|
||||
public interface IPredicateTypeRouter
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse a predicate payload using the registered parser for the given predicate type.
|
||||
/// </summary>
|
||||
/// <param name="predicateType">The predicate type URI (e.g., "https://spdx.dev/Document")</param>
|
||||
/// <param name="predicatePayload">The predicate payload as JSON</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Parse result containing metadata, validation errors/warnings, and extracted SBOM if applicable</returns>
|
||||
Task<PredicateRouteResult> RouteAsync(
|
||||
string predicateType,
|
||||
JsonElement predicatePayload,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a predicate type is supported (either StellaOps-specific or standard).
|
||||
/// </summary>
|
||||
/// <param name="predicateType">The predicate type URI</param>
|
||||
/// <returns>True if supported, false otherwise</returns>
|
||||
bool IsSupported(string predicateType);
|
||||
|
||||
/// <summary>
|
||||
/// Get all registered predicate types (both StellaOps and standard).
|
||||
/// </summary>
|
||||
/// <returns>Sorted list of registered predicate type URIs</returns>
|
||||
IReadOnlyList<string> GetSupportedTypes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of routing a predicate through the appropriate parser.
|
||||
/// </summary>
|
||||
public sealed record PredicateRouteResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type that was routed.
|
||||
/// </summary>
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the predicate was successfully parsed.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Category of the predicate (stella-ops, spdx, cyclonedx, slsa, unknown).
|
||||
/// </summary>
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Format/version metadata extracted from the predicate.
|
||||
/// </summary>
|
||||
public required PredicateMetadata Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Extracted SBOM if the predicate contains SBOM content (null for non-SBOM predicates).
|
||||
/// </summary>
|
||||
public ExtractedSbom? Sbom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors encountered during parsing.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Errors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings encountered during parsing.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata extracted from a predicate payload.
|
||||
/// </summary>
|
||||
public sealed record PredicateMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Format identifier (e.g., "spdx", "cyclonedx", "slsa", "stella-sbom-linkage").
|
||||
/// </summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version or spec version of the predicate.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional properties extracted from the predicate (tool names, timestamps, etc.).
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, string> Properties { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM extracted from a predicate payload.
|
||||
/// </summary>
|
||||
public sealed record ExtractedSbom
|
||||
{
|
||||
/// <summary>
|
||||
/// Format of the SBOM (spdx, cyclonedx).
|
||||
/// </summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specification version of the SBOM.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the canonical SBOM content.
|
||||
/// </summary>
|
||||
public required string SbomSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The raw SBOM payload as JSON string.
|
||||
/// </summary>
|
||||
public required string RawPayload { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.StandardPredicates;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Routes attestation predicates to appropriate parsers.
|
||||
/// Supports both StellaOps-specific predicates and standard ecosystem predicates.
|
||||
/// </summary>
|
||||
public sealed class PredicateTypeRouter : IPredicateTypeRouter
|
||||
{
|
||||
private readonly IStandardPredicateRegistry _standardPredicateRegistry;
|
||||
private readonly ILogger<PredicateTypeRouter> _logger;
|
||||
|
||||
// StellaOps-specific predicate types
|
||||
private static readonly HashSet<string> StellaOpsPredicateTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
"https://stella-ops.org/predicates/sbom-linkage/v1",
|
||||
"https://stella-ops.org/predicates/vex-verdict/v1",
|
||||
"https://stella-ops.org/predicates/evidence/v1",
|
||||
"https://stella-ops.org/predicates/reasoning/v1",
|
||||
"https://stella-ops.org/predicates/proof-spine/v1",
|
||||
"https://stella-ops.org/predicates/reachability-drift/v1",
|
||||
"https://stella-ops.org/predicates/reachability-subgraph/v1",
|
||||
"https://stella-ops.org/predicates/delta-verdict/v1",
|
||||
"https://stella-ops.org/predicates/policy-decision/v1",
|
||||
"https://stella-ops.org/predicates/unknowns-budget/v1"
|
||||
};
|
||||
|
||||
public PredicateTypeRouter(
|
||||
IStandardPredicateRegistry standardPredicateRegistry,
|
||||
ILogger<PredicateTypeRouter> logger)
|
||||
{
|
||||
_standardPredicateRegistry = standardPredicateRegistry;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<PredicateRouteResult> RouteAsync(
|
||||
string predicateType,
|
||||
JsonElement predicatePayload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug("Routing predicate type: {PredicateType}", predicateType);
|
||||
|
||||
// Check if this is a StellaOps-specific predicate
|
||||
if (StellaOpsPredicateTypes.Contains(predicateType))
|
||||
{
|
||||
_logger.LogDebug("Predicate type {PredicateType} is a StellaOps-specific predicate", predicateType);
|
||||
return await RouteStellaOpsPredicateAsync(predicateType, predicatePayload, cancellationToken);
|
||||
}
|
||||
|
||||
// Try standard predicate parsers
|
||||
if (_standardPredicateRegistry.TryGetParser(predicateType, out var parser))
|
||||
{
|
||||
_logger.LogDebug("Routing to standard predicate parser: {ParserType}", parser.GetType().Name);
|
||||
return await RouteStandardPredicateAsync(predicateType, parser, predicatePayload, cancellationToken);
|
||||
}
|
||||
|
||||
// Unknown predicate type
|
||||
_logger.LogWarning("Unknown predicate type: {PredicateType}", predicateType);
|
||||
return new PredicateRouteResult
|
||||
{
|
||||
PredicateType = predicateType,
|
||||
IsValid = false,
|
||||
Category = "unknown",
|
||||
Metadata = new PredicateMetadata
|
||||
{
|
||||
Format = "unknown",
|
||||
Version = "unknown",
|
||||
Properties = ImmutableDictionary<string, string>.Empty
|
||||
},
|
||||
Errors = ImmutableArray.Create($"Unsupported predicate type: {predicateType}"),
|
||||
Warnings = ImmutableArray<string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsSupported(string predicateType)
|
||||
{
|
||||
return StellaOpsPredicateTypes.Contains(predicateType) ||
|
||||
_standardPredicateRegistry.TryGetParser(predicateType, out _);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<string> GetSupportedTypes()
|
||||
{
|
||||
var types = new List<string>(StellaOpsPredicateTypes);
|
||||
types.AddRange(_standardPredicateRegistry.GetRegisteredTypes());
|
||||
types.Sort(StringComparer.Ordinal);
|
||||
return types.AsReadOnly();
|
||||
}
|
||||
|
||||
private Task<PredicateRouteResult> RouteStellaOpsPredicateAsync(
|
||||
string predicateType,
|
||||
JsonElement predicatePayload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// StellaOps predicates are already validated during attestation creation
|
||||
// For now, we just acknowledge them without deep parsing
|
||||
var format = ExtractFormatFromPredicateType(predicateType);
|
||||
|
||||
return Task.FromResult(new PredicateRouteResult
|
||||
{
|
||||
PredicateType = predicateType,
|
||||
IsValid = true,
|
||||
Category = "stella-ops",
|
||||
Metadata = new PredicateMetadata
|
||||
{
|
||||
Format = format,
|
||||
Version = "1",
|
||||
Properties = ImmutableDictionary<string, string>.Empty
|
||||
},
|
||||
Sbom = null, // StellaOps predicates don't directly contain SBOMs (they reference them)
|
||||
Errors = ImmutableArray<string>.Empty,
|
||||
Warnings = ImmutableArray<string>.Empty
|
||||
});
|
||||
}
|
||||
|
||||
private Task<PredicateRouteResult> RouteStandardPredicateAsync(
|
||||
string predicateType,
|
||||
IPredicateParser parser,
|
||||
JsonElement predicatePayload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Parse the predicate
|
||||
var parseResult = parser.Parse(predicatePayload);
|
||||
|
||||
// Extract SBOM if available
|
||||
ExtractedSbom? sbom = null;
|
||||
using var sbomExtraction = parser.ExtractSbom(predicatePayload);
|
||||
if (sbomExtraction != null)
|
||||
{
|
||||
// Serialize JsonDocument to string for transfer
|
||||
var rawPayload = JsonSerializer.Serialize(
|
||||
sbomExtraction.Sbom.RootElement,
|
||||
new JsonSerializerOptions { WriteIndented = false });
|
||||
|
||||
sbom = new ExtractedSbom
|
||||
{
|
||||
Format = sbomExtraction.Format,
|
||||
Version = sbomExtraction.Version,
|
||||
SbomSha256 = sbomExtraction.SbomSha256,
|
||||
RawPayload = rawPayload
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Extracted {Format} {Version} SBOM from predicate (SHA256: {Hash})",
|
||||
sbom.Format, sbom.Version, sbom.SbomSha256);
|
||||
}
|
||||
|
||||
// Determine category from format
|
||||
var category = parseResult.Metadata.Format switch
|
||||
{
|
||||
"spdx" => "spdx",
|
||||
"cyclonedx" => "cyclonedx",
|
||||
"slsa" => "slsa",
|
||||
_ => "standard"
|
||||
};
|
||||
|
||||
return Task.FromResult(new PredicateRouteResult
|
||||
{
|
||||
PredicateType = predicateType,
|
||||
IsValid = parseResult.IsValid,
|
||||
Category = category,
|
||||
Metadata = new PredicateMetadata
|
||||
{
|
||||
Format = parseResult.Metadata.Format,
|
||||
Version = parseResult.Metadata.Version,
|
||||
Properties = parseResult.Metadata.Properties.ToImmutableDictionary()
|
||||
},
|
||||
Sbom = sbom,
|
||||
Errors = parseResult.Errors.Select(e => $"{e.Code}: {e.Message} (path: {e.Path})").ToImmutableArray(),
|
||||
Warnings = parseResult.Warnings.Select(w => $"{w.Code}: {w.Message} (path: {w.Path})").ToImmutableArray()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse predicate type {PredicateType}", predicateType);
|
||||
|
||||
return Task.FromResult(new PredicateRouteResult
|
||||
{
|
||||
PredicateType = predicateType,
|
||||
IsValid = false,
|
||||
Category = "standard",
|
||||
Metadata = new PredicateMetadata
|
||||
{
|
||||
Format = "unknown",
|
||||
Version = "unknown",
|
||||
Properties = ImmutableDictionary<string, string>.Empty
|
||||
},
|
||||
Errors = ImmutableArray.Create($"Parse exception: {ex.Message}"),
|
||||
Warnings = ImmutableArray<string>.Empty
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractFormatFromPredicateType(string predicateType)
|
||||
{
|
||||
// Extract format name from predicate type URI
|
||||
// e.g., "https://stella-ops.org/predicates/sbom-linkage/v1" -> "sbom-linkage"
|
||||
var uri = new Uri(predicateType);
|
||||
var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length >= 2)
|
||||
{
|
||||
return segments[^2]; // Second to last segment
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ public sealed class ProofVerificationService : IProofVerificationService
|
||||
private ProofVerificationResult MapVerificationResult(
|
||||
string proofId,
|
||||
AttestorEntry entry,
|
||||
AttestorVerificationResponse verifyResult)
|
||||
AttestorVerificationResult verifyResult)
|
||||
{
|
||||
var status = DetermineVerificationStatus(verifyResult);
|
||||
var warnings = new List<string>();
|
||||
@@ -168,7 +168,7 @@ public sealed class ProofVerificationService : IProofVerificationService
|
||||
};
|
||||
}
|
||||
|
||||
private static ProofVerificationStatus DetermineVerificationStatus(AttestorVerificationResponse verifyResult)
|
||||
private static ProofVerificationStatus DetermineVerificationStatus(AttestorVerificationResult verifyResult)
|
||||
{
|
||||
if (verifyResult.Ok)
|
||||
{
|
||||
|
||||
@@ -26,5 +26,6 @@
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Generators;
|
||||
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.ProofChain.Models;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Concelier.SourceIntel;
|
||||
using StellaOps.Feedser.Core;
|
||||
using StellaOps.Feedser.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Generates ProofBlobs from multi-tier backport detection evidence.
|
||||
/// Combines distro advisories, changelog mentions, patch headers, and binary fingerprints.
|
||||
/// </summary>
|
||||
public sealed class BackportProofGenerator
|
||||
{
|
||||
private const string ToolVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Generate proof from distro advisory evidence (Tier 1).
|
||||
/// </summary>
|
||||
public static ProofBlob FromDistroAdvisory(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
string advisorySource,
|
||||
string advisoryId,
|
||||
string fixedVersion,
|
||||
DateTimeOffset advisoryDate,
|
||||
JsonDocument advisoryData)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:distro:{advisorySource}:{advisoryId}";
|
||||
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(advisoryData));
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
Type = EvidenceType.DistroAdvisory,
|
||||
Source = advisorySource,
|
||||
Timestamp = advisoryDate,
|
||||
Data = advisoryData,
|
||||
DataHash = dataHash
|
||||
};
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "", // Will be computed
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = new[] { evidence },
|
||||
Method = "distro_advisory_tier1",
|
||||
Confidence = 0.98, // Highest confidence - authoritative source
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate proof from changelog evidence (Tier 2).
|
||||
/// </summary>
|
||||
public static ProofBlob FromChangelog(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
ChangelogEntry changelogEntry,
|
||||
string changelogSource)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:changelog:{changelogSource}:{changelogEntry.Version}";
|
||||
|
||||
var changelogData = JsonDocument.Parse(JsonSerializer.Serialize(changelogEntry));
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(changelogData));
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
Type = EvidenceType.ChangelogMention,
|
||||
Source = changelogSource,
|
||||
Timestamp = changelogEntry.Date,
|
||||
Data = changelogData,
|
||||
DataHash = dataHash
|
||||
};
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = new[] { evidence },
|
||||
Method = "changelog_mention_tier2",
|
||||
Confidence = changelogEntry.Confidence,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate proof from patch header evidence (Tier 3).
|
||||
/// </summary>
|
||||
public static ProofBlob FromPatchHeader(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
PatchHeaderParseResult patchResult)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:patch_header:{patchResult.PatchFilePath}";
|
||||
|
||||
var patchData = JsonDocument.Parse(JsonSerializer.Serialize(patchResult));
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(patchData));
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
Type = EvidenceType.PatchHeader,
|
||||
Source = patchResult.Origin,
|
||||
Timestamp = patchResult.ParsedAt,
|
||||
Data = patchData,
|
||||
DataHash = dataHash
|
||||
};
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = new[] { evidence },
|
||||
Method = "patch_header_tier3",
|
||||
Confidence = patchResult.Confidence,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate proof from patch signature (HunkSig) evidence (Tier 3+).
|
||||
/// </summary>
|
||||
public static ProofBlob FromPatchSignature(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
PatchSignature patchSig,
|
||||
bool exactMatch)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:hunksig:{patchSig.CommitSha}";
|
||||
|
||||
var patchData = JsonDocument.Parse(JsonSerializer.Serialize(patchSig));
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(patchData));
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
Type = EvidenceType.PatchHeader, // Reuse PatchHeader type
|
||||
Source = patchSig.UpstreamRepo,
|
||||
Timestamp = patchSig.ExtractedAt,
|
||||
Data = patchData,
|
||||
DataHash = dataHash
|
||||
};
|
||||
|
||||
// Confidence based on match quality
|
||||
var confidence = exactMatch ? 0.90 : 0.75;
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = new[] { evidence },
|
||||
Method = exactMatch ? "hunksig_exact_tier3" : "hunksig_fuzzy_tier3",
|
||||
Confidence = confidence,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate proof from binary fingerprint evidence (Tier 4).
|
||||
/// </summary>
|
||||
public static ProofBlob FromBinaryFingerprint(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
string fingerprintMethod,
|
||||
string fingerprintValue,
|
||||
JsonDocument fingerprintData,
|
||||
double confidence)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:binary:{fingerprintMethod}:{fingerprintValue}";
|
||||
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(fingerprintData));
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
Type = EvidenceType.BinaryFingerprint,
|
||||
Source = fingerprintMethod,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = fingerprintData,
|
||||
DataHash = dataHash
|
||||
};
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = new[] { evidence },
|
||||
Method = $"binary_{fingerprintMethod}_tier4",
|
||||
Confidence = confidence,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combine multiple evidence sources into a single proof with aggregated confidence.
|
||||
/// </summary>
|
||||
public static ProofBlob CombineEvidence(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
IReadOnlyList<ProofEvidence> evidences)
|
||||
{
|
||||
if (evidences.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one evidence required", nameof(evidences));
|
||||
}
|
||||
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
|
||||
// Aggregate confidence: use highest tier evidence as base, boost for multiple sources
|
||||
var confidence = ComputeAggregateConfidence(evidences);
|
||||
|
||||
// Determine method based on evidence types
|
||||
var method = DetermineMethod(evidences);
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = evidences,
|
||||
Method = method,
|
||||
Confidence = confidence,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate "not affected" proof when package version is below introduced range.
|
||||
/// </summary>
|
||||
public static ProofBlob NotAffected(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
string reason,
|
||||
JsonDocument versionData)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:version_comparison:{cveId}";
|
||||
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(versionData));
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
Type = EvidenceType.VersionComparison,
|
||||
Source = "version_comparison",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = versionData,
|
||||
DataHash = dataHash
|
||||
};
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.NotAffected,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = new[] { evidence },
|
||||
Method = reason,
|
||||
Confidence = 0.95,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate "vulnerable" proof when no fix evidence found.
|
||||
/// </summary>
|
||||
public static ProofBlob Vulnerable(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
string reason)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
|
||||
// Empty evidence list - absence of fix is the evidence
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.Vulnerable,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = Array.Empty<ProofEvidence>(),
|
||||
Method = reason,
|
||||
Confidence = 0.85, // Lower confidence - absence of evidence is not evidence of absence
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate "unknown" proof when confidence is too low or data insufficient.
|
||||
/// </summary>
|
||||
public static ProofBlob Unknown(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
string reason,
|
||||
IReadOnlyList<ProofEvidence> partialEvidences)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.Unknown,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = partialEvidences,
|
||||
Method = reason,
|
||||
Confidence = 0.0,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
}
|
||||
|
||||
private static double ComputeAggregateConfidence(IReadOnlyList<ProofEvidence> evidences)
|
||||
{
|
||||
// Confidence aggregation strategy:
|
||||
// 1. Start with highest individual confidence
|
||||
// 2. Add bonus for multiple independent sources
|
||||
// 3. Cap at 0.98 (never 100% certain)
|
||||
|
||||
var baseConfidence = evidences.Count switch
|
||||
{
|
||||
0 => 0.0,
|
||||
1 => DetermineEvidenceConfidence(evidences[0].Type),
|
||||
_ => evidences.Max(e => DetermineEvidenceConfidence(e.Type))
|
||||
};
|
||||
|
||||
// Bonus for multiple sources (diminishing returns)
|
||||
var multiSourceBonus = evidences.Count switch
|
||||
{
|
||||
<= 1 => 0.0,
|
||||
2 => 0.05,
|
||||
3 => 0.08,
|
||||
_ => 0.10
|
||||
};
|
||||
|
||||
return Math.Min(baseConfidence + multiSourceBonus, 0.98);
|
||||
}
|
||||
|
||||
private static double DetermineEvidenceConfidence(EvidenceType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
EvidenceType.DistroAdvisory => 0.98,
|
||||
EvidenceType.ChangelogMention => 0.80,
|
||||
EvidenceType.PatchHeader => 0.85,
|
||||
EvidenceType.BinaryFingerprint => 0.70,
|
||||
EvidenceType.VersionComparison => 0.95,
|
||||
EvidenceType.BuildCatalog => 0.90,
|
||||
_ => 0.50
|
||||
};
|
||||
}
|
||||
|
||||
private static string DetermineMethod(IReadOnlyList<ProofEvidence> evidences)
|
||||
{
|
||||
var types = evidences.Select(e => e.Type).Distinct().OrderBy(t => t).ToList();
|
||||
|
||||
if (types.Count == 1)
|
||||
{
|
||||
return types[0] switch
|
||||
{
|
||||
EvidenceType.DistroAdvisory => "distro_advisory_tier1",
|
||||
EvidenceType.ChangelogMention => "changelog_mention_tier2",
|
||||
EvidenceType.PatchHeader => "patch_header_tier3",
|
||||
EvidenceType.BinaryFingerprint => "binary_fingerprint_tier4",
|
||||
EvidenceType.VersionComparison => "version_comparison",
|
||||
EvidenceType.BuildCatalog => "build_catalog",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
// Multiple evidence types - use combined method name
|
||||
return $"multi_tier_combined_{types.Count}";
|
||||
}
|
||||
|
||||
private static string GenerateSnapshotId()
|
||||
{
|
||||
// Snapshot ID format: YYYYMMDD-HHMMSS-UTC
|
||||
return DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss") + "-UTC";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Generators;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Models;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates ProofBlob evidence into VEX verdicts with proof_ref fields.
|
||||
/// Implements proof-carrying VEX statements for cryptographic auditability.
|
||||
/// </summary>
|
||||
public sealed class VexProofIntegrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate VEX verdict statement from ProofBlob.
|
||||
/// </summary>
|
||||
public static VexVerdictStatement GenerateVexWithProof(
|
||||
ProofBlob proof,
|
||||
string sbomEntryId,
|
||||
string policyVersion,
|
||||
string reasoningId)
|
||||
{
|
||||
var status = DetermineVexStatus(proof.Type);
|
||||
var justification = DetermineJustification(proof);
|
||||
|
||||
var payload = new VexVerdictProofPayload
|
||||
{
|
||||
SbomEntryId = sbomEntryId,
|
||||
VulnerabilityId = ExtractCveId(proof.SubjectId),
|
||||
Status = status,
|
||||
Justification = justification,
|
||||
PolicyVersion = policyVersion,
|
||||
ReasoningId = reasoningId,
|
||||
VexVerdictId = "", // Will be computed
|
||||
ProofRef = proof.ProofId,
|
||||
ProofMethod = proof.Method,
|
||||
ProofConfidence = proof.Confidence,
|
||||
EvidenceSummary = GenerateEvidenceSummary(proof.Evidences)
|
||||
};
|
||||
|
||||
// Compute VexVerdictId from canonical payload
|
||||
var vexId = CanonJson.HashPrefixed(payload);
|
||||
payload = payload with { VexVerdictId = vexId };
|
||||
|
||||
// Create subject for the VEX statement
|
||||
var subject = new Subject
|
||||
{
|
||||
Name = sbomEntryId,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ExtractPurlHash(proof.SubjectId)
|
||||
}
|
||||
};
|
||||
|
||||
return new VexVerdictStatement
|
||||
{
|
||||
Subject = new[] { subject },
|
||||
Predicate = ConvertToStandardPayload(payload)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate multiple VEX verdicts from a batch of ProofBlobs.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<VexVerdictStatement> GenerateBatchVex(
|
||||
IReadOnlyList<ProofBlob> proofs,
|
||||
string policyVersion,
|
||||
Func<ProofBlob, string> sbomEntryIdResolver,
|
||||
Func<ProofBlob, string> reasoningIdResolver)
|
||||
{
|
||||
var statements = new List<VexVerdictStatement>();
|
||||
|
||||
foreach (var proof in proofs)
|
||||
{
|
||||
var sbomEntryId = sbomEntryIdResolver(proof);
|
||||
var reasoningId = reasoningIdResolver(proof);
|
||||
|
||||
var statement = GenerateVexWithProof(proof, sbomEntryId, policyVersion, reasoningId);
|
||||
statements.Add(statement);
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create proof-carrying VEX verdict with extended metadata.
|
||||
/// Returns both standard VEX statement and extended proof payload for storage.
|
||||
/// </summary>
|
||||
public static (VexVerdictStatement Statement, VexVerdictProofPayload ProofPayload) GenerateWithProofMetadata(
|
||||
ProofBlob proof,
|
||||
string sbomEntryId,
|
||||
string policyVersion,
|
||||
string reasoningId)
|
||||
{
|
||||
var status = DetermineVexStatus(proof.Type);
|
||||
var justification = DetermineJustification(proof);
|
||||
|
||||
var proofPayload = new VexVerdictProofPayload
|
||||
{
|
||||
SbomEntryId = sbomEntryId,
|
||||
VulnerabilityId = ExtractCveId(proof.SubjectId),
|
||||
Status = status,
|
||||
Justification = justification,
|
||||
PolicyVersion = policyVersion,
|
||||
ReasoningId = reasoningId,
|
||||
VexVerdictId = "", // Will be computed
|
||||
ProofRef = proof.ProofId,
|
||||
ProofMethod = proof.Method,
|
||||
ProofConfidence = proof.Confidence,
|
||||
EvidenceSummary = GenerateEvidenceSummary(proof.Evidences)
|
||||
};
|
||||
|
||||
var vexId = CanonJson.HashPrefixed(proofPayload);
|
||||
proofPayload = proofPayload with { VexVerdictId = vexId };
|
||||
|
||||
var subject = new Subject
|
||||
{
|
||||
Name = sbomEntryId,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ExtractPurlHash(proof.SubjectId)
|
||||
}
|
||||
};
|
||||
|
||||
var statement = new VexVerdictStatement
|
||||
{
|
||||
Subject = new[] { subject },
|
||||
Predicate = ConvertToStandardPayload(proofPayload)
|
||||
};
|
||||
|
||||
return (statement, proofPayload);
|
||||
}
|
||||
|
||||
private static string DetermineVexStatus(ProofBlobType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
ProofBlobType.BackportFixed => "fixed",
|
||||
ProofBlobType.NotAffected => "not_affected",
|
||||
ProofBlobType.Vulnerable => "affected",
|
||||
ProofBlobType.Unknown => "under_investigation",
|
||||
_ => "under_investigation"
|
||||
};
|
||||
}
|
||||
|
||||
private static string DetermineJustification(ProofBlob proof)
|
||||
{
|
||||
return proof.Type switch
|
||||
{
|
||||
ProofBlobType.BackportFixed =>
|
||||
$"Backport fix detected via {proof.Method} with {proof.Confidence:P0} confidence",
|
||||
ProofBlobType.NotAffected =>
|
||||
$"Not affected: {proof.Method}",
|
||||
ProofBlobType.Vulnerable =>
|
||||
$"No fix evidence found via {proof.Method}",
|
||||
ProofBlobType.Unknown =>
|
||||
$"Insufficient evidence: {proof.Method}",
|
||||
_ => "Unknown status"
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceSummary GenerateEvidenceSummary(IReadOnlyList<ProofEvidence> evidences)
|
||||
{
|
||||
var tiers = evidences
|
||||
.GroupBy(e => e.Type)
|
||||
.Select(g => new TierSummary
|
||||
{
|
||||
Type = g.Key.ToString(),
|
||||
Count = g.Count(),
|
||||
Sources = g.Select(e => e.Source).Distinct().ToList()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new EvidenceSummary
|
||||
{
|
||||
TotalEvidences = evidences.Count,
|
||||
Tiers = tiers,
|
||||
EvidenceIds = evidences.Select(e => e.EvidenceId).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractCveId(string subjectId)
|
||||
{
|
||||
// SubjectId format: "CVE-XXXX-YYYY:pkg:..."
|
||||
var parts = subjectId.Split(':', 2);
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
private static string ExtractPurlHash(string subjectId)
|
||||
{
|
||||
// Generate hash from PURL portion
|
||||
var parts = subjectId.Split(':', 2);
|
||||
if (parts.Length > 1)
|
||||
{
|
||||
return CanonJson.Sha256Hex(System.Text.Encoding.UTF8.GetBytes(parts[1]));
|
||||
}
|
||||
return CanonJson.Sha256Hex(System.Text.Encoding.UTF8.GetBytes(subjectId));
|
||||
}
|
||||
|
||||
private static VexVerdictPayload ConvertToStandardPayload(VexVerdictProofPayload proofPayload)
|
||||
{
|
||||
// Convert to standard payload (without proof extensions) for in-toto compatibility
|
||||
return new VexVerdictPayload
|
||||
{
|
||||
SbomEntryId = proofPayload.SbomEntryId,
|
||||
VulnerabilityId = proofPayload.VulnerabilityId,
|
||||
Status = proofPayload.Status,
|
||||
Justification = proofPayload.Justification,
|
||||
PolicyVersion = proofPayload.PolicyVersion,
|
||||
ReasoningId = proofPayload.ReasoningId,
|
||||
VexVerdictId = proofPayload.VexVerdictId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended VEX verdict payload with proof references.
|
||||
/// </summary>
|
||||
public sealed record VexVerdictProofPayload
|
||||
{
|
||||
[JsonPropertyName("sbomEntryId")]
|
||||
public required string SbomEntryId { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("reasoningId")]
|
||||
public required string ReasoningId { get; init; }
|
||||
|
||||
[JsonPropertyName("vexVerdictId")]
|
||||
public required string VexVerdictId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the ProofBlob ID (SHA-256 hash).
|
||||
/// Format: "sha256:..."
|
||||
/// </summary>
|
||||
[JsonPropertyName("proof_ref")]
|
||||
public required string ProofRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method used to generate the proof.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proof_method")]
|
||||
public required string ProofMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score of the proof (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("proof_confidence")]
|
||||
public required double ProofConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary of evidence used in the proof.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence_summary")]
|
||||
public required EvidenceSummary EvidenceSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of evidence tiers used in a proof.
|
||||
/// </summary>
|
||||
public sealed record EvidenceSummary
|
||||
{
|
||||
[JsonPropertyName("total_evidences")]
|
||||
public required int TotalEvidences { get; init; }
|
||||
|
||||
[JsonPropertyName("tiers")]
|
||||
public required IReadOnlyList<TierSummary> Tiers { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence_ids")]
|
||||
public required IReadOnlyList<string> EvidenceIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a single evidence tier.
|
||||
/// </summary>
|
||||
public sealed record TierSummary
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public required int Count { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public required IReadOnlyList<string> Sources { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Models;
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Proof blob containing cryptographic evidence for a vulnerability verdict.
|
||||
/// </summary>
|
||||
public sealed record ProofBlob
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique proof identifier (SHA-256 hash of canonical proof).
|
||||
/// Format: "sha256:..."
|
||||
/// </summary>
|
||||
public required string ProofId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject identifier (CVE + PURL).
|
||||
/// Format: "CVE-XXXX-YYYY:pkg:..."
|
||||
/// </summary>
|
||||
public required string SubjectId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of proof.
|
||||
/// </summary>
|
||||
public required ProofBlobType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when proof was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence entries supporting this proof.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ProofEvidence> Evidences { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detection method used.
|
||||
/// </summary>
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0.0-1.0).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tool version that generated this proof.
|
||||
/// </summary>
|
||||
public required string ToolVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot ID for feed/policy versions.
|
||||
/// </summary>
|
||||
public required string SnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed hash of this proof (excludes this field).
|
||||
/// Set by ProofHashing.WithHash().
|
||||
/// </summary>
|
||||
public string? ProofHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual evidence entry within a proof blob.
|
||||
/// </summary>
|
||||
public sealed record ProofEvidence
|
||||
{
|
||||
public required string EvidenceId { get; init; }
|
||||
public required EvidenceType Type { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required JsonDocument Data { get; init; }
|
||||
public required string DataHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of proof blob.
|
||||
/// </summary>
|
||||
public enum ProofBlobType
|
||||
{
|
||||
BackportFixed,
|
||||
NotAffected,
|
||||
Vulnerable,
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of evidence.
|
||||
/// </summary>
|
||||
public enum EvidenceType
|
||||
{
|
||||
DistroAdvisory,
|
||||
ChangelogMention,
|
||||
PatchHeader,
|
||||
BinaryFingerprint,
|
||||
VersionComparison,
|
||||
BuildCatalog
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace StellaOps.Attestor.ProofChain;
|
||||
|
||||
using StellaOps.Attestor.ProofChain.Models;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Utilities for computing canonical hashes of proof blobs.
|
||||
/// </summary>
|
||||
public static class ProofHashing
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute canonical hash of a proof blob.
|
||||
/// Excludes the ProofHash field itself to avoid circularity.
|
||||
/// </summary>
|
||||
public static string ComputeProofHash(ProofBlob blob)
|
||||
{
|
||||
if (blob == null) throw new ArgumentNullException(nameof(blob));
|
||||
|
||||
// Clone without ProofHash field
|
||||
var normalized = blob with { ProofHash = null };
|
||||
|
||||
// Canonicalize and hash
|
||||
var canonical = CanonJson.Canonicalize(normalized);
|
||||
return CanonJson.Sha256Hex(canonical);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a proof blob with its hash computed.
|
||||
/// </summary>
|
||||
public static ProofBlob WithHash(ProofBlob blob)
|
||||
{
|
||||
var hash = ComputeProofHash(blob);
|
||||
return blob with { ProofHash = hash };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a proof blob's hash matches its content.
|
||||
/// </summary>
|
||||
public static bool VerifyHash(ProofBlob blob)
|
||||
{
|
||||
if (blob.ProofHash == null) return false;
|
||||
|
||||
var computed = ComputeProofHash(blob);
|
||||
return computed == blob.ProofHash;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -13,7 +11,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\..\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user