Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IAttestationBundler.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0005 - Implement IAttestationBundler service
|
||||
// Description: Service interface for creating attestation bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing attestation bundles.
|
||||
/// </summary>
|
||||
public interface IAttestationBundler
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new attestation bundle for a time period.
|
||||
/// </summary>
|
||||
/// <param name="request">Bundle creation parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created attestation bundle.</returns>
|
||||
Task<AttestationBundle> CreateBundleAsync(
|
||||
BundleCreationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get an existing bundle by ID.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID (sha256:<merkle_root>).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The bundle if found, null otherwise.</returns>
|
||||
Task<AttestationBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List bundles matching the specified criteria.
|
||||
/// </summary>
|
||||
/// <param name="request">List parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated bundle list.</returns>
|
||||
Task<BundleListResult> ListBundlesAsync(
|
||||
BundleListRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify the integrity of a bundle (Merkle tree and optional org signature).
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<BundleVerificationResult> VerifyBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for bundle creation.
|
||||
/// </summary>
|
||||
/// <param name="PeriodStart">Start of the attestation collection period.</param>
|
||||
/// <param name="PeriodEnd">End of the attestation collection period.</param>
|
||||
/// <param name="TenantId">Optional tenant identifier for multi-tenant filtering.</param>
|
||||
/// <param name="SignWithOrgKey">Whether to sign the bundle with an organization key.</param>
|
||||
/// <param name="OrgKeyId">Organization key ID to use for signing.</param>
|
||||
public record BundleCreationRequest(
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
string? TenantId = null,
|
||||
bool SignWithOrgKey = false,
|
||||
string? OrgKeyId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for listing bundles.
|
||||
/// </summary>
|
||||
/// <param name="PeriodStart">Optional start of period filter.</param>
|
||||
/// <param name="PeriodEnd">Optional end of period filter.</param>
|
||||
/// <param name="TenantId">Optional tenant filter.</param>
|
||||
/// <param name="Limit">Maximum number of results.</param>
|
||||
/// <param name="Cursor">Pagination cursor.</param>
|
||||
public record BundleListRequest(
|
||||
DateTimeOffset? PeriodStart = null,
|
||||
DateTimeOffset? PeriodEnd = null,
|
||||
string? TenantId = null,
|
||||
int Limit = 20,
|
||||
string? Cursor = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a bundle list operation.
|
||||
/// </summary>
|
||||
/// <param name="Bundles">The matching bundles (metadata only).</param>
|
||||
/// <param name="NextCursor">Cursor for the next page, null if no more results.</param>
|
||||
public record BundleListResult(
|
||||
IReadOnlyList<BundleListItem> Bundles,
|
||||
string? NextCursor);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle metadata for list results.
|
||||
/// </summary>
|
||||
/// <param name="BundleId">The bundle ID.</param>
|
||||
/// <param name="PeriodStart">Start of collection period.</param>
|
||||
/// <param name="PeriodEnd">End of collection period.</param>
|
||||
/// <param name="AttestationCount">Number of attestations.</param>
|
||||
/// <param name="CreatedAt">Bundle creation timestamp.</param>
|
||||
/// <param name="HasOrgSignature">Whether the bundle has an org signature.</param>
|
||||
public record BundleListItem(
|
||||
string BundleId,
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
int AttestationCount,
|
||||
DateTimeOffset CreatedAt,
|
||||
bool HasOrgSignature);
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle verification.
|
||||
/// </summary>
|
||||
/// <param name="Valid">Whether the bundle is valid.</param>
|
||||
/// <param name="MerkleRootVerified">Whether the Merkle root matches.</param>
|
||||
/// <param name="OrgSignatureVerified">Whether the org signature is valid (if present).</param>
|
||||
/// <param name="AttestationsVerified">Number of attestations verified.</param>
|
||||
/// <param name="Issues">Any verification issues found.</param>
|
||||
/// <param name="VerifiedAt">Verification timestamp.</param>
|
||||
public record BundleVerificationResult(
|
||||
bool Valid,
|
||||
bool MerkleRootVerified,
|
||||
bool? OrgSignatureVerified,
|
||||
int AttestationsVerified,
|
||||
IReadOnlyList<BundleVerificationIssue> Issues,
|
||||
DateTimeOffset VerifiedAt);
|
||||
|
||||
/// <summary>
|
||||
/// A verification issue found during bundle verification.
|
||||
/// </summary>
|
||||
/// <param name="Severity">Issue severity.</param>
|
||||
/// <param name="Code">Machine-readable issue code.</param>
|
||||
/// <param name="Message">Human-readable message.</param>
|
||||
/// <param name="EntryId">Related attestation entry ID, if applicable.</param>
|
||||
public record BundleVerificationIssue(
|
||||
VerificationIssueSeverity Severity,
|
||||
string Code,
|
||||
string Message,
|
||||
string? EntryId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for verification issues.
|
||||
/// </summary>
|
||||
public enum VerificationIssueSeverity
|
||||
{
|
||||
/// <summary>Informational message.</summary>
|
||||
Info,
|
||||
/// <summary>Warning that may affect trust.</summary>
|
||||
Warning,
|
||||
/// <summary>Error that affects verification.</summary>
|
||||
Error,
|
||||
/// <summary>Critical error that invalidates the bundle.</summary>
|
||||
Critical
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IBundleAggregator.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0003 - Implement IBundleAggregator for collecting attestations
|
||||
// Description: Interface for aggregating attestations from storage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for aggregating attestations from storage for bundling.
|
||||
/// </summary>
|
||||
public interface IBundleAggregator
|
||||
{
|
||||
/// <summary>
|
||||
/// Collect attestations for a time period.
|
||||
/// </summary>
|
||||
/// <param name="request">Aggregation parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collected attestations in deterministic order.</returns>
|
||||
IAsyncEnumerable<BundledAttestation> AggregateAsync(
|
||||
AggregationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Count attestations for a time period without loading them.
|
||||
/// </summary>
|
||||
/// <param name="request">Aggregation parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attestation count.</returns>
|
||||
Task<int> CountAsync(
|
||||
AggregationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for attestation aggregation.
|
||||
/// </summary>
|
||||
/// <param name="PeriodStart">Start of the collection period.</param>
|
||||
/// <param name="PeriodEnd">End of the collection period.</param>
|
||||
/// <param name="TenantId">Optional tenant filter.</param>
|
||||
/// <param name="PredicateTypes">Optional filter for specific predicate types.</param>
|
||||
/// <param name="BatchSize">Number of attestations to fetch per batch.</param>
|
||||
public record AggregationRequest(
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
string? TenantId = null,
|
||||
IReadOnlyList<string>? PredicateTypes = null,
|
||||
int BatchSize = 500);
|
||||
@@ -0,0 +1,138 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IBundleStore.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0009 - Implement IBundleStore for S3/RustFS
|
||||
// Description: Interface for bundle storage and retrieval
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Storage abstraction for attestation bundles.
|
||||
/// Supports S3-compatible storage (RustFS) and filesystem backends.
|
||||
/// </summary>
|
||||
public interface IBundleStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Store a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to store.</param>
|
||||
/// <param name="options">Storage options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task StoreBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
BundleStorageOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a bundle by ID.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The bundle if found, null otherwise.</returns>
|
||||
Task<AttestationBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a bundle exists.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the bundle exists.</returns>
|
||||
Task<bool> ExistsAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the bundle was deleted.</returns>
|
||||
Task<bool> DeleteBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List bundle metadata with pagination.
|
||||
/// </summary>
|
||||
/// <param name="request">List parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated list of bundle metadata.</returns>
|
||||
Task<BundleListResult> ListBundlesAsync(
|
||||
BundleListRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Export a bundle to a stream (with optional compression).
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="output">The output stream.</param>
|
||||
/// <param name="options">Export options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task ExportBundleAsync(
|
||||
string bundleId,
|
||||
Stream output,
|
||||
BundleExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle storage.
|
||||
/// </summary>
|
||||
/// <param name="Compression">Compression format (none, gzip, zstd).</param>
|
||||
/// <param name="ObjectLock">Object lock mode for WORM protection.</param>
|
||||
/// <param name="RetentionDays">Retention period in days.</param>
|
||||
public record BundleStorageOptions(
|
||||
BundleCompression Compression = BundleCompression.Zstd,
|
||||
ObjectLockMode ObjectLock = ObjectLockMode.None,
|
||||
int? RetentionDays = null);
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle export.
|
||||
/// </summary>
|
||||
/// <param name="Format">Export format (json or cbor).</param>
|
||||
/// <param name="Compression">Compression format.</param>
|
||||
public record BundleExportOptions(
|
||||
BundleFormat Format = BundleFormat.Json,
|
||||
BundleCompression Compression = BundleCompression.Zstd);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle serialization format.
|
||||
/// </summary>
|
||||
public enum BundleFormat
|
||||
{
|
||||
/// <summary>JSON format for human readability.</summary>
|
||||
Json,
|
||||
/// <summary>CBOR format for compact size.</summary>
|
||||
Cbor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle compression format.
|
||||
/// </summary>
|
||||
public enum BundleCompression
|
||||
{
|
||||
/// <summary>No compression.</summary>
|
||||
None,
|
||||
/// <summary>Gzip compression.</summary>
|
||||
Gzip,
|
||||
/// <summary>Zstandard compression (default).</summary>
|
||||
Zstd
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Object lock mode for WORM protection.
|
||||
/// </summary>
|
||||
public enum ObjectLockMode
|
||||
{
|
||||
/// <summary>No object lock.</summary>
|
||||
None,
|
||||
/// <summary>Governance mode (can be bypassed with special permissions).</summary>
|
||||
Governance,
|
||||
/// <summary>Compliance mode (cannot be bypassed).</summary>
|
||||
Compliance
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOrgKeySigner.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0006 - Implement IOrgKeySigner interface
|
||||
// Description: Interface for organization key signing of bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for signing bundles with organization keys.
|
||||
/// Supports KMS/HSM-backed keys for high-assurance signing.
|
||||
/// </summary>
|
||||
public interface IOrgKeySigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign a bundle digest with an organization key.
|
||||
/// </summary>
|
||||
/// <param name="bundleDigest">SHA-256 digest of the canonical bundle content.</param>
|
||||
/// <param name="keyId">Key identifier to use for signing.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The organization signature.</returns>
|
||||
Task<OrgSignature> SignBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify an organization signature on a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundleDigest">SHA-256 digest of the canonical bundle content.</param>
|
||||
/// <param name="signature">The signature to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the signature is valid.</returns>
|
||||
Task<bool> VerifyBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
OrgSignature signature,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the current signing key ID based on configuration and rotation policy.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The active key ID.</returns>
|
||||
Task<string> GetActiveKeyIdAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List available signing keys.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Available key information.</returns>
|
||||
Task<IReadOnlyList<OrgKeyInfo>> ListKeysAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organization signing key information.
|
||||
/// </summary>
|
||||
/// <param name="KeyId">Unique key identifier.</param>
|
||||
/// <param name="Algorithm">Signing algorithm (e.g., "ECDSA_P256", "Ed25519").</param>
|
||||
/// <param name="Fingerprint">Key fingerprint (SHA-256 of public key).</param>
|
||||
/// <param name="ValidFrom">Start of key validity period.</param>
|
||||
/// <param name="ValidUntil">End of key validity period (null if no expiration).</param>
|
||||
/// <param name="IsActive">Whether this key is currently active for signing.</param>
|
||||
public record OrgKeyInfo(
|
||||
string KeyId,
|
||||
string Algorithm,
|
||||
string Fingerprint,
|
||||
DateTimeOffset ValidFrom,
|
||||
DateTimeOffset? ValidUntil,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,387 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundlingOptions.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0013, 0016 - Bundle retention policy schema and job configuration
|
||||
// Description: Configuration options for attestation bundling and retention
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for attestation bundling.
|
||||
/// </summary>
|
||||
public sealed class BundlingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether bundling is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Schedule configuration for automated bundling.
|
||||
/// </summary>
|
||||
public BundleScheduleOptions Schedule { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Aggregation settings for collecting attestations.
|
||||
/// </summary>
|
||||
public BundleAggregationOptions Aggregation { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Organization key signing settings.
|
||||
/// </summary>
|
||||
public BundleSigningOptions Signing { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Retention policy settings.
|
||||
/// </summary>
|
||||
public BundleRetentionOptions Retention { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Storage settings for bundles.
|
||||
/// </summary>
|
||||
public BundleStorageOptions Storage { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Export settings.
|
||||
/// </summary>
|
||||
public BundleExportOptions Export { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedule options for bundle rotation.
|
||||
/// </summary>
|
||||
public sealed class BundleScheduleOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Cron expression for rotation schedule.
|
||||
/// Default: Monthly on the 1st at 02:00 UTC.
|
||||
/// </summary>
|
||||
public string Cron { get; set; } = "0 2 1 * *";
|
||||
|
||||
/// <summary>
|
||||
/// Rotation cadence.
|
||||
/// </summary>
|
||||
public string Cadence { get; set; } = "monthly";
|
||||
|
||||
/// <summary>
|
||||
/// Timezone for schedule evaluation.
|
||||
/// </summary>
|
||||
public string Timezone { get; set; } = "UTC";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to skip weekends for rotation.
|
||||
/// </summary>
|
||||
public bool SkipWeekends { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregation options for collecting attestations into bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleAggregationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Look-back period in days for attestation collection.
|
||||
/// </summary>
|
||||
public int LookbackDays { get; set; } = 31;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum attestations per bundle.
|
||||
/// If exceeded, multiple bundles are created.
|
||||
/// </summary>
|
||||
public int MaxAttestationsPerBundle { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Batch size for database queries.
|
||||
/// </summary>
|
||||
public int QueryBatchSize { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum attestations required to create a bundle.
|
||||
/// </summary>
|
||||
public int MinAttestationsForBundle { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include failed attestations in bundles.
|
||||
/// </summary>
|
||||
public bool IncludeFailedAttestations { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Predicate types to include. Empty = all types.
|
||||
/// </summary>
|
||||
public IList<string> PredicateTypes { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing options for organization key signing of bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleSigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to sign bundles with organization key.
|
||||
/// </summary>
|
||||
public bool SignWithOrgKey { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Organization key ID to use (null = use active key).
|
||||
/// </summary>
|
||||
public string? OrgKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key rotation configuration.
|
||||
/// </summary>
|
||||
public IList<KeyRotationEntry> KeyRotation { get; set; } = new List<KeyRotationEntry>();
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm.
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = "ECDSA_P256";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include certificate chain in signature.
|
||||
/// </summary>
|
||||
public bool IncludeCertificateChain { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key rotation schedule entry.
|
||||
/// </summary>
|
||||
public sealed class KeyRotationEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Start of key validity.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// End of key validity.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidUntil { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retention policy options for bundle lifecycle management.
|
||||
/// </summary>
|
||||
public sealed class BundleRetentionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether retention policy enforcement is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default retention period in months.
|
||||
/// </summary>
|
||||
public int DefaultMonths { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum retention period in months (cannot be overridden lower).
|
||||
/// </summary>
|
||||
public int MinimumMonths { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retention period in months.
|
||||
/// </summary>
|
||||
public int MaximumMonths { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Per-tenant retention overrides.
|
||||
/// </summary>
|
||||
public IDictionary<string, int> TenantOverrides { get; set; } = new Dictionary<string, int>();
|
||||
|
||||
/// <summary>
|
||||
/// Per-predicate type retention overrides.
|
||||
/// </summary>
|
||||
public IDictionary<string, int> PredicateTypeOverrides { get; set; } = new Dictionary<string, int>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether to delete or archive expired bundles.
|
||||
/// </summary>
|
||||
public RetentionAction ExpiryAction { get; set; } = RetentionAction.Delete;
|
||||
|
||||
/// <summary>
|
||||
/// Archive storage tier for archived bundles.
|
||||
/// </summary>
|
||||
public string ArchiveStorageTier { get; set; } = "glacier";
|
||||
|
||||
/// <summary>
|
||||
/// Grace period in days before deletion (warning period).
|
||||
/// </summary>
|
||||
public int GracePeriodDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to send notifications before bundle expiry.
|
||||
/// </summary>
|
||||
public bool NotifyBeforeExpiry { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Days before expiry to send notification.
|
||||
/// </summary>
|
||||
public int NotifyDaysBeforeExpiry { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bundles to process per retention run.
|
||||
/// </summary>
|
||||
public int MaxBundlesPerRun { get; set; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when a bundle expires.
|
||||
/// </summary>
|
||||
public enum RetentionAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Delete expired bundles permanently.
|
||||
/// </summary>
|
||||
Delete,
|
||||
|
||||
/// <summary>
|
||||
/// Archive expired bundles to cold storage.
|
||||
/// </summary>
|
||||
Archive,
|
||||
|
||||
/// <summary>
|
||||
/// Mark as expired but retain.
|
||||
/// </summary>
|
||||
MarkOnly
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage options for bundle persistence.
|
||||
/// </summary>
|
||||
public sealed class BundleStorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Storage backend type.
|
||||
/// </summary>
|
||||
public string Backend { get; set; } = "s3";
|
||||
|
||||
/// <summary>
|
||||
/// S3 storage configuration.
|
||||
/// </summary>
|
||||
public BundleS3Options S3 { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem storage configuration.
|
||||
/// </summary>
|
||||
public BundleFilesystemOptions Filesystem { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL metadata storage configuration.
|
||||
/// </summary>
|
||||
public BundlePostgresOptions Postgres { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// S3 storage options for bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleS3Options
|
||||
{
|
||||
/// <summary>
|
||||
/// S3 bucket name.
|
||||
/// </summary>
|
||||
public string Bucket { get; set; } = "stellaops-attestor";
|
||||
|
||||
/// <summary>
|
||||
/// Object key prefix.
|
||||
/// </summary>
|
||||
public string Prefix { get; set; } = "bundles/";
|
||||
|
||||
/// <summary>
|
||||
/// Object lock mode for WORM protection.
|
||||
/// </summary>
|
||||
public string? ObjectLock { get; set; } = "governance";
|
||||
|
||||
/// <summary>
|
||||
/// Storage class for new objects.
|
||||
/// </summary>
|
||||
public string StorageClass { get; set; } = "STANDARD";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable server-side encryption.
|
||||
/// </summary>
|
||||
public bool ServerSideEncryption { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// KMS key for encryption.
|
||||
/// </summary>
|
||||
public string? KmsKeyId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem storage options for bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleFilesystemOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base path for bundle storage.
|
||||
/// </summary>
|
||||
public string Path { get; set; } = "/var/lib/stellaops/attestor/bundles";
|
||||
|
||||
/// <summary>
|
||||
/// Directory permissions (octal).
|
||||
/// </summary>
|
||||
public string DirectoryPermissions { get; set; } = "0750";
|
||||
|
||||
/// <summary>
|
||||
/// File permissions (octal).
|
||||
/// </summary>
|
||||
public string FilePermissions { get; set; } = "0640";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL options for bundle metadata.
|
||||
/// </summary>
|
||||
public sealed class BundlePostgresOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema name.
|
||||
/// </summary>
|
||||
public string Schema { get; set; } = "attestor";
|
||||
|
||||
/// <summary>
|
||||
/// Bundles table name.
|
||||
/// </summary>
|
||||
public string BundlesTable { get; set; } = "bundles";
|
||||
|
||||
/// <summary>
|
||||
/// Bundle entries table name.
|
||||
/// </summary>
|
||||
public string EntriesTable { get; set; } = "bundle_entries";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export options for bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleExportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to include bundles in Offline Kit.
|
||||
/// </summary>
|
||||
public bool IncludeInOfflineKit { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Compression algorithm for export.
|
||||
/// </summary>
|
||||
public string Compression { get; set; } = "zstd";
|
||||
|
||||
/// <summary>
|
||||
/// Compression level.
|
||||
/// </summary>
|
||||
public int CompressionLevel { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bundle age to include in exports (months).
|
||||
/// </summary>
|
||||
public int MaxAgeMonths { get; set; } = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Supported export formats.
|
||||
/// </summary>
|
||||
public IList<string> SupportedFormats { get; set; } = new List<string> { "json", "cbor" };
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationBundle.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0002 - Define AttestationBundle record and schema
|
||||
// Description: Aggregated attestation bundle for long-term verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Attestation bundle aggregating multiple attestations for a time period.
|
||||
/// Contains all material needed for offline verification including Merkle tree
|
||||
/// for integrity and optional organization signature for endorsement.
|
||||
/// </summary>
|
||||
public sealed record AttestationBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle metadata including period, version, and creation timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public required BundleMetadata Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All attestations included in this bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestations")]
|
||||
public required IReadOnlyList<BundledAttestation> Attestations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle tree information for bundle integrity verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("merkleTree")]
|
||||
public required MerkleTreeInfo MerkleTree { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional organization signature for bundle endorsement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("orgSignature")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public OrgSignature? OrgSignature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle metadata containing identification and temporal information.
|
||||
/// </summary>
|
||||
public sealed record BundleMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressed bundle ID: sha256:<merkle_root>
|
||||
/// </summary>
|
||||
[JsonPropertyName("bundleId")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle schema version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when this bundle was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of the attestation collection period (inclusive).
|
||||
/// </summary>
|
||||
[JsonPropertyName("periodStart")]
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of the attestation collection period (inclusive).
|
||||
/// </summary>
|
||||
[JsonPropertyName("periodEnd")]
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of attestations in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestationCount")]
|
||||
public required int AttestationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant identifier for multi-tenant deployments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprint of the organization signing key (if signed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("orgKeyFingerprint")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? OrgKeyFingerprint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual attestation entry within a bundle.
|
||||
/// </summary>
|
||||
public sealed record BundledAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique entry identifier (typically the Rekor UUID).
|
||||
/// </summary>
|
||||
[JsonPropertyName("entryId")]
|
||||
public required string EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor UUID if registered with transparency log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorUuid")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RekorUuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if registered with transparency log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 digest of the artifact this attestation covers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type (e.g., "verdict.stella/v1", "sbom.stella/v1").
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the attestation was signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing mode used: "keyless" (Fulcio), "kms", "hsm", or "fido2".
|
||||
/// </summary>
|
||||
[JsonPropertyName("signingMode")]
|
||||
public required string SigningMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity information about the signer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signingIdentity")]
|
||||
public required SigningIdentity SigningIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor inclusion proof for transparency verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inclusionProof")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public RekorInclusionProof? InclusionProof { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The DSSE envelope containing the attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public required DsseEnvelopeData Envelope { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing identity information.
|
||||
/// </summary>
|
||||
public sealed record SigningIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// OIDC issuer URL for keyless signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuer")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject identifier (e.g., email, service account).
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject Alternative Name from certificate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("san")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? San { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier for KMS/HSM signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log inclusion proof.
|
||||
/// </summary>
|
||||
public sealed record RekorInclusionProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Checkpoint containing tree size and root hash.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public required CheckpointData Checkpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle audit path from leaf to root.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required IReadOnlyList<string> Path { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor checkpoint data.
|
||||
/// </summary>
|
||||
public sealed record CheckpointData
|
||||
{
|
||||
/// <summary>
|
||||
/// Log origin identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("origin")]
|
||||
public required string Origin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree size at checkpoint time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded root hash.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rootHash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checkpoint timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope data for serialization.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeData
|
||||
{
|
||||
/// <summary>
|
||||
/// Payload type (e.g., "application/vnd.in-toto+json").
|
||||
/// </summary>
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signatures over the payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signatures")]
|
||||
public required IReadOnlyList<EnvelopeSignature> Signatures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate chain for signature verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("certificateChain")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? CertificateChain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature within a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record EnvelopeSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyid")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle tree information for bundle integrity.
|
||||
/// </summary>
|
||||
public sealed record MerkleTreeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Hash algorithm used (always SHA256).
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = "SHA256";
|
||||
|
||||
/// <summary>
|
||||
/// Merkle root hash in sha256:<hex> format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("root")]
|
||||
public required string Root { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of leaves (attestations) in the tree.
|
||||
/// </summary>
|
||||
[JsonPropertyName("leafCount")]
|
||||
public required int LeafCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organization signature for bundle endorsement.
|
||||
/// </summary>
|
||||
public sealed record OrgSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier used for signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature algorithm (e.g., "ECDSA_P256", "Ed25519", "RSA_PSS_SHA256").
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithm")]
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature over the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public required string Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the signature was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PEM-encoded certificate chain for signature verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("certificateChain")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? CertificateChain { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationBundler.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0005 - Implement IAttestationBundler service
|
||||
// Description: Service implementation for creating attestation bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing attestation bundles.
|
||||
/// Implements deterministic bundling with optional organization signing.
|
||||
/// </summary>
|
||||
public sealed class AttestationBundler : IAttestationBundler
|
||||
{
|
||||
private readonly IBundleAggregator _aggregator;
|
||||
private readonly IBundleStore _store;
|
||||
private readonly IOrgKeySigner? _orgSigner;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly ILogger<AttestationBundler> _logger;
|
||||
private readonly BundlingOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new attestation bundler.
|
||||
/// </summary>
|
||||
public AttestationBundler(
|
||||
IBundleAggregator aggregator,
|
||||
IBundleStore store,
|
||||
IMerkleTreeBuilder merkleBuilder,
|
||||
ILogger<AttestationBundler> logger,
|
||||
IOptions<BundlingOptions> options,
|
||||
IOrgKeySigner? orgSigner = null)
|
||||
{
|
||||
_aggregator = aggregator ?? throw new ArgumentNullException(nameof(aggregator));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new BundlingOptions();
|
||||
_orgSigner = orgSigner;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationBundle> CreateBundleAsync(
|
||||
BundleCreationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Creating attestation bundle for period {PeriodStart} to {PeriodEnd}",
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd);
|
||||
|
||||
// Collect attestations in deterministic order
|
||||
var attestations = await CollectAttestationsAsync(request, cancellationToken);
|
||||
|
||||
if (attestations.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No attestations found for the specified period");
|
||||
throw new InvalidOperationException("No attestations found for the specified period.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Collected {Count} attestations for bundling", attestations.Count);
|
||||
|
||||
// Build deterministic Merkle tree
|
||||
var merkleTree = BuildMerkleTree(attestations);
|
||||
var merkleRoot = Convert.ToHexString(merkleTree.Root).ToLowerInvariant();
|
||||
var bundleId = $"sha256:{merkleRoot}";
|
||||
|
||||
_logger.LogInformation("Computed Merkle root: {MerkleRoot}", bundleId);
|
||||
|
||||
// Create bundle metadata
|
||||
var metadata = new BundleMetadata
|
||||
{
|
||||
BundleId = bundleId,
|
||||
Version = "1.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PeriodStart = request.PeriodStart,
|
||||
PeriodEnd = request.PeriodEnd,
|
||||
AttestationCount = attestations.Count,
|
||||
TenantId = request.TenantId
|
||||
};
|
||||
|
||||
// Create bundle
|
||||
var bundle = new AttestationBundle
|
||||
{
|
||||
Metadata = metadata,
|
||||
Attestations = attestations,
|
||||
MerkleTree = new MerkleTreeInfo
|
||||
{
|
||||
Algorithm = "SHA256",
|
||||
Root = bundleId,
|
||||
LeafCount = attestations.Count
|
||||
}
|
||||
};
|
||||
|
||||
// Sign with organization key if requested
|
||||
if (request.SignWithOrgKey && _orgSigner != null)
|
||||
{
|
||||
bundle = await SignBundleAsync(bundle, request.OrgKeyId, cancellationToken);
|
||||
}
|
||||
|
||||
// Store the bundle
|
||||
await _store.StoreBundleAsync(bundle, cancellationToken: cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created attestation bundle {BundleId} with {Count} attestations",
|
||||
bundleId,
|
||||
attestations.Count);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
return await _store.GetBundleAsync(bundleId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleListResult> ListBundlesAsync(
|
||||
BundleListRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
return await _store.ListBundlesAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleVerificationResult> VerifyBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var issues = new List<BundleVerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Verify Merkle root
|
||||
var merkleValid = VerifyMerkleRoot(bundle, issues);
|
||||
|
||||
// Verify org signature if present
|
||||
bool? orgSigValid = null;
|
||||
if (bundle.OrgSignature != null && _orgSigner != null)
|
||||
{
|
||||
orgSigValid = await VerifyOrgSignatureAsync(bundle, issues, cancellationToken);
|
||||
}
|
||||
|
||||
var valid = merkleValid && (orgSigValid ?? true);
|
||||
|
||||
return new BundleVerificationResult(
|
||||
Valid: valid,
|
||||
MerkleRootVerified: merkleValid,
|
||||
OrgSignatureVerified: orgSigValid,
|
||||
AttestationsVerified: bundle.Attestations.Count,
|
||||
Issues: issues,
|
||||
VerifiedAt: verifiedAt);
|
||||
}
|
||||
|
||||
private async Task<List<BundledAttestation>> CollectAttestationsAsync(
|
||||
BundleCreationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var aggregationRequest = new AggregationRequest(
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd,
|
||||
request.TenantId,
|
||||
null,
|
||||
_options.Aggregation.QueryBatchSize);
|
||||
|
||||
var attestations = new List<BundledAttestation>();
|
||||
|
||||
await foreach (var attestation in _aggregator.AggregateAsync(aggregationRequest, cancellationToken))
|
||||
{
|
||||
attestations.Add(attestation);
|
||||
|
||||
if (attestations.Count >= _options.Aggregation.MaxAttestationsPerBundle)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Reached maximum attestations per bundle limit ({Max})",
|
||||
_options.Aggregation.MaxAttestationsPerBundle);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort deterministically by entry ID for stable Merkle root
|
||||
attestations.Sort((a, b) => string.Compare(a.EntryId, b.EntryId, StringComparison.Ordinal));
|
||||
|
||||
return attestations;
|
||||
}
|
||||
|
||||
private MerkleTreeWithProofs BuildMerkleTree(List<BundledAttestation> attestations)
|
||||
{
|
||||
// Create leaf values from attestation entry IDs (deterministic)
|
||||
var leafValues = attestations
|
||||
.Select(a => (ReadOnlyMemory<byte>)Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
return _merkleBuilder.BuildTree(leafValues);
|
||||
}
|
||||
|
||||
private async Task<AttestationBundle> SignBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
string? keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_orgSigner == null)
|
||||
{
|
||||
throw new InvalidOperationException("Organization signer is not configured.");
|
||||
}
|
||||
|
||||
// Use active key if not specified
|
||||
keyId ??= await _orgSigner.GetActiveKeyIdAsync(cancellationToken);
|
||||
|
||||
// Compute bundle digest (over canonical JSON of Merkle root and attestation IDs)
|
||||
var digestData = ComputeBundleDigest(bundle);
|
||||
|
||||
// Sign the digest
|
||||
var signature = await _orgSigner.SignBundleAsync(digestData, keyId, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signed bundle {BundleId} with org key {KeyId}",
|
||||
bundle.Metadata.BundleId,
|
||||
keyId);
|
||||
|
||||
// Return bundle with signature and updated metadata
|
||||
return bundle with
|
||||
{
|
||||
Metadata = bundle.Metadata with
|
||||
{
|
||||
OrgKeyFingerprint = $"sha256:{ComputeKeyFingerprint(keyId)}"
|
||||
},
|
||||
OrgSignature = signature
|
||||
};
|
||||
}
|
||||
|
||||
private bool VerifyMerkleRoot(AttestationBundle bundle, List<BundleVerificationIssue> issues)
|
||||
{
|
||||
try
|
||||
{
|
||||
var leafValues = bundle.Attestations
|
||||
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
|
||||
.Select(a => (ReadOnlyMemory<byte>)Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
var computedRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
|
||||
var computedRootHex = $"sha256:{Convert.ToHexString(computedRoot).ToLowerInvariant()}";
|
||||
|
||||
if (computedRootHex != bundle.MerkleTree.Root)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"MERKLE_ROOT_MISMATCH",
|
||||
$"Computed Merkle root {computedRootHex} does not match bundle root {bundle.MerkleTree.Root}"));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"MERKLE_VERIFY_ERROR",
|
||||
$"Failed to verify Merkle root: {ex.Message}"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> VerifyOrgSignatureAsync(
|
||||
AttestationBundle bundle,
|
||||
List<BundleVerificationIssue> issues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_orgSigner == null || bundle.OrgSignature == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var digestData = ComputeBundleDigest(bundle);
|
||||
var valid = await _orgSigner.VerifyBundleAsync(digestData, bundle.OrgSignature, cancellationToken);
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
$"Organization signature verification failed for key {bundle.OrgSignature.KeyId}"));
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"ORG_SIG_VERIFY_ERROR",
|
||||
$"Failed to verify organization signature: {ex.Message}"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputeBundleDigest(AttestationBundle bundle)
|
||||
{
|
||||
// Compute digest over merkle root + sorted attestation IDs
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(bundle.MerkleTree.Root);
|
||||
foreach (var attestation in bundle.Attestations.OrderBy(a => a.EntryId, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(attestation.EntryId);
|
||||
}
|
||||
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
}
|
||||
|
||||
private static string ComputeKeyFingerprint(string keyId)
|
||||
{
|
||||
// Simple fingerprint - in production this would use the actual public key
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(keyId));
|
||||
return Convert.ToHexString(hash[..16]).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineKitBundleProvider.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0017 - Integrate with Offline Kit export
|
||||
// Description: Provides attestation bundles for Offline Kit exports
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of an Offline Kit bundle export.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitBundleExportResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundles included in the export.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<BundleExportInfo> Bundles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total attestations across all bundles.
|
||||
/// </summary>
|
||||
public required int TotalAttestations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total export size in bytes.
|
||||
/// </summary>
|
||||
public required long TotalSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Export timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExportedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an exported bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleExportInfo(
|
||||
string BundleId,
|
||||
string FileName,
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
int AttestationCount,
|
||||
long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Options for Offline Kit bundle export.
|
||||
/// </summary>
|
||||
public sealed class OfflineKitExportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum age of bundles to include (in months).
|
||||
/// Default: 12 months.
|
||||
/// </summary>
|
||||
public int MaxAgeMonths { get; set; } = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Export format.
|
||||
/// </summary>
|
||||
public BundleFormat Format { get; set; } = BundleFormat.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Compression algorithm.
|
||||
/// </summary>
|
||||
public BundleCompression Compression { get; set; } = BundleCompression.Zstd;
|
||||
|
||||
/// <summary>
|
||||
/// Include only signed bundles.
|
||||
/// </summary>
|
||||
public bool RequireOrgSignature { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant filter (null = all tenants).
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Offline Kit bundle provider.
|
||||
/// </summary>
|
||||
public interface IOfflineKitBundleProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Export bundles for inclusion in Offline Kit.
|
||||
/// </summary>
|
||||
/// <param name="outputDirectory">Directory to write bundle files.</param>
|
||||
/// <param name="options">Export options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Export result with bundle information.</returns>
|
||||
Task<OfflineKitBundleExportResult> ExportForOfflineKitAsync(
|
||||
string outputDirectory,
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get bundle manifest for Offline Kit.
|
||||
/// </summary>
|
||||
/// <param name="options">Export options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of bundles that would be included.</returns>
|
||||
Task<IReadOnlyList<BundleListItem>> GetOfflineKitManifestAsync(
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides attestation bundles for Offline Kit exports.
|
||||
/// Integrates with the Offline Kit to include bundled attestations
|
||||
/// for long-term offline verification.
|
||||
/// </summary>
|
||||
public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
|
||||
{
|
||||
private readonly IBundleStore _bundleStore;
|
||||
private readonly BundlingOptions _options;
|
||||
private readonly ILogger<OfflineKitBundleProvider> _logger;
|
||||
|
||||
public OfflineKitBundleProvider(
|
||||
IBundleStore bundleStore,
|
||||
IOptions<BundlingOptions> options,
|
||||
ILogger<OfflineKitBundleProvider> logger)
|
||||
{
|
||||
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
_options = options?.Value ?? new BundlingOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<OfflineKitBundleExportResult> ExportForOfflineKitAsync(
|
||||
string outputDirectory,
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new OfflineKitExportOptions();
|
||||
|
||||
if (!_options.Export.IncludeInOfflineKit)
|
||||
{
|
||||
_logger.LogDebug("Offline Kit bundle export is disabled");
|
||||
return new OfflineKitBundleExportResult
|
||||
{
|
||||
Bundles = [],
|
||||
TotalAttestations = 0,
|
||||
TotalSizeBytes = 0,
|
||||
ExportedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exporting bundles for Offline Kit. MaxAge={MaxAge} months, Format={Format}",
|
||||
options.MaxAgeMonths,
|
||||
options.Format);
|
||||
|
||||
// Ensure output directory exists
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
// Get bundles to export
|
||||
var bundles = await GetOfflineKitManifestAsync(options, cancellationToken);
|
||||
|
||||
var exportedBundles = new List<BundleExportInfo>();
|
||||
long totalSize = 0;
|
||||
int totalAttestations = 0;
|
||||
|
||||
foreach (var bundleInfo in bundles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exportInfo = await ExportBundleAsync(
|
||||
bundleInfo,
|
||||
outputDirectory,
|
||||
options,
|
||||
cancellationToken);
|
||||
|
||||
if (exportInfo != null)
|
||||
{
|
||||
exportedBundles.Add(exportInfo);
|
||||
totalSize += exportInfo.SizeBytes;
|
||||
totalAttestations += exportInfo.AttestationCount;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to export bundle {BundleId} for Offline Kit",
|
||||
bundleInfo.BundleId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exported {Count} bundles for Offline Kit. Total: {Attestations} attestations, {Size} bytes",
|
||||
exportedBundles.Count,
|
||||
totalAttestations,
|
||||
totalSize);
|
||||
|
||||
return new OfflineKitBundleExportResult
|
||||
{
|
||||
Bundles = exportedBundles,
|
||||
TotalAttestations = totalAttestations,
|
||||
TotalSizeBytes = totalSize,
|
||||
ExportedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<BundleListItem>> GetOfflineKitManifestAsync(
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new OfflineKitExportOptions();
|
||||
|
||||
var cutoffDate = DateTimeOffset.UtcNow.AddMonths(-options.MaxAgeMonths);
|
||||
var result = new List<BundleListItem>();
|
||||
string? cursor = null;
|
||||
|
||||
do
|
||||
{
|
||||
var listResult = await _bundleStore.ListBundlesAsync(
|
||||
new BundleListRequest(
|
||||
PeriodStart: cutoffDate,
|
||||
TenantId: options.TenantId,
|
||||
Limit: 100,
|
||||
Cursor: cursor),
|
||||
cancellationToken);
|
||||
|
||||
foreach (var bundle in listResult.Bundles)
|
||||
{
|
||||
// Filter by org signature if required
|
||||
if (options.RequireOrgSignature && !bundle.HasOrgSignature)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(bundle);
|
||||
}
|
||||
|
||||
cursor = listResult.NextCursor;
|
||||
}
|
||||
while (cursor != null);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<BundleExportInfo?> ExportBundleAsync(
|
||||
BundleListItem bundleInfo,
|
||||
string outputDirectory,
|
||||
OfflineKitExportOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fileName = GenerateFileName(bundleInfo.BundleId, options);
|
||||
var filePath = Path.Combine(outputDirectory, fileName);
|
||||
|
||||
await using var fileStream = File.Create(filePath);
|
||||
|
||||
await _bundleStore.ExportBundleAsync(
|
||||
bundleInfo.BundleId,
|
||||
fileStream,
|
||||
new Abstractions.BundleExportOptions(options.Format, options.Compression),
|
||||
cancellationToken);
|
||||
|
||||
await fileStream.FlushAsync(cancellationToken);
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Exported bundle {BundleId} to {FileName} ({Size} bytes)",
|
||||
bundleInfo.BundleId,
|
||||
fileName,
|
||||
fileInfo.Length);
|
||||
|
||||
return new BundleExportInfo(
|
||||
bundleInfo.BundleId,
|
||||
fileName,
|
||||
bundleInfo.PeriodStart,
|
||||
bundleInfo.PeriodEnd,
|
||||
bundleInfo.AttestationCount,
|
||||
fileInfo.Length);
|
||||
}
|
||||
|
||||
private static string GenerateFileName(string bundleId, OfflineKitExportOptions options)
|
||||
{
|
||||
// Bundle ID format: sha256:abc123...
|
||||
var hash = bundleId.StartsWith("sha256:")
|
||||
? bundleId[7..Math.Min(bundleId.Length, 7 + 12)]
|
||||
: bundleId[..Math.Min(bundleId.Length, 12)];
|
||||
|
||||
var extension = options.Format switch
|
||||
{
|
||||
BundleFormat.Cbor => ".cbor",
|
||||
_ => ".json"
|
||||
};
|
||||
|
||||
var compression = options.Compression switch
|
||||
{
|
||||
BundleCompression.Gzip => ".gz",
|
||||
BundleCompression.Zstd => ".zst",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
return $"bundle-{hash}{extension}{compression}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RetentionPolicyEnforcer.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0014 - Implement retention policy enforcement
|
||||
// Description: Service for enforcing bundle retention policies
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a retention policy enforcement run.
|
||||
/// </summary>
|
||||
public sealed record RetentionEnforcementResult
|
||||
{
|
||||
/// <summary>
|
||||
/// When the enforcement run started.
|
||||
/// </summary>
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the enforcement run completed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles evaluated.
|
||||
/// </summary>
|
||||
public required int BundlesEvaluated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles deleted.
|
||||
/// </summary>
|
||||
public required int BundlesDeleted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles archived.
|
||||
/// </summary>
|
||||
public required int BundlesArchived { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles marked as expired.
|
||||
/// </summary>
|
||||
public required int BundlesMarkedExpired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles approaching expiry (within notification window).
|
||||
/// </summary>
|
||||
public required int BundlesApproachingExpiry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundles that failed to process.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<BundleEnforcementFailure> Failures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the enforcement run succeeded (no critical failures).
|
||||
/// </summary>
|
||||
public bool Success => Failures.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a bundle that failed retention enforcement.
|
||||
/// </summary>
|
||||
public sealed record BundleEnforcementFailure(
|
||||
string BundleId,
|
||||
string Reason,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Details about a bundle approaching expiry.
|
||||
/// </summary>
|
||||
public sealed record BundleExpiryNotification(
|
||||
string BundleId,
|
||||
string? TenantId,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
int DaysUntilExpiry);
|
||||
|
||||
/// <summary>
|
||||
/// Interface for retention policy enforcement.
|
||||
/// </summary>
|
||||
public interface IRetentionPolicyEnforcer
|
||||
{
|
||||
/// <summary>
|
||||
/// Run retention policy enforcement.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Enforcement result with statistics.</returns>
|
||||
Task<RetentionEnforcementResult> EnforceAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get bundles approaching expiry for notification.
|
||||
/// </summary>
|
||||
/// <param name="daysBeforeExpiry">Days before expiry to check.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of bundles approaching expiry.</returns>
|
||||
Task<IReadOnlyList<BundleExpiryNotification>> GetApproachingExpiryAsync(
|
||||
int daysBeforeExpiry,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Calculate expiry date for a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to evaluate.</param>
|
||||
/// <returns>Expiry date for the bundle.</returns>
|
||||
DateTimeOffset CalculateExpiryDate(BundleListItem bundle);
|
||||
|
||||
/// <summary>
|
||||
/// Calculate expiry date for a bundle with metadata.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant ID.</param>
|
||||
/// <param name="createdAt">Bundle creation date.</param>
|
||||
/// <returns>Expiry date for the bundle.</returns>
|
||||
DateTimeOffset CalculateExpiryDate(string? tenantId, DateTimeOffset createdAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for archiving bundles to cold storage.
|
||||
/// </summary>
|
||||
public interface IBundleArchiver
|
||||
{
|
||||
/// <summary>
|
||||
/// Archive a bundle to cold storage.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID to archive.</param>
|
||||
/// <param name="storageTier">Target storage tier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if archived successfully.</returns>
|
||||
Task<bool> ArchiveAsync(
|
||||
string bundleId,
|
||||
string storageTier,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for notifying about bundle expiry.
|
||||
/// </summary>
|
||||
public interface IBundleExpiryNotifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Send notifications for bundles approaching expiry.
|
||||
/// </summary>
|
||||
/// <param name="notifications">List of expiry notifications.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task NotifyAsync(
|
||||
IReadOnlyList<BundleExpiryNotification> notifications,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for enforcing bundle retention policies.
|
||||
/// Handles expiry, deletion, archival, and notifications.
|
||||
/// </summary>
|
||||
public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
|
||||
{
|
||||
private readonly IBundleStore _bundleStore;
|
||||
private readonly IBundleArchiver? _archiver;
|
||||
private readonly IBundleExpiryNotifier? _notifier;
|
||||
private readonly BundleRetentionOptions _options;
|
||||
private readonly ILogger<RetentionPolicyEnforcer> _logger;
|
||||
|
||||
public RetentionPolicyEnforcer(
|
||||
IBundleStore bundleStore,
|
||||
IOptions<BundlingOptions> options,
|
||||
ILogger<RetentionPolicyEnforcer> logger,
|
||||
IBundleArchiver? archiver = null,
|
||||
IBundleExpiryNotifier? notifier = null)
|
||||
{
|
||||
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
_options = options?.Value?.Retention ?? new BundleRetentionOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_archiver = archiver;
|
||||
_notifier = notifier;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<RetentionEnforcementResult> EnforceAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var failures = new List<BundleEnforcementFailure>();
|
||||
int evaluated = 0;
|
||||
int deleted = 0;
|
||||
int archived = 0;
|
||||
int markedExpired = 0;
|
||||
int approachingExpiry = 0;
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Retention policy enforcement is disabled");
|
||||
return new RetentionEnforcementResult
|
||||
{
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
BundlesEvaluated = 0,
|
||||
BundlesDeleted = 0,
|
||||
BundlesArchived = 0,
|
||||
BundlesMarkedExpired = 0,
|
||||
BundlesApproachingExpiry = 0,
|
||||
Failures = failures
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting retention policy enforcement. ExpiryAction={Action}, DefaultMonths={Months}",
|
||||
_options.ExpiryAction,
|
||||
_options.DefaultMonths);
|
||||
|
||||
// Process bundles in batches
|
||||
string? cursor = null;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var notificationCutoff = now.AddDays(_options.NotifyDaysBeforeExpiry);
|
||||
var gracePeriodCutoff = now.AddDays(-_options.GracePeriodDays);
|
||||
var expiredNotifications = new List<BundleExpiryNotification>();
|
||||
|
||||
do
|
||||
{
|
||||
var listResult = await _bundleStore.ListBundlesAsync(
|
||||
new BundleListRequest(Limit: _options.MaxBundlesPerRun, Cursor: cursor),
|
||||
cancellationToken);
|
||||
|
||||
foreach (var bundle in listResult.Bundles)
|
||||
{
|
||||
evaluated++;
|
||||
var expiryDate = CalculateExpiryDate(bundle);
|
||||
|
||||
// Check if bundle has expired
|
||||
if (expiryDate <= now)
|
||||
{
|
||||
// Check grace period
|
||||
if (expiryDate <= gracePeriodCutoff)
|
||||
{
|
||||
// Past grace period - take expiry action
|
||||
var result = await HandleExpiredBundleAsync(bundle, cancellationToken);
|
||||
if (result.Success)
|
||||
{
|
||||
switch (_options.ExpiryAction)
|
||||
{
|
||||
case RetentionAction.Delete:
|
||||
deleted++;
|
||||
break;
|
||||
case RetentionAction.Archive:
|
||||
archived++;
|
||||
break;
|
||||
case RetentionAction.MarkOnly:
|
||||
markedExpired++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
failures.Add(result.Failure!);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// In grace period - mark as expired but don't delete yet
|
||||
markedExpired++;
|
||||
_logger.LogDebug(
|
||||
"Bundle {BundleId} in grace period, expires {ExpiryDate}",
|
||||
bundle.BundleId,
|
||||
expiryDate);
|
||||
}
|
||||
}
|
||||
// Check if approaching expiry (for notifications)
|
||||
else if (_options.NotifyBeforeExpiry && expiryDate <= notificationCutoff)
|
||||
{
|
||||
approachingExpiry++;
|
||||
expiredNotifications.Add(new BundleExpiryNotification(
|
||||
bundle.BundleId,
|
||||
null, // TenantId not in BundleListItem - would need full bundle fetch
|
||||
bundle.CreatedAt,
|
||||
expiryDate,
|
||||
(int)(expiryDate - now).TotalDays));
|
||||
}
|
||||
}
|
||||
|
||||
cursor = listResult.NextCursor;
|
||||
}
|
||||
while (cursor != null && evaluated < _options.MaxBundlesPerRun);
|
||||
|
||||
// Send notifications for approaching expiry
|
||||
if (_notifier != null && expiredNotifications.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _notifier.NotifyAsync(expiredNotifications, cancellationToken);
|
||||
_logger.LogInformation(
|
||||
"Sent {Count} expiry notifications",
|
||||
expiredNotifications.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send expiry notifications");
|
||||
}
|
||||
}
|
||||
|
||||
var completedAt = DateTimeOffset.UtcNow;
|
||||
_logger.LogInformation(
|
||||
"Retention enforcement completed. Evaluated={Evaluated}, Deleted={Deleted}, Archived={Archived}, Marked={Marked}, Approaching={Approaching}, Failed={Failed}",
|
||||
evaluated, deleted, archived, markedExpired, approachingExpiry, failures.Count);
|
||||
|
||||
return new RetentionEnforcementResult
|
||||
{
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
BundlesEvaluated = evaluated,
|
||||
BundlesDeleted = deleted,
|
||||
BundlesArchived = archived,
|
||||
BundlesMarkedExpired = markedExpired,
|
||||
BundlesApproachingExpiry = approachingExpiry,
|
||||
Failures = failures
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<BundleExpiryNotification>> GetApproachingExpiryAsync(
|
||||
int daysBeforeExpiry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var notifications = new List<BundleExpiryNotification>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var cutoff = now.AddDays(daysBeforeExpiry);
|
||||
string? cursor = null;
|
||||
|
||||
do
|
||||
{
|
||||
var listResult = await _bundleStore.ListBundlesAsync(
|
||||
new BundleListRequest(Limit: 100, Cursor: cursor),
|
||||
cancellationToken);
|
||||
|
||||
foreach (var bundle in listResult.Bundles)
|
||||
{
|
||||
var expiryDate = CalculateExpiryDate(bundle);
|
||||
if (expiryDate > now && expiryDate <= cutoff)
|
||||
{
|
||||
notifications.Add(new BundleExpiryNotification(
|
||||
bundle.BundleId,
|
||||
null,
|
||||
bundle.CreatedAt,
|
||||
expiryDate,
|
||||
(int)(expiryDate - now).TotalDays));
|
||||
}
|
||||
}
|
||||
|
||||
cursor = listResult.NextCursor;
|
||||
}
|
||||
while (cursor != null);
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset CalculateExpiryDate(BundleListItem bundle)
|
||||
{
|
||||
return CalculateExpiryDate(null, bundle.CreatedAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset CalculateExpiryDate(string? tenantId, DateTimeOffset createdAt)
|
||||
{
|
||||
int retentionMonths = _options.DefaultMonths;
|
||||
|
||||
// Check for tenant-specific override
|
||||
if (!string.IsNullOrEmpty(tenantId) &&
|
||||
_options.TenantOverrides.TryGetValue(tenantId, out var tenantMonths))
|
||||
{
|
||||
retentionMonths = Math.Max(tenantMonths, _options.MinimumMonths);
|
||||
retentionMonths = Math.Min(retentionMonths, _options.MaximumMonths);
|
||||
}
|
||||
|
||||
return createdAt.AddMonths(retentionMonths);
|
||||
}
|
||||
|
||||
private async Task<(bool Success, BundleEnforcementFailure? Failure)> HandleExpiredBundleAsync(
|
||||
BundleListItem bundle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (_options.ExpiryAction)
|
||||
{
|
||||
case RetentionAction.Delete:
|
||||
var deleted = await _bundleStore.DeleteBundleAsync(bundle.BundleId, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
_logger.LogInformation("Deleted expired bundle {BundleId}", bundle.BundleId);
|
||||
return (true, null);
|
||||
}
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Delete failed",
|
||||
"Bundle could not be deleted"));
|
||||
|
||||
case RetentionAction.Archive:
|
||||
if (_archiver == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Archive action configured but no archiver available for bundle {BundleId}",
|
||||
bundle.BundleId);
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Archive unavailable",
|
||||
"No archiver configured"));
|
||||
}
|
||||
|
||||
var archived = await _archiver.ArchiveAsync(
|
||||
bundle.BundleId,
|
||||
_options.ArchiveStorageTier,
|
||||
cancellationToken);
|
||||
|
||||
if (archived)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Archived expired bundle {BundleId} to {Tier}",
|
||||
bundle.BundleId,
|
||||
_options.ArchiveStorageTier);
|
||||
return (true, null);
|
||||
}
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Archive failed",
|
||||
"Bundle could not be archived"));
|
||||
|
||||
case RetentionAction.MarkOnly:
|
||||
_logger.LogDebug("Marked bundle {BundleId} as expired", bundle.BundleId);
|
||||
return (true, null);
|
||||
|
||||
default:
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Unknown action",
|
||||
$"Unsupported expiry action: {_options.ExpiryAction}"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to process expired bundle {BundleId}",
|
||||
bundle.BundleId);
|
||||
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Exception",
|
||||
ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KmsOrgKeySigner.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0007 - Implement KmsOrgKeySigner
|
||||
// Description: KMS-backed organization key signing for bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// KMS-backed organization key signer for attestation bundles.
|
||||
/// Supports AWS KMS, Azure Key Vault, Google Cloud KMS, and HashiCorp Vault.
|
||||
/// </summary>
|
||||
public sealed class KmsOrgKeySigner : IOrgKeySigner
|
||||
{
|
||||
private readonly IKmsProvider _kmsProvider;
|
||||
private readonly ILogger<KmsOrgKeySigner> _logger;
|
||||
private readonly OrgSigningOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new KMS organization key signer.
|
||||
/// </summary>
|
||||
public KmsOrgKeySigner(
|
||||
IKmsProvider kmsProvider,
|
||||
ILogger<KmsOrgKeySigner> logger,
|
||||
IOptions<OrgSigningOptions> options)
|
||||
{
|
||||
_kmsProvider = kmsProvider ?? throw new ArgumentNullException(nameof(kmsProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new OrgSigningOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OrgSignature> SignBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundleDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
_logger.LogInformation("Signing bundle with org key {KeyId}", keyId);
|
||||
|
||||
// Get key metadata
|
||||
var keyInfo = await _kmsProvider.GetKeyInfoAsync(keyId, cancellationToken);
|
||||
if (keyInfo == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Signing key '{keyId}' not found in KMS.");
|
||||
}
|
||||
|
||||
// Verify key is active
|
||||
if (!keyInfo.IsActive)
|
||||
{
|
||||
throw new InvalidOperationException($"Signing key '{keyId}' is not active.");
|
||||
}
|
||||
|
||||
// Check key expiry
|
||||
if (keyInfo.ValidUntil.HasValue && keyInfo.ValidUntil.Value < DateTimeOffset.UtcNow)
|
||||
{
|
||||
throw new InvalidOperationException($"Signing key '{keyId}' has expired.");
|
||||
}
|
||||
|
||||
// Sign the digest
|
||||
var signatureBytes = await _kmsProvider.SignAsync(
|
||||
keyId,
|
||||
bundleDigest,
|
||||
keyInfo.Algorithm,
|
||||
cancellationToken);
|
||||
|
||||
// Get certificate chain if available
|
||||
var certChain = await _kmsProvider.GetCertificateChainAsync(keyId, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Successfully signed bundle with key {KeyId}, algorithm {Algorithm}",
|
||||
keyId,
|
||||
keyInfo.Algorithm);
|
||||
|
||||
return new OrgSignature
|
||||
{
|
||||
KeyId = keyId,
|
||||
Algorithm = keyInfo.Algorithm,
|
||||
Signature = Convert.ToBase64String(signatureBytes),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = certChain
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> VerifyBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
OrgSignature signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundleDigest);
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(signature.Signature);
|
||||
|
||||
var isValid = await _kmsProvider.VerifyAsync(
|
||||
signature.KeyId,
|
||||
bundleDigest,
|
||||
signatureBytes,
|
||||
signature.Algorithm,
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bundle signature verification {Result} for key {KeyId}",
|
||||
isValid ? "succeeded" : "failed",
|
||||
signature.KeyId);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Bundle signature verification failed for key {KeyId}",
|
||||
signature.KeyId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> GetActiveKeyIdAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check for configured active key
|
||||
if (!string.IsNullOrEmpty(_options.ActiveKeyId))
|
||||
{
|
||||
return _options.ActiveKeyId;
|
||||
}
|
||||
|
||||
// List keys and find the active one based on rotation policy
|
||||
var keys = await ListKeysAsync(cancellationToken);
|
||||
var activeKey = keys
|
||||
.Where(k => k.IsActive)
|
||||
.Where(k => !k.ValidUntil.HasValue || k.ValidUntil.Value > DateTimeOffset.UtcNow)
|
||||
.OrderByDescending(k => k.ValidFrom)
|
||||
.FirstOrDefault();
|
||||
|
||||
return activeKey?.KeyId
|
||||
?? throw new InvalidOperationException("No active signing key found.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<OrgKeyInfo>> ListKeysAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var kmsKeys = await _kmsProvider.ListKeysAsync(_options.KeyPrefix, cancellationToken);
|
||||
|
||||
return kmsKeys
|
||||
.Select(k => new OrgKeyInfo(
|
||||
k.KeyId,
|
||||
k.Algorithm,
|
||||
k.Fingerprint,
|
||||
k.ValidFrom,
|
||||
k.ValidUntil,
|
||||
k.IsActive))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for organization signing.
|
||||
/// </summary>
|
||||
public sealed class OrgSigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The active key ID to use for signing.
|
||||
/// If not set, the most recent active key is used.
|
||||
/// </summary>
|
||||
public string? ActiveKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key prefix for filtering keys in KMS.
|
||||
/// </summary>
|
||||
public string KeyPrefix { get; set; } = "stellaops/org-signing/";
|
||||
|
||||
/// <summary>
|
||||
/// Default signing algorithm.
|
||||
/// </summary>
|
||||
public string DefaultAlgorithm { get; set; } = "ECDSA_P256";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for KMS provider abstraction.
|
||||
/// </summary>
|
||||
public interface IKmsProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign data with a KMS key.
|
||||
/// </summary>
|
||||
Task<byte[]> SignAsync(
|
||||
string keyId,
|
||||
byte[] data,
|
||||
string algorithm,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a signature with a KMS key.
|
||||
/// </summary>
|
||||
Task<bool> VerifyAsync(
|
||||
string keyId,
|
||||
byte[] data,
|
||||
byte[] signature,
|
||||
string algorithm,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get information about a key.
|
||||
/// </summary>
|
||||
Task<KmsKeyInfo?> GetKeyInfoAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List keys matching a prefix.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KmsKeyInfo>> ListKeysAsync(
|
||||
string? prefix = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the certificate chain for a key.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>?> GetCertificateChainAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// KMS key information.
|
||||
/// </summary>
|
||||
public sealed record KmsKeyInfo(
|
||||
string KeyId,
|
||||
string Algorithm,
|
||||
string Fingerprint,
|
||||
DateTimeOffset ValidFrom,
|
||||
DateTimeOffset? ValidUntil,
|
||||
bool IsActive);
|
||||
|
||||
/// <summary>
|
||||
/// Local (in-memory) key signer for testing and development.
|
||||
/// </summary>
|
||||
public sealed class LocalOrgKeySigner : IOrgKeySigner
|
||||
{
|
||||
private readonly Dictionary<string, (ECDsa Key, OrgKeyInfo Info)> _keys = new();
|
||||
private readonly ILogger<LocalOrgKeySigner> _logger;
|
||||
private string? _activeKeyId;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new local key signer.
|
||||
/// </summary>
|
||||
public LocalOrgKeySigner(ILogger<LocalOrgKeySigner> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate and add a new key.
|
||||
/// </summary>
|
||||
public void AddKey(string keyId, bool isActive = true)
|
||||
{
|
||||
var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyBytes = key.ExportSubjectPublicKeyInfo();
|
||||
var fingerprint = Convert.ToHexString(SHA256.HashData(publicKeyBytes)).ToLowerInvariant();
|
||||
|
||||
var info = new OrgKeyInfo(
|
||||
keyId,
|
||||
"ECDSA_P256",
|
||||
fingerprint,
|
||||
DateTimeOffset.UtcNow,
|
||||
null,
|
||||
isActive);
|
||||
|
||||
_keys[keyId] = (key, info);
|
||||
|
||||
if (isActive)
|
||||
{
|
||||
_activeKeyId = keyId;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Added local signing key {KeyId}", keyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<OrgSignature> SignBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_keys.TryGetValue(keyId, out var keyPair))
|
||||
{
|
||||
throw new InvalidOperationException($"Key '{keyId}' not found.");
|
||||
}
|
||||
|
||||
var signature = keyPair.Key.SignData(bundleDigest, HashAlgorithmName.SHA256);
|
||||
|
||||
return Task.FromResult(new OrgSignature
|
||||
{
|
||||
KeyId = keyId,
|
||||
Algorithm = "ECDSA_P256",
|
||||
Signature = Convert.ToBase64String(signature),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = null
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> VerifyBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
OrgSignature signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_keys.TryGetValue(signature.KeyId, out var keyPair))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(signature.Signature);
|
||||
var isValid = keyPair.Key.VerifyData(bundleDigest, signatureBytes, HashAlgorithmName.SHA256);
|
||||
return Task.FromResult(isValid);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> GetActiveKeyIdAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_activeKeyId == null)
|
||||
{
|
||||
throw new InvalidOperationException("No active signing key.");
|
||||
}
|
||||
return Task.FromResult(_activeKeyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<OrgKeyInfo>> ListKeysAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<OrgKeyInfo>>(
|
||||
_keys.Values.Select(k => k.Info).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Bundling</RootNamespace>
|
||||
<Description>Attestation bundle aggregation and rotation for long-term verification in air-gapped environments.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user