Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IAttestationBundler.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0005 - Implement IAttestationBundler service
|
||||
// Description: Service interface for creating attestation bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing attestation bundles.
|
||||
/// </summary>
|
||||
public interface IAttestationBundler
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new attestation bundle for a time period.
|
||||
/// </summary>
|
||||
/// <param name="request">Bundle creation parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created attestation bundle.</returns>
|
||||
Task<AttestationBundle> CreateBundleAsync(
|
||||
BundleCreationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get an existing bundle by ID.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID (sha256:<merkle_root>).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The bundle if found, null otherwise.</returns>
|
||||
Task<AttestationBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List bundles matching the specified criteria.
|
||||
/// </summary>
|
||||
/// <param name="request">List parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated bundle list.</returns>
|
||||
Task<BundleListResult> ListBundlesAsync(
|
||||
BundleListRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify the integrity of a bundle (Merkle tree and optional org signature).
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<BundleVerificationResult> VerifyBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for bundle creation.
|
||||
/// </summary>
|
||||
/// <param name="PeriodStart">Start of the attestation collection period.</param>
|
||||
/// <param name="PeriodEnd">End of the attestation collection period.</param>
|
||||
/// <param name="TenantId">Optional tenant identifier for multi-tenant filtering.</param>
|
||||
/// <param name="SignWithOrgKey">Whether to sign the bundle with an organization key.</param>
|
||||
/// <param name="OrgKeyId">Organization key ID to use for signing.</param>
|
||||
public record BundleCreationRequest(
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
string? TenantId = null,
|
||||
bool SignWithOrgKey = false,
|
||||
string? OrgKeyId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for listing bundles.
|
||||
/// </summary>
|
||||
/// <param name="PeriodStart">Optional start of period filter.</param>
|
||||
/// <param name="PeriodEnd">Optional end of period filter.</param>
|
||||
/// <param name="TenantId">Optional tenant filter.</param>
|
||||
/// <param name="Limit">Maximum number of results.</param>
|
||||
/// <param name="Cursor">Pagination cursor.</param>
|
||||
public record BundleListRequest(
|
||||
DateTimeOffset? PeriodStart = null,
|
||||
DateTimeOffset? PeriodEnd = null,
|
||||
string? TenantId = null,
|
||||
int Limit = 20,
|
||||
string? Cursor = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a bundle list operation.
|
||||
/// </summary>
|
||||
/// <param name="Bundles">The matching bundles (metadata only).</param>
|
||||
/// <param name="NextCursor">Cursor for the next page, null if no more results.</param>
|
||||
public record BundleListResult(
|
||||
IReadOnlyList<BundleListItem> Bundles,
|
||||
string? NextCursor);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle metadata for list results.
|
||||
/// </summary>
|
||||
/// <param name="BundleId">The bundle ID.</param>
|
||||
/// <param name="PeriodStart">Start of collection period.</param>
|
||||
/// <param name="PeriodEnd">End of collection period.</param>
|
||||
/// <param name="AttestationCount">Number of attestations.</param>
|
||||
/// <param name="CreatedAt">Bundle creation timestamp.</param>
|
||||
/// <param name="HasOrgSignature">Whether the bundle has an org signature.</param>
|
||||
public record BundleListItem(
|
||||
string BundleId,
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
int AttestationCount,
|
||||
DateTimeOffset CreatedAt,
|
||||
bool HasOrgSignature);
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle verification.
|
||||
/// </summary>
|
||||
/// <param name="Valid">Whether the bundle is valid.</param>
|
||||
/// <param name="MerkleRootVerified">Whether the Merkle root matches.</param>
|
||||
/// <param name="OrgSignatureVerified">Whether the org signature is valid (if present).</param>
|
||||
/// <param name="AttestationsVerified">Number of attestations verified.</param>
|
||||
/// <param name="Issues">Any verification issues found.</param>
|
||||
/// <param name="VerifiedAt">Verification timestamp.</param>
|
||||
public record BundleVerificationResult(
|
||||
bool Valid,
|
||||
bool MerkleRootVerified,
|
||||
bool? OrgSignatureVerified,
|
||||
int AttestationsVerified,
|
||||
IReadOnlyList<BundleVerificationIssue> Issues,
|
||||
DateTimeOffset VerifiedAt);
|
||||
|
||||
/// <summary>
|
||||
/// A verification issue found during bundle verification.
|
||||
/// </summary>
|
||||
/// <param name="Severity">Issue severity.</param>
|
||||
/// <param name="Code">Machine-readable issue code.</param>
|
||||
/// <param name="Message">Human-readable message.</param>
|
||||
/// <param name="EntryId">Related attestation entry ID, if applicable.</param>
|
||||
public record BundleVerificationIssue(
|
||||
VerificationIssueSeverity Severity,
|
||||
string Code,
|
||||
string Message,
|
||||
string? EntryId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for verification issues.
|
||||
/// </summary>
|
||||
public enum VerificationIssueSeverity
|
||||
{
|
||||
/// <summary>Informational message.</summary>
|
||||
Info,
|
||||
/// <summary>Warning that may affect trust.</summary>
|
||||
Warning,
|
||||
/// <summary>Error that affects verification.</summary>
|
||||
Error,
|
||||
/// <summary>Critical error that invalidates the bundle.</summary>
|
||||
Critical
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IBundleAggregator.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0003 - Implement IBundleAggregator for collecting attestations
|
||||
// Description: Interface for aggregating attestations from storage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for aggregating attestations from storage for bundling.
|
||||
/// </summary>
|
||||
public interface IBundleAggregator
|
||||
{
|
||||
/// <summary>
|
||||
/// Collect attestations for a time period.
|
||||
/// </summary>
|
||||
/// <param name="request">Aggregation parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collected attestations in deterministic order.</returns>
|
||||
IAsyncEnumerable<BundledAttestation> AggregateAsync(
|
||||
AggregationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Count attestations for a time period without loading them.
|
||||
/// </summary>
|
||||
/// <param name="request">Aggregation parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attestation count.</returns>
|
||||
Task<int> CountAsync(
|
||||
AggregationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for attestation aggregation.
|
||||
/// </summary>
|
||||
/// <param name="PeriodStart">Start of the collection period.</param>
|
||||
/// <param name="PeriodEnd">End of the collection period.</param>
|
||||
/// <param name="TenantId">Optional tenant filter.</param>
|
||||
/// <param name="PredicateTypes">Optional filter for specific predicate types.</param>
|
||||
/// <param name="BatchSize">Number of attestations to fetch per batch.</param>
|
||||
public record AggregationRequest(
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
string? TenantId = null,
|
||||
IReadOnlyList<string>? PredicateTypes = null,
|
||||
int BatchSize = 500);
|
||||
@@ -0,0 +1,138 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IBundleStore.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0009 - Implement IBundleStore for S3/RustFS
|
||||
// Description: Interface for bundle storage and retrieval
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Storage abstraction for attestation bundles.
|
||||
/// Supports S3-compatible storage (RustFS) and filesystem backends.
|
||||
/// </summary>
|
||||
public interface IBundleStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Store a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to store.</param>
|
||||
/// <param name="options">Storage options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task StoreBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
BundleStorageOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a bundle by ID.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The bundle if found, null otherwise.</returns>
|
||||
Task<AttestationBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a bundle exists.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the bundle exists.</returns>
|
||||
Task<bool> ExistsAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the bundle was deleted.</returns>
|
||||
Task<bool> DeleteBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List bundle metadata with pagination.
|
||||
/// </summary>
|
||||
/// <param name="request">List parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated list of bundle metadata.</returns>
|
||||
Task<BundleListResult> ListBundlesAsync(
|
||||
BundleListRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Export a bundle to a stream (with optional compression).
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="output">The output stream.</param>
|
||||
/// <param name="options">Export options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task ExportBundleAsync(
|
||||
string bundleId,
|
||||
Stream output,
|
||||
BundleExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle storage.
|
||||
/// </summary>
|
||||
/// <param name="Compression">Compression format (none, gzip, zstd).</param>
|
||||
/// <param name="ObjectLock">Object lock mode for WORM protection.</param>
|
||||
/// <param name="RetentionDays">Retention period in days.</param>
|
||||
public record BundleStorageOptions(
|
||||
BundleCompression Compression = BundleCompression.Zstd,
|
||||
ObjectLockMode ObjectLock = ObjectLockMode.None,
|
||||
int? RetentionDays = null);
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle export.
|
||||
/// </summary>
|
||||
/// <param name="Format">Export format (json or cbor).</param>
|
||||
/// <param name="Compression">Compression format.</param>
|
||||
public record BundleExportOptions(
|
||||
BundleFormat Format = BundleFormat.Json,
|
||||
BundleCompression Compression = BundleCompression.Zstd);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle serialization format.
|
||||
/// </summary>
|
||||
public enum BundleFormat
|
||||
{
|
||||
/// <summary>JSON format for human readability.</summary>
|
||||
Json,
|
||||
/// <summary>CBOR format for compact size.</summary>
|
||||
Cbor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle compression format.
|
||||
/// </summary>
|
||||
public enum BundleCompression
|
||||
{
|
||||
/// <summary>No compression.</summary>
|
||||
None,
|
||||
/// <summary>Gzip compression.</summary>
|
||||
Gzip,
|
||||
/// <summary>Zstandard compression (default).</summary>
|
||||
Zstd
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Object lock mode for WORM protection.
|
||||
/// </summary>
|
||||
public enum ObjectLockMode
|
||||
{
|
||||
/// <summary>No object lock.</summary>
|
||||
None,
|
||||
/// <summary>Governance mode (can be bypassed with special permissions).</summary>
|
||||
Governance,
|
||||
/// <summary>Compliance mode (cannot be bypassed).</summary>
|
||||
Compliance
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOrgKeySigner.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0006 - Implement IOrgKeySigner interface
|
||||
// Description: Interface for organization key signing of bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for signing bundles with organization keys.
|
||||
/// Supports KMS/HSM-backed keys for high-assurance signing.
|
||||
/// </summary>
|
||||
public interface IOrgKeySigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign a bundle digest with an organization key.
|
||||
/// </summary>
|
||||
/// <param name="bundleDigest">SHA-256 digest of the canonical bundle content.</param>
|
||||
/// <param name="keyId">Key identifier to use for signing.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The organization signature.</returns>
|
||||
Task<OrgSignature> SignBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify an organization signature on a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundleDigest">SHA-256 digest of the canonical bundle content.</param>
|
||||
/// <param name="signature">The signature to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the signature is valid.</returns>
|
||||
Task<bool> VerifyBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
OrgSignature signature,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the current signing key ID based on configuration and rotation policy.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The active key ID.</returns>
|
||||
Task<string> GetActiveKeyIdAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List available signing keys.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Available key information.</returns>
|
||||
Task<IReadOnlyList<OrgKeyInfo>> ListKeysAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organization signing key information.
|
||||
/// </summary>
|
||||
/// <param name="KeyId">Unique key identifier.</param>
|
||||
/// <param name="Algorithm">Signing algorithm (e.g., "ECDSA_P256", "Ed25519").</param>
|
||||
/// <param name="Fingerprint">Key fingerprint (SHA-256 of public key).</param>
|
||||
/// <param name="ValidFrom">Start of key validity period.</param>
|
||||
/// <param name="ValidUntil">End of key validity period (null if no expiration).</param>
|
||||
/// <param name="IsActive">Whether this key is currently active for signing.</param>
|
||||
public record OrgKeyInfo(
|
||||
string KeyId,
|
||||
string Algorithm,
|
||||
string Fingerprint,
|
||||
DateTimeOffset ValidFrom,
|
||||
DateTimeOffset? ValidUntil,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,387 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundlingOptions.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0013, 0016 - Bundle retention policy schema and job configuration
|
||||
// Description: Configuration options for attestation bundling and retention
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for attestation bundling.
|
||||
/// </summary>
|
||||
public sealed class BundlingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether bundling is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Schedule configuration for automated bundling.
|
||||
/// </summary>
|
||||
public BundleScheduleOptions Schedule { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Aggregation settings for collecting attestations.
|
||||
/// </summary>
|
||||
public BundleAggregationOptions Aggregation { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Organization key signing settings.
|
||||
/// </summary>
|
||||
public BundleSigningOptions Signing { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Retention policy settings.
|
||||
/// </summary>
|
||||
public BundleRetentionOptions Retention { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Storage settings for bundles.
|
||||
/// </summary>
|
||||
public BundleStorageOptions Storage { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Export settings.
|
||||
/// </summary>
|
||||
public BundleExportOptions Export { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedule options for bundle rotation.
|
||||
/// </summary>
|
||||
public sealed class BundleScheduleOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Cron expression for rotation schedule.
|
||||
/// Default: Monthly on the 1st at 02:00 UTC.
|
||||
/// </summary>
|
||||
public string Cron { get; set; } = "0 2 1 * *";
|
||||
|
||||
/// <summary>
|
||||
/// Rotation cadence.
|
||||
/// </summary>
|
||||
public string Cadence { get; set; } = "monthly";
|
||||
|
||||
/// <summary>
|
||||
/// Timezone for schedule evaluation.
|
||||
/// </summary>
|
||||
public string Timezone { get; set; } = "UTC";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to skip weekends for rotation.
|
||||
/// </summary>
|
||||
public bool SkipWeekends { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregation options for collecting attestations into bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleAggregationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Look-back period in days for attestation collection.
|
||||
/// </summary>
|
||||
public int LookbackDays { get; set; } = 31;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum attestations per bundle.
|
||||
/// If exceeded, multiple bundles are created.
|
||||
/// </summary>
|
||||
public int MaxAttestationsPerBundle { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Batch size for database queries.
|
||||
/// </summary>
|
||||
public int QueryBatchSize { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum attestations required to create a bundle.
|
||||
/// </summary>
|
||||
public int MinAttestationsForBundle { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include failed attestations in bundles.
|
||||
/// </summary>
|
||||
public bool IncludeFailedAttestations { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Predicate types to include. Empty = all types.
|
||||
/// </summary>
|
||||
public IList<string> PredicateTypes { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing options for organization key signing of bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleSigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to sign bundles with organization key.
|
||||
/// </summary>
|
||||
public bool SignWithOrgKey { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Organization key ID to use (null = use active key).
|
||||
/// </summary>
|
||||
public string? OrgKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key rotation configuration.
|
||||
/// </summary>
|
||||
public IList<KeyRotationEntry> KeyRotation { get; set; } = new List<KeyRotationEntry>();
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm.
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = "ECDSA_P256";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include certificate chain in signature.
|
||||
/// </summary>
|
||||
public bool IncludeCertificateChain { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key rotation schedule entry.
|
||||
/// </summary>
|
||||
public sealed class KeyRotationEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Start of key validity.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// End of key validity.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidUntil { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retention policy options for bundle lifecycle management.
|
||||
/// </summary>
|
||||
public sealed class BundleRetentionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether retention policy enforcement is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default retention period in months.
|
||||
/// </summary>
|
||||
public int DefaultMonths { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum retention period in months (cannot be overridden lower).
|
||||
/// </summary>
|
||||
public int MinimumMonths { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retention period in months.
|
||||
/// </summary>
|
||||
public int MaximumMonths { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Per-tenant retention overrides.
|
||||
/// </summary>
|
||||
public IDictionary<string, int> TenantOverrides { get; set; } = new Dictionary<string, int>();
|
||||
|
||||
/// <summary>
|
||||
/// Per-predicate type retention overrides.
|
||||
/// </summary>
|
||||
public IDictionary<string, int> PredicateTypeOverrides { get; set; } = new Dictionary<string, int>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether to delete or archive expired bundles.
|
||||
/// </summary>
|
||||
public RetentionAction ExpiryAction { get; set; } = RetentionAction.Delete;
|
||||
|
||||
/// <summary>
|
||||
/// Archive storage tier for archived bundles.
|
||||
/// </summary>
|
||||
public string ArchiveStorageTier { get; set; } = "glacier";
|
||||
|
||||
/// <summary>
|
||||
/// Grace period in days before deletion (warning period).
|
||||
/// </summary>
|
||||
public int GracePeriodDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to send notifications before bundle expiry.
|
||||
/// </summary>
|
||||
public bool NotifyBeforeExpiry { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Days before expiry to send notification.
|
||||
/// </summary>
|
||||
public int NotifyDaysBeforeExpiry { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bundles to process per retention run.
|
||||
/// </summary>
|
||||
public int MaxBundlesPerRun { get; set; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when a bundle expires.
|
||||
/// </summary>
|
||||
public enum RetentionAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Delete expired bundles permanently.
|
||||
/// </summary>
|
||||
Delete,
|
||||
|
||||
/// <summary>
|
||||
/// Archive expired bundles to cold storage.
|
||||
/// </summary>
|
||||
Archive,
|
||||
|
||||
/// <summary>
|
||||
/// Mark as expired but retain.
|
||||
/// </summary>
|
||||
MarkOnly
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage options for bundle persistence.
|
||||
/// </summary>
|
||||
public sealed class BundleStorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Storage backend type.
|
||||
/// </summary>
|
||||
public string Backend { get; set; } = "s3";
|
||||
|
||||
/// <summary>
|
||||
/// S3 storage configuration.
|
||||
/// </summary>
|
||||
public BundleS3Options S3 { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem storage configuration.
|
||||
/// </summary>
|
||||
public BundleFilesystemOptions Filesystem { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL metadata storage configuration.
|
||||
/// </summary>
|
||||
public BundlePostgresOptions Postgres { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// S3 storage options for bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleS3Options
|
||||
{
|
||||
/// <summary>
|
||||
/// S3 bucket name.
|
||||
/// </summary>
|
||||
public string Bucket { get; set; } = "stellaops-attestor";
|
||||
|
||||
/// <summary>
|
||||
/// Object key prefix.
|
||||
/// </summary>
|
||||
public string Prefix { get; set; } = "bundles/";
|
||||
|
||||
/// <summary>
|
||||
/// Object lock mode for WORM protection.
|
||||
/// </summary>
|
||||
public string? ObjectLock { get; set; } = "governance";
|
||||
|
||||
/// <summary>
|
||||
/// Storage class for new objects.
|
||||
/// </summary>
|
||||
public string StorageClass { get; set; } = "STANDARD";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable server-side encryption.
|
||||
/// </summary>
|
||||
public bool ServerSideEncryption { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// KMS key for encryption.
|
||||
/// </summary>
|
||||
public string? KmsKeyId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem storage options for bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleFilesystemOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base path for bundle storage.
|
||||
/// </summary>
|
||||
public string Path { get; set; } = "/var/lib/stellaops/attestor/bundles";
|
||||
|
||||
/// <summary>
|
||||
/// Directory permissions (octal).
|
||||
/// </summary>
|
||||
public string DirectoryPermissions { get; set; } = "0750";
|
||||
|
||||
/// <summary>
|
||||
/// File permissions (octal).
|
||||
/// </summary>
|
||||
public string FilePermissions { get; set; } = "0640";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL options for bundle metadata.
|
||||
/// </summary>
|
||||
public sealed class BundlePostgresOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema name.
|
||||
/// </summary>
|
||||
public string Schema { get; set; } = "attestor";
|
||||
|
||||
/// <summary>
|
||||
/// Bundles table name.
|
||||
/// </summary>
|
||||
public string BundlesTable { get; set; } = "bundles";
|
||||
|
||||
/// <summary>
|
||||
/// Bundle entries table name.
|
||||
/// </summary>
|
||||
public string EntriesTable { get; set; } = "bundle_entries";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export options for bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleExportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to include bundles in Offline Kit.
|
||||
/// </summary>
|
||||
public bool IncludeInOfflineKit { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Compression algorithm for export.
|
||||
/// </summary>
|
||||
public string Compression { get; set; } = "zstd";
|
||||
|
||||
/// <summary>
|
||||
/// Compression level.
|
||||
/// </summary>
|
||||
public int CompressionLevel { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bundle age to include in exports (months).
|
||||
/// </summary>
|
||||
public int MaxAgeMonths { get; set; } = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Supported export formats.
|
||||
/// </summary>
|
||||
public IList<string> SupportedFormats { get; set; } = new List<string> { "json", "cbor" };
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationBundle.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0002 - Define AttestationBundle record and schema
|
||||
// Description: Aggregated attestation bundle for long-term verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Attestation bundle aggregating multiple attestations for a time period.
|
||||
/// Contains all material needed for offline verification including Merkle tree
|
||||
/// for integrity and optional organization signature for endorsement.
|
||||
/// </summary>
|
||||
public sealed record AttestationBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle metadata including period, version, and creation timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public required BundleMetadata Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All attestations included in this bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestations")]
|
||||
public required IReadOnlyList<BundledAttestation> Attestations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle tree information for bundle integrity verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("merkleTree")]
|
||||
public required MerkleTreeInfo MerkleTree { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional organization signature for bundle endorsement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("orgSignature")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public OrgSignature? OrgSignature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle metadata containing identification and temporal information.
|
||||
/// </summary>
|
||||
public sealed record BundleMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressed bundle ID: sha256:<merkle_root>
|
||||
/// </summary>
|
||||
[JsonPropertyName("bundleId")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle schema version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when this bundle was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of the attestation collection period (inclusive).
|
||||
/// </summary>
|
||||
[JsonPropertyName("periodStart")]
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of the attestation collection period (inclusive).
|
||||
/// </summary>
|
||||
[JsonPropertyName("periodEnd")]
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of attestations in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestationCount")]
|
||||
public required int AttestationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant identifier for multi-tenant deployments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprint of the organization signing key (if signed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("orgKeyFingerprint")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? OrgKeyFingerprint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual attestation entry within a bundle.
|
||||
/// </summary>
|
||||
public sealed record BundledAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique entry identifier (typically the Rekor UUID).
|
||||
/// </summary>
|
||||
[JsonPropertyName("entryId")]
|
||||
public required string EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor UUID if registered with transparency log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorUuid")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RekorUuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if registered with transparency log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 digest of the artifact this attestation covers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type (e.g., "verdict.stella/v1", "sbom.stella/v1").
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the attestation was signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing mode used: "keyless" (Fulcio), "kms", "hsm", or "fido2".
|
||||
/// </summary>
|
||||
[JsonPropertyName("signingMode")]
|
||||
public required string SigningMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity information about the signer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signingIdentity")]
|
||||
public required SigningIdentity SigningIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor inclusion proof for transparency verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inclusionProof")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public RekorInclusionProof? InclusionProof { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The DSSE envelope containing the attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public required DsseEnvelopeData Envelope { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing identity information.
|
||||
/// </summary>
|
||||
public sealed record SigningIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// OIDC issuer URL for keyless signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuer")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject identifier (e.g., email, service account).
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject Alternative Name from certificate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("san")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? San { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier for KMS/HSM signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log inclusion proof.
|
||||
/// </summary>
|
||||
public sealed record RekorInclusionProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Checkpoint containing tree size and root hash.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public required CheckpointData Checkpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle audit path from leaf to root.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required IReadOnlyList<string> Path { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor checkpoint data.
|
||||
/// </summary>
|
||||
public sealed record CheckpointData
|
||||
{
|
||||
/// <summary>
|
||||
/// Log origin identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("origin")]
|
||||
public required string Origin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree size at checkpoint time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded root hash.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rootHash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checkpoint timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope data for serialization.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeData
|
||||
{
|
||||
/// <summary>
|
||||
/// Payload type (e.g., "application/vnd.in-toto+json").
|
||||
/// </summary>
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signatures over the payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signatures")]
|
||||
public required IReadOnlyList<EnvelopeSignature> Signatures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate chain for signature verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("certificateChain")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? CertificateChain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature within a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record EnvelopeSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyid")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle tree information for bundle integrity.
|
||||
/// </summary>
|
||||
public sealed record MerkleTreeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Hash algorithm used (always SHA256).
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = "SHA256";
|
||||
|
||||
/// <summary>
|
||||
/// Merkle root hash in sha256:<hex> format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("root")]
|
||||
public required string Root { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of leaves (attestations) in the tree.
|
||||
/// </summary>
|
||||
[JsonPropertyName("leafCount")]
|
||||
public required int LeafCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organization signature for bundle endorsement.
|
||||
/// </summary>
|
||||
public sealed record OrgSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier used for signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature algorithm (e.g., "ECDSA_P256", "Ed25519", "RSA_PSS_SHA256").
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithm")]
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature over the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public required string Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the signature was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PEM-encoded certificate chain for signature verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("certificateChain")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? CertificateChain { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationBundler.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0005 - Implement IAttestationBundler service
|
||||
// Description: Service implementation for creating attestation bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing attestation bundles.
|
||||
/// Implements deterministic bundling with optional organization signing.
|
||||
/// </summary>
|
||||
public sealed class AttestationBundler : IAttestationBundler
|
||||
{
|
||||
private readonly IBundleAggregator _aggregator;
|
||||
private readonly IBundleStore _store;
|
||||
private readonly IOrgKeySigner? _orgSigner;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly ILogger<AttestationBundler> _logger;
|
||||
private readonly BundlingOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new attestation bundler.
|
||||
/// </summary>
|
||||
public AttestationBundler(
|
||||
IBundleAggregator aggregator,
|
||||
IBundleStore store,
|
||||
IMerkleTreeBuilder merkleBuilder,
|
||||
ILogger<AttestationBundler> logger,
|
||||
IOptions<BundlingOptions> options,
|
||||
IOrgKeySigner? orgSigner = null)
|
||||
{
|
||||
_aggregator = aggregator ?? throw new ArgumentNullException(nameof(aggregator));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new BundlingOptions();
|
||||
_orgSigner = orgSigner;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationBundle> CreateBundleAsync(
|
||||
BundleCreationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Creating attestation bundle for period {PeriodStart} to {PeriodEnd}",
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd);
|
||||
|
||||
// Collect attestations in deterministic order
|
||||
var attestations = await CollectAttestationsAsync(request, cancellationToken);
|
||||
|
||||
if (attestations.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No attestations found for the specified period");
|
||||
throw new InvalidOperationException("No attestations found for the specified period.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Collected {Count} attestations for bundling", attestations.Count);
|
||||
|
||||
// Build deterministic Merkle tree
|
||||
var merkleTree = BuildMerkleTree(attestations);
|
||||
var merkleRoot = Convert.ToHexString(merkleTree.Root).ToLowerInvariant();
|
||||
var bundleId = $"sha256:{merkleRoot}";
|
||||
|
||||
_logger.LogInformation("Computed Merkle root: {MerkleRoot}", bundleId);
|
||||
|
||||
// Create bundle metadata
|
||||
var metadata = new BundleMetadata
|
||||
{
|
||||
BundleId = bundleId,
|
||||
Version = "1.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PeriodStart = request.PeriodStart,
|
||||
PeriodEnd = request.PeriodEnd,
|
||||
AttestationCount = attestations.Count,
|
||||
TenantId = request.TenantId
|
||||
};
|
||||
|
||||
// Create bundle
|
||||
var bundle = new AttestationBundle
|
||||
{
|
||||
Metadata = metadata,
|
||||
Attestations = attestations,
|
||||
MerkleTree = new MerkleTreeInfo
|
||||
{
|
||||
Algorithm = "SHA256",
|
||||
Root = bundleId,
|
||||
LeafCount = attestations.Count
|
||||
}
|
||||
};
|
||||
|
||||
// Sign with organization key if requested
|
||||
if (request.SignWithOrgKey && _orgSigner != null)
|
||||
{
|
||||
bundle = await SignBundleAsync(bundle, request.OrgKeyId, cancellationToken);
|
||||
}
|
||||
|
||||
// Store the bundle
|
||||
await _store.StoreBundleAsync(bundle, cancellationToken: cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created attestation bundle {BundleId} with {Count} attestations",
|
||||
bundleId,
|
||||
attestations.Count);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
return await _store.GetBundleAsync(bundleId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleListResult> ListBundlesAsync(
|
||||
BundleListRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
return await _store.ListBundlesAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleVerificationResult> VerifyBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var issues = new List<BundleVerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Verify Merkle root
|
||||
var merkleValid = VerifyMerkleRoot(bundle, issues);
|
||||
|
||||
// Verify org signature if present
|
||||
bool? orgSigValid = null;
|
||||
if (bundle.OrgSignature != null && _orgSigner != null)
|
||||
{
|
||||
orgSigValid = await VerifyOrgSignatureAsync(bundle, issues, cancellationToken);
|
||||
}
|
||||
|
||||
var valid = merkleValid && (orgSigValid ?? true);
|
||||
|
||||
return new BundleVerificationResult(
|
||||
Valid: valid,
|
||||
MerkleRootVerified: merkleValid,
|
||||
OrgSignatureVerified: orgSigValid,
|
||||
AttestationsVerified: bundle.Attestations.Count,
|
||||
Issues: issues,
|
||||
VerifiedAt: verifiedAt);
|
||||
}
|
||||
|
||||
private async Task<List<BundledAttestation>> CollectAttestationsAsync(
|
||||
BundleCreationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var aggregationRequest = new AggregationRequest(
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd,
|
||||
request.TenantId,
|
||||
null,
|
||||
_options.Aggregation.QueryBatchSize);
|
||||
|
||||
var attestations = new List<BundledAttestation>();
|
||||
|
||||
await foreach (var attestation in _aggregator.AggregateAsync(aggregationRequest, cancellationToken))
|
||||
{
|
||||
attestations.Add(attestation);
|
||||
|
||||
if (attestations.Count >= _options.Aggregation.MaxAttestationsPerBundle)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Reached maximum attestations per bundle limit ({Max})",
|
||||
_options.Aggregation.MaxAttestationsPerBundle);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort deterministically by entry ID for stable Merkle root
|
||||
attestations.Sort((a, b) => string.Compare(a.EntryId, b.EntryId, StringComparison.Ordinal));
|
||||
|
||||
return attestations;
|
||||
}
|
||||
|
||||
private MerkleTreeWithProofs BuildMerkleTree(List<BundledAttestation> attestations)
|
||||
{
|
||||
// Create leaf values from attestation entry IDs (deterministic)
|
||||
var leafValues = attestations
|
||||
.Select(a => (ReadOnlyMemory<byte>)Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
return _merkleBuilder.BuildTree(leafValues);
|
||||
}
|
||||
|
||||
private async Task<AttestationBundle> SignBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
string? keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_orgSigner == null)
|
||||
{
|
||||
throw new InvalidOperationException("Organization signer is not configured.");
|
||||
}
|
||||
|
||||
// Use active key if not specified
|
||||
keyId ??= await _orgSigner.GetActiveKeyIdAsync(cancellationToken);
|
||||
|
||||
// Compute bundle digest (over canonical JSON of Merkle root and attestation IDs)
|
||||
var digestData = ComputeBundleDigest(bundle);
|
||||
|
||||
// Sign the digest
|
||||
var signature = await _orgSigner.SignBundleAsync(digestData, keyId, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signed bundle {BundleId} with org key {KeyId}",
|
||||
bundle.Metadata.BundleId,
|
||||
keyId);
|
||||
|
||||
// Return bundle with signature and updated metadata
|
||||
return bundle with
|
||||
{
|
||||
Metadata = bundle.Metadata with
|
||||
{
|
||||
OrgKeyFingerprint = $"sha256:{ComputeKeyFingerprint(keyId)}"
|
||||
},
|
||||
OrgSignature = signature
|
||||
};
|
||||
}
|
||||
|
||||
private bool VerifyMerkleRoot(AttestationBundle bundle, List<BundleVerificationIssue> issues)
|
||||
{
|
||||
try
|
||||
{
|
||||
var leafValues = bundle.Attestations
|
||||
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
|
||||
.Select(a => (ReadOnlyMemory<byte>)Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
var computedRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
|
||||
var computedRootHex = $"sha256:{Convert.ToHexString(computedRoot).ToLowerInvariant()}";
|
||||
|
||||
if (computedRootHex != bundle.MerkleTree.Root)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"MERKLE_ROOT_MISMATCH",
|
||||
$"Computed Merkle root {computedRootHex} does not match bundle root {bundle.MerkleTree.Root}"));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"MERKLE_VERIFY_ERROR",
|
||||
$"Failed to verify Merkle root: {ex.Message}"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> VerifyOrgSignatureAsync(
|
||||
AttestationBundle bundle,
|
||||
List<BundleVerificationIssue> issues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_orgSigner == null || bundle.OrgSignature == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var digestData = ComputeBundleDigest(bundle);
|
||||
var valid = await _orgSigner.VerifyBundleAsync(digestData, bundle.OrgSignature, cancellationToken);
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
$"Organization signature verification failed for key {bundle.OrgSignature.KeyId}"));
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"ORG_SIG_VERIFY_ERROR",
|
||||
$"Failed to verify organization signature: {ex.Message}"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputeBundleDigest(AttestationBundle bundle)
|
||||
{
|
||||
// Compute digest over merkle root + sorted attestation IDs
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(bundle.MerkleTree.Root);
|
||||
foreach (var attestation in bundle.Attestations.OrderBy(a => a.EntryId, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(attestation.EntryId);
|
||||
}
|
||||
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
}
|
||||
|
||||
private static string ComputeKeyFingerprint(string keyId)
|
||||
{
|
||||
// Simple fingerprint - in production this would use the actual public key
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(keyId));
|
||||
return Convert.ToHexString(hash[..16]).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineKitBundleProvider.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0017 - Integrate with Offline Kit export
|
||||
// Description: Provides attestation bundles for Offline Kit exports
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of an Offline Kit bundle export.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitBundleExportResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundles included in the export.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<BundleExportInfo> Bundles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total attestations across all bundles.
|
||||
/// </summary>
|
||||
public required int TotalAttestations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total export size in bytes.
|
||||
/// </summary>
|
||||
public required long TotalSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Export timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExportedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an exported bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleExportInfo(
|
||||
string BundleId,
|
||||
string FileName,
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
int AttestationCount,
|
||||
long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Options for Offline Kit bundle export.
|
||||
/// </summary>
|
||||
public sealed class OfflineKitExportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum age of bundles to include (in months).
|
||||
/// Default: 12 months.
|
||||
/// </summary>
|
||||
public int MaxAgeMonths { get; set; } = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Export format.
|
||||
/// </summary>
|
||||
public BundleFormat Format { get; set; } = BundleFormat.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Compression algorithm.
|
||||
/// </summary>
|
||||
public BundleCompression Compression { get; set; } = BundleCompression.Zstd;
|
||||
|
||||
/// <summary>
|
||||
/// Include only signed bundles.
|
||||
/// </summary>
|
||||
public bool RequireOrgSignature { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant filter (null = all tenants).
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Offline Kit bundle provider.
|
||||
/// </summary>
|
||||
public interface IOfflineKitBundleProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Export bundles for inclusion in Offline Kit.
|
||||
/// </summary>
|
||||
/// <param name="outputDirectory">Directory to write bundle files.</param>
|
||||
/// <param name="options">Export options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Export result with bundle information.</returns>
|
||||
Task<OfflineKitBundleExportResult> ExportForOfflineKitAsync(
|
||||
string outputDirectory,
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get bundle manifest for Offline Kit.
|
||||
/// </summary>
|
||||
/// <param name="options">Export options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of bundles that would be included.</returns>
|
||||
Task<IReadOnlyList<BundleListItem>> GetOfflineKitManifestAsync(
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides attestation bundles for Offline Kit exports.
|
||||
/// Integrates with the Offline Kit to include bundled attestations
|
||||
/// for long-term offline verification.
|
||||
/// </summary>
|
||||
public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
|
||||
{
|
||||
private readonly IBundleStore _bundleStore;
|
||||
private readonly BundlingOptions _options;
|
||||
private readonly ILogger<OfflineKitBundleProvider> _logger;
|
||||
|
||||
public OfflineKitBundleProvider(
|
||||
IBundleStore bundleStore,
|
||||
IOptions<BundlingOptions> options,
|
||||
ILogger<OfflineKitBundleProvider> logger)
|
||||
{
|
||||
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
_options = options?.Value ?? new BundlingOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<OfflineKitBundleExportResult> ExportForOfflineKitAsync(
|
||||
string outputDirectory,
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new OfflineKitExportOptions();
|
||||
|
||||
if (!_options.Export.IncludeInOfflineKit)
|
||||
{
|
||||
_logger.LogDebug("Offline Kit bundle export is disabled");
|
||||
return new OfflineKitBundleExportResult
|
||||
{
|
||||
Bundles = [],
|
||||
TotalAttestations = 0,
|
||||
TotalSizeBytes = 0,
|
||||
ExportedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exporting bundles for Offline Kit. MaxAge={MaxAge} months, Format={Format}",
|
||||
options.MaxAgeMonths,
|
||||
options.Format);
|
||||
|
||||
// Ensure output directory exists
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
// Get bundles to export
|
||||
var bundles = await GetOfflineKitManifestAsync(options, cancellationToken);
|
||||
|
||||
var exportedBundles = new List<BundleExportInfo>();
|
||||
long totalSize = 0;
|
||||
int totalAttestations = 0;
|
||||
|
||||
foreach (var bundleInfo in bundles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exportInfo = await ExportBundleAsync(
|
||||
bundleInfo,
|
||||
outputDirectory,
|
||||
options,
|
||||
cancellationToken);
|
||||
|
||||
if (exportInfo != null)
|
||||
{
|
||||
exportedBundles.Add(exportInfo);
|
||||
totalSize += exportInfo.SizeBytes;
|
||||
totalAttestations += exportInfo.AttestationCount;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to export bundle {BundleId} for Offline Kit",
|
||||
bundleInfo.BundleId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exported {Count} bundles for Offline Kit. Total: {Attestations} attestations, {Size} bytes",
|
||||
exportedBundles.Count,
|
||||
totalAttestations,
|
||||
totalSize);
|
||||
|
||||
return new OfflineKitBundleExportResult
|
||||
{
|
||||
Bundles = exportedBundles,
|
||||
TotalAttestations = totalAttestations,
|
||||
TotalSizeBytes = totalSize,
|
||||
ExportedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<BundleListItem>> GetOfflineKitManifestAsync(
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new OfflineKitExportOptions();
|
||||
|
||||
var cutoffDate = DateTimeOffset.UtcNow.AddMonths(-options.MaxAgeMonths);
|
||||
var result = new List<BundleListItem>();
|
||||
string? cursor = null;
|
||||
|
||||
do
|
||||
{
|
||||
var listResult = await _bundleStore.ListBundlesAsync(
|
||||
new BundleListRequest(
|
||||
PeriodStart: cutoffDate,
|
||||
TenantId: options.TenantId,
|
||||
Limit: 100,
|
||||
Cursor: cursor),
|
||||
cancellationToken);
|
||||
|
||||
foreach (var bundle in listResult.Bundles)
|
||||
{
|
||||
// Filter by org signature if required
|
||||
if (options.RequireOrgSignature && !bundle.HasOrgSignature)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(bundle);
|
||||
}
|
||||
|
||||
cursor = listResult.NextCursor;
|
||||
}
|
||||
while (cursor != null);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<BundleExportInfo?> ExportBundleAsync(
|
||||
BundleListItem bundleInfo,
|
||||
string outputDirectory,
|
||||
OfflineKitExportOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fileName = GenerateFileName(bundleInfo.BundleId, options);
|
||||
var filePath = Path.Combine(outputDirectory, fileName);
|
||||
|
||||
await using var fileStream = File.Create(filePath);
|
||||
|
||||
await _bundleStore.ExportBundleAsync(
|
||||
bundleInfo.BundleId,
|
||||
fileStream,
|
||||
new Abstractions.BundleExportOptions(options.Format, options.Compression),
|
||||
cancellationToken);
|
||||
|
||||
await fileStream.FlushAsync(cancellationToken);
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Exported bundle {BundleId} to {FileName} ({Size} bytes)",
|
||||
bundleInfo.BundleId,
|
||||
fileName,
|
||||
fileInfo.Length);
|
||||
|
||||
return new BundleExportInfo(
|
||||
bundleInfo.BundleId,
|
||||
fileName,
|
||||
bundleInfo.PeriodStart,
|
||||
bundleInfo.PeriodEnd,
|
||||
bundleInfo.AttestationCount,
|
||||
fileInfo.Length);
|
||||
}
|
||||
|
||||
private static string GenerateFileName(string bundleId, OfflineKitExportOptions options)
|
||||
{
|
||||
// Bundle ID format: sha256:abc123...
|
||||
var hash = bundleId.StartsWith("sha256:")
|
||||
? bundleId[7..Math.Min(bundleId.Length, 7 + 12)]
|
||||
: bundleId[..Math.Min(bundleId.Length, 12)];
|
||||
|
||||
var extension = options.Format switch
|
||||
{
|
||||
BundleFormat.Cbor => ".cbor",
|
||||
_ => ".json"
|
||||
};
|
||||
|
||||
var compression = options.Compression switch
|
||||
{
|
||||
BundleCompression.Gzip => ".gz",
|
||||
BundleCompression.Zstd => ".zst",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
return $"bundle-{hash}{extension}{compression}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RetentionPolicyEnforcer.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0014 - Implement retention policy enforcement
|
||||
// Description: Service for enforcing bundle retention policies
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a retention policy enforcement run.
|
||||
/// </summary>
|
||||
public sealed record RetentionEnforcementResult
|
||||
{
|
||||
/// <summary>
|
||||
/// When the enforcement run started.
|
||||
/// </summary>
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the enforcement run completed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles evaluated.
|
||||
/// </summary>
|
||||
public required int BundlesEvaluated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles deleted.
|
||||
/// </summary>
|
||||
public required int BundlesDeleted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles archived.
|
||||
/// </summary>
|
||||
public required int BundlesArchived { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles marked as expired.
|
||||
/// </summary>
|
||||
public required int BundlesMarkedExpired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles approaching expiry (within notification window).
|
||||
/// </summary>
|
||||
public required int BundlesApproachingExpiry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundles that failed to process.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<BundleEnforcementFailure> Failures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the enforcement run succeeded (no critical failures).
|
||||
/// </summary>
|
||||
public bool Success => Failures.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a bundle that failed retention enforcement.
|
||||
/// </summary>
|
||||
public sealed record BundleEnforcementFailure(
|
||||
string BundleId,
|
||||
string Reason,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Details about a bundle approaching expiry.
|
||||
/// </summary>
|
||||
public sealed record BundleExpiryNotification(
|
||||
string BundleId,
|
||||
string? TenantId,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
int DaysUntilExpiry);
|
||||
|
||||
/// <summary>
|
||||
/// Interface for retention policy enforcement.
|
||||
/// </summary>
|
||||
public interface IRetentionPolicyEnforcer
|
||||
{
|
||||
/// <summary>
|
||||
/// Run retention policy enforcement.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Enforcement result with statistics.</returns>
|
||||
Task<RetentionEnforcementResult> EnforceAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get bundles approaching expiry for notification.
|
||||
/// </summary>
|
||||
/// <param name="daysBeforeExpiry">Days before expiry to check.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of bundles approaching expiry.</returns>
|
||||
Task<IReadOnlyList<BundleExpiryNotification>> GetApproachingExpiryAsync(
|
||||
int daysBeforeExpiry,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Calculate expiry date for a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to evaluate.</param>
|
||||
/// <returns>Expiry date for the bundle.</returns>
|
||||
DateTimeOffset CalculateExpiryDate(BundleListItem bundle);
|
||||
|
||||
/// <summary>
|
||||
/// Calculate expiry date for a bundle with metadata.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant ID.</param>
|
||||
/// <param name="createdAt">Bundle creation date.</param>
|
||||
/// <returns>Expiry date for the bundle.</returns>
|
||||
DateTimeOffset CalculateExpiryDate(string? tenantId, DateTimeOffset createdAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for archiving bundles to cold storage.
|
||||
/// </summary>
|
||||
public interface IBundleArchiver
|
||||
{
|
||||
/// <summary>
|
||||
/// Archive a bundle to cold storage.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID to archive.</param>
|
||||
/// <param name="storageTier">Target storage tier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if archived successfully.</returns>
|
||||
Task<bool> ArchiveAsync(
|
||||
string bundleId,
|
||||
string storageTier,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for notifying about bundle expiry.
|
||||
/// </summary>
|
||||
public interface IBundleExpiryNotifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Send notifications for bundles approaching expiry.
|
||||
/// </summary>
|
||||
/// <param name="notifications">List of expiry notifications.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task NotifyAsync(
|
||||
IReadOnlyList<BundleExpiryNotification> notifications,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for enforcing bundle retention policies.
|
||||
/// Handles expiry, deletion, archival, and notifications.
|
||||
/// </summary>
|
||||
public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
|
||||
{
|
||||
private readonly IBundleStore _bundleStore;
|
||||
private readonly IBundleArchiver? _archiver;
|
||||
private readonly IBundleExpiryNotifier? _notifier;
|
||||
private readonly BundleRetentionOptions _options;
|
||||
private readonly ILogger<RetentionPolicyEnforcer> _logger;
|
||||
|
||||
public RetentionPolicyEnforcer(
|
||||
IBundleStore bundleStore,
|
||||
IOptions<BundlingOptions> options,
|
||||
ILogger<RetentionPolicyEnforcer> logger,
|
||||
IBundleArchiver? archiver = null,
|
||||
IBundleExpiryNotifier? notifier = null)
|
||||
{
|
||||
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
_options = options?.Value?.Retention ?? new BundleRetentionOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_archiver = archiver;
|
||||
_notifier = notifier;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<RetentionEnforcementResult> EnforceAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var failures = new List<BundleEnforcementFailure>();
|
||||
int evaluated = 0;
|
||||
int deleted = 0;
|
||||
int archived = 0;
|
||||
int markedExpired = 0;
|
||||
int approachingExpiry = 0;
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Retention policy enforcement is disabled");
|
||||
return new RetentionEnforcementResult
|
||||
{
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
BundlesEvaluated = 0,
|
||||
BundlesDeleted = 0,
|
||||
BundlesArchived = 0,
|
||||
BundlesMarkedExpired = 0,
|
||||
BundlesApproachingExpiry = 0,
|
||||
Failures = failures
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting retention policy enforcement. ExpiryAction={Action}, DefaultMonths={Months}",
|
||||
_options.ExpiryAction,
|
||||
_options.DefaultMonths);
|
||||
|
||||
// Process bundles in batches
|
||||
string? cursor = null;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var notificationCutoff = now.AddDays(_options.NotifyDaysBeforeExpiry);
|
||||
var gracePeriodCutoff = now.AddDays(-_options.GracePeriodDays);
|
||||
var expiredNotifications = new List<BundleExpiryNotification>();
|
||||
|
||||
do
|
||||
{
|
||||
var listResult = await _bundleStore.ListBundlesAsync(
|
||||
new BundleListRequest(Limit: _options.MaxBundlesPerRun, Cursor: cursor),
|
||||
cancellationToken);
|
||||
|
||||
foreach (var bundle in listResult.Bundles)
|
||||
{
|
||||
evaluated++;
|
||||
var expiryDate = CalculateExpiryDate(bundle);
|
||||
|
||||
// Check if bundle has expired
|
||||
if (expiryDate <= now)
|
||||
{
|
||||
// Check grace period
|
||||
if (expiryDate <= gracePeriodCutoff)
|
||||
{
|
||||
// Past grace period - take expiry action
|
||||
var result = await HandleExpiredBundleAsync(bundle, cancellationToken);
|
||||
if (result.Success)
|
||||
{
|
||||
switch (_options.ExpiryAction)
|
||||
{
|
||||
case RetentionAction.Delete:
|
||||
deleted++;
|
||||
break;
|
||||
case RetentionAction.Archive:
|
||||
archived++;
|
||||
break;
|
||||
case RetentionAction.MarkOnly:
|
||||
markedExpired++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
failures.Add(result.Failure!);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// In grace period - mark as expired but don't delete yet
|
||||
markedExpired++;
|
||||
_logger.LogDebug(
|
||||
"Bundle {BundleId} in grace period, expires {ExpiryDate}",
|
||||
bundle.BundleId,
|
||||
expiryDate);
|
||||
}
|
||||
}
|
||||
// Check if approaching expiry (for notifications)
|
||||
else if (_options.NotifyBeforeExpiry && expiryDate <= notificationCutoff)
|
||||
{
|
||||
approachingExpiry++;
|
||||
expiredNotifications.Add(new BundleExpiryNotification(
|
||||
bundle.BundleId,
|
||||
null, // TenantId not in BundleListItem - would need full bundle fetch
|
||||
bundle.CreatedAt,
|
||||
expiryDate,
|
||||
(int)(expiryDate - now).TotalDays));
|
||||
}
|
||||
}
|
||||
|
||||
cursor = listResult.NextCursor;
|
||||
}
|
||||
while (cursor != null && evaluated < _options.MaxBundlesPerRun);
|
||||
|
||||
// Send notifications for approaching expiry
|
||||
if (_notifier != null && expiredNotifications.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _notifier.NotifyAsync(expiredNotifications, cancellationToken);
|
||||
_logger.LogInformation(
|
||||
"Sent {Count} expiry notifications",
|
||||
expiredNotifications.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send expiry notifications");
|
||||
}
|
||||
}
|
||||
|
||||
var completedAt = DateTimeOffset.UtcNow;
|
||||
_logger.LogInformation(
|
||||
"Retention enforcement completed. Evaluated={Evaluated}, Deleted={Deleted}, Archived={Archived}, Marked={Marked}, Approaching={Approaching}, Failed={Failed}",
|
||||
evaluated, deleted, archived, markedExpired, approachingExpiry, failures.Count);
|
||||
|
||||
return new RetentionEnforcementResult
|
||||
{
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
BundlesEvaluated = evaluated,
|
||||
BundlesDeleted = deleted,
|
||||
BundlesArchived = archived,
|
||||
BundlesMarkedExpired = markedExpired,
|
||||
BundlesApproachingExpiry = approachingExpiry,
|
||||
Failures = failures
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<BundleExpiryNotification>> GetApproachingExpiryAsync(
|
||||
int daysBeforeExpiry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var notifications = new List<BundleExpiryNotification>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var cutoff = now.AddDays(daysBeforeExpiry);
|
||||
string? cursor = null;
|
||||
|
||||
do
|
||||
{
|
||||
var listResult = await _bundleStore.ListBundlesAsync(
|
||||
new BundleListRequest(Limit: 100, Cursor: cursor),
|
||||
cancellationToken);
|
||||
|
||||
foreach (var bundle in listResult.Bundles)
|
||||
{
|
||||
var expiryDate = CalculateExpiryDate(bundle);
|
||||
if (expiryDate > now && expiryDate <= cutoff)
|
||||
{
|
||||
notifications.Add(new BundleExpiryNotification(
|
||||
bundle.BundleId,
|
||||
null,
|
||||
bundle.CreatedAt,
|
||||
expiryDate,
|
||||
(int)(expiryDate - now).TotalDays));
|
||||
}
|
||||
}
|
||||
|
||||
cursor = listResult.NextCursor;
|
||||
}
|
||||
while (cursor != null);
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset CalculateExpiryDate(BundleListItem bundle)
|
||||
{
|
||||
return CalculateExpiryDate(null, bundle.CreatedAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset CalculateExpiryDate(string? tenantId, DateTimeOffset createdAt)
|
||||
{
|
||||
int retentionMonths = _options.DefaultMonths;
|
||||
|
||||
// Check for tenant-specific override
|
||||
if (!string.IsNullOrEmpty(tenantId) &&
|
||||
_options.TenantOverrides.TryGetValue(tenantId, out var tenantMonths))
|
||||
{
|
||||
retentionMonths = Math.Max(tenantMonths, _options.MinimumMonths);
|
||||
retentionMonths = Math.Min(retentionMonths, _options.MaximumMonths);
|
||||
}
|
||||
|
||||
return createdAt.AddMonths(retentionMonths);
|
||||
}
|
||||
|
||||
private async Task<(bool Success, BundleEnforcementFailure? Failure)> HandleExpiredBundleAsync(
|
||||
BundleListItem bundle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (_options.ExpiryAction)
|
||||
{
|
||||
case RetentionAction.Delete:
|
||||
var deleted = await _bundleStore.DeleteBundleAsync(bundle.BundleId, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
_logger.LogInformation("Deleted expired bundle {BundleId}", bundle.BundleId);
|
||||
return (true, null);
|
||||
}
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Delete failed",
|
||||
"Bundle could not be deleted"));
|
||||
|
||||
case RetentionAction.Archive:
|
||||
if (_archiver == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Archive action configured but no archiver available for bundle {BundleId}",
|
||||
bundle.BundleId);
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Archive unavailable",
|
||||
"No archiver configured"));
|
||||
}
|
||||
|
||||
var archived = await _archiver.ArchiveAsync(
|
||||
bundle.BundleId,
|
||||
_options.ArchiveStorageTier,
|
||||
cancellationToken);
|
||||
|
||||
if (archived)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Archived expired bundle {BundleId} to {Tier}",
|
||||
bundle.BundleId,
|
||||
_options.ArchiveStorageTier);
|
||||
return (true, null);
|
||||
}
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Archive failed",
|
||||
"Bundle could not be archived"));
|
||||
|
||||
case RetentionAction.MarkOnly:
|
||||
_logger.LogDebug("Marked bundle {BundleId} as expired", bundle.BundleId);
|
||||
return (true, null);
|
||||
|
||||
default:
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Unknown action",
|
||||
$"Unsupported expiry action: {_options.ExpiryAction}"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to process expired bundle {BundleId}",
|
||||
bundle.BundleId);
|
||||
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Exception",
|
||||
ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KmsOrgKeySigner.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0007 - Implement KmsOrgKeySigner
|
||||
// Description: KMS-backed organization key signing for bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// KMS-backed organization key signer for attestation bundles.
|
||||
/// Supports AWS KMS, Azure Key Vault, Google Cloud KMS, and HashiCorp Vault.
|
||||
/// </summary>
|
||||
public sealed class KmsOrgKeySigner : IOrgKeySigner
|
||||
{
|
||||
private readonly IKmsProvider _kmsProvider;
|
||||
private readonly ILogger<KmsOrgKeySigner> _logger;
|
||||
private readonly OrgSigningOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new KMS organization key signer.
|
||||
/// </summary>
|
||||
public KmsOrgKeySigner(
|
||||
IKmsProvider kmsProvider,
|
||||
ILogger<KmsOrgKeySigner> logger,
|
||||
IOptions<OrgSigningOptions> options)
|
||||
{
|
||||
_kmsProvider = kmsProvider ?? throw new ArgumentNullException(nameof(kmsProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new OrgSigningOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OrgSignature> SignBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundleDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
_logger.LogInformation("Signing bundle with org key {KeyId}", keyId);
|
||||
|
||||
// Get key metadata
|
||||
var keyInfo = await _kmsProvider.GetKeyInfoAsync(keyId, cancellationToken);
|
||||
if (keyInfo == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Signing key '{keyId}' not found in KMS.");
|
||||
}
|
||||
|
||||
// Verify key is active
|
||||
if (!keyInfo.IsActive)
|
||||
{
|
||||
throw new InvalidOperationException($"Signing key '{keyId}' is not active.");
|
||||
}
|
||||
|
||||
// Check key expiry
|
||||
if (keyInfo.ValidUntil.HasValue && keyInfo.ValidUntil.Value < DateTimeOffset.UtcNow)
|
||||
{
|
||||
throw new InvalidOperationException($"Signing key '{keyId}' has expired.");
|
||||
}
|
||||
|
||||
// Sign the digest
|
||||
var signatureBytes = await _kmsProvider.SignAsync(
|
||||
keyId,
|
||||
bundleDigest,
|
||||
keyInfo.Algorithm,
|
||||
cancellationToken);
|
||||
|
||||
// Get certificate chain if available
|
||||
var certChain = await _kmsProvider.GetCertificateChainAsync(keyId, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Successfully signed bundle with key {KeyId}, algorithm {Algorithm}",
|
||||
keyId,
|
||||
keyInfo.Algorithm);
|
||||
|
||||
return new OrgSignature
|
||||
{
|
||||
KeyId = keyId,
|
||||
Algorithm = keyInfo.Algorithm,
|
||||
Signature = Convert.ToBase64String(signatureBytes),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = certChain
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> VerifyBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
OrgSignature signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundleDigest);
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(signature.Signature);
|
||||
|
||||
var isValid = await _kmsProvider.VerifyAsync(
|
||||
signature.KeyId,
|
||||
bundleDigest,
|
||||
signatureBytes,
|
||||
signature.Algorithm,
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bundle signature verification {Result} for key {KeyId}",
|
||||
isValid ? "succeeded" : "failed",
|
||||
signature.KeyId);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Bundle signature verification failed for key {KeyId}",
|
||||
signature.KeyId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> GetActiveKeyIdAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check for configured active key
|
||||
if (!string.IsNullOrEmpty(_options.ActiveKeyId))
|
||||
{
|
||||
return _options.ActiveKeyId;
|
||||
}
|
||||
|
||||
// List keys and find the active one based on rotation policy
|
||||
var keys = await ListKeysAsync(cancellationToken);
|
||||
var activeKey = keys
|
||||
.Where(k => k.IsActive)
|
||||
.Where(k => !k.ValidUntil.HasValue || k.ValidUntil.Value > DateTimeOffset.UtcNow)
|
||||
.OrderByDescending(k => k.ValidFrom)
|
||||
.FirstOrDefault();
|
||||
|
||||
return activeKey?.KeyId
|
||||
?? throw new InvalidOperationException("No active signing key found.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<OrgKeyInfo>> ListKeysAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var kmsKeys = await _kmsProvider.ListKeysAsync(_options.KeyPrefix, cancellationToken);
|
||||
|
||||
return kmsKeys
|
||||
.Select(k => new OrgKeyInfo(
|
||||
k.KeyId,
|
||||
k.Algorithm,
|
||||
k.Fingerprint,
|
||||
k.ValidFrom,
|
||||
k.ValidUntil,
|
||||
k.IsActive))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for organization signing.
|
||||
/// </summary>
|
||||
public sealed class OrgSigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The active key ID to use for signing.
|
||||
/// If not set, the most recent active key is used.
|
||||
/// </summary>
|
||||
public string? ActiveKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key prefix for filtering keys in KMS.
|
||||
/// </summary>
|
||||
public string KeyPrefix { get; set; } = "stellaops/org-signing/";
|
||||
|
||||
/// <summary>
|
||||
/// Default signing algorithm.
|
||||
/// </summary>
|
||||
public string DefaultAlgorithm { get; set; } = "ECDSA_P256";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for KMS provider abstraction.
|
||||
/// </summary>
|
||||
public interface IKmsProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign data with a KMS key.
|
||||
/// </summary>
|
||||
Task<byte[]> SignAsync(
|
||||
string keyId,
|
||||
byte[] data,
|
||||
string algorithm,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a signature with a KMS key.
|
||||
/// </summary>
|
||||
Task<bool> VerifyAsync(
|
||||
string keyId,
|
||||
byte[] data,
|
||||
byte[] signature,
|
||||
string algorithm,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get information about a key.
|
||||
/// </summary>
|
||||
Task<KmsKeyInfo?> GetKeyInfoAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List keys matching a prefix.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KmsKeyInfo>> ListKeysAsync(
|
||||
string? prefix = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the certificate chain for a key.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>?> GetCertificateChainAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// KMS key information.
|
||||
/// </summary>
|
||||
public sealed record KmsKeyInfo(
|
||||
string KeyId,
|
||||
string Algorithm,
|
||||
string Fingerprint,
|
||||
DateTimeOffset ValidFrom,
|
||||
DateTimeOffset? ValidUntil,
|
||||
bool IsActive);
|
||||
|
||||
/// <summary>
|
||||
/// Local (in-memory) key signer for testing and development.
|
||||
/// </summary>
|
||||
public sealed class LocalOrgKeySigner : IOrgKeySigner
|
||||
{
|
||||
private readonly Dictionary<string, (ECDsa Key, OrgKeyInfo Info)> _keys = new();
|
||||
private readonly ILogger<LocalOrgKeySigner> _logger;
|
||||
private string? _activeKeyId;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new local key signer.
|
||||
/// </summary>
|
||||
public LocalOrgKeySigner(ILogger<LocalOrgKeySigner> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate and add a new key.
|
||||
/// </summary>
|
||||
public void AddKey(string keyId, bool isActive = true)
|
||||
{
|
||||
var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyBytes = key.ExportSubjectPublicKeyInfo();
|
||||
var fingerprint = Convert.ToHexString(SHA256.HashData(publicKeyBytes)).ToLowerInvariant();
|
||||
|
||||
var info = new OrgKeyInfo(
|
||||
keyId,
|
||||
"ECDSA_P256",
|
||||
fingerprint,
|
||||
DateTimeOffset.UtcNow,
|
||||
null,
|
||||
isActive);
|
||||
|
||||
_keys[keyId] = (key, info);
|
||||
|
||||
if (isActive)
|
||||
{
|
||||
_activeKeyId = keyId;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Added local signing key {KeyId}", keyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<OrgSignature> SignBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_keys.TryGetValue(keyId, out var keyPair))
|
||||
{
|
||||
throw new InvalidOperationException($"Key '{keyId}' not found.");
|
||||
}
|
||||
|
||||
var signature = keyPair.Key.SignData(bundleDigest, HashAlgorithmName.SHA256);
|
||||
|
||||
return Task.FromResult(new OrgSignature
|
||||
{
|
||||
KeyId = keyId,
|
||||
Algorithm = "ECDSA_P256",
|
||||
Signature = Convert.ToBase64String(signature),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = null
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> VerifyBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
OrgSignature signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_keys.TryGetValue(signature.KeyId, out var keyPair))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(signature.Signature);
|
||||
var isValid = keyPair.Key.VerifyData(bundleDigest, signatureBytes, HashAlgorithmName.SHA256);
|
||||
return Task.FromResult(isValid);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> GetActiveKeyIdAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_activeKeyId == null)
|
||||
{
|
||||
throw new InvalidOperationException("No active signing key.");
|
||||
}
|
||||
return Task.FromResult(_activeKeyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<OrgKeyInfo>> ListKeysAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<OrgKeyInfo>>(
|
||||
_keys.Values.Select(k => k.Info).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Bundling</RootNamespace>
|
||||
<Description>Attestation bundle aggregation and rotation for long-term verification in air-gapped environments.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,104 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOfflineRootStore.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0003 - Implement IOfflineRootStore interface
|
||||
// Description: Interface for loading trust roots for offline verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Store for trust roots used in offline verification.
|
||||
/// Provides access to Fulcio roots, organization signing keys, and Rekor checkpoints.
|
||||
/// </summary>
|
||||
public interface IOfflineRootStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Get Fulcio root certificates for keyless signature verification.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of Fulcio root certificates.</returns>
|
||||
Task<X509Certificate2Collection> GetFulcioRootsAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get organization signing keys for bundle signature verification.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of organization signing certificates.</returns>
|
||||
Task<X509Certificate2Collection> GetOrgSigningKeysAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get Rekor public keys for checkpoint verification.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of Rekor public key certificates.</returns>
|
||||
Task<X509Certificate2Collection> GetRekorKeysAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Import root certificates from a PEM file.
|
||||
/// </summary>
|
||||
/// <param name="pemPath">Path to the PEM file.</param>
|
||||
/// <param name="rootType">Type of roots being imported.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task ImportRootsAsync(
|
||||
string pemPath,
|
||||
RootType rootType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific organization key by ID.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The certificate if found, null otherwise.</returns>
|
||||
Task<X509Certificate2?> GetOrgKeyByIdAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List all available root certificates with metadata.
|
||||
/// </summary>
|
||||
/// <param name="rootType">Type of roots to list.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Root certificate metadata.</returns>
|
||||
Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
|
||||
RootType rootType,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of trust root.
|
||||
/// </summary>
|
||||
public enum RootType
|
||||
{
|
||||
/// <summary>Fulcio root certificates for keyless signing.</summary>
|
||||
Fulcio,
|
||||
/// <summary>Organization signing keys for bundle endorsement.</summary>
|
||||
OrgSigning,
|
||||
/// <summary>Rekor public keys for transparency log verification.</summary>
|
||||
Rekor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a root certificate.
|
||||
/// </summary>
|
||||
/// <param name="Thumbprint">Certificate thumbprint (SHA-256).</param>
|
||||
/// <param name="Subject">Certificate subject DN.</param>
|
||||
/// <param name="Issuer">Certificate issuer DN.</param>
|
||||
/// <param name="NotBefore">Certificate validity start.</param>
|
||||
/// <param name="NotAfter">Certificate validity end.</param>
|
||||
/// <param name="KeyId">Optional key identifier.</param>
|
||||
/// <param name="RootType">Type of this root certificate.</param>
|
||||
public record RootCertificateInfo(
|
||||
string Thumbprint,
|
||||
string Subject,
|
||||
string Issuer,
|
||||
DateTimeOffset NotBefore,
|
||||
DateTimeOffset NotAfter,
|
||||
string? KeyId,
|
||||
RootType RootType);
|
||||
@@ -0,0 +1,70 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOfflineVerifier.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0005 - Implement IOfflineVerifier interface
|
||||
// Description: Interface for offline verification of attestation bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for offline verification of attestation bundles.
|
||||
/// Enables air-gapped environments to verify attestations using bundled proofs
|
||||
/// and locally stored root certificates.
|
||||
/// </summary>
|
||||
public interface IOfflineVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify an attestation bundle offline.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The attestation bundle to verify.</param>
|
||||
/// <param name="options">Verification options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result with detailed status.</returns>
|
||||
Task<OfflineVerificationResult> VerifyBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a single attestation within a bundle offline.
|
||||
/// </summary>
|
||||
/// <param name="attestation">The attestation to verify.</param>
|
||||
/// <param name="options">Verification options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result for the single attestation.</returns>
|
||||
Task<OfflineVerificationResult> VerifyAttestationAsync(
|
||||
BundledAttestation attestation,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify an attestation for a specific artifact digest.
|
||||
/// Looks up the attestation in the bundle by artifact digest.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">The artifact digest to look up.</param>
|
||||
/// <param name="bundlePath">Path to the bundle file.</param>
|
||||
/// <param name="options">Verification options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result for attestations covering the artifact.</returns>
|
||||
Task<OfflineVerificationResult> VerifyByArtifactAsync(
|
||||
string artifactDigest,
|
||||
string bundlePath,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get verification summaries for all attestations in a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to summarize.</param>
|
||||
/// <param name="options">Verification options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of attestation verification summaries.</returns>
|
||||
Task<IReadOnlyList<AttestationVerificationSummary>> GetVerificationSummariesAsync(
|
||||
AttestationBundle bundle,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineVerificationResult.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0002 - Define OfflineVerificationResult and options
|
||||
// Description: Models for offline verification results
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of offline verification of an attestation bundle.
|
||||
/// </summary>
|
||||
/// <param name="Valid">Whether all verification checks passed.</param>
|
||||
/// <param name="MerkleProofValid">Whether the Merkle proof verification passed.</param>
|
||||
/// <param name="SignaturesValid">Whether all DSSE signatures are valid.</param>
|
||||
/// <param name="CertificateChainValid">Whether certificate chains validate to trusted roots.</param>
|
||||
/// <param name="OrgSignatureValid">Whether the organization signature is valid.</param>
|
||||
/// <param name="OrgSignatureKeyId">Key ID used for org signature (if present).</param>
|
||||
/// <param name="VerifiedAt">Timestamp when verification was performed.</param>
|
||||
/// <param name="Issues">List of verification issues found.</param>
|
||||
public record OfflineVerificationResult(
|
||||
bool Valid,
|
||||
bool MerkleProofValid,
|
||||
bool SignaturesValid,
|
||||
bool CertificateChainValid,
|
||||
bool OrgSignatureValid,
|
||||
string? OrgSignatureKeyId,
|
||||
DateTimeOffset VerifiedAt,
|
||||
IReadOnlyList<VerificationIssue> Issues);
|
||||
|
||||
/// <summary>
|
||||
/// A single verification issue.
|
||||
/// </summary>
|
||||
/// <param name="Severity">Issue severity level.</param>
|
||||
/// <param name="Code">Machine-readable issue code.</param>
|
||||
/// <param name="Message">Human-readable message.</param>
|
||||
/// <param name="AttestationId">Related attestation ID, if applicable.</param>
|
||||
public record VerificationIssue(
|
||||
VerificationIssueSeverity Severity,
|
||||
string Code,
|
||||
string Message,
|
||||
string? AttestationId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for verification issues.
|
||||
/// </summary>
|
||||
public enum VerificationIssueSeverity
|
||||
{
|
||||
/// <summary>Informational message.</summary>
|
||||
Info,
|
||||
/// <summary>Warning that may affect trust.</summary>
|
||||
Warning,
|
||||
/// <summary>Error that affects verification.</summary>
|
||||
Error,
|
||||
/// <summary>Critical error that invalidates verification.</summary>
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for offline verification.
|
||||
/// </summary>
|
||||
/// <param name="VerifyMerkleProof">Whether to verify Merkle inclusion proofs.</param>
|
||||
/// <param name="VerifySignatures">Whether to verify DSSE signatures.</param>
|
||||
/// <param name="VerifyCertificateChain">Whether to verify certificate chains.</param>
|
||||
/// <param name="VerifyOrgSignature">Whether to verify organization signature.</param>
|
||||
/// <param name="RequireOrgSignature">Fail if org signature is missing.</param>
|
||||
/// <param name="FulcioRootPath">Path to Fulcio root certificates (overrides default).</param>
|
||||
/// <param name="OrgKeyPath">Path to organization signing keys (overrides default).</param>
|
||||
/// <param name="StrictMode">Enable strict verification (all checks must pass).</param>
|
||||
public record OfflineVerificationOptions(
|
||||
bool VerifyMerkleProof = true,
|
||||
bool VerifySignatures = true,
|
||||
bool VerifyCertificateChain = true,
|
||||
bool VerifyOrgSignature = true,
|
||||
bool RequireOrgSignature = false,
|
||||
string? FulcioRootPath = null,
|
||||
string? OrgKeyPath = null,
|
||||
bool StrictMode = false);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of an attestation for verification reporting.
|
||||
/// </summary>
|
||||
/// <param name="EntryId">Attestation entry ID.</param>
|
||||
/// <param name="ArtifactDigest">Artifact digest covered by this attestation.</param>
|
||||
/// <param name="PredicateType">Predicate type.</param>
|
||||
/// <param name="SignedAt">When the attestation was signed.</param>
|
||||
/// <param name="SigningIdentity">Identity that signed the attestation.</param>
|
||||
/// <param name="VerificationStatus">Status of this attestation's verification.</param>
|
||||
public record AttestationVerificationSummary(
|
||||
string EntryId,
|
||||
string ArtifactDigest,
|
||||
string PredicateType,
|
||||
DateTimeOffset SignedAt,
|
||||
string? SigningIdentity,
|
||||
AttestationVerificationStatus VerificationStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Verification status of an individual attestation.
|
||||
/// </summary>
|
||||
public enum AttestationVerificationStatus
|
||||
{
|
||||
/// <summary>Verification passed.</summary>
|
||||
Valid,
|
||||
/// <summary>Signature verification failed.</summary>
|
||||
InvalidSignature,
|
||||
/// <summary>Certificate chain verification failed.</summary>
|
||||
InvalidCertificateChain,
|
||||
/// <summary>Merkle inclusion proof failed.</summary>
|
||||
InvalidMerkleProof,
|
||||
/// <summary>Verification encountered an error.</summary>
|
||||
Error
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FileSystemRootStore.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0004 - Implement FileSystemRootStore
|
||||
// Description: File-based root certificate store for offline verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Services;
|
||||
|
||||
/// <summary>
|
||||
/// File system-based implementation of IOfflineRootStore.
|
||||
/// Loads root certificates from configured paths for offline verification.
|
||||
/// </summary>
|
||||
public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
{
|
||||
private readonly ILogger<FileSystemRootStore> _logger;
|
||||
private readonly OfflineRootStoreOptions _options;
|
||||
|
||||
private X509Certificate2Collection? _fulcioRoots;
|
||||
private X509Certificate2Collection? _orgSigningKeys;
|
||||
private X509Certificate2Collection? _rekorKeys;
|
||||
private readonly SemaphoreSlim _loadLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new file system root store.
|
||||
/// </summary>
|
||||
public FileSystemRootStore(
|
||||
ILogger<FileSystemRootStore> logger,
|
||||
IOptions<OfflineRootStoreOptions> options)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new OfflineRootStoreOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<X509Certificate2Collection> GetFulcioRootsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_fulcioRoots == null)
|
||||
{
|
||||
await LoadRootsAsync(RootType.Fulcio, cancellationToken);
|
||||
}
|
||||
|
||||
return _fulcioRoots ?? new X509Certificate2Collection();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<X509Certificate2Collection> GetOrgSigningKeysAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_orgSigningKeys == null)
|
||||
{
|
||||
await LoadRootsAsync(RootType.OrgSigning, cancellationToken);
|
||||
}
|
||||
|
||||
return _orgSigningKeys ?? new X509Certificate2Collection();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<X509Certificate2Collection> GetRekorKeysAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_rekorKeys == null)
|
||||
{
|
||||
await LoadRootsAsync(RootType.Rekor, cancellationToken);
|
||||
}
|
||||
|
||||
return _rekorKeys ?? new X509Certificate2Collection();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ImportRootsAsync(
|
||||
string pemPath,
|
||||
RootType rootType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pemPath);
|
||||
|
||||
if (!File.Exists(pemPath))
|
||||
{
|
||||
throw new FileNotFoundException($"PEM file not found: {pemPath}");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Importing {RootType} roots from {Path}", rootType, pemPath);
|
||||
|
||||
var pemContent = await File.ReadAllTextAsync(pemPath, cancellationToken);
|
||||
var certs = ParsePemCertificates(pemContent);
|
||||
|
||||
if (certs.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No certificates found in {pemPath}");
|
||||
}
|
||||
|
||||
// Get target directory based on root type
|
||||
var targetDir = GetRootDirectory(rootType);
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
// Save each certificate
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
var thumbprint = ComputeThumbprint(cert);
|
||||
var targetPath = Path.Combine(targetDir, $"{thumbprint}.pem");
|
||||
|
||||
var pemBytes = Encoding.UTF8.GetBytes(
|
||||
"-----BEGIN CERTIFICATE-----\n" +
|
||||
Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks) +
|
||||
"\n-----END CERTIFICATE-----\n");
|
||||
|
||||
await File.WriteAllBytesAsync(targetPath, pemBytes, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Imported certificate {Subject} with thumbprint {Thumbprint}",
|
||||
cert.Subject,
|
||||
thumbprint);
|
||||
}
|
||||
|
||||
// Invalidate cache to reload
|
||||
InvalidateCache(rootType);
|
||||
|
||||
_logger.LogInformation("Imported {Count} {RootType} certificates", certs.Count, rootType);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<X509Certificate2?> GetOrgKeyByIdAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
var keys = await GetOrgSigningKeysAsync(cancellationToken);
|
||||
|
||||
foreach (var cert in keys)
|
||||
{
|
||||
// Check various key identifier extensions
|
||||
var ski = cert.Extensions["2.5.29.14"]; // Subject Key Identifier
|
||||
if (ski != null)
|
||||
{
|
||||
var skiData = ski.RawData;
|
||||
var skiHex = Convert.ToHexString(skiData).ToLowerInvariant();
|
||||
if (skiHex.Contains(keyId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return cert;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check thumbprint
|
||||
if (ComputeThumbprint(cert).Equals(keyId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return cert;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
|
||||
RootType rootType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var certs = rootType switch
|
||||
{
|
||||
RootType.Fulcio => await GetFulcioRootsAsync(cancellationToken),
|
||||
RootType.OrgSigning => await GetOrgSigningKeysAsync(cancellationToken),
|
||||
RootType.Rekor => await GetRekorKeysAsync(cancellationToken),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(rootType))
|
||||
};
|
||||
|
||||
var result = new List<RootCertificateInfo>();
|
||||
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
result.Add(new RootCertificateInfo(
|
||||
Thumbprint: ComputeThumbprint(cert),
|
||||
Subject: cert.Subject,
|
||||
Issuer: cert.Issuer,
|
||||
NotBefore: new DateTimeOffset(cert.NotBefore.ToUniversalTime(), TimeSpan.Zero),
|
||||
NotAfter: new DateTimeOffset(cert.NotAfter.ToUniversalTime(), TimeSpan.Zero),
|
||||
KeyId: GetSubjectKeyIdentifier(cert),
|
||||
RootType: rootType));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task LoadRootsAsync(RootType rootType, CancellationToken cancellationToken)
|
||||
{
|
||||
await _loadLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Double-check after acquiring lock
|
||||
if (GetCachedCollection(rootType) != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var path = GetRootPath(rootType);
|
||||
var collection = new X509Certificate2Collection();
|
||||
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// Single file
|
||||
var certs = await LoadPemFileAsync(path, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
}
|
||||
else if (Directory.Exists(path))
|
||||
{
|
||||
// Directory of PEM files
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*.pem"))
|
||||
{
|
||||
var certs = await LoadPemFileAsync(file, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also try Offline Kit path if configured
|
||||
var offlineKitPath = GetOfflineKitPath(rootType);
|
||||
if (!string.IsNullOrEmpty(offlineKitPath) && Directory.Exists(offlineKitPath))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(offlineKitPath, "*.pem"))
|
||||
{
|
||||
var certs = await LoadPemFileAsync(file, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
}
|
||||
}
|
||||
|
||||
SetCachedCollection(rootType, collection);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded {Count} {RootType} certificates",
|
||||
collection.Count,
|
||||
rootType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<X509Certificate2Collection> LoadPemFileAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pemContent = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
return ParsePemCertificates(pemContent);
|
||||
}
|
||||
|
||||
private static X509Certificate2Collection ParsePemCertificates(string pemContent)
|
||||
{
|
||||
var collection = new X509Certificate2Collection();
|
||||
|
||||
const string beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const string endMarker = "-----END CERTIFICATE-----";
|
||||
|
||||
var startIndex = 0;
|
||||
while (true)
|
||||
{
|
||||
var begin = pemContent.IndexOf(beginMarker, startIndex, StringComparison.Ordinal);
|
||||
if (begin < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var end = pemContent.IndexOf(endMarker, begin, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var base64Start = begin + beginMarker.Length;
|
||||
var base64Content = pemContent[base64Start..end]
|
||||
.Replace("\r", "")
|
||||
.Replace("\n", "")
|
||||
.Trim();
|
||||
|
||||
var certBytes = Convert.FromBase64String(base64Content);
|
||||
collection.Add(new X509Certificate2(certBytes));
|
||||
|
||||
startIndex = end + endMarker.Length;
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
private string GetRootPath(RootType rootType) => rootType switch
|
||||
{
|
||||
RootType.Fulcio => _options.FulcioBundlePath ?? "",
|
||||
RootType.OrgSigning => _options.OrgSigningBundlePath ?? "",
|
||||
RootType.Rekor => _options.RekorBundlePath ?? "",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private string GetRootDirectory(RootType rootType) => rootType switch
|
||||
{
|
||||
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"),
|
||||
_ => _options.BaseRootPath
|
||||
};
|
||||
|
||||
private string? GetOfflineKitPath(RootType rootType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_options.OfflineKitPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return rootType switch
|
||||
{
|
||||
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"),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private X509Certificate2Collection? GetCachedCollection(RootType rootType) => rootType switch
|
||||
{
|
||||
RootType.Fulcio => _fulcioRoots,
|
||||
RootType.OrgSigning => _orgSigningKeys,
|
||||
RootType.Rekor => _rekorKeys,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private void SetCachedCollection(RootType rootType, X509Certificate2Collection collection)
|
||||
{
|
||||
switch (rootType)
|
||||
{
|
||||
case RootType.Fulcio:
|
||||
_fulcioRoots = collection;
|
||||
break;
|
||||
case RootType.OrgSigning:
|
||||
_orgSigningKeys = collection;
|
||||
break;
|
||||
case RootType.Rekor:
|
||||
_rekorKeys = collection;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void InvalidateCache(RootType rootType)
|
||||
{
|
||||
switch (rootType)
|
||||
{
|
||||
case RootType.Fulcio:
|
||||
_fulcioRoots = null;
|
||||
break;
|
||||
case RootType.OrgSigning:
|
||||
_orgSigningKeys = null;
|
||||
break;
|
||||
case RootType.Rekor:
|
||||
_rekorKeys = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeThumbprint(X509Certificate2 cert)
|
||||
{
|
||||
var hash = SHA256.HashData(cert.RawData);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? GetSubjectKeyIdentifier(X509Certificate2 cert)
|
||||
{
|
||||
var extension = cert.Extensions["2.5.29.14"];
|
||||
if (extension == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip the ASN.1 header (typically 2 bytes for OCTET STRING)
|
||||
var data = extension.RawData;
|
||||
if (data.Length > 2 && data[0] == 0x04) // OCTET STRING
|
||||
{
|
||||
var length = data[1];
|
||||
if (data.Length >= 2 + length)
|
||||
{
|
||||
return Convert.ToHexString(data[2..(2 + length)]).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
return Convert.ToHexString(data).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the file system root store.
|
||||
/// </summary>
|
||||
public sealed class OfflineRootStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base path for all root certificates.
|
||||
/// </summary>
|
||||
public string BaseRootPath { get; set; } = "/etc/stellaops/roots";
|
||||
|
||||
/// <summary>
|
||||
/// Path to Fulcio root certificates (file or directory).
|
||||
/// </summary>
|
||||
public string? FulcioBundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to organization signing keys (file or directory).
|
||||
/// </summary>
|
||||
public string? OrgSigningBundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to Rekor public keys (file or directory).
|
||||
/// </summary>
|
||||
public string? RekorBundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to Offline Kit installation.
|
||||
/// </summary>
|
||||
public string? OfflineKitPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use roots from the Offline Kit.
|
||||
/// </summary>
|
||||
public bool UseOfflineKit { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineVerifier.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0006 - Implement OfflineVerifier service
|
||||
// Description: Offline verification service for attestation bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
// Alias to resolve ambiguity with Bundling.Abstractions.VerificationIssueSeverity
|
||||
using Severity = StellaOps.Attestor.Offline.Models.VerificationIssueSeverity;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Offline verification service for attestation bundles.
|
||||
/// Enables air-gapped environments to verify attestations using bundled proofs.
|
||||
/// </summary>
|
||||
public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
private readonly IOfflineRootStore _rootStore;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly IOrgKeySigner? _orgSigner;
|
||||
private readonly ILogger<OfflineVerifier> _logger;
|
||||
private readonly OfflineVerificationConfig _config;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new offline verifier.
|
||||
/// </summary>
|
||||
public OfflineVerifier(
|
||||
IOfflineRootStore rootStore,
|
||||
IMerkleTreeBuilder merkleBuilder,
|
||||
ILogger<OfflineVerifier> logger,
|
||||
IOptions<OfflineVerificationConfig> config,
|
||||
IOrgKeySigner? orgSigner = null)
|
||||
{
|
||||
_rootStore = rootStore ?? throw new ArgumentNullException(nameof(rootStore));
|
||||
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_config = config?.Value ?? new OfflineVerificationConfig();
|
||||
_orgSigner = orgSigner;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OfflineVerificationResult> VerifyBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
var issues = new List<VerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting offline verification of bundle {BundleId} with {Count} attestations",
|
||||
bundle.Metadata.BundleId,
|
||||
bundle.Attestations.Count);
|
||||
|
||||
// 1. Verify bundle Merkle root
|
||||
var merkleValid = true;
|
||||
if (options.VerifyMerkleProof)
|
||||
{
|
||||
merkleValid = VerifyMerkleTree(bundle, issues);
|
||||
}
|
||||
|
||||
// 2. Verify org signature (if present and required)
|
||||
var orgSigValid = true;
|
||||
string? orgSigKeyId = null;
|
||||
if (bundle.OrgSignature != null)
|
||||
{
|
||||
orgSigKeyId = bundle.OrgSignature.KeyId;
|
||||
if (options.VerifyOrgSignature)
|
||||
{
|
||||
orgSigValid = await VerifyOrgSignatureAsync(bundle, issues, cancellationToken);
|
||||
}
|
||||
}
|
||||
else if (options.RequireOrgSignature)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_MISSING",
|
||||
"Required organization signature is missing"));
|
||||
orgSigValid = false;
|
||||
}
|
||||
|
||||
// 3. Verify each attestation
|
||||
var signaturesValid = true;
|
||||
var certsValid = true;
|
||||
|
||||
if (options.VerifySignatures || options.VerifyCertificateChain)
|
||||
{
|
||||
var fulcioRoots = options.VerifyCertificateChain
|
||||
? await _rootStore.GetFulcioRootsAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
foreach (var attestation in bundle.Attestations)
|
||||
{
|
||||
// Verify DSSE signature
|
||||
if (options.VerifySignatures)
|
||||
{
|
||||
var sigValid = VerifyDsseSignature(attestation, issues);
|
||||
if (!sigValid)
|
||||
{
|
||||
signaturesValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify certificate chain
|
||||
if (options.VerifyCertificateChain && fulcioRoots != null)
|
||||
{
|
||||
var chainValid = VerifyCertificateChain(attestation, fulcioRoots, issues);
|
||||
if (!chainValid)
|
||||
{
|
||||
certsValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Rekor inclusion proof (if present)
|
||||
if (options.VerifyMerkleProof && attestation.InclusionProof != null)
|
||||
{
|
||||
VerifyRekorInclusionProof(attestation, issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var valid = merkleValid && signaturesValid && certsValid && orgSigValid;
|
||||
|
||||
if (options.StrictMode && issues.Any(i => i.Severity >= Severity.Warning))
|
||||
{
|
||||
valid = false;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Offline verification of bundle {BundleId} completed: {Status}",
|
||||
bundle.Metadata.BundleId,
|
||||
valid ? "VALID" : "INVALID");
|
||||
|
||||
return new OfflineVerificationResult(
|
||||
Valid: valid,
|
||||
MerkleProofValid: merkleValid,
|
||||
SignaturesValid: signaturesValid,
|
||||
CertificateChainValid: certsValid,
|
||||
OrgSignatureValid: orgSigValid,
|
||||
OrgSignatureKeyId: orgSigKeyId,
|
||||
VerifiedAt: verifiedAt,
|
||||
Issues: issues);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OfflineVerificationResult> VerifyAttestationAsync(
|
||||
BundledAttestation attestation,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
var issues = new List<VerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting offline verification of attestation {EntryId}",
|
||||
attestation.EntryId);
|
||||
|
||||
var signaturesValid = true;
|
||||
var certsValid = true;
|
||||
var merkleValid = true;
|
||||
|
||||
// Verify DSSE signature
|
||||
if (options.VerifySignatures)
|
||||
{
|
||||
signaturesValid = VerifyDsseSignature(attestation, issues);
|
||||
}
|
||||
|
||||
// Verify certificate chain
|
||||
if (options.VerifyCertificateChain)
|
||||
{
|
||||
var fulcioRoots = await _rootStore.GetFulcioRootsAsync(cancellationToken);
|
||||
certsValid = VerifyCertificateChain(attestation, fulcioRoots, issues);
|
||||
}
|
||||
|
||||
// Verify Rekor inclusion proof
|
||||
if (options.VerifyMerkleProof && attestation.InclusionProof != null)
|
||||
{
|
||||
merkleValid = VerifyRekorInclusionProof(attestation, issues);
|
||||
}
|
||||
|
||||
var valid = signaturesValid && certsValid && merkleValid;
|
||||
|
||||
return new OfflineVerificationResult(
|
||||
Valid: valid,
|
||||
MerkleProofValid: merkleValid,
|
||||
SignaturesValid: signaturesValid,
|
||||
CertificateChainValid: certsValid,
|
||||
OrgSignatureValid: true, // Not applicable for single attestation
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: verifiedAt,
|
||||
Issues: issues);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OfflineVerificationResult> VerifyByArtifactAsync(
|
||||
string artifactDigest,
|
||||
string bundlePath,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loading bundle from {Path} to verify artifact {Digest}",
|
||||
bundlePath,
|
||||
artifactDigest);
|
||||
|
||||
// Load bundle from file
|
||||
var bundle = await LoadBundleAsync(bundlePath, cancellationToken);
|
||||
|
||||
// Find attestations for this artifact
|
||||
var matchingAttestations = bundle.Attestations
|
||||
.Where(a => a.ArtifactDigest.Equals(artifactDigest, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (matchingAttestations.Count == 0)
|
||||
{
|
||||
return new OfflineVerificationResult(
|
||||
Valid: false,
|
||||
MerkleProofValid: false,
|
||||
SignaturesValid: false,
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: DateTimeOffset.UtcNow,
|
||||
Issues: new List<VerificationIssue>
|
||||
{
|
||||
new(Severity.Critical,
|
||||
"ARTIFACT_NOT_FOUND",
|
||||
$"No attestations found for artifact {artifactDigest}")
|
||||
});
|
||||
}
|
||||
|
||||
// Create a filtered bundle with only matching attestations
|
||||
var filteredBundle = bundle with
|
||||
{
|
||||
Attestations = matchingAttestations
|
||||
};
|
||||
|
||||
return await VerifyBundleAsync(filteredBundle, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AttestationVerificationSummary>> GetVerificationSummariesAsync(
|
||||
AttestationBundle bundle,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
var summaries = new List<AttestationVerificationSummary>();
|
||||
|
||||
var fulcioRoots = options.VerifyCertificateChain
|
||||
? await _rootStore.GetFulcioRootsAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
foreach (var attestation in bundle.Attestations)
|
||||
{
|
||||
var issues = new List<VerificationIssue>();
|
||||
var status = AttestationVerificationStatus.Valid;
|
||||
|
||||
// Verify signature
|
||||
if (options.VerifySignatures && !VerifyDsseSignature(attestation, issues))
|
||||
{
|
||||
status = AttestationVerificationStatus.InvalidSignature;
|
||||
}
|
||||
|
||||
// Verify certificate chain
|
||||
if (status == AttestationVerificationStatus.Valid &&
|
||||
options.VerifyCertificateChain &&
|
||||
fulcioRoots != null &&
|
||||
!VerifyCertificateChain(attestation, fulcioRoots, issues))
|
||||
{
|
||||
status = AttestationVerificationStatus.InvalidCertificateChain;
|
||||
}
|
||||
|
||||
// Verify Merkle proof
|
||||
if (status == AttestationVerificationStatus.Valid &&
|
||||
options.VerifyMerkleProof &&
|
||||
attestation.InclusionProof != null &&
|
||||
!VerifyRekorInclusionProof(attestation, issues))
|
||||
{
|
||||
status = AttestationVerificationStatus.InvalidMerkleProof;
|
||||
}
|
||||
|
||||
// Get signing identity
|
||||
var identity = attestation.SigningIdentity.Subject ??
|
||||
attestation.SigningIdentity.San ??
|
||||
attestation.SigningIdentity.KeyId;
|
||||
|
||||
summaries.Add(new AttestationVerificationSummary(
|
||||
EntryId: attestation.EntryId,
|
||||
ArtifactDigest: attestation.ArtifactDigest,
|
||||
PredicateType: attestation.PredicateType,
|
||||
SignedAt: attestation.SignedAt,
|
||||
SigningIdentity: identity,
|
||||
VerificationStatus: status));
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private bool VerifyMerkleTree(AttestationBundle bundle, List<VerificationIssue> issues)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Sort attestations deterministically
|
||||
var sortedAttestations = bundle.Attestations
|
||||
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Create leaf values from entry IDs
|
||||
var leafValues = sortedAttestations
|
||||
.Select(a => (ReadOnlyMemory<byte>)Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
var computedRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
|
||||
var computedRootHex = $"sha256:{Convert.ToHexString(computedRoot).ToLowerInvariant()}";
|
||||
|
||||
if (computedRootHex != bundle.MerkleTree.Root)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"MERKLE_ROOT_MISMATCH",
|
||||
$"Computed Merkle root {computedRootHex} does not match bundle root {bundle.MerkleTree.Root}"));
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Merkle root verified: {Root}", bundle.MerkleTree.Root);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"MERKLE_VERIFY_ERROR",
|
||||
$"Failed to verify Merkle root: {ex.Message}"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> VerifyOrgSignatureAsync(
|
||||
AttestationBundle bundle,
|
||||
List<VerificationIssue> issues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (bundle.OrgSignature == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Compute bundle digest
|
||||
var digestData = ComputeBundleDigest(bundle);
|
||||
|
||||
// Try using the org signer if available
|
||||
if (_orgSigner != null)
|
||||
{
|
||||
var valid = await _orgSigner.VerifyBundleAsync(
|
||||
digestData,
|
||||
bundle.OrgSignature,
|
||||
cancellationToken);
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
$"Organization signature verification failed for key {bundle.OrgSignature.KeyId}"));
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
// Try using certificate from root store
|
||||
var cert = await _rootStore.GetOrgKeyByIdAsync(
|
||||
bundle.OrgSignature.KeyId,
|
||||
cancellationToken);
|
||||
|
||||
if (cert == null)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_KEY_NOT_FOUND",
|
||||
$"Organization key {bundle.OrgSignature.KeyId} not found in root store"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify signature using the certificate
|
||||
var signatureBytes = Convert.FromBase64String(bundle.OrgSignature.Signature);
|
||||
var algorithm = bundle.OrgSignature.Algorithm switch
|
||||
{
|
||||
"ECDSA_P256" => HashAlgorithmName.SHA256,
|
||||
"Ed25519" => HashAlgorithmName.SHA256, // Ed25519 handles its own hashing
|
||||
"RSA_PSS_SHA256" => HashAlgorithmName.SHA256,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
|
||||
using var pubKey = cert.GetECDsaPublicKey();
|
||||
if (pubKey != null)
|
||||
{
|
||||
var valid = pubKey.VerifyData(digestData, signatureBytes, algorithm);
|
||||
if (!valid)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
$"ECDSA signature verification failed"));
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
using var rsaKey = cert.GetRSAPublicKey();
|
||||
if (rsaKey != null)
|
||||
{
|
||||
var valid = rsaKey.VerifyData(
|
||||
digestData,
|
||||
signatureBytes,
|
||||
algorithm,
|
||||
RSASignaturePadding.Pss);
|
||||
if (!valid)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
$"RSA signature verification failed"));
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_KEY_UNSUPPORTED",
|
||||
$"Unsupported key type for organization signature verification"));
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_VERIFY_ERROR",
|
||||
$"Failed to verify organization signature: {ex.Message}"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool VerifyDsseSignature(BundledAttestation attestation, List<VerificationIssue> issues)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (attestation.Envelope.Signatures.Count == 0)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_NO_SIGNATURES",
|
||||
$"No signatures in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify at least one signature is present and has non-empty sig
|
||||
foreach (var sig in attestation.Envelope.Signatures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sig.Sig))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_EMPTY_SIG",
|
||||
$"Empty signature in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Full cryptographic verification requires the certificate chain
|
||||
// Here we just validate structure; chain verification handles crypto
|
||||
_logger.LogDebug("DSSE envelope structure verified for {EntryId}", attestation.EntryId);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_VERIFY_ERROR",
|
||||
$"Failed to verify DSSE signature for {attestation.EntryId}: {ex.Message}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool VerifyCertificateChain(
|
||||
BundledAttestation attestation,
|
||||
X509Certificate2Collection fulcioRoots,
|
||||
List<VerificationIssue> issues)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (attestation.Envelope.CertificateChain == null ||
|
||||
attestation.Envelope.CertificateChain.Count == 0)
|
||||
{
|
||||
// Keyful attestations may not have certificate chains
|
||||
if (attestation.SigningMode == "keyless")
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"CERT_CHAIN_MISSING",
|
||||
$"Keyless attestation {attestation.EntryId} missing certificate chain",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // Non-keyless attestations may use other verification
|
||||
}
|
||||
|
||||
// Parse leaf certificate
|
||||
var leafPem = attestation.Envelope.CertificateChain[0];
|
||||
var leafCert = ParseCertificateFromPem(leafPem);
|
||||
if (leafCert == null)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"CERT_PARSE_FAILED",
|
||||
$"Failed to parse leaf certificate for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build chain
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // Offline mode
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
|
||||
// Add intermediates
|
||||
foreach (var certPem in attestation.Envelope.CertificateChain.Skip(1))
|
||||
{
|
||||
var cert = ParseCertificateFromPem(certPem);
|
||||
if (cert != null)
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
// Add Fulcio roots
|
||||
foreach (var root in fulcioRoots)
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(root);
|
||||
}
|
||||
|
||||
// Build and verify
|
||||
var built = chain.Build(leafCert);
|
||||
if (!built)
|
||||
{
|
||||
var statusInfo = string.Join(", ",
|
||||
chain.ChainStatus.Select(s => $"{s.Status}: {s.StatusInformation}"));
|
||||
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Warning,
|
||||
"CERT_CHAIN_BUILD_FAILED",
|
||||
$"Certificate chain build failed for {attestation.EntryId}: {statusInfo}",
|
||||
attestation.EntryId));
|
||||
}
|
||||
|
||||
// Verify chain terminates at a Fulcio root
|
||||
var chainRoot = chain.ChainElements[^1].Certificate;
|
||||
var matchesRoot = fulcioRoots.Any(r =>
|
||||
r.Thumbprint.Equals(chainRoot.Thumbprint, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!matchesRoot)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"CERT_CHAIN_UNTRUSTED",
|
||||
$"Certificate chain for {attestation.EntryId} does not terminate at trusted Fulcio root",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Certificate chain verified for {EntryId}", attestation.EntryId);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"CERT_VERIFY_ERROR",
|
||||
$"Failed to verify certificate chain for {attestation.EntryId}: {ex.Message}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool VerifyRekorInclusionProof(
|
||||
BundledAttestation attestation,
|
||||
List<VerificationIssue> issues)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (attestation.InclusionProof == null)
|
||||
{
|
||||
return true; // Not required if not present
|
||||
}
|
||||
|
||||
// Basic validation of proof structure
|
||||
if (attestation.InclusionProof.Path.Count == 0)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Warning,
|
||||
"REKOR_PROOF_EMPTY",
|
||||
$"Empty Rekor inclusion proof path for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(attestation.InclusionProof.Checkpoint.RootHash))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Warning,
|
||||
"REKOR_CHECKPOINT_MISSING",
|
||||
$"Missing Rekor checkpoint root hash for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Full verification would recompute the Merkle path
|
||||
// For offline verification, we trust the bundled proof
|
||||
_logger.LogDebug(
|
||||
"Rekor inclusion proof present for {EntryId} at index {Index}",
|
||||
attestation.EntryId,
|
||||
attestation.RekorLogIndex);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Warning,
|
||||
"REKOR_PROOF_ERROR",
|
||||
$"Failed to verify Rekor inclusion proof for {attestation.EntryId}: {ex.Message}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputeBundleDigest(AttestationBundle bundle)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(bundle.MerkleTree.Root);
|
||||
foreach (var attestation in bundle.Attestations.OrderBy(a => a.EntryId, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(attestation.EntryId);
|
||||
}
|
||||
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
}
|
||||
|
||||
private static X509Certificate2? ParseCertificateFromPem(string pem)
|
||||
{
|
||||
try
|
||||
{
|
||||
const string beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const string endMarker = "-----END CERTIFICATE-----";
|
||||
|
||||
var begin = pem.IndexOf(beginMarker, StringComparison.Ordinal);
|
||||
var end = pem.IndexOf(endMarker, StringComparison.Ordinal);
|
||||
|
||||
if (begin < 0 || end < 0)
|
||||
{
|
||||
// Try as raw base64
|
||||
var certBytes = Convert.FromBase64String(pem.Trim());
|
||||
return new X509Certificate2(certBytes);
|
||||
}
|
||||
|
||||
var base64Start = begin + beginMarker.Length;
|
||||
var base64Content = pem[base64Start..end]
|
||||
.Replace("\r", "")
|
||||
.Replace("\n", "")
|
||||
.Trim();
|
||||
|
||||
var bytes = Convert.FromBase64String(base64Content);
|
||||
return new X509Certificate2(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AttestationBundle> LoadBundleAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
var bundle = await JsonSerializer.DeserializeAsync<AttestationBundle>(
|
||||
stream,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return bundle ?? throw new InvalidOperationException($"Failed to deserialize bundle from {path}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for offline verification.
|
||||
/// </summary>
|
||||
public sealed class OfflineVerificationConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable strict mode by default.
|
||||
/// </summary>
|
||||
public bool StrictModeDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Require organization signature by default.
|
||||
/// </summary>
|
||||
public bool RequireOrgSignatureDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow verification of unbundled attestations.
|
||||
/// </summary>
|
||||
public bool AllowUnbundled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bundle cache size in MB.
|
||||
/// </summary>
|
||||
public int MaxCacheSizeMb { get; set; } = 1024;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Offline</RootNamespace>
|
||||
<Description>Offline verification of attestation bundles for air-gapped environments.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,374 @@
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for AIAuthorityClassifier.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-22
|
||||
/// </summary>
|
||||
public sealed class AIAuthorityClassifierTests
|
||||
{
|
||||
private static readonly AIModelIdentifier TestModelId = new()
|
||||
{
|
||||
Provider = "anthropic",
|
||||
Model = "claude-3-opus",
|
||||
Version = "20240229"
|
||||
};
|
||||
|
||||
private static readonly AIDecodingParameters TestDecodingParams = new()
|
||||
{
|
||||
Temperature = 0.0,
|
||||
Seed = 12345
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ClassifyExplanation_HighCitationRate_ReturnsEvidenceBacked()
|
||||
{
|
||||
// Arrange
|
||||
var classifier = new AIAuthorityClassifier();
|
||||
var predicate = CreateExplanationPredicate(citationRate: 0.85, confidenceScore: 0.8, verifiedRate: 0.95);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyExplanation(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.EvidenceBacked, result.Authority);
|
||||
Assert.True(result.QualityScore > 0.7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyExplanation_LowCitationRate_ReturnsSuggestion()
|
||||
{
|
||||
// Arrange
|
||||
var classifier = new AIAuthorityClassifier();
|
||||
var predicate = CreateExplanationPredicate(citationRate: 0.5, confidenceScore: 0.6, verifiedRate: 0.7);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyExplanation(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.Suggestion, result.Authority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyExplanation_VeryHighQuality_ReturnsAuthorityThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var thresholds = new AIAuthorityThresholds { AuthorityThresholdScore = 0.9 };
|
||||
var classifier = new AIAuthorityClassifier(thresholds);
|
||||
var predicate = CreateExplanationPredicate(citationRate: 0.98, confidenceScore: 0.95, verifiedRate: 1.0);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyExplanation(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.AuthorityThreshold, result.Authority);
|
||||
Assert.True(result.CanAutoProcess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyRemediationPlan_WithResolvableEvidence_ReturnsEvidenceBacked()
|
||||
{
|
||||
// Arrange
|
||||
Func<string, bool> resolver = _ => true; // All evidence is resolvable
|
||||
var classifier = new AIAuthorityClassifier(evidenceResolver: resolver);
|
||||
var predicate = CreateRemediationPredicate(evidenceCount: 5, prReady: true);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyRemediationPlan(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.EvidenceBacked, result.Authority);
|
||||
Assert.Equal(5, result.ResolvableEvidenceCount);
|
||||
Assert.Equal(0, result.UnresolvableEvidenceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyRemediationPlan_WithUnresolvableEvidence_ReturnsSuggestion()
|
||||
{
|
||||
// Arrange
|
||||
Func<string, bool> resolver = ref => ref.Contains("valid"); // Only some evidence is resolvable
|
||||
var classifier = new AIAuthorityClassifier(evidenceResolver: resolver);
|
||||
var predicate = CreateRemediationPredicate(evidenceCount: 5, prReady: false);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyRemediationPlan(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.Suggestion, result.Authority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyVexDraft_AutoApprovable_CanAutoProcess()
|
||||
{
|
||||
// Arrange
|
||||
var classifier = new AIAuthorityClassifier();
|
||||
var predicate = CreateVexDraftPredicate(
|
||||
avgConfidence: 0.95,
|
||||
evidenceCount: 3,
|
||||
hasConflicts: false);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyVexDraft(predicate);
|
||||
|
||||
// Assert
|
||||
// Note: CanAutoProcess depends on AutoApprovable in the predicate
|
||||
Assert.True(result.QualityScore > 0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyPolicyDraft_AllTestsPassed_HighQuality()
|
||||
{
|
||||
// Arrange
|
||||
var classifier = new AIAuthorityClassifier();
|
||||
var predicate = CreatePolicyDraftPredicate(
|
||||
avgConfidence: 0.9,
|
||||
passedTestCount: 5,
|
||||
totalTestCount: 5,
|
||||
validationPassed: true);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyPolicyDraft(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.QualityScore > 0.7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyPolicyDraft_FailedTests_LowerQuality()
|
||||
{
|
||||
// Arrange
|
||||
var classifier = new AIAuthorityClassifier();
|
||||
var predicate = CreatePolicyDraftPredicate(
|
||||
avgConfidence: 0.9,
|
||||
passedTestCount: 2,
|
||||
totalTestCount: 5,
|
||||
validationPassed: false);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyPolicyDraft(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.QualityScore < 0.7);
|
||||
Assert.False(result.CanAutoProcess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomThresholds_AreRespected()
|
||||
{
|
||||
// Arrange
|
||||
var thresholds = new AIAuthorityThresholds
|
||||
{
|
||||
MinCitationRate = 0.5,
|
||||
MinConfidenceScore = 0.5,
|
||||
MinVerifiedCitationRate = 0.5
|
||||
};
|
||||
var classifier = new AIAuthorityClassifier(thresholds);
|
||||
var predicate = CreateExplanationPredicate(citationRate: 0.6, confidenceScore: 0.6, verifiedRate: 0.6);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyExplanation(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.EvidenceBacked, result.Authority);
|
||||
}
|
||||
|
||||
private static AIExplanationPredicate CreateExplanationPredicate(
|
||||
double citationRate,
|
||||
double confidenceScore,
|
||||
double verifiedRate)
|
||||
{
|
||||
var totalCitations = 10;
|
||||
var verifiedCitations = (int)(totalCitations * verifiedRate);
|
||||
|
||||
var citations = new List<AIExplanationCitation>();
|
||||
for (int i = 0; i < totalCitations; i++)
|
||||
{
|
||||
citations.Add(new AIExplanationCitation
|
||||
{
|
||||
ClaimIndex = i,
|
||||
ClaimText = $"Claim {i}",
|
||||
EvidenceId = $"sha256:evidence{i}",
|
||||
EvidenceType = "sbom",
|
||||
Verified = i < verifiedCitations
|
||||
});
|
||||
}
|
||||
|
||||
return new AIExplanationPredicate
|
||||
{
|
||||
ArtifactId = "sha256:test123",
|
||||
ModelId = TestModelId,
|
||||
PromptTemplateVersion = "explanation@v1",
|
||||
DecodingParams = TestDecodingParams,
|
||||
InputHashes = ["sha256:input1"],
|
||||
Authority = AIArtifactAuthority.Suggestion,
|
||||
GeneratedAt = "2025-12-26T00:00:00Z",
|
||||
OutputHash = "sha256:output1",
|
||||
ExplanationType = AIExplanationType.Exploitability,
|
||||
Content = "This is a test explanation with sufficient content.",
|
||||
Citations = citations,
|
||||
ConfidenceScore = confidenceScore,
|
||||
CitationRate = citationRate,
|
||||
Subject = "CVE-2025-1234"
|
||||
};
|
||||
}
|
||||
|
||||
private static AIRemediationPlanPredicate CreateRemediationPredicate(int evidenceCount, bool prReady)
|
||||
{
|
||||
var evidenceRefs = new List<string>();
|
||||
for (int i = 0; i < evidenceCount; i++)
|
||||
{
|
||||
evidenceRefs.Add($"sha256:evidence{i}");
|
||||
}
|
||||
|
||||
return new AIRemediationPlanPredicate
|
||||
{
|
||||
ArtifactId = "sha256:test123",
|
||||
ModelId = TestModelId,
|
||||
PromptTemplateVersion = "remediation@v1",
|
||||
DecodingParams = TestDecodingParams,
|
||||
InputHashes = ["sha256:input1"],
|
||||
Authority = AIArtifactAuthority.Suggestion,
|
||||
GeneratedAt = "2025-12-26T00:00:00Z",
|
||||
OutputHash = "sha256:output1",
|
||||
VulnerabilityId = "CVE-2025-1234",
|
||||
AffectedComponent = "pkg:npm/example@1.0.0",
|
||||
Steps =
|
||||
[
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
ActionType = RemediationActionType.PackageUpgrade,
|
||||
Description = "Upgrade package",
|
||||
Target = "pkg:npm/example@1.0.0",
|
||||
ProposedValue = "1.0.1",
|
||||
RiskReduction = 0.8,
|
||||
CanAutomate = true
|
||||
}
|
||||
],
|
||||
ExpectedDelta = 0.7,
|
||||
RiskAssessment = new RemediationRiskAssessment
|
||||
{
|
||||
RiskBefore = 0.9,
|
||||
RiskAfter = 0.2,
|
||||
BreakingChanges = []
|
||||
},
|
||||
VerificationStatus = RemediationVerificationStatus.Verified,
|
||||
PrReady = prReady,
|
||||
EvidenceRefs = evidenceRefs
|
||||
};
|
||||
}
|
||||
|
||||
private static AIVexDraftPredicate CreateVexDraftPredicate(
|
||||
double avgConfidence,
|
||||
int evidenceCount,
|
||||
bool hasConflicts)
|
||||
{
|
||||
var evidenceRefs = new List<string>();
|
||||
for (int i = 0; i < evidenceCount; i++)
|
||||
{
|
||||
evidenceRefs.Add($"sha256:evidence{i}");
|
||||
}
|
||||
|
||||
return new AIVexDraftPredicate
|
||||
{
|
||||
ArtifactId = "sha256:test123",
|
||||
ModelId = TestModelId,
|
||||
PromptTemplateVersion = "vexdraft@v1",
|
||||
DecodingParams = TestDecodingParams,
|
||||
InputHashes = ["sha256:input1"],
|
||||
Authority = AIArtifactAuthority.Suggestion,
|
||||
GeneratedAt = "2025-12-26T00:00:00Z",
|
||||
OutputHash = "sha256:output1",
|
||||
VexStatements =
|
||||
[
|
||||
new AIVexStatementDraft
|
||||
{
|
||||
VulnerabilityId = "CVE-2025-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = "not_affected",
|
||||
Justification = "vulnerable_code_not_in_execute_path",
|
||||
Confidence = avgConfidence,
|
||||
SupportingEvidence = evidenceRefs
|
||||
}
|
||||
],
|
||||
Justifications =
|
||||
[
|
||||
new AIVexJustification
|
||||
{
|
||||
StatementIndex = 0,
|
||||
Reasoning = "Code path analysis shows function is never called",
|
||||
EvidencePoints = ["Reachability analysis", "Call graph"],
|
||||
ConflictsWithExisting = hasConflicts
|
||||
}
|
||||
],
|
||||
EvidenceRefs = evidenceRefs,
|
||||
TargetFormat = "openvex",
|
||||
AutoApprovable = !hasConflicts && avgConfidence > 0.9,
|
||||
Scope = "image",
|
||||
ScopeId = "sha256:image123"
|
||||
};
|
||||
}
|
||||
|
||||
private static AIPolicyDraftPredicate CreatePolicyDraftPredicate(
|
||||
double avgConfidence,
|
||||
int passedTestCount,
|
||||
int totalTestCount,
|
||||
bool validationPassed)
|
||||
{
|
||||
var testCases = new List<PolicyRuleTestCase>();
|
||||
for (int i = 0; i < totalTestCount; i++)
|
||||
{
|
||||
testCases.Add(new PolicyRuleTestCase
|
||||
{
|
||||
TestId = $"test-{i}",
|
||||
RuleId = "rule-1",
|
||||
Description = $"Test case {i}",
|
||||
Input = "{}",
|
||||
ExpectedOutcome = "pass",
|
||||
Passed = i < passedTestCount
|
||||
});
|
||||
}
|
||||
|
||||
return new AIPolicyDraftPredicate
|
||||
{
|
||||
ArtifactId = "sha256:test123",
|
||||
ModelId = TestModelId,
|
||||
PromptTemplateVersion = "policydraft@v1",
|
||||
DecodingParams = TestDecodingParams,
|
||||
InputHashes = ["sha256:input1"],
|
||||
Authority = AIArtifactAuthority.Suggestion,
|
||||
GeneratedAt = "2025-12-26T00:00:00Z",
|
||||
OutputHash = "sha256:output1",
|
||||
NaturalLanguageInput = "Block critical CVEs in production",
|
||||
Rules =
|
||||
[
|
||||
new AIPolicyRuleDraft
|
||||
{
|
||||
RuleId = "rule-1",
|
||||
RuleType = PolicyRuleType.Gate,
|
||||
Name = "Block Critical CVEs",
|
||||
Description = "Block deployments with critical vulnerabilities",
|
||||
Condition = "severity == 'critical' && environment == 'prod'",
|
||||
Action = "block",
|
||||
Priority = 100,
|
||||
OriginalInput = "Block critical CVEs in production",
|
||||
Confidence = avgConfidence
|
||||
}
|
||||
],
|
||||
TestCases = testCases,
|
||||
ValidationResult = new PolicyValidationResult
|
||||
{
|
||||
SyntaxValid = true,
|
||||
SemanticsValid = validationPassed,
|
||||
OverallPassed = validationPassed
|
||||
},
|
||||
TargetPolicyPack = "default",
|
||||
TargetVersion = "1.0.0",
|
||||
DetectedIntents = ["gate", "severity-filter", "environment-scope"],
|
||||
DeployReady = validationPassed
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AuditHashLogger.cs
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-19
|
||||
// Description: Pre-canonical hash debug logging for audit trails
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Logs both raw and canonical SHA-256 hashes for audit trails.
|
||||
/// Enables debugging of canonicalization issues by comparing pre/post hashes.
|
||||
/// </summary>
|
||||
public sealed class AuditHashLogger
|
||||
{
|
||||
private readonly ILogger<AuditHashLogger> _logger;
|
||||
private readonly bool _enableDetailedLogging;
|
||||
|
||||
public AuditHashLogger(ILogger<AuditHashLogger> logger, bool enableDetailedLogging = false)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_enableDetailedLogging = enableDetailedLogging;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs hash information for an artifact being canonicalized.
|
||||
/// </summary>
|
||||
/// <param name="artifactId">Unique identifier for the artifact.</param>
|
||||
/// <param name="artifactType">Type of artifact (e.g., "proof", "verdict", "attestation").</param>
|
||||
/// <param name="rawBytes">Raw bytes before canonicalization.</param>
|
||||
/// <param name="canonicalBytes">Bytes after canonicalization.</param>
|
||||
public void LogHashAudit(
|
||||
string artifactId,
|
||||
string artifactType,
|
||||
ReadOnlySpan<byte> rawBytes,
|
||||
ReadOnlySpan<byte> canonicalBytes)
|
||||
{
|
||||
var rawHash = ComputeSha256(rawBytes);
|
||||
var canonicalHash = ComputeSha256(canonicalBytes);
|
||||
|
||||
var hashesMatch = rawHash.Equals(canonicalHash, StringComparison.Ordinal);
|
||||
|
||||
if (hashesMatch)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Hash audit for {ArtifactType} {ArtifactId}: raw and canonical hashes match ({Hash})",
|
||||
artifactType,
|
||||
artifactId,
|
||||
canonicalHash);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Hash audit for {ArtifactType} {ArtifactId}: raw={RawHash}, canonical={CanonicalHash}, size_delta={SizeDelta}",
|
||||
artifactType,
|
||||
artifactId,
|
||||
rawHash,
|
||||
canonicalHash,
|
||||
canonicalBytes.Length - rawBytes.Length);
|
||||
|
||||
if (_enableDetailedLogging && _logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
LogDetailedDiff(artifactId, rawBytes, canonicalBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs hash information with structured data for telemetry.
|
||||
/// </summary>
|
||||
public HashAuditRecord CreateAuditRecord(
|
||||
string artifactId,
|
||||
string artifactType,
|
||||
ReadOnlySpan<byte> rawBytes,
|
||||
ReadOnlySpan<byte> canonicalBytes,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var rawHash = ComputeSha256(rawBytes);
|
||||
var canonicalHash = ComputeSha256(canonicalBytes);
|
||||
|
||||
var record = new HashAuditRecord
|
||||
{
|
||||
ArtifactId = artifactId,
|
||||
ArtifactType = artifactType,
|
||||
RawHash = rawHash,
|
||||
CanonicalHash = canonicalHash,
|
||||
RawSizeBytes = rawBytes.Length,
|
||||
CanonicalSizeBytes = canonicalBytes.Length,
|
||||
HashesMatch = rawHash.Equals(canonicalHash, StringComparison.Ordinal),
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
CorrelationId = correlationId
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"Created hash audit record for {ArtifactType} {ArtifactId}: match={Match}, raw_size={RawSize}, canonical_size={CanonicalSize}",
|
||||
artifactType,
|
||||
artifactId,
|
||||
record.HashesMatch,
|
||||
record.RawSizeBytes,
|
||||
record.CanonicalSizeBytes);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that two canonical representations produce the same hash.
|
||||
/// </summary>
|
||||
public bool ValidateDeterminism(
|
||||
string artifactId,
|
||||
ReadOnlySpan<byte> firstCanonical,
|
||||
ReadOnlySpan<byte> secondCanonical)
|
||||
{
|
||||
var firstHash = ComputeSha256(firstCanonical);
|
||||
var secondHash = ComputeSha256(secondCanonical);
|
||||
|
||||
var isValid = firstHash.Equals(secondHash, StringComparison.Ordinal);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Determinism validation failed for {ArtifactId}: first={FirstHash}, second={SecondHash}",
|
||||
artifactId,
|
||||
firstHash,
|
||||
secondHash);
|
||||
|
||||
if (_enableDetailedLogging && _logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var firstSize = firstCanonical.Length;
|
||||
var secondSize = secondCanonical.Length;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Determinism failure details for {ArtifactId}: size1={Size1}, size2={Size2}, diff={Diff}",
|
||||
artifactId,
|
||||
firstSize,
|
||||
secondSize,
|
||||
Math.Abs(firstSize - secondSize));
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
private void LogDetailedDiff(string artifactId, ReadOnlySpan<byte> raw, ReadOnlySpan<byte> canonical)
|
||||
{
|
||||
// Find first difference position
|
||||
var minLen = Math.Min(raw.Length, canonical.Length);
|
||||
var firstDiffPos = -1;
|
||||
|
||||
for (var i = 0; i < minLen; i++)
|
||||
{
|
||||
if (raw[i] != canonical[i])
|
||||
{
|
||||
firstDiffPos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstDiffPos == -1 && raw.Length != canonical.Length)
|
||||
{
|
||||
firstDiffPos = minLen;
|
||||
}
|
||||
|
||||
if (firstDiffPos >= 0)
|
||||
{
|
||||
// Get context around difference
|
||||
var contextStart = Math.Max(0, firstDiffPos - 20);
|
||||
var contextEnd = Math.Min(minLen, firstDiffPos + 20);
|
||||
|
||||
var rawContext = raw.Length > contextStart
|
||||
? Encoding.UTF8.GetString(raw.Slice(contextStart, Math.Min(40, raw.Length - contextStart)))
|
||||
: string.Empty;
|
||||
|
||||
var canonicalContext = canonical.Length > contextStart
|
||||
? Encoding.UTF8.GetString(canonical.Slice(contextStart, Math.Min(40, canonical.Length - contextStart)))
|
||||
: string.Empty;
|
||||
|
||||
_logger.LogTrace(
|
||||
"First difference at position {Position} for {ArtifactId}: raw=\"{RawContext}\", canonical=\"{CanonicalContext}\"",
|
||||
firstDiffPos,
|
||||
artifactId,
|
||||
EscapeForLog(rawContext),
|
||||
EscapeForLog(canonicalContext));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(ReadOnlySpan<byte> data)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(data, hash);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string EscapeForLog(string value)
|
||||
{
|
||||
return value
|
||||
.Replace("\n", "\\n")
|
||||
.Replace("\r", "\\r")
|
||||
.Replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record of a hash audit for structured logging/telemetry.
|
||||
/// </summary>
|
||||
public sealed record HashAuditRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the artifact.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of artifact (proof, verdict, attestation, etc.).
|
||||
/// </summary>
|
||||
public required string ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of raw bytes before canonicalization.
|
||||
/// </summary>
|
||||
public required string RawHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of canonical bytes.
|
||||
/// </summary>
|
||||
public required string CanonicalHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of raw bytes.
|
||||
/// </summary>
|
||||
public required int RawSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of canonical bytes.
|
||||
/// </summary>
|
||||
public required int CanonicalSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether raw and canonical hashes match.
|
||||
/// </summary>
|
||||
public required bool HashesMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp of the audit.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size delta (positive = canonical is larger).
|
||||
/// </summary>
|
||||
public int SizeDelta => CanonicalSizeBytes - RawSizeBytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact types for hash auditing.
|
||||
/// </summary>
|
||||
public static class AuditArtifactTypes
|
||||
{
|
||||
public const string Proof = "proof";
|
||||
public const string Verdict = "verdict";
|
||||
public const string Attestation = "attestation";
|
||||
public const string Spine = "spine";
|
||||
public const string Manifest = "manifest";
|
||||
public const string VexDocument = "vex_document";
|
||||
public const string SbomFragment = "sbom_fragment";
|
||||
public const string PolicySnapshot = "policy_snapshot";
|
||||
public const string FeedSnapshot = "feed_snapshot";
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -9,7 +10,12 @@ namespace StellaOps.Attestor.ProofChain.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Implements RFC 8785 JSON Canonicalization Scheme (JCS) for stable hashing.
|
||||
/// Includes optional NFC (Unicode Normalization Form C) normalization for string stability.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// NFC normalization ensures that equivalent Unicode sequences (e.g., composed vs decomposed characters)
|
||||
/// produce identical canonical output, which is critical for cross-platform determinism.
|
||||
/// </remarks>
|
||||
public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
{
|
||||
/// <summary>
|
||||
@@ -17,17 +23,31 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
/// </summary>
|
||||
private const string VersionFieldName = "_canonVersion";
|
||||
|
||||
private readonly bool _enableNfcNormalization;
|
||||
|
||||
private static readonly JsonWriterOptions CanonicalWriterOptions = new()
|
||||
{
|
||||
Indented = false,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new RFC 8785 JSON canonicalizer.
|
||||
/// </summary>
|
||||
/// <param name="enableNfcNormalization">
|
||||
/// Whether to apply NFC normalization to string values.
|
||||
/// Default is true for maximum cross-platform stability.
|
||||
/// </param>
|
||||
public Rfc8785JsonCanonicalizer(bool enableNfcNormalization = true)
|
||||
{
|
||||
_enableNfcNormalization = enableNfcNormalization;
|
||||
}
|
||||
|
||||
public byte[] Canonicalize(ReadOnlySpan<byte> utf8Json)
|
||||
{
|
||||
var reader = new Utf8JsonReader(utf8Json, isFinalBlock: true, state: default);
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
return Canonicalize(document.RootElement);
|
||||
return CanonicalizeParsed(document.RootElement);
|
||||
}
|
||||
|
||||
public byte[] CanonicalizeWithVersion(ReadOnlySpan<byte> utf8Json, string version)
|
||||
@@ -36,10 +56,10 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
|
||||
var reader = new Utf8JsonReader(utf8Json, isFinalBlock: true, state: default);
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
return CanonicalizeWithVersion(document.RootElement, version);
|
||||
return CanonicalizeParsedWithVersion(document.RootElement, version);
|
||||
}
|
||||
|
||||
private static byte[] Canonicalize(JsonElement element)
|
||||
private byte[] CanonicalizeParsed(JsonElement element)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, CanonicalWriterOptions))
|
||||
@@ -50,7 +70,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] CanonicalizeWithVersion(JsonElement element, string version)
|
||||
private byte[] CanonicalizeParsedWithVersion(JsonElement element, string version)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, CanonicalWriterOptions))
|
||||
@@ -61,14 +81,14 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalWithVersion(Utf8JsonWriter writer, JsonElement element, string version)
|
||||
private void WriteCanonicalWithVersion(Utf8JsonWriter writer, JsonElement element, string version)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
// Write version marker first (underscore prefix ensures it stays first after sorting)
|
||||
writer.WriteString(VersionFieldName, version);
|
||||
writer.WriteString(VersionFieldName, NormalizeString(version));
|
||||
|
||||
// Write remaining properties sorted
|
||||
var properties = new List<(string Name, JsonElement Value)>();
|
||||
@@ -80,7 +100,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
|
||||
foreach (var (name, value) in properties)
|
||||
{
|
||||
writer.WritePropertyName(name);
|
||||
writer.WritePropertyName(NormalizeString(name));
|
||||
WriteCanonical(writer, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
@@ -89,14 +109,14 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
{
|
||||
// Non-object root: wrap in versioned object
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString(VersionFieldName, version);
|
||||
writer.WriteString(VersionFieldName, NormalizeString(version));
|
||||
writer.WritePropertyName("_value");
|
||||
WriteCanonical(writer, element);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
|
||||
private void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
@@ -107,7 +127,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
WriteArray(writer, element);
|
||||
return;
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
writer.WriteStringValue(NormalizeString(element.GetString()));
|
||||
return;
|
||||
case JsonValueKind.Number:
|
||||
WriteNumber(writer, element);
|
||||
@@ -126,7 +146,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteObject(Utf8JsonWriter writer, JsonElement element)
|
||||
private void WriteObject(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
var properties = new List<(string Name, JsonElement Value)>();
|
||||
foreach (var property in element.EnumerateObject())
|
||||
@@ -139,13 +159,13 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
writer.WriteStartObject();
|
||||
foreach (var (name, value) in properties)
|
||||
{
|
||||
writer.WritePropertyName(name);
|
||||
writer.WritePropertyName(NormalizeString(name));
|
||||
WriteCanonical(writer, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteArray(Utf8JsonWriter writer, JsonElement element)
|
||||
private void WriteArray(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
@@ -155,6 +175,25 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies NFC normalization to a string if enabled.
|
||||
/// </summary>
|
||||
private string? NormalizeString(string? value)
|
||||
{
|
||||
if (value is null || !_enableNfcNormalization)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// Only normalize if the string is not already in NFC form
|
||||
if (value.IsNormalized(NormalizationForm.FormC))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
|
||||
private static void WriteNumber(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
var raw = element.GetRawText();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace StellaOps.Attestor.ProofChain.MediaTypes;
|
||||
|
||||
/// <summary>
|
||||
/// OCI media types for AI artifacts.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Tasks: AIATTEST-12, AIATTEST-13, AIATTEST-14, AIATTEST-15
|
||||
/// </summary>
|
||||
public static class AIArtifactMediaTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Media type for AI explanation attestations.
|
||||
/// Task: AIATTEST-12
|
||||
/// </summary>
|
||||
public const string AIExplanation = "application/vnd.stellaops.ai.explanation+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for AI remediation plan attestations.
|
||||
/// Task: AIATTEST-13
|
||||
/// </summary>
|
||||
public const string AIRemediation = "application/vnd.stellaops.ai.remediation+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for AI VEX draft attestations.
|
||||
/// Task: AIATTEST-14
|
||||
/// </summary>
|
||||
public const string AIVexDraft = "application/vnd.stellaops.ai.vexdraft+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for AI policy draft attestations.
|
||||
/// Task: AIATTEST-15
|
||||
/// </summary>
|
||||
public const string AIPolicyDraft = "application/vnd.stellaops.ai.policydraft+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for AI artifact replay manifests.
|
||||
/// Task: AIATTEST-18
|
||||
/// </summary>
|
||||
public const string AIReplayManifest = "application/vnd.stellaops.ai.replay+json";
|
||||
|
||||
/// <summary>
|
||||
/// Annotation key for AI artifact type.
|
||||
/// </summary>
|
||||
public const string ArtifactTypeAnnotation = "org.stellaops.ai.artifact-type";
|
||||
|
||||
/// <summary>
|
||||
/// Annotation key for AI authority level.
|
||||
/// </summary>
|
||||
public const string AuthorityAnnotation = "org.stellaops.ai.authority";
|
||||
|
||||
/// <summary>
|
||||
/// Annotation key for AI model identifier.
|
||||
/// </summary>
|
||||
public const string ModelIdAnnotation = "org.stellaops.ai.model-id";
|
||||
|
||||
/// <summary>
|
||||
/// Annotation key for replay capability.
|
||||
/// </summary>
|
||||
public const string ReplayableAnnotation = "org.stellaops.ai.replayable";
|
||||
|
||||
/// <summary>
|
||||
/// Get the media type for a predicate type URI.
|
||||
/// </summary>
|
||||
public static string? GetMediaTypeForPredicateType(string predicateType) => predicateType switch
|
||||
{
|
||||
"ai-explanation.stella/v1" => AIExplanation,
|
||||
"ai-remediation.stella/v1" => AIRemediation,
|
||||
"ai-vexdraft.stella/v1" => AIVexDraft,
|
||||
"ai-policydraft.stella/v1" => AIPolicyDraft,
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Get the predicate type URI for a media type.
|
||||
/// </summary>
|
||||
public static string? GetPredicateTypeForMediaType(string mediaType) => mediaType switch
|
||||
{
|
||||
AIExplanation => "ai-explanation.stella/v1",
|
||||
AIRemediation => "ai-remediation.stella/v1",
|
||||
AIVexDraft => "ai-vexdraft.stella/v1",
|
||||
AIPolicyDraft => "ai-policydraft.stella/v1",
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Check if a media type is an AI artifact type.
|
||||
/// </summary>
|
||||
public static bool IsAIArtifactMediaType(string mediaType) =>
|
||||
mediaType is AIExplanation or AIRemediation or AIVexDraft or AIPolicyDraft or AIReplayManifest;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Authority level for AI-generated artifacts.
|
||||
/// Determines how the artifact should be treated in decisioning.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<AIArtifactAuthority>))]
|
||||
public enum AIArtifactAuthority
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure suggestion - not backed by evidence, requires human review.
|
||||
/// </summary>
|
||||
Suggestion,
|
||||
|
||||
/// <summary>
|
||||
/// Evidence-backed - citations verified, evidence refs resolvable.
|
||||
/// Qualifies when: citation rate ≥ 80% AND all evidence refs valid.
|
||||
/// </summary>
|
||||
EvidenceBacked,
|
||||
|
||||
/// <summary>
|
||||
/// Meets configurable authority threshold for automated processing.
|
||||
/// </summary>
|
||||
AuthorityThreshold
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Model identifier format for tracking AI model versions.
|
||||
/// </summary>
|
||||
public sealed record AIModelIdentifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider of the model (e.g., "anthropic", "openai", "local").
|
||||
/// </summary>
|
||||
[JsonPropertyName("provider")]
|
||||
public required string Provider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model name/family (e.g., "claude-3-opus", "gpt-4").
|
||||
/// </summary>
|
||||
[JsonPropertyName("model")]
|
||||
public required string Model { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model version string (e.g., "20240229", "0613").
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For local models: SHA-256 digest of weights.
|
||||
/// Null for cloud-hosted models.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weightsDigest")]
|
||||
public string? WeightsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical string representation: provider:model:version
|
||||
/// </summary>
|
||||
public override string ToString() =>
|
||||
$"{Provider}:{Model}:{Version}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decoding parameters used during AI generation.
|
||||
/// Required for deterministic replay.
|
||||
/// </summary>
|
||||
public sealed record AIDecodingParameters
|
||||
{
|
||||
/// <summary>
|
||||
/// Temperature setting (0.0 = deterministic, higher = more random).
|
||||
/// </summary>
|
||||
[JsonPropertyName("temperature")]
|
||||
public double Temperature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top-p (nucleus sampling) value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("topP")]
|
||||
public double? TopP { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top-k sampling value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("topK")]
|
||||
public int? TopK { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum tokens to generate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxTokens")]
|
||||
public int? MaxTokens { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Random seed for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("seed")]
|
||||
public long? Seed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base predicate for all AI-generated artifacts.
|
||||
/// Captures metadata required for replay, inspection, and authority classification.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-01
|
||||
/// </summary>
|
||||
public abstract record AIArtifactBasePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this AI artifact.
|
||||
/// Format: sha256:<64-hex-chars>
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactId")]
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model identification (provider:model:version or hash for local).
|
||||
/// </summary>
|
||||
[JsonPropertyName("modelId")]
|
||||
public required AIModelIdentifier ModelId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the prompt template used.
|
||||
/// Format: <template-name>@<version>
|
||||
/// </summary>
|
||||
[JsonPropertyName("promptTemplateVersion")]
|
||||
public required string PromptTemplateVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Decoding parameters for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decodingParams")]
|
||||
public required AIDecodingParameters DecodingParams { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hashes of all inputs (context documents, queries, etc.).
|
||||
/// Order-sensitive for replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inputHashes")]
|
||||
public required IReadOnlyList<string> InputHashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authority classification of this artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("authority")]
|
||||
public required AIArtifactAuthority Authority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the artifact was generated (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required string GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the generated output.
|
||||
/// Used for replay verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outputHash")]
|
||||
public required string OutputHash { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for authority classification thresholds.
|
||||
/// </summary>
|
||||
public sealed record AIAuthorityThresholds
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum citation rate for Evidence-Backed classification.
|
||||
/// Default: 0.8 (80%)
|
||||
/// </summary>
|
||||
public double MinCitationRate { get; init; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence score for Evidence-Backed classification.
|
||||
/// Default: 0.7 (70%)
|
||||
/// </summary>
|
||||
public double MinConfidenceScore { get; init; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Whether all evidence refs must be resolvable.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool RequireResolvableEvidence { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum verified citations ratio for Evidence-Backed.
|
||||
/// Default: 0.9 (90%)
|
||||
/// </summary>
|
||||
public double MinVerifiedCitationRate { get; init; } = 0.9;
|
||||
|
||||
/// <summary>
|
||||
/// Custom authority threshold score (0.0-1.0) for AuthorityThreshold classification.
|
||||
/// If overall score meets this, artifact can be auto-processed.
|
||||
/// Default: 0.95
|
||||
/// </summary>
|
||||
public double AuthorityThresholdScore { get; init; } = 0.95;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of authority classification.
|
||||
/// </summary>
|
||||
public sealed record AIAuthorityClassificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Determined authority level.
|
||||
/// </summary>
|
||||
public required AIArtifactAuthority Authority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall quality score (0.0-1.0).
|
||||
/// </summary>
|
||||
public required double QualityScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Citation rate if applicable.
|
||||
/// </summary>
|
||||
public double? CitationRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verified citation rate if applicable.
|
||||
/// </summary>
|
||||
public double? VerifiedCitationRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of resolvable evidence refs.
|
||||
/// </summary>
|
||||
public int? ResolvableEvidenceCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of unresolvable evidence refs.
|
||||
/// </summary>
|
||||
public int? UnresolvableEvidenceCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reasons for the classification decision.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Reasons { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the artifact can be auto-processed without human review.
|
||||
/// </summary>
|
||||
public required bool CanAutoProcess { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies AI artifacts into authority levels based on evidence backing.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-07
|
||||
/// </summary>
|
||||
public sealed class AIAuthorityClassifier
|
||||
{
|
||||
private readonly AIAuthorityThresholds _thresholds;
|
||||
private readonly Func<string, bool>? _evidenceResolver;
|
||||
|
||||
public AIAuthorityClassifier(AIAuthorityThresholds? thresholds = null, Func<string, bool>? evidenceResolver = null)
|
||||
{
|
||||
_thresholds = thresholds ?? new AIAuthorityThresholds();
|
||||
_evidenceResolver = evidenceResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify an explanation predicate.
|
||||
/// </summary>
|
||||
public AIAuthorityClassificationResult ClassifyExplanation(AIExplanationPredicate predicate)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
var qualityScore = CalculateExplanationQualityScore(predicate, reasons);
|
||||
|
||||
var verifiedRate = predicate.Citations.Count > 0
|
||||
? (double)predicate.Citations.Count(c => c.Verified) / predicate.Citations.Count
|
||||
: 0;
|
||||
|
||||
var authority = DetermineAuthority(
|
||||
predicate.CitationRate,
|
||||
verifiedRate,
|
||||
predicate.ConfidenceScore,
|
||||
qualityScore,
|
||||
reasons);
|
||||
|
||||
return new AIAuthorityClassificationResult
|
||||
{
|
||||
Authority = authority,
|
||||
QualityScore = qualityScore,
|
||||
CitationRate = predicate.CitationRate,
|
||||
VerifiedCitationRate = verifiedRate,
|
||||
Reasons = reasons,
|
||||
CanAutoProcess = authority == AIArtifactAuthority.AuthorityThreshold
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify a remediation plan predicate.
|
||||
/// </summary>
|
||||
public AIAuthorityClassificationResult ClassifyRemediationPlan(AIRemediationPlanPredicate predicate)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
var evidenceRefs = predicate.EvidenceRefs;
|
||||
|
||||
var resolvableCount = evidenceRefs.Count(ref => _evidenceResolver?.Invoke(ref) ?? true);
|
||||
var unresolvableCount = evidenceRefs.Count - resolvableCount;
|
||||
|
||||
var qualityScore = CalculateRemediationQualityScore(predicate, resolvableCount, reasons);
|
||||
|
||||
var evidenceBackingRate = evidenceRefs.Count > 0
|
||||
? (double)resolvableCount / evidenceRefs.Count
|
||||
: 0;
|
||||
|
||||
var authority = DetermineAuthority(
|
||||
evidenceBackingRate,
|
||||
evidenceBackingRate,
|
||||
predicate.RiskAssessment.RiskBefore - predicate.RiskAssessment.RiskAfter,
|
||||
qualityScore,
|
||||
reasons);
|
||||
|
||||
return new AIAuthorityClassificationResult
|
||||
{
|
||||
Authority = authority,
|
||||
QualityScore = qualityScore,
|
||||
ResolvableEvidenceCount = resolvableCount,
|
||||
UnresolvableEvidenceCount = unresolvableCount,
|
||||
Reasons = reasons,
|
||||
CanAutoProcess = authority == AIArtifactAuthority.AuthorityThreshold && predicate.PrReady
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify a VEX draft predicate.
|
||||
/// </summary>
|
||||
public AIAuthorityClassificationResult ClassifyVexDraft(AIVexDraftPredicate predicate)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
var evidenceRefs = predicate.EvidenceRefs;
|
||||
|
||||
var resolvableCount = evidenceRefs.Count(ref => _evidenceResolver?.Invoke(ref) ?? true);
|
||||
|
||||
var avgConfidence = predicate.VexStatements.Count > 0
|
||||
? predicate.VexStatements.Average(s => s.Confidence)
|
||||
: 0;
|
||||
|
||||
var qualityScore = CalculateVexDraftQualityScore(predicate, resolvableCount, avgConfidence, reasons);
|
||||
|
||||
var evidenceBackingRate = evidenceRefs.Count > 0
|
||||
? (double)resolvableCount / evidenceRefs.Count
|
||||
: 0;
|
||||
|
||||
var authority = DetermineAuthority(
|
||||
evidenceBackingRate,
|
||||
evidenceBackingRate,
|
||||
avgConfidence,
|
||||
qualityScore,
|
||||
reasons);
|
||||
|
||||
return new AIAuthorityClassificationResult
|
||||
{
|
||||
Authority = authority,
|
||||
QualityScore = qualityScore,
|
||||
ResolvableEvidenceCount = resolvableCount,
|
||||
UnresolvableEvidenceCount = evidenceRefs.Count - resolvableCount,
|
||||
Reasons = reasons,
|
||||
CanAutoProcess = authority == AIArtifactAuthority.AuthorityThreshold && predicate.AutoApprovable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify a policy draft predicate.
|
||||
/// </summary>
|
||||
public AIAuthorityClassificationResult ClassifyPolicyDraft(AIPolicyDraftPredicate predicate)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
|
||||
var avgConfidence = predicate.Rules.Count > 0
|
||||
? predicate.Rules.Average(r => r.Confidence)
|
||||
: 0;
|
||||
|
||||
var passedTestRate = predicate.TestCases.Count > 0
|
||||
? (double)predicate.TestCases.Count(t => t.Passed == true) / predicate.TestCases.Count
|
||||
: 0;
|
||||
|
||||
var qualityScore = CalculatePolicyDraftQualityScore(predicate, avgConfidence, passedTestRate, reasons);
|
||||
|
||||
var authority = DetermineAuthority(
|
||||
passedTestRate,
|
||||
passedTestRate,
|
||||
avgConfidence,
|
||||
qualityScore,
|
||||
reasons);
|
||||
|
||||
return new AIAuthorityClassificationResult
|
||||
{
|
||||
Authority = authority,
|
||||
QualityScore = qualityScore,
|
||||
Reasons = reasons,
|
||||
CanAutoProcess = authority == AIArtifactAuthority.AuthorityThreshold
|
||||
&& predicate.ValidationResult.OverallPassed
|
||||
&& predicate.DeployReady
|
||||
};
|
||||
}
|
||||
|
||||
private AIArtifactAuthority DetermineAuthority(
|
||||
double citationRate,
|
||||
double verifiedRate,
|
||||
double confidenceScore,
|
||||
double qualityScore,
|
||||
List<string> reasons)
|
||||
{
|
||||
if (qualityScore >= _thresholds.AuthorityThresholdScore)
|
||||
{
|
||||
reasons.Add($"Quality score {qualityScore:P0} meets authority threshold {_thresholds.AuthorityThresholdScore:P0}");
|
||||
return AIArtifactAuthority.AuthorityThreshold;
|
||||
}
|
||||
|
||||
if (citationRate >= _thresholds.MinCitationRate &&
|
||||
verifiedRate >= _thresholds.MinVerifiedCitationRate &&
|
||||
confidenceScore >= _thresholds.MinConfidenceScore)
|
||||
{
|
||||
reasons.Add($"Citation rate {citationRate:P0} >= {_thresholds.MinCitationRate:P0}");
|
||||
reasons.Add($"Verified rate {verifiedRate:P0} >= {_thresholds.MinVerifiedCitationRate:P0}");
|
||||
reasons.Add($"Confidence {confidenceScore:P0} >= {_thresholds.MinConfidenceScore:P0}");
|
||||
return AIArtifactAuthority.EvidenceBacked;
|
||||
}
|
||||
|
||||
if (citationRate < _thresholds.MinCitationRate)
|
||||
reasons.Add($"Citation rate {citationRate:P0} < {_thresholds.MinCitationRate:P0}");
|
||||
if (verifiedRate < _thresholds.MinVerifiedCitationRate)
|
||||
reasons.Add($"Verified rate {verifiedRate:P0} < {_thresholds.MinVerifiedCitationRate:P0}");
|
||||
if (confidenceScore < _thresholds.MinConfidenceScore)
|
||||
reasons.Add($"Confidence {confidenceScore:P0} < {_thresholds.MinConfidenceScore:P0}");
|
||||
|
||||
return AIArtifactAuthority.Suggestion;
|
||||
}
|
||||
|
||||
private double CalculateExplanationQualityScore(AIExplanationPredicate predicate, List<string> reasons)
|
||||
{
|
||||
var citationWeight = 0.35;
|
||||
var verifiedWeight = 0.30;
|
||||
var confidenceWeight = 0.20;
|
||||
var contentWeight = 0.15;
|
||||
|
||||
var verifiedRate = predicate.Citations.Count > 0
|
||||
? (double)predicate.Citations.Count(c => c.Verified) / predicate.Citations.Count
|
||||
: 0;
|
||||
|
||||
var contentScore = Math.Min(1.0, predicate.Content.Length / 500.0); // Reasonable explanation length
|
||||
|
||||
return (predicate.CitationRate * citationWeight) +
|
||||
(verifiedRate * verifiedWeight) +
|
||||
(predicate.ConfidenceScore * confidenceWeight) +
|
||||
(contentScore * contentWeight);
|
||||
}
|
||||
|
||||
private double CalculateRemediationQualityScore(AIRemediationPlanPredicate predicate, int resolvableCount, List<string> reasons)
|
||||
{
|
||||
var evidenceWeight = 0.30;
|
||||
var riskDeltaWeight = 0.25;
|
||||
var automationWeight = 0.20;
|
||||
var verificationWeight = 0.25;
|
||||
|
||||
var evidenceScore = predicate.EvidenceRefs.Count > 0
|
||||
? (double)resolvableCount / predicate.EvidenceRefs.Count
|
||||
: 0;
|
||||
|
||||
var riskDelta = predicate.ExpectedDelta;
|
||||
var riskScore = Math.Min(1.0, Math.Max(0, riskDelta));
|
||||
|
||||
var autoSteps = predicate.Steps.Count(s => s.CanAutomate);
|
||||
var automationScore = predicate.Steps.Count > 0 ? (double)autoSteps / predicate.Steps.Count : 0;
|
||||
|
||||
var verificationScore = predicate.VerificationStatus switch
|
||||
{
|
||||
RemediationVerificationStatus.Verified => 0.8,
|
||||
RemediationVerificationStatus.Applied => 1.0,
|
||||
RemediationVerificationStatus.Stale => 0.5,
|
||||
_ => 0.2
|
||||
};
|
||||
|
||||
return (evidenceScore * evidenceWeight) +
|
||||
(riskScore * riskDeltaWeight) +
|
||||
(automationScore * automationWeight) +
|
||||
(verificationScore * verificationWeight);
|
||||
}
|
||||
|
||||
private double CalculateVexDraftQualityScore(AIVexDraftPredicate predicate, int resolvableCount, double avgConfidence, List<string> reasons)
|
||||
{
|
||||
var evidenceWeight = 0.35;
|
||||
var confidenceWeight = 0.30;
|
||||
var justificationWeight = 0.20;
|
||||
var conflictWeight = 0.15;
|
||||
|
||||
var evidenceScore = predicate.EvidenceRefs.Count > 0
|
||||
? (double)resolvableCount / predicate.EvidenceRefs.Count
|
||||
: 0;
|
||||
|
||||
var nonConflicting = predicate.Justifications.Count(j => !j.ConflictsWithExisting);
|
||||
var conflictScore = predicate.Justifications.Count > 0
|
||||
? (double)nonConflicting / predicate.Justifications.Count
|
||||
: 1.0;
|
||||
|
||||
var hasJustifications = predicate.Justifications.Count > 0 ? 1.0 : 0.0;
|
||||
|
||||
return (evidenceScore * evidenceWeight) +
|
||||
(avgConfidence * confidenceWeight) +
|
||||
(hasJustifications * justificationWeight) +
|
||||
(conflictScore * conflictWeight);
|
||||
}
|
||||
|
||||
private double CalculatePolicyDraftQualityScore(AIPolicyDraftPredicate predicate, double avgConfidence, double passedTestRate, List<string> reasons)
|
||||
{
|
||||
var confidenceWeight = 0.25;
|
||||
var testWeight = 0.35;
|
||||
var validationWeight = 0.25;
|
||||
var clarityWeight = 0.15;
|
||||
|
||||
var validationScore = predicate.ValidationResult.OverallPassed ? 1.0 : 0.3;
|
||||
|
||||
var ambiguityCount = predicate.Rules.Sum(r => r.Ambiguities?.Count ?? 0);
|
||||
var clarityScore = predicate.Rules.Count > 0
|
||||
? 1.0 - Math.Min(1.0, ambiguityCount / (predicate.Rules.Count * 2.0))
|
||||
: 0;
|
||||
|
||||
return (avgConfidence * confidenceWeight) +
|
||||
(passedTestRate * testWeight) +
|
||||
(validationScore * validationWeight) +
|
||||
(clarityScore * clarityWeight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Type of explanation generated by AI.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<AIExplanationType>))]
|
||||
public enum AIExplanationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Explanation of why a vulnerability is exploitable.
|
||||
/// </summary>
|
||||
Exploitability,
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of a code path or call graph.
|
||||
/// </summary>
|
||||
CodePath,
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of a policy decision.
|
||||
/// </summary>
|
||||
PolicyDecision,
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of risk factors.
|
||||
/// </summary>
|
||||
RiskFactors,
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of remediation options.
|
||||
/// </summary>
|
||||
RemediationOptions,
|
||||
|
||||
/// <summary>
|
||||
/// Plain language summary for non-technical audiences.
|
||||
/// </summary>
|
||||
PlainLanguageSummary,
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of evidence chain.
|
||||
/// </summary>
|
||||
EvidenceChain
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Citation linking AI claims to evidence sources.
|
||||
/// </summary>
|
||||
public sealed record AIExplanationCitation
|
||||
{
|
||||
/// <summary>
|
||||
/// Index of the claim in the explanation (0-based).
|
||||
/// </summary>
|
||||
[JsonPropertyName("claimIndex")]
|
||||
public required int ClaimIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Text of the cited claim.
|
||||
/// </summary>
|
||||
[JsonPropertyName("claimText")]
|
||||
public required string ClaimText { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence node ID this claim references.
|
||||
/// Format: sha256:<64-hex-chars>
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceId")]
|
||||
public required string EvidenceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of evidence (e.g., "sbom", "vex", "reachability", "runtime").
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceType")]
|
||||
public required string EvidenceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the citation was verified against the evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified")]
|
||||
public required bool Verified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate for AI-generated explanations.
|
||||
/// Extends AIArtifactBase with explanation-specific fields.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-02
|
||||
/// </summary>
|
||||
public sealed record AIExplanationPredicate : AIArtifactBasePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of explanation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("explanationType")]
|
||||
public required AIExplanationType ExplanationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The explanation content (markdown supported).
|
||||
/// </summary>
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Citations linking claims to evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("citations")]
|
||||
public required IReadOnlyList<AIExplanationCitation> Citations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score for the explanation (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidenceScore")]
|
||||
public required double ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Citation rate: ratio of cited claims to total claims.
|
||||
/// Used for authority classification (≥0.8 for EvidenceBacked).
|
||||
/// </summary>
|
||||
[JsonPropertyName("citationRate")]
|
||||
public required double CitationRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject being explained (CVE ID, PURL, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Context scope (image digest, build ID, service name).
|
||||
/// </summary>
|
||||
[JsonPropertyName("contextScope")]
|
||||
public string? ContextScope { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Type of policy rule.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<PolicyRuleType>))]
|
||||
public enum PolicyRuleType
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate rule (block/warn/allow).
|
||||
/// </summary>
|
||||
Gate,
|
||||
|
||||
/// <summary>
|
||||
/// Threshold rule (e.g., max critical count).
|
||||
/// </summary>
|
||||
Threshold,
|
||||
|
||||
/// <summary>
|
||||
/// Exception rule.
|
||||
/// </summary>
|
||||
Exception,
|
||||
|
||||
/// <summary>
|
||||
/// SLA rule.
|
||||
/// </summary>
|
||||
Sla,
|
||||
|
||||
/// <summary>
|
||||
/// Notification rule.
|
||||
/// </summary>
|
||||
Notification,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation rule.
|
||||
/// </summary>
|
||||
Escalation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draft policy rule generated from natural language.
|
||||
/// </summary>
|
||||
public sealed record AIPolicyRuleDraft
|
||||
{
|
||||
/// <summary>
|
||||
/// Rule identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleId")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleType")]
|
||||
public required PolicyRuleType RuleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable rule name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule condition in lattice logic syntax.
|
||||
/// </summary>
|
||||
[JsonPropertyName("condition")]
|
||||
public required string Condition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when condition matches.
|
||||
/// </summary>
|
||||
[JsonPropertyName("action")]
|
||||
public required string Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule priority (higher = evaluated first).
|
||||
/// </summary>
|
||||
[JsonPropertyName("priority")]
|
||||
public required int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original natural language input.
|
||||
/// </summary>
|
||||
[JsonPropertyName("originalInput")]
|
||||
public required string OriginalInput { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// AI confidence in the translation (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ambiguities detected in the input.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ambiguities")]
|
||||
public IReadOnlyList<string>? Ambiguities { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for validating a policy rule.
|
||||
/// </summary>
|
||||
public sealed record PolicyRuleTestCase
|
||||
{
|
||||
/// <summary>
|
||||
/// Test case identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("testId")]
|
||||
public required string TestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule ID being tested.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleId")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Test case description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input scenario (JSON blob matching rule input schema).
|
||||
/// </summary>
|
||||
[JsonPropertyName("input")]
|
||||
public required string Input { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected outcome.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expectedOutcome")]
|
||||
public required string ExpectedOutcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the test passed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("passed")]
|
||||
public bool? Passed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual outcome if test was run.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actualOutcome")]
|
||||
public string? ActualOutcome { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation result for the policy draft.
|
||||
/// </summary>
|
||||
public sealed record PolicyValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the policy is syntactically valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("syntaxValid")]
|
||||
public required bool SyntaxValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the policy is semantically valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("semanticsValid")]
|
||||
public required bool SemanticsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Syntax errors if any.
|
||||
/// </summary>
|
||||
[JsonPropertyName("syntaxErrors")]
|
||||
public IReadOnlyList<string>? SyntaxErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Semantic warnings if any.
|
||||
/// </summary>
|
||||
[JsonPropertyName("semanticWarnings")]
|
||||
public IReadOnlyList<string>? SemanticWarnings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Test cases that failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("failedTests")]
|
||||
public IReadOnlyList<string>? FailedTests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall validation passed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overallPassed")]
|
||||
public required bool OverallPassed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate for AI-generated policy drafts from natural language.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-05
|
||||
/// </summary>
|
||||
public sealed record AIPolicyDraftPredicate : AIArtifactBasePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Original natural language policy intent.
|
||||
/// </summary>
|
||||
[JsonPropertyName("naturalLanguageInput")]
|
||||
public required string NaturalLanguageInput { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Draft rules translated from natural language.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rules")]
|
||||
public required IReadOnlyList<AIPolicyRuleDraft> Rules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Test cases for validation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("testCases")]
|
||||
public required IReadOnlyList<PolicyRuleTestCase> TestCases { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("validationResult")]
|
||||
public required PolicyValidationResult ValidationResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target policy pack name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetPolicyPack")]
|
||||
public required string TargetPolicyPack { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetVersion")]
|
||||
public required string TargetVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected intent categories.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detectedIntents")]
|
||||
public required IReadOnlyList<string> DetectedIntents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Clarification questions for ambiguous inputs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("clarificationQuestions")]
|
||||
public IReadOnlyList<string>? ClarificationQuestions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the draft is ready for deployment.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deployReady")]
|
||||
public required bool DeployReady { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Status of a remediation step.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RemediationStepStatus>))]
|
||||
public enum RemediationStepStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Step has not been started.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// Step is in progress.
|
||||
/// </summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>
|
||||
/// Step completed successfully.
|
||||
/// </summary>
|
||||
Complete,
|
||||
|
||||
/// <summary>
|
||||
/// Step was skipped (e.g., not applicable).
|
||||
/// </summary>
|
||||
Skipped,
|
||||
|
||||
/// <summary>
|
||||
/// Step failed.
|
||||
/// </summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of remediation action.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RemediationActionType>))]
|
||||
public enum RemediationActionType
|
||||
{
|
||||
/// <summary>
|
||||
/// Upgrade a package to a fixed version.
|
||||
/// </summary>
|
||||
PackageUpgrade,
|
||||
|
||||
/// <summary>
|
||||
/// Apply a patch to source code.
|
||||
/// </summary>
|
||||
SourcePatch,
|
||||
|
||||
/// <summary>
|
||||
/// Apply a configuration change.
|
||||
/// </summary>
|
||||
ConfigurationChange,
|
||||
|
||||
/// <summary>
|
||||
/// Add a VEX statement.
|
||||
/// </summary>
|
||||
VexStatement,
|
||||
|
||||
/// <summary>
|
||||
/// Apply a compensating control.
|
||||
/// </summary>
|
||||
CompensatingControl,
|
||||
|
||||
/// <summary>
|
||||
/// Accept the risk (with justification).
|
||||
/// </summary>
|
||||
RiskAcceptance,
|
||||
|
||||
/// <summary>
|
||||
/// Remove the affected component.
|
||||
/// </summary>
|
||||
ComponentRemoval
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single step in a remediation plan.
|
||||
/// </summary>
|
||||
public sealed record RemediationStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Order of this step (1-based).
|
||||
/// </summary>
|
||||
[JsonPropertyName("order")]
|
||||
public required int Order { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of action.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actionType")]
|
||||
public required RemediationActionType ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target component (PURL, file path, config key).
|
||||
/// </summary>
|
||||
[JsonPropertyName("target")]
|
||||
public required string Target { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current value (version, setting, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentValue")]
|
||||
public string? CurrentValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Proposed new value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proposedValue")]
|
||||
public required string ProposedValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Estimated risk reduction (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskReduction")]
|
||||
public required double RiskReduction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this step can be automated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("canAutomate")]
|
||||
public required bool CanAutomate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Automation script or command if automatable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("automationScript")]
|
||||
public string? AutomationScript { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status of this step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public RemediationStepStatus Status { get; init; } = RemediationStepStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence references supporting this step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public IReadOnlyList<string>? EvidenceRefs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risk assessment for the remediation plan.
|
||||
/// </summary>
|
||||
public sealed record RemediationRiskAssessment
|
||||
{
|
||||
/// <summary>
|
||||
/// Risk level before remediation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskBefore")]
|
||||
public required double RiskBefore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected risk level after remediation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskAfter")]
|
||||
public required double RiskAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Potential breaking changes from this remediation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("breakingChanges")]
|
||||
public required IReadOnlyList<string> BreakingChanges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required test coverage for safe rollout.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiredTestCoverage")]
|
||||
public IReadOnlyList<string>? RequiredTestCoverage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification status of the remediation plan.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RemediationVerificationStatus>))]
|
||||
public enum RemediationVerificationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan not yet verified.
|
||||
/// </summary>
|
||||
Unverified,
|
||||
|
||||
/// <summary>
|
||||
/// Plan verified against current state.
|
||||
/// </summary>
|
||||
Verified,
|
||||
|
||||
/// <summary>
|
||||
/// Plan verified but state has drifted.
|
||||
/// </summary>
|
||||
Stale,
|
||||
|
||||
/// <summary>
|
||||
/// Plan applied and verified as effective.
|
||||
/// </summary>
|
||||
Applied,
|
||||
|
||||
/// <summary>
|
||||
/// Plan verification failed.
|
||||
/// </summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate for AI-generated remediation plans.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-03
|
||||
/// </summary>
|
||||
public sealed record AIRemediationPlanPredicate : AIArtifactBasePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability being remediated (CVE ID, GHSA, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected component (PURL).
|
||||
/// </summary>
|
||||
[JsonPropertyName("affectedComponent")]
|
||||
public required string AffectedComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered remediation steps.
|
||||
/// </summary>
|
||||
[JsonPropertyName("steps")]
|
||||
public required IReadOnlyList<RemediationStep> Steps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected delta in risk score after remediation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expectedDelta")]
|
||||
public required double ExpectedDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk assessment for this plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskAssessment")]
|
||||
public required RemediationRiskAssessment RiskAssessment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification status of the plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verificationStatus")]
|
||||
public required RemediationVerificationStatus VerificationStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a PR can be auto-generated for this plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("prReady")]
|
||||
public required bool PrReady { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Git commit SHA if a fix branch exists.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixBranchCommit")]
|
||||
public string? FixBranchCommit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence references supporting this plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public required IReadOnlyList<string> EvidenceRefs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Draft VEX statement generated by AI.
|
||||
/// </summary>
|
||||
public sealed record AIVexStatementDraft
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability ID (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected product identifier (PURL).
|
||||
/// </summary>
|
||||
[JsonPropertyName("productId")]
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Proposed VEX status: not_affected, affected, fixed, under_investigation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification category per VEX spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed impact statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("impactStatement")]
|
||||
public string? ImpactStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action statement if status is "affected".
|
||||
/// </summary>
|
||||
[JsonPropertyName("actionStatement")]
|
||||
public string? ActionStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// AI confidence in this draft (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence nodes supporting this draft.
|
||||
/// </summary>
|
||||
[JsonPropertyName("supportingEvidence")]
|
||||
public required IReadOnlyList<string> SupportingEvidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Justification for a VEX statement draft.
|
||||
/// </summary>
|
||||
public sealed record AIVexJustification
|
||||
{
|
||||
/// <summary>
|
||||
/// Index of the VEX statement this justification applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statementIndex")]
|
||||
public required int StatementIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reasoning for the proposed status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reasoning")]
|
||||
public required string Reasoning { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key evidence points.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidencePoints")]
|
||||
public required IReadOnlyList<string> EvidencePoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Counter-arguments or caveats.
|
||||
/// </summary>
|
||||
[JsonPropertyName("caveats")]
|
||||
public IReadOnlyList<string>? Caveats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this justification conflicts with existing VEX.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflictsWithExisting")]
|
||||
public required bool ConflictsWithExisting { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If conflicting, the existing VEX statement ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflictingVexId")]
|
||||
public string? ConflictingVexId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate for AI-generated VEX drafts.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-04
|
||||
/// </summary>
|
||||
public sealed record AIVexDraftPredicate : AIArtifactBasePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Draft VEX statements.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexStatements")]
|
||||
public required IReadOnlyList<AIVexStatementDraft> VexStatements { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justifications for each statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justifications")]
|
||||
public required IReadOnlyList<AIVexJustification> Justifications { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence node IDs referenced.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public required IReadOnlyList<string> EvidenceRefs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target VEX format for export (openvex, cyclonedx, csaf).
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetFormat")]
|
||||
public required string TargetFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether all drafts can be auto-approved based on evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("autoApprovable")]
|
||||
public required bool AutoApprovable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human review required reasons (if any).
|
||||
/// </summary>
|
||||
[JsonPropertyName("reviewRequired")]
|
||||
public IReadOnlyList<string>? ReviewRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope of this VEX draft (image, service, release).
|
||||
/// </summary>
|
||||
[JsonPropertyName("scope")]
|
||||
public required string Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope identifier (image digest, service name, release tag).
|
||||
/// </summary>
|
||||
[JsonPropertyName("scopeId")]
|
||||
public required string ScopeId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Input artifact for replay.
|
||||
/// </summary>
|
||||
public sealed record ReplayInputArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the input content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of input (e.g., "sbom", "vex", "policy", "context").
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Storage location (OCI ref, blob ID, inline).
|
||||
/// </summary>
|
||||
[JsonPropertyName("location")]
|
||||
public required string Location { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Order in input sequence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("order")]
|
||||
public required int Order { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompt template snapshot for replay.
|
||||
/// </summary>
|
||||
public sealed record ReplayPromptTemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// Template name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Template version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the template content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Template storage location.
|
||||
/// </summary>
|
||||
[JsonPropertyName("location")]
|
||||
public required string Location { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest capturing all inputs for deterministic AI artifact replay.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-18
|
||||
/// </summary>
|
||||
public sealed record AIArtifactReplayManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique manifest ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("manifestId")]
|
||||
public required string ManifestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the artifact this manifest enables replay for.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactId")]
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type (explanation, remediation, vexdraft, policydraft).
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactType")]
|
||||
public required string ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model identifier used for generation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("modelId")]
|
||||
public required AIModelIdentifier ModelId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Decoding parameters for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decodingParams")]
|
||||
public required AIDecodingParameters DecodingParams { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Prompt template used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("promptTemplate")]
|
||||
public required ReplayPromptTemplate PromptTemplate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All input artifacts in order.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inputs")]
|
||||
public required IReadOnlyList<ReplayInputArtifact> Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected output hash for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expectedOutputHash")]
|
||||
public required string ExpectedOutputHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original generation timestamp (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required string GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether all inputs are available for replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replayable")]
|
||||
public required bool Replayable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reasons if not replayable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("notReplayableReasons")]
|
||||
public IReadOnlyList<string>? NotReplayableReasons { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Status of a replay attempt.
|
||||
/// </summary>
|
||||
public enum ReplayStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Replay not started.
|
||||
/// </summary>
|
||||
NotStarted,
|
||||
|
||||
/// <summary>
|
||||
/// Replay in progress.
|
||||
/// </summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>
|
||||
/// Replay completed successfully with matching output.
|
||||
/// </summary>
|
||||
MatchedOutput,
|
||||
|
||||
/// <summary>
|
||||
/// Replay completed but output diverged.
|
||||
/// </summary>
|
||||
DivergedOutput,
|
||||
|
||||
/// <summary>
|
||||
/// Replay failed due to missing inputs.
|
||||
/// </summary>
|
||||
FailedMissingInputs,
|
||||
|
||||
/// <summary>
|
||||
/// Replay failed due to unavailable model.
|
||||
/// </summary>
|
||||
FailedModelUnavailable,
|
||||
|
||||
/// <summary>
|
||||
/// Replay failed with error.
|
||||
/// </summary>
|
||||
FailedError
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an AI artifact replay attempt.
|
||||
/// </summary>
|
||||
public sealed record ReplayResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Manifest used for replay.
|
||||
/// </summary>
|
||||
public required AIArtifactReplayManifest Manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay status.
|
||||
/// </summary>
|
||||
public required ReplayStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the replayed output (if successful).
|
||||
/// </summary>
|
||||
public string? ReplayedOutputHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether output matches expected.
|
||||
/// </summary>
|
||||
public bool? OutputMatches { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Divergence details if output differs.
|
||||
/// </summary>
|
||||
public string? DivergenceDetails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay duration in milliseconds.
|
||||
/// </summary>
|
||||
public long? DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of replay attempt (UTC ISO-8601).
|
||||
/// </summary>
|
||||
public required string AttemptedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification result for AI artifact replay.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-20
|
||||
/// </summary>
|
||||
public sealed record ReplayVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact ID being verified.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether verification passed.
|
||||
/// </summary>
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay result.
|
||||
/// </summary>
|
||||
public required ReplayResult ReplayResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in verification (1.0 for matching, lower for diverged).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification notes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for re-executing AI generation with pinned inputs.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-19
|
||||
/// </summary>
|
||||
public interface IAIArtifactReplayer
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempt to replay an AI artifact generation.
|
||||
/// </summary>
|
||||
/// <param name="manifest">Replay manifest with all inputs.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Replay result.</returns>
|
||||
Task<ReplayResult> ReplayAsync(AIArtifactReplayManifest manifest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify an AI artifact by replaying and comparing output.
|
||||
/// </summary>
|
||||
/// <param name="manifest">Replay manifest.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<ReplayVerificationResult> VerifyAsync(AIArtifactReplayManifest manifest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a manifest is replayable (all inputs available, model accessible).
|
||||
/// </summary>
|
||||
/// <param name="manifest">Replay manifest to check.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if replayable, false otherwise with reasons.</returns>
|
||||
Task<(bool Replayable, IReadOnlyList<string> Reasons)> CheckReplayableAsync(
|
||||
AIArtifactReplayManifest manifest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Build a replay manifest from an AI artifact base predicate.
|
||||
/// </summary>
|
||||
/// <param name="predicate">The AI artifact predicate.</param>
|
||||
/// <param name="artifactType">Type of artifact.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Replay manifest.</returns>
|
||||
Task<AIArtifactReplayManifest> BuildManifestAsync(
|
||||
AIArtifactBasePredicate predicate,
|
||||
string artifactType,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements.AI;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for AI-generated explanations.
|
||||
/// Predicate type: ai-explanation.stella/v1
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-08
|
||||
/// </summary>
|
||||
public sealed record AIExplanationStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "ai-explanation.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The AI explanation predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required AIExplanationPredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements.AI;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for AI-generated policy drafts.
|
||||
/// Predicate type: ai-policydraft.stella/v1
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-11
|
||||
/// </summary>
|
||||
public sealed record AIPolicyDraftStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "ai-policydraft.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The AI policy draft predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required AIPolicyDraftPredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements.AI;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for AI-generated remediation plans.
|
||||
/// Predicate type: ai-remediation.stella/v1
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-09
|
||||
/// </summary>
|
||||
public sealed record AIRemediationPlanStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "ai-remediation.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The AI remediation plan predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required AIRemediationPlanPredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements.AI;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for AI-generated VEX drafts.
|
||||
/// Predicate type: ai-vexdraft.stella/v1
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-10
|
||||
/// </summary>
|
||||
public sealed record AIVexDraftStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "ai-vexdraft.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The AI VEX draft predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required AIVexDraftPredicate Predicate { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user