save progress
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user