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:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -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:&lt;merkle_root&gt;).</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
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:&lt;merkle_root&gt;
/// </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:&lt;hex&gt; 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; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:&lt;64-hex-chars&gt;
/// </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: &lt;template-name&gt;@&lt;version&gt;
/// </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; }
}

View File

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

View File

@@ -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:&lt;64-hex-chars&gt;
/// </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; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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