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>