save progress

This commit is contained in:
StellaOps Bot
2026-01-04 19:08:47 +02:00
parent f7d27c6fda
commit 75611a505f
97 changed files with 4531 additions and 293 deletions

View File

@@ -69,6 +69,18 @@ public interface IOfflineRootStore
Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
RootType rootType,
CancellationToken cancellationToken = default);
/// <summary>
/// Get a rule bundle signing key by ID and bundle type.
/// </summary>
/// <param name="keyId">The key identifier.</param>
/// <param name="bundleType">The bundle type (e.g., "secrets", "malware").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The envelope key if found, null otherwise.</returns>
Task<StellaOps.Attestor.Envelope.EnvelopeKey?> GetRuleBundleSigningKeyAsync(
string keyId,
string bundleType,
CancellationToken cancellationToken = default);
}
/// <summary>
@@ -81,7 +93,9 @@ public enum RootType
/// <summary>Organization signing keys for bundle endorsement.</summary>
OrgSigning,
/// <summary>Rekor public keys for transparency log verification.</summary>
Rekor
Rekor,
/// <summary>Rule bundle signing keys for secrets/malware rule bundles.</summary>
RuleBundleSigning
}
/// <summary>

View File

@@ -0,0 +1,168 @@
// -----------------------------------------------------------------------------
// IRuleBundleSignatureVerifier.cs
// Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration)
// Task: OKS-004 - Add Attestor mirror support for bundle verification
// Description: Interface for verifying rule bundle signatures offline
// -----------------------------------------------------------------------------
using StellaOps.Attestor.Offline.Models;
namespace StellaOps.Attestor.Offline.Abstractions;
/// <summary>
/// Service for verifying rule bundle (secrets, malware, etc.) signatures offline.
/// Enables air-gapped environments to verify rule bundle signatures using
/// locally stored signing keys.
/// </summary>
public interface IRuleBundleSignatureVerifier
{
/// <summary>
/// Verify a rule bundle signature.
/// </summary>
/// <param name="request">The verification request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result with detailed status.</returns>
Task<RuleBundleSignatureResult> VerifyAsync(
RuleBundleSignatureRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Verify a rule bundle from a directory.
/// </summary>
/// <param name="bundleDirectory">Directory containing the rule bundle.</param>
/// <param name="bundleId">Expected bundle identifier.</param>
/// <param name="options">Verification options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<RuleBundleSignatureResult> VerifyDirectoryAsync(
string bundleDirectory,
string bundleId,
RuleBundleVerificationOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for verifying a rule bundle signature.
/// </summary>
public sealed record RuleBundleSignatureRequest
{
/// <summary>
/// The DSSE envelope containing the signature.
/// </summary>
public required byte[] EnvelopeBytes { get; init; }
/// <summary>
/// The payload (manifest) that was signed.
/// </summary>
public required byte[] PayloadBytes { get; init; }
/// <summary>
/// Expected bundle identifier.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// Expected bundle type (e.g., "secrets", "malware").
/// </summary>
public required string BundleType { get; init; }
/// <summary>
/// Expected bundle version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Key ID that should have signed the bundle (optional).
/// </summary>
public string? ExpectedKeyId { get; init; }
}
/// <summary>
/// Result of rule bundle signature verification.
/// </summary>
public sealed record RuleBundleSignatureResult
{
/// <summary>
/// Whether the signature is valid.
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// Key ID that signed the bundle.
/// </summary>
public string? SignerKeyId { get; init; }
/// <summary>
/// Algorithm used for signing.
/// </summary>
public string? Algorithm { get; init; }
/// <summary>
/// When the signature was verified.
/// </summary>
public DateTimeOffset VerifiedAt { get; init; }
/// <summary>
/// Error message if verification failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Detailed verification issues.
/// </summary>
public IReadOnlyList<VerificationIssue> Issues { get; init; } = [];
/// <summary>
/// Create a successful result.
/// </summary>
public static RuleBundleSignatureResult Success(
string signerKeyId,
string algorithm,
DateTimeOffset verifiedAt) => new()
{
IsValid = true,
SignerKeyId = signerKeyId,
Algorithm = algorithm,
VerifiedAt = verifiedAt
};
/// <summary>
/// Create a failed result.
/// </summary>
public static RuleBundleSignatureResult Failure(
string error,
DateTimeOffset verifiedAt,
IReadOnlyList<VerificationIssue>? issues = null) => new()
{
IsValid = false,
Error = error,
VerifiedAt = verifiedAt,
Issues = issues ?? []
};
}
/// <summary>
/// Options for rule bundle verification.
/// </summary>
public sealed record RuleBundleVerificationOptions
{
/// <summary>
/// Path to the signing key file.
/// </summary>
public string? SigningKeyPath { get; init; }
/// <summary>
/// Expected signer key ID.
/// </summary>
public string? ExpectedKeyId { get; init; }
/// <summary>
/// Whether to require a valid signature.
/// </summary>
public bool RequireSignature { get; init; } = true;
/// <summary>
/// Whether to use strict mode (fail on any warning).
/// </summary>
public bool StrictMode { get; init; }
}

View File

@@ -8,8 +8,10 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.Offline.Abstractions;
namespace StellaOps.Attestor.Offline.Services;
@@ -26,6 +28,8 @@ public sealed class FileSystemRootStore : IOfflineRootStore
private X509Certificate2Collection? _fulcioRoots;
private X509Certificate2Collection? _orgSigningKeys;
private X509Certificate2Collection? _rekorKeys;
private X509Certificate2Collection? _ruleBundleSigningKeys;
private readonly Dictionary<string, EnvelopeKey> _ruleBundleKeyCache = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _loadLock = new(1, 1);
/// <summary>
@@ -75,6 +79,20 @@ public sealed class FileSystemRootStore : IOfflineRootStore
return _rekorKeys ?? new X509Certificate2Collection();
}
/// <summary>
/// Get rule bundle signing key certificates.
/// </summary>
public async Task<X509Certificate2Collection> GetRuleBundleSigningKeysAsync(
CancellationToken cancellationToken = default)
{
if (_ruleBundleSigningKeys == null)
{
await LoadRootsAsync(RootType.RuleBundleSigning, cancellationToken);
}
return _ruleBundleSigningKeys ?? new X509Certificate2Collection();
}
/// <inheritdoc />
public async Task ImportRootsAsync(
string pemPath,
@@ -160,6 +178,66 @@ public sealed class FileSystemRootStore : IOfflineRootStore
return null;
}
/// <inheritdoc />
public async Task<EnvelopeKey?> GetRuleBundleSigningKeyAsync(
string keyId,
string bundleType,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleType);
// Check cache first
var cacheKey = $"{bundleType}:{keyId}";
if (_ruleBundleKeyCache.TryGetValue(cacheKey, out var cachedKey))
{
return cachedKey;
}
// Load signing keys if not loaded
if (_ruleBundleSigningKeys == null)
{
await LoadRootsAsync(RootType.RuleBundleSigning, cancellationToken);
}
// Look for the key in the certificate store
if (_ruleBundleSigningKeys != null)
{
foreach (var cert in _ruleBundleSigningKeys)
{
var certKeyId = GetSubjectKeyIdentifier(cert) ?? ComputeThumbprint(cert);
if (certKeyId.Equals(keyId, StringComparison.OrdinalIgnoreCase))
{
var envelopeKey = CreateEnvelopeKeyFromCertificate(cert);
if (envelopeKey != null)
{
_ruleBundleKeyCache[cacheKey] = envelopeKey;
return envelopeKey;
}
}
}
}
// Try loading from JSON key file
var jsonKeyPath = GetRuleBundleKeyPath(bundleType, keyId);
if (!string.IsNullOrEmpty(jsonKeyPath) && File.Exists(jsonKeyPath))
{
var envelopeKey = await LoadEnvelopeKeyFromJsonAsync(jsonKeyPath, cancellationToken);
if (envelopeKey != null)
{
_ruleBundleKeyCache[cacheKey] = envelopeKey;
return envelopeKey;
}
}
_logger.LogWarning(
"Rule bundle signing key not found: keyId={KeyId} bundleType={BundleType}",
keyId,
bundleType);
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
RootType rootType,
@@ -170,6 +248,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
RootType.Fulcio => await GetFulcioRootsAsync(cancellationToken),
RootType.OrgSigning => await GetOrgSigningKeysAsync(cancellationToken),
RootType.Rekor => await GetRekorKeysAsync(cancellationToken),
RootType.RuleBundleSigning => await GetRuleBundleSigningKeysAsync(cancellationToken),
_ => throw new ArgumentOutOfRangeException(nameof(rootType))
};
@@ -297,6 +376,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
RootType.Fulcio => _options.FulcioBundlePath ?? "",
RootType.OrgSigning => _options.OrgSigningBundlePath ?? "",
RootType.Rekor => _options.RekorBundlePath ?? "",
RootType.RuleBundleSigning => _options.RuleBundleSigningPath ?? "",
_ => ""
};
@@ -305,6 +385,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
RootType.Fulcio => _options.FulcioBundlePath ?? Path.Combine(_options.BaseRootPath, "fulcio"),
RootType.OrgSigning => _options.OrgSigningBundlePath ?? Path.Combine(_options.BaseRootPath, "org-signing"),
RootType.Rekor => _options.RekorBundlePath ?? Path.Combine(_options.BaseRootPath, "rekor"),
RootType.RuleBundleSigning => _options.RuleBundleSigningPath ?? Path.Combine(_options.BaseRootPath, "rule-bundle-signing"),
_ => _options.BaseRootPath
};
@@ -320,6 +401,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
RootType.Fulcio => Path.Combine(_options.OfflineKitPath, "roots", "fulcio"),
RootType.OrgSigning => Path.Combine(_options.OfflineKitPath, "roots", "org-signing"),
RootType.Rekor => Path.Combine(_options.OfflineKitPath, "roots", "rekor"),
RootType.RuleBundleSigning => Path.Combine(_options.OfflineKitPath, "roots", "rule-bundle-signing"),
_ => null
};
}
@@ -329,6 +411,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
RootType.Fulcio => _fulcioRoots,
RootType.OrgSigning => _orgSigningKeys,
RootType.Rekor => _rekorKeys,
RootType.RuleBundleSigning => _ruleBundleSigningKeys,
_ => null
};
@@ -345,6 +428,9 @@ public sealed class FileSystemRootStore : IOfflineRootStore
case RootType.Rekor:
_rekorKeys = collection;
break;
case RootType.RuleBundleSigning:
_ruleBundleSigningKeys = collection;
break;
}
}
@@ -361,9 +447,130 @@ public sealed class FileSystemRootStore : IOfflineRootStore
case RootType.Rekor:
_rekorKeys = null;
break;
case RootType.RuleBundleSigning:
_ruleBundleSigningKeys = null;
_ruleBundleKeyCache.Clear();
break;
}
}
private string? GetRuleBundleKeyPath(string bundleType, string keyId)
{
var basePath = _options.RuleBundleSigningPath ?? Path.Combine(_options.BaseRootPath, "rule-bundle-signing");
if (string.IsNullOrEmpty(basePath))
{
return null;
}
// Try bundle-type specific path first
var typeSpecificPath = Path.Combine(basePath, bundleType, $"{keyId}.json");
if (File.Exists(typeSpecificPath))
{
return typeSpecificPath;
}
// Fall back to general path
return Path.Combine(basePath, $"{keyId}.json");
}
private static async Task<EnvelopeKey?> LoadEnvelopeKeyFromJsonAsync(
string path,
CancellationToken cancellationToken)
{
try
{
var json = await File.ReadAllTextAsync(path, cancellationToken);
using var doc = JsonDocument.Parse(json);
var algorithm = doc.RootElement.TryGetProperty("algorithm", out var alg)
? alg.GetString() ?? "ES256"
: "ES256";
var keyId = doc.RootElement.TryGetProperty("keyId", out var kid)
? kid.GetString() ?? ""
: "";
var publicKeyBase64 = doc.RootElement.TryGetProperty("publicKey", out var pk)
? pk.GetString()
: null;
if (string.IsNullOrEmpty(publicKeyBase64))
{
return null;
}
var publicKeyBytes = Convert.FromBase64String(publicKeyBase64);
// Create EnvelopeKey based on algorithm
return algorithm.ToUpperInvariant() switch
{
"ES256" => EnvelopeKey.CreateEcdsaVerifier("ES256", LoadEcParameters(publicKeyBytes, ECCurve.NamedCurves.nistP256)),
"ES384" => EnvelopeKey.CreateEcdsaVerifier("ES384", LoadEcParameters(publicKeyBytes, ECCurve.NamedCurves.nistP384)),
"ES512" => EnvelopeKey.CreateEcdsaVerifier("ES512", LoadEcParameters(publicKeyBytes, ECCurve.NamedCurves.nistP521)),
"ED25519" => EnvelopeKey.CreateEd25519Verifier(publicKeyBytes),
_ => null
};
}
catch
{
return null;
}
}
private static ECParameters LoadEcParameters(byte[] publicKey, ECCurve curve)
{
// Assume the key is in uncompressed format (0x04 prefix + X + Y)
var keySize = curve.Oid.Value switch
{
"1.2.840.10045.3.1.7" => 32, // P-256
"1.3.132.0.34" => 48, // P-384
"1.3.132.0.35" => 66, // P-521
_ => 32
};
if (publicKey.Length == 2 * keySize + 1 && publicKey[0] == 0x04)
{
return new ECParameters
{
Curve = curve,
Q = new ECPoint
{
X = publicKey[1..(keySize + 1)],
Y = publicKey[(keySize + 1)..]
}
};
}
// Try to parse as SubjectPublicKeyInfo (DER format)
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(publicKey, out _);
return ecdsa.ExportParameters(false);
}
private static EnvelopeKey? CreateEnvelopeKeyFromCertificate(X509Certificate2 cert)
{
try
{
using var ecdsa = cert.GetECDsaPublicKey();
if (ecdsa != null)
{
var parameters = ecdsa.ExportParameters(false);
var algorithmId = parameters.Curve.Oid.Value switch
{
"1.2.840.10045.3.1.7" => "ES256",
"1.3.132.0.34" => "ES384",
"1.3.132.0.35" => "ES512",
_ => "ES256"
};
return EnvelopeKey.CreateEcdsaVerifier(algorithmId, parameters);
}
}
catch
{
// Swallow and try other key types
}
return null;
}
private static string ComputeThumbprint(X509Certificate2 cert)
{
var hash = SHA256.HashData(cert.RawData);
@@ -418,6 +625,11 @@ public sealed class OfflineRootStoreOptions
/// </summary>
public string? RekorBundlePath { get; set; }
/// <summary>
/// Path to rule bundle signing keys (file or directory).
/// </summary>
public string? RuleBundleSigningPath { get; set; }
/// <summary>
/// Path to Offline Kit installation.
/// </summary>

View File

@@ -0,0 +1,346 @@
// -----------------------------------------------------------------------------
// RuleBundleSignatureVerifier.cs
// Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration)
// Task: OKS-004 - Add Attestor mirror support for bundle verification
// Description: Verifies rule bundle signatures offline
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.Offline.Abstractions;
using StellaOps.Attestor.Offline.Models;
namespace StellaOps.Attestor.Offline.Services;
/// <summary>
/// Verifies rule bundle (secrets, malware, etc.) signatures offline.
/// </summary>
public sealed class RuleBundleSignatureVerifier : IRuleBundleSignatureVerifier
{
private readonly IOfflineRootStore _rootStore;
private readonly EnvelopeSignatureService _signatureService = new();
private readonly ILogger<RuleBundleSignatureVerifier> _logger;
private readonly TimeProvider _timeProvider;
public RuleBundleSignatureVerifier(
IOfflineRootStore rootStore,
ILogger<RuleBundleSignatureVerifier> logger,
TimeProvider? timeProvider = null)
{
_rootStore = rootStore ?? throw new ArgumentNullException(nameof(rootStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<RuleBundleSignatureResult> VerifyAsync(
RuleBundleSignatureRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleId);
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleType);
var verifiedAt = _timeProvider.GetUtcNow();
var issues = new List<VerificationIssue>();
_logger.LogInformation(
"Verifying rule bundle signature: bundle_id={BundleId} bundle_type={BundleType} version={Version}",
request.BundleId,
request.BundleType,
request.Version);
try
{
// Parse DSSE envelope
DsseEnvelope envelope;
try
{
envelope = ParseDsseEnvelope(request.EnvelopeBytes);
}
catch (Exception ex)
{
issues.Add(new VerificationIssue(
VerificationIssueSeverity.Critical,
"ENVELOPE_PARSE_FAILED",
$"Failed to parse DSSE envelope: {ex.Message}"));
return RuleBundleSignatureResult.Failure(
$"envelope-parse-failed:{ex.GetType().Name.ToLowerInvariant()}",
verifiedAt,
issues);
}
// Verify payload type
if (envelope.PayloadType != "application/vnd.stellaops.rulebundle.manifest+json" &&
envelope.PayloadType != "application/json")
{
issues.Add(new VerificationIssue(
VerificationIssueSeverity.Warning,
"UNEXPECTED_PAYLOAD_TYPE",
$"Unexpected payload type: {envelope.PayloadType}"));
}
// Verify payload digest matches
var envelopePayloadBytes = Convert.FromBase64String(envelope.Payload);
var envelopePayloadDigest = ComputeSha256Digest(envelopePayloadBytes);
var requestPayloadDigest = ComputeSha256Digest(request.PayloadBytes);
if (envelopePayloadDigest != requestPayloadDigest)
{
issues.Add(new VerificationIssue(
VerificationIssueSeverity.Critical,
"PAYLOAD_DIGEST_MISMATCH",
$"Envelope payload digest {envelopePayloadDigest} does not match provided payload {requestPayloadDigest}"));
return RuleBundleSignatureResult.Failure(
"payload-digest-mismatch",
verifiedAt,
issues);
}
// Verify signatures
if (envelope.Signatures.Count == 0)
{
issues.Add(new VerificationIssue(
VerificationIssueSeverity.Critical,
"NO_SIGNATURES",
"DSSE envelope has no signatures"));
return RuleBundleSignatureResult.Failure(
"no-signatures",
verifiedAt,
issues);
}
// Get the signer key
var signature = envelope.Signatures[0];
var signerKeyId = signature.KeyId;
if (request.ExpectedKeyId != null &&
!string.Equals(signerKeyId, request.ExpectedKeyId, StringComparison.Ordinal))
{
issues.Add(new VerificationIssue(
VerificationIssueSeverity.Critical,
"KEYID_MISMATCH",
$"Expected key ID {request.ExpectedKeyId} but got {signerKeyId}"));
return RuleBundleSignatureResult.Failure(
$"keyid-mismatch:expected={request.ExpectedKeyId}:actual={signerKeyId}",
verifiedAt,
issues);
}
// Look up the signing key from the root store
var signingKey = await _rootStore.GetRuleBundleSigningKeyAsync(
signerKeyId,
request.BundleType,
cancellationToken);
if (signingKey == null)
{
issues.Add(new VerificationIssue(
VerificationIssueSeverity.Critical,
"KEY_NOT_FOUND",
$"Signing key {signerKeyId} not found in root store for bundle type {request.BundleType}"));
return RuleBundleSignatureResult.Failure(
$"key-not-found:{signerKeyId}",
verifiedAt,
issues);
}
// Verify the DSSE signature
var signatureBytes = Convert.FromBase64String(signature.Sig);
var dsseSignature = new EnvelopeSignature(
signerKeyId,
signingKey.AlgorithmId,
signatureBytes);
var verifyResult = _signatureService.VerifyDsse(
envelope.PayloadType,
envelopePayloadBytes,
dsseSignature,
signingKey);
if (!verifyResult.IsSuccess || !verifyResult.Value)
{
var errorMessage = verifyResult.IsSuccess
? "Signature verification failed"
: $"Signature verification failed: {verifyResult.Error.Code}";
issues.Add(new VerificationIssue(
VerificationIssueSeverity.Critical,
"SIGNATURE_INVALID",
errorMessage));
return RuleBundleSignatureResult.Failure(
"signature-invalid",
verifiedAt,
issues);
}
_logger.LogInformation(
"Rule bundle signature verified: bundle_id={BundleId} signer_key_id={SignerKeyId}",
request.BundleId,
signerKeyId);
return RuleBundleSignatureResult.Success(
signerKeyId,
signingKey.AlgorithmId,
verifiedAt);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to verify rule bundle signature: bundle_id={BundleId}",
request.BundleId);
issues.Add(new VerificationIssue(
VerificationIssueSeverity.Critical,
"VERIFICATION_ERROR",
$"Verification failed: {ex.Message}"));
return RuleBundleSignatureResult.Failure(
$"verification-error:{ex.GetType().Name.ToLowerInvariant()}",
verifiedAt,
issues);
}
}
/// <inheritdoc />
public async Task<RuleBundleSignatureResult> VerifyDirectoryAsync(
string bundleDirectory,
string bundleId,
RuleBundleVerificationOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundleDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
var verifiedAt = _timeProvider.GetUtcNow();
var issues = new List<VerificationIssue>();
options ??= new RuleBundleVerificationOptions();
// Find manifest file
var manifestPath = Path.Combine(bundleDirectory, $"{bundleId}.manifest.json");
if (!File.Exists(manifestPath))
{
issues.Add(new VerificationIssue(
VerificationIssueSeverity.Critical,
"MANIFEST_NOT_FOUND",
$"Manifest not found at {manifestPath}"));
if (options.RequireSignature)
{
return RuleBundleSignatureResult.Failure(
"manifest-not-found",
verifiedAt,
issues);
}
return new RuleBundleSignatureResult
{
IsValid = true,
VerifiedAt = verifiedAt,
Issues = issues
};
}
// Find signature file
var signaturePath = Path.Combine(bundleDirectory, $"{bundleId}.manifest.sig");
if (!File.Exists(signaturePath))
{
issues.Add(new VerificationIssue(
VerificationIssueSeverity.Warning,
"SIGNATURE_NOT_FOUND",
$"Signature file not found at {signaturePath}"));
if (options.RequireSignature)
{
return RuleBundleSignatureResult.Failure(
"signature-not-found",
verifiedAt,
issues);
}
return new RuleBundleSignatureResult
{
IsValid = true,
VerifiedAt = verifiedAt,
Issues = issues
};
}
// Read manifest and signature
var manifestBytes = await File.ReadAllBytesAsync(manifestPath, cancellationToken);
var signatureBytes = await File.ReadAllBytesAsync(signaturePath, cancellationToken);
// Parse manifest to get bundle type and version
string bundleType;
string version;
try
{
using var doc = JsonDocument.Parse(manifestBytes);
bundleType = doc.RootElement.TryGetProperty("bundleType", out var bt)
? bt.GetString() ?? "unknown"
: "unknown";
version = doc.RootElement.TryGetProperty("version", out var v)
? v.GetString() ?? "0.0"
: "0.0";
}
catch
{
bundleType = "unknown";
version = "0.0";
}
var request = new RuleBundleSignatureRequest
{
EnvelopeBytes = signatureBytes,
PayloadBytes = manifestBytes,
BundleId = bundleId,
BundleType = bundleType,
Version = version,
ExpectedKeyId = options.ExpectedKeyId
};
return await VerifyAsync(request, cancellationToken);
}
private static DsseEnvelope ParseDsseEnvelope(byte[] envelopeBytes)
{
var json = Encoding.UTF8.GetString(envelopeBytes);
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(json, JsonOptions);
return envelope ?? throw new InvalidOperationException("Failed to parse DSSE envelope");
}
private static string ComputeSha256Digest(byte[] data)
{
var hash = SHA256.HashData(data);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
}
/// <summary>
/// DSSE envelope structure for parsing.
/// </summary>
internal sealed class DsseEnvelope
{
public string PayloadType { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public List<DsseSignature> Signatures { get; set; } = [];
}
/// <summary>
/// DSSE signature structure.
/// </summary>
internal sealed class DsseSignature
{
public string KeyId { get; set; } = string.Empty;
public string Sig { get; set; } = string.Empty;
}