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,483 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundlesController.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0010-0012 - Create bundle API endpoints
|
||||
// Description: API endpoints for attestation bundle management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for attestation bundle management.
|
||||
/// Bundles aggregate attestations for a time period with optional org-key signing.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/bundles")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class BundlesController : ControllerBase
|
||||
{
|
||||
private readonly IAttestationBundler _bundler;
|
||||
private readonly ILogger<BundlesController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new BundlesController.
|
||||
/// </summary>
|
||||
public BundlesController(
|
||||
IAttestationBundler bundler,
|
||||
ILogger<BundlesController> logger)
|
||||
{
|
||||
_bundler = bundler ?? throw new ArgumentNullException(nameof(bundler));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new attestation bundle for a time period.
|
||||
/// </summary>
|
||||
/// <param name="request">Bundle creation parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The created bundle metadata.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(BundleCreatedResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<BundleCreatedResponse>> CreateBundleAsync(
|
||||
[FromBody] CreateBundleRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (request.PeriodEnd <= request.PeriodStart)
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid period",
|
||||
Detail = "periodEnd must be after periodStart",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Creating bundle for period {Start} to {End}",
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd);
|
||||
|
||||
try
|
||||
{
|
||||
var creationRequest = new BundleCreationRequest(
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd,
|
||||
request.TenantId,
|
||||
request.SignWithOrgKey,
|
||||
request.OrgKeyId);
|
||||
|
||||
var bundle = await _bundler.CreateBundleAsync(creationRequest, ct);
|
||||
|
||||
var response = new BundleCreatedResponse
|
||||
{
|
||||
BundleId = bundle.Metadata.BundleId,
|
||||
Status = "created",
|
||||
AttestationCount = bundle.Attestations.Count,
|
||||
PeriodStart = bundle.Metadata.PeriodStart,
|
||||
PeriodEnd = bundle.Metadata.PeriodEnd,
|
||||
CreatedAt = bundle.Metadata.CreatedAt,
|
||||
HasOrgSignature = bundle.OrgSignature != null
|
||||
};
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetBundleAsync),
|
||||
new { bundleId = bundle.Metadata.BundleId },
|
||||
response);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create bundle");
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Bundle creation failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get bundle metadata by ID.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID (sha256:...).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Bundle metadata.</returns>
|
||||
[HttpGet("{bundleId}")]
|
||||
[ProducesResponseType(typeof(BundleMetadataResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BundleMetadataResponse>> GetBundleAsync(
|
||||
[FromRoute] string bundleId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsValidBundleId(bundleId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid bundle ID",
|
||||
Detail = "Bundle ID must be in format sha256:<64-hex>",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var bundle = await _bundler.GetBundleAsync(bundleId, ct);
|
||||
|
||||
if (bundle == null)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Bundle not found",
|
||||
Detail = $"No bundle found with ID {bundleId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new BundleMetadataResponse
|
||||
{
|
||||
BundleId = bundle.Metadata.BundleId,
|
||||
Version = bundle.Metadata.Version,
|
||||
PeriodStart = bundle.Metadata.PeriodStart,
|
||||
PeriodEnd = bundle.Metadata.PeriodEnd,
|
||||
AttestationCount = bundle.Metadata.AttestationCount,
|
||||
MerkleRoot = bundle.MerkleTree.Root,
|
||||
OrgSignature = bundle.OrgSignature != null
|
||||
? new OrgSignatureInfo
|
||||
{
|
||||
KeyId = bundle.OrgSignature.KeyId,
|
||||
Algorithm = bundle.OrgSignature.Algorithm,
|
||||
SignedAt = bundle.OrgSignature.SignedAt
|
||||
}
|
||||
: null,
|
||||
CreatedAt = bundle.Metadata.CreatedAt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List bundles with pagination.
|
||||
/// </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 results (default 20).</param>
|
||||
/// <param name="cursor">Pagination cursor.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Paginated list of bundles.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(BundleListResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BundleListResponse>> ListBundlesAsync(
|
||||
[FromQuery] DateTimeOffset? periodStart,
|
||||
[FromQuery] DateTimeOffset? periodEnd,
|
||||
[FromQuery] string? tenantId,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string? cursor = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = new BundleListRequest(
|
||||
periodStart,
|
||||
periodEnd,
|
||||
tenantId,
|
||||
Math.Clamp(limit, 1, 100),
|
||||
cursor);
|
||||
|
||||
var result = await _bundler.ListBundlesAsync(request, ct);
|
||||
|
||||
var bundles = result.Bundles.Select(b => new BundleListItem
|
||||
{
|
||||
BundleId = b.BundleId,
|
||||
PeriodStart = b.PeriodStart,
|
||||
PeriodEnd = b.PeriodEnd,
|
||||
AttestationCount = b.AttestationCount,
|
||||
CreatedAt = b.CreatedAt,
|
||||
HasOrgSignature = b.HasOrgSignature
|
||||
}).ToList();
|
||||
|
||||
return Ok(new BundleListResponse
|
||||
{
|
||||
Bundles = bundles,
|
||||
NextCursor = result.NextCursor
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify bundle integrity and signatures.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
[HttpPost("{bundleId}/verify")]
|
||||
[ProducesResponseType(typeof(BundleVerifyResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BundleVerifyResponse>> VerifyBundleAsync(
|
||||
[FromRoute] string bundleId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsValidBundleId(bundleId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid bundle ID",
|
||||
Detail = "Bundle ID must be in format sha256:<64-hex>",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var bundle = await _bundler.GetBundleAsync(bundleId, ct);
|
||||
|
||||
if (bundle == null)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Bundle not found",
|
||||
Detail = $"No bundle found with ID {bundleId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var result = await _bundler.VerifyBundleAsync(bundle, ct);
|
||||
|
||||
return Ok(new BundleVerifyResponse
|
||||
{
|
||||
Valid = result.Valid,
|
||||
MerkleRootVerified = result.MerkleRootVerified,
|
||||
OrgSignatureVerified = result.OrgSignatureVerified,
|
||||
AttestationsVerified = result.AttestationsVerified,
|
||||
Issues = result.Issues.Select(i => new BundleIssueDto
|
||||
{
|
||||
Severity = i.Severity.ToString().ToLowerInvariant(),
|
||||
Code = i.Code,
|
||||
Message = i.Message,
|
||||
EntryId = i.EntryId
|
||||
}).ToList(),
|
||||
VerifiedAt = result.VerifiedAt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific attestation from a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="entryId">The attestation entry ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The attestation.</returns>
|
||||
[HttpGet("{bundleId}/attestations/{entryId}")]
|
||||
[ProducesResponseType(typeof(BundledAttestation), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BundledAttestation>> GetAttestationAsync(
|
||||
[FromRoute] string bundleId,
|
||||
[FromRoute] string entryId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var bundle = await _bundler.GetBundleAsync(bundleId, ct);
|
||||
|
||||
if (bundle == null)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Bundle not found",
|
||||
Detail = $"No bundle found with ID {bundleId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var attestation = bundle.Attestations.FirstOrDefault(a =>
|
||||
string.Equals(a.EntryId, entryId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (attestation == null)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Attestation not found",
|
||||
Detail = $"No attestation found with entry ID {entryId} in bundle {bundleId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(attestation);
|
||||
}
|
||||
|
||||
private static bool IsValidBundleId(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
if (!value.StartsWith("sha256:", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var hex = value.AsSpan()["sha256:".Length..];
|
||||
if (hex.Length != 64)
|
||||
return false;
|
||||
|
||||
foreach (var c in hex)
|
||||
{
|
||||
if (c is not ((>= '0' and <= '9') or (>= 'a' and <= 'f')))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
/// <summary>Request to create a bundle.</summary>
|
||||
public sealed record CreateBundleRequest
|
||||
{
|
||||
/// <summary>Start of attestation collection period.</summary>
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>End of attestation collection period.</summary>
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>Optional tenant ID filter.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Whether to sign with organization key.</summary>
|
||||
public bool SignWithOrgKey { get; init; } = true;
|
||||
|
||||
/// <summary>Organization key ID to use (uses active key if not specified).</summary>
|
||||
public string? OrgKeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Response after bundle creation.</summary>
|
||||
public sealed record BundleCreatedResponse
|
||||
{
|
||||
/// <summary>The created bundle ID.</summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>Creation status.</summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>Number of attestations in the bundle.</summary>
|
||||
public required int AttestationCount { get; init; }
|
||||
|
||||
/// <summary>Period start.</summary>
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>Period end.</summary>
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>When the bundle was created.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Whether the bundle has an org signature.</summary>
|
||||
public required bool HasOrgSignature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bundle metadata response.</summary>
|
||||
public sealed record BundleMetadataResponse
|
||||
{
|
||||
/// <summary>Bundle ID.</summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>Schema version.</summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>Period start.</summary>
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>Period end.</summary>
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>Number of attestations.</summary>
|
||||
public required int AttestationCount { get; init; }
|
||||
|
||||
/// <summary>Merkle root.</summary>
|
||||
public required string MerkleRoot { get; init; }
|
||||
|
||||
/// <summary>Org signature info if present.</summary>
|
||||
public OrgSignatureInfo? OrgSignature { get; init; }
|
||||
|
||||
/// <summary>Creation timestamp.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Org signature info.</summary>
|
||||
public sealed record OrgSignatureInfo
|
||||
{
|
||||
/// <summary>Key ID.</summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>Algorithm.</summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>When signed.</summary>
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bundle list response.</summary>
|
||||
public sealed record BundleListResponse
|
||||
{
|
||||
/// <summary>The bundles.</summary>
|
||||
public required IReadOnlyList<BundleListItem> Bundles { get; init; }
|
||||
|
||||
/// <summary>Next page cursor.</summary>
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bundle list item.</summary>
|
||||
public sealed record BundleListItem
|
||||
{
|
||||
/// <summary>Bundle ID.</summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>Period start.</summary>
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>Period end.</summary>
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>Attestation count.</summary>
|
||||
public required int AttestationCount { get; init; }
|
||||
|
||||
/// <summary>Creation time.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Whether has org signature.</summary>
|
||||
public required bool HasOrgSignature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bundle verification response.</summary>
|
||||
public sealed record BundleVerifyResponse
|
||||
{
|
||||
/// <summary>Overall validity.</summary>
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>Merkle root verified.</summary>
|
||||
public required bool MerkleRootVerified { get; init; }
|
||||
|
||||
/// <summary>Org signature verified (if present).</summary>
|
||||
public bool? OrgSignatureVerified { get; init; }
|
||||
|
||||
/// <summary>Number of attestations verified.</summary>
|
||||
public required int AttestationsVerified { get; init; }
|
||||
|
||||
/// <summary>Issues found.</summary>
|
||||
public required IReadOnlyList<BundleIssueDto> Issues { get; init; }
|
||||
|
||||
/// <summary>Verification timestamp.</summary>
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bundle issue DTO.</summary>
|
||||
public sealed record BundleIssueDto
|
||||
{
|
||||
/// <summary>Issue severity.</summary>
|
||||
public required string Severity { get; init; }
|
||||
|
||||
/// <summary>Issue code.</summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>Issue message.</summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>Related entry ID.</summary>
|
||||
public string? EntryId { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -28,5 +28,6 @@
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IAttestationBundler.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0005 - Implement IAttestationBundler service
|
||||
// Description: Service interface for creating attestation bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing attestation bundles.
|
||||
/// </summary>
|
||||
public interface IAttestationBundler
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new attestation bundle for a time period.
|
||||
/// </summary>
|
||||
/// <param name="request">Bundle creation parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created attestation bundle.</returns>
|
||||
Task<AttestationBundle> CreateBundleAsync(
|
||||
BundleCreationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get an existing bundle by ID.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID (sha256:<merkle_root>).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The bundle if found, null otherwise.</returns>
|
||||
Task<AttestationBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List bundles matching the specified criteria.
|
||||
/// </summary>
|
||||
/// <param name="request">List parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated bundle list.</returns>
|
||||
Task<BundleListResult> ListBundlesAsync(
|
||||
BundleListRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify the integrity of a bundle (Merkle tree and optional org signature).
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<BundleVerificationResult> VerifyBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for bundle creation.
|
||||
/// </summary>
|
||||
/// <param name="PeriodStart">Start of the attestation collection period.</param>
|
||||
/// <param name="PeriodEnd">End of the attestation collection period.</param>
|
||||
/// <param name="TenantId">Optional tenant identifier for multi-tenant filtering.</param>
|
||||
/// <param name="SignWithOrgKey">Whether to sign the bundle with an organization key.</param>
|
||||
/// <param name="OrgKeyId">Organization key ID to use for signing.</param>
|
||||
public record BundleCreationRequest(
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
string? TenantId = null,
|
||||
bool SignWithOrgKey = false,
|
||||
string? OrgKeyId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for listing bundles.
|
||||
/// </summary>
|
||||
/// <param name="PeriodStart">Optional start of period filter.</param>
|
||||
/// <param name="PeriodEnd">Optional end of period filter.</param>
|
||||
/// <param name="TenantId">Optional tenant filter.</param>
|
||||
/// <param name="Limit">Maximum number of results.</param>
|
||||
/// <param name="Cursor">Pagination cursor.</param>
|
||||
public record BundleListRequest(
|
||||
DateTimeOffset? PeriodStart = null,
|
||||
DateTimeOffset? PeriodEnd = null,
|
||||
string? TenantId = null,
|
||||
int Limit = 20,
|
||||
string? Cursor = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a bundle list operation.
|
||||
/// </summary>
|
||||
/// <param name="Bundles">The matching bundles (metadata only).</param>
|
||||
/// <param name="NextCursor">Cursor for the next page, null if no more results.</param>
|
||||
public record BundleListResult(
|
||||
IReadOnlyList<BundleListItem> Bundles,
|
||||
string? NextCursor);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle metadata for list results.
|
||||
/// </summary>
|
||||
/// <param name="BundleId">The bundle ID.</param>
|
||||
/// <param name="PeriodStart">Start of collection period.</param>
|
||||
/// <param name="PeriodEnd">End of collection period.</param>
|
||||
/// <param name="AttestationCount">Number of attestations.</param>
|
||||
/// <param name="CreatedAt">Bundle creation timestamp.</param>
|
||||
/// <param name="HasOrgSignature">Whether the bundle has an org signature.</param>
|
||||
public record BundleListItem(
|
||||
string BundleId,
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
int AttestationCount,
|
||||
DateTimeOffset CreatedAt,
|
||||
bool HasOrgSignature);
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle verification.
|
||||
/// </summary>
|
||||
/// <param name="Valid">Whether the bundle is valid.</param>
|
||||
/// <param name="MerkleRootVerified">Whether the Merkle root matches.</param>
|
||||
/// <param name="OrgSignatureVerified">Whether the org signature is valid (if present).</param>
|
||||
/// <param name="AttestationsVerified">Number of attestations verified.</param>
|
||||
/// <param name="Issues">Any verification issues found.</param>
|
||||
/// <param name="VerifiedAt">Verification timestamp.</param>
|
||||
public record BundleVerificationResult(
|
||||
bool Valid,
|
||||
bool MerkleRootVerified,
|
||||
bool? OrgSignatureVerified,
|
||||
int AttestationsVerified,
|
||||
IReadOnlyList<BundleVerificationIssue> Issues,
|
||||
DateTimeOffset VerifiedAt);
|
||||
|
||||
/// <summary>
|
||||
/// A verification issue found during bundle verification.
|
||||
/// </summary>
|
||||
/// <param name="Severity">Issue severity.</param>
|
||||
/// <param name="Code">Machine-readable issue code.</param>
|
||||
/// <param name="Message">Human-readable message.</param>
|
||||
/// <param name="EntryId">Related attestation entry ID, if applicable.</param>
|
||||
public record BundleVerificationIssue(
|
||||
VerificationIssueSeverity Severity,
|
||||
string Code,
|
||||
string Message,
|
||||
string? EntryId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for verification issues.
|
||||
/// </summary>
|
||||
public enum VerificationIssueSeverity
|
||||
{
|
||||
/// <summary>Informational message.</summary>
|
||||
Info,
|
||||
/// <summary>Warning that may affect trust.</summary>
|
||||
Warning,
|
||||
/// <summary>Error that affects verification.</summary>
|
||||
Error,
|
||||
/// <summary>Critical error that invalidates the bundle.</summary>
|
||||
Critical
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IBundleAggregator.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0003 - Implement IBundleAggregator for collecting attestations
|
||||
// Description: Interface for aggregating attestations from storage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for aggregating attestations from storage for bundling.
|
||||
/// </summary>
|
||||
public interface IBundleAggregator
|
||||
{
|
||||
/// <summary>
|
||||
/// Collect attestations for a time period.
|
||||
/// </summary>
|
||||
/// <param name="request">Aggregation parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collected attestations in deterministic order.</returns>
|
||||
IAsyncEnumerable<BundledAttestation> AggregateAsync(
|
||||
AggregationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Count attestations for a time period without loading them.
|
||||
/// </summary>
|
||||
/// <param name="request">Aggregation parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attestation count.</returns>
|
||||
Task<int> CountAsync(
|
||||
AggregationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for attestation aggregation.
|
||||
/// </summary>
|
||||
/// <param name="PeriodStart">Start of the collection period.</param>
|
||||
/// <param name="PeriodEnd">End of the collection period.</param>
|
||||
/// <param name="TenantId">Optional tenant filter.</param>
|
||||
/// <param name="PredicateTypes">Optional filter for specific predicate types.</param>
|
||||
/// <param name="BatchSize">Number of attestations to fetch per batch.</param>
|
||||
public record AggregationRequest(
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
string? TenantId = null,
|
||||
IReadOnlyList<string>? PredicateTypes = null,
|
||||
int BatchSize = 500);
|
||||
@@ -0,0 +1,138 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IBundleStore.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0009 - Implement IBundleStore for S3/RustFS
|
||||
// Description: Interface for bundle storage and retrieval
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Storage abstraction for attestation bundles.
|
||||
/// Supports S3-compatible storage (RustFS) and filesystem backends.
|
||||
/// </summary>
|
||||
public interface IBundleStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Store a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to store.</param>
|
||||
/// <param name="options">Storage options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task StoreBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
BundleStorageOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a bundle by ID.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The bundle if found, null otherwise.</returns>
|
||||
Task<AttestationBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a bundle exists.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the bundle exists.</returns>
|
||||
Task<bool> ExistsAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the bundle was deleted.</returns>
|
||||
Task<bool> DeleteBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List bundle metadata with pagination.
|
||||
/// </summary>
|
||||
/// <param name="request">List parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated list of bundle metadata.</returns>
|
||||
Task<BundleListResult> ListBundlesAsync(
|
||||
BundleListRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Export a bundle to a stream (with optional compression).
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="output">The output stream.</param>
|
||||
/// <param name="options">Export options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task ExportBundleAsync(
|
||||
string bundleId,
|
||||
Stream output,
|
||||
BundleExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle storage.
|
||||
/// </summary>
|
||||
/// <param name="Compression">Compression format (none, gzip, zstd).</param>
|
||||
/// <param name="ObjectLock">Object lock mode for WORM protection.</param>
|
||||
/// <param name="RetentionDays">Retention period in days.</param>
|
||||
public record BundleStorageOptions(
|
||||
BundleCompression Compression = BundleCompression.Zstd,
|
||||
ObjectLockMode ObjectLock = ObjectLockMode.None,
|
||||
int? RetentionDays = null);
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle export.
|
||||
/// </summary>
|
||||
/// <param name="Format">Export format (json or cbor).</param>
|
||||
/// <param name="Compression">Compression format.</param>
|
||||
public record BundleExportOptions(
|
||||
BundleFormat Format = BundleFormat.Json,
|
||||
BundleCompression Compression = BundleCompression.Zstd);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle serialization format.
|
||||
/// </summary>
|
||||
public enum BundleFormat
|
||||
{
|
||||
/// <summary>JSON format for human readability.</summary>
|
||||
Json,
|
||||
/// <summary>CBOR format for compact size.</summary>
|
||||
Cbor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle compression format.
|
||||
/// </summary>
|
||||
public enum BundleCompression
|
||||
{
|
||||
/// <summary>No compression.</summary>
|
||||
None,
|
||||
/// <summary>Gzip compression.</summary>
|
||||
Gzip,
|
||||
/// <summary>Zstandard compression (default).</summary>
|
||||
Zstd
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Object lock mode for WORM protection.
|
||||
/// </summary>
|
||||
public enum ObjectLockMode
|
||||
{
|
||||
/// <summary>No object lock.</summary>
|
||||
None,
|
||||
/// <summary>Governance mode (can be bypassed with special permissions).</summary>
|
||||
Governance,
|
||||
/// <summary>Compliance mode (cannot be bypassed).</summary>
|
||||
Compliance
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOrgKeySigner.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0006 - Implement IOrgKeySigner interface
|
||||
// Description: Interface for organization key signing of bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for signing bundles with organization keys.
|
||||
/// Supports KMS/HSM-backed keys for high-assurance signing.
|
||||
/// </summary>
|
||||
public interface IOrgKeySigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign a bundle digest with an organization key.
|
||||
/// </summary>
|
||||
/// <param name="bundleDigest">SHA-256 digest of the canonical bundle content.</param>
|
||||
/// <param name="keyId">Key identifier to use for signing.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The organization signature.</returns>
|
||||
Task<OrgSignature> SignBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify an organization signature on a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundleDigest">SHA-256 digest of the canonical bundle content.</param>
|
||||
/// <param name="signature">The signature to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the signature is valid.</returns>
|
||||
Task<bool> VerifyBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
OrgSignature signature,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the current signing key ID based on configuration and rotation policy.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The active key ID.</returns>
|
||||
Task<string> GetActiveKeyIdAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List available signing keys.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Available key information.</returns>
|
||||
Task<IReadOnlyList<OrgKeyInfo>> ListKeysAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organization signing key information.
|
||||
/// </summary>
|
||||
/// <param name="KeyId">Unique key identifier.</param>
|
||||
/// <param name="Algorithm">Signing algorithm (e.g., "ECDSA_P256", "Ed25519").</param>
|
||||
/// <param name="Fingerprint">Key fingerprint (SHA-256 of public key).</param>
|
||||
/// <param name="ValidFrom">Start of key validity period.</param>
|
||||
/// <param name="ValidUntil">End of key validity period (null if no expiration).</param>
|
||||
/// <param name="IsActive">Whether this key is currently active for signing.</param>
|
||||
public record OrgKeyInfo(
|
||||
string KeyId,
|
||||
string Algorithm,
|
||||
string Fingerprint,
|
||||
DateTimeOffset ValidFrom,
|
||||
DateTimeOffset? ValidUntil,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,387 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundlingOptions.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0013, 0016 - Bundle retention policy schema and job configuration
|
||||
// Description: Configuration options for attestation bundling and retention
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for attestation bundling.
|
||||
/// </summary>
|
||||
public sealed class BundlingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether bundling is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Schedule configuration for automated bundling.
|
||||
/// </summary>
|
||||
public BundleScheduleOptions Schedule { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Aggregation settings for collecting attestations.
|
||||
/// </summary>
|
||||
public BundleAggregationOptions Aggregation { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Organization key signing settings.
|
||||
/// </summary>
|
||||
public BundleSigningOptions Signing { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Retention policy settings.
|
||||
/// </summary>
|
||||
public BundleRetentionOptions Retention { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Storage settings for bundles.
|
||||
/// </summary>
|
||||
public BundleStorageOptions Storage { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Export settings.
|
||||
/// </summary>
|
||||
public BundleExportOptions Export { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedule options for bundle rotation.
|
||||
/// </summary>
|
||||
public sealed class BundleScheduleOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Cron expression for rotation schedule.
|
||||
/// Default: Monthly on the 1st at 02:00 UTC.
|
||||
/// </summary>
|
||||
public string Cron { get; set; } = "0 2 1 * *";
|
||||
|
||||
/// <summary>
|
||||
/// Rotation cadence.
|
||||
/// </summary>
|
||||
public string Cadence { get; set; } = "monthly";
|
||||
|
||||
/// <summary>
|
||||
/// Timezone for schedule evaluation.
|
||||
/// </summary>
|
||||
public string Timezone { get; set; } = "UTC";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to skip weekends for rotation.
|
||||
/// </summary>
|
||||
public bool SkipWeekends { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregation options for collecting attestations into bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleAggregationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Look-back period in days for attestation collection.
|
||||
/// </summary>
|
||||
public int LookbackDays { get; set; } = 31;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum attestations per bundle.
|
||||
/// If exceeded, multiple bundles are created.
|
||||
/// </summary>
|
||||
public int MaxAttestationsPerBundle { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Batch size for database queries.
|
||||
/// </summary>
|
||||
public int QueryBatchSize { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum attestations required to create a bundle.
|
||||
/// </summary>
|
||||
public int MinAttestationsForBundle { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include failed attestations in bundles.
|
||||
/// </summary>
|
||||
public bool IncludeFailedAttestations { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Predicate types to include. Empty = all types.
|
||||
/// </summary>
|
||||
public IList<string> PredicateTypes { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing options for organization key signing of bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleSigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to sign bundles with organization key.
|
||||
/// </summary>
|
||||
public bool SignWithOrgKey { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Organization key ID to use (null = use active key).
|
||||
/// </summary>
|
||||
public string? OrgKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key rotation configuration.
|
||||
/// </summary>
|
||||
public IList<KeyRotationEntry> KeyRotation { get; set; } = new List<KeyRotationEntry>();
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm.
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = "ECDSA_P256";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include certificate chain in signature.
|
||||
/// </summary>
|
||||
public bool IncludeCertificateChain { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key rotation schedule entry.
|
||||
/// </summary>
|
||||
public sealed class KeyRotationEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Start of key validity.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// End of key validity.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidUntil { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retention policy options for bundle lifecycle management.
|
||||
/// </summary>
|
||||
public sealed class BundleRetentionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether retention policy enforcement is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default retention period in months.
|
||||
/// </summary>
|
||||
public int DefaultMonths { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum retention period in months (cannot be overridden lower).
|
||||
/// </summary>
|
||||
public int MinimumMonths { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retention period in months.
|
||||
/// </summary>
|
||||
public int MaximumMonths { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Per-tenant retention overrides.
|
||||
/// </summary>
|
||||
public IDictionary<string, int> TenantOverrides { get; set; } = new Dictionary<string, int>();
|
||||
|
||||
/// <summary>
|
||||
/// Per-predicate type retention overrides.
|
||||
/// </summary>
|
||||
public IDictionary<string, int> PredicateTypeOverrides { get; set; } = new Dictionary<string, int>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether to delete or archive expired bundles.
|
||||
/// </summary>
|
||||
public RetentionAction ExpiryAction { get; set; } = RetentionAction.Delete;
|
||||
|
||||
/// <summary>
|
||||
/// Archive storage tier for archived bundles.
|
||||
/// </summary>
|
||||
public string ArchiveStorageTier { get; set; } = "glacier";
|
||||
|
||||
/// <summary>
|
||||
/// Grace period in days before deletion (warning period).
|
||||
/// </summary>
|
||||
public int GracePeriodDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to send notifications before bundle expiry.
|
||||
/// </summary>
|
||||
public bool NotifyBeforeExpiry { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Days before expiry to send notification.
|
||||
/// </summary>
|
||||
public int NotifyDaysBeforeExpiry { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bundles to process per retention run.
|
||||
/// </summary>
|
||||
public int MaxBundlesPerRun { get; set; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when a bundle expires.
|
||||
/// </summary>
|
||||
public enum RetentionAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Delete expired bundles permanently.
|
||||
/// </summary>
|
||||
Delete,
|
||||
|
||||
/// <summary>
|
||||
/// Archive expired bundles to cold storage.
|
||||
/// </summary>
|
||||
Archive,
|
||||
|
||||
/// <summary>
|
||||
/// Mark as expired but retain.
|
||||
/// </summary>
|
||||
MarkOnly
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage options for bundle persistence.
|
||||
/// </summary>
|
||||
public sealed class BundleStorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Storage backend type.
|
||||
/// </summary>
|
||||
public string Backend { get; set; } = "s3";
|
||||
|
||||
/// <summary>
|
||||
/// S3 storage configuration.
|
||||
/// </summary>
|
||||
public BundleS3Options S3 { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem storage configuration.
|
||||
/// </summary>
|
||||
public BundleFilesystemOptions Filesystem { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL metadata storage configuration.
|
||||
/// </summary>
|
||||
public BundlePostgresOptions Postgres { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// S3 storage options for bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleS3Options
|
||||
{
|
||||
/// <summary>
|
||||
/// S3 bucket name.
|
||||
/// </summary>
|
||||
public string Bucket { get; set; } = "stellaops-attestor";
|
||||
|
||||
/// <summary>
|
||||
/// Object key prefix.
|
||||
/// </summary>
|
||||
public string Prefix { get; set; } = "bundles/";
|
||||
|
||||
/// <summary>
|
||||
/// Object lock mode for WORM protection.
|
||||
/// </summary>
|
||||
public string? ObjectLock { get; set; } = "governance";
|
||||
|
||||
/// <summary>
|
||||
/// Storage class for new objects.
|
||||
/// </summary>
|
||||
public string StorageClass { get; set; } = "STANDARD";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable server-side encryption.
|
||||
/// </summary>
|
||||
public bool ServerSideEncryption { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// KMS key for encryption.
|
||||
/// </summary>
|
||||
public string? KmsKeyId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem storage options for bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleFilesystemOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base path for bundle storage.
|
||||
/// </summary>
|
||||
public string Path { get; set; } = "/var/lib/stellaops/attestor/bundles";
|
||||
|
||||
/// <summary>
|
||||
/// Directory permissions (octal).
|
||||
/// </summary>
|
||||
public string DirectoryPermissions { get; set; } = "0750";
|
||||
|
||||
/// <summary>
|
||||
/// File permissions (octal).
|
||||
/// </summary>
|
||||
public string FilePermissions { get; set; } = "0640";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL options for bundle metadata.
|
||||
/// </summary>
|
||||
public sealed class BundlePostgresOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema name.
|
||||
/// </summary>
|
||||
public string Schema { get; set; } = "attestor";
|
||||
|
||||
/// <summary>
|
||||
/// Bundles table name.
|
||||
/// </summary>
|
||||
public string BundlesTable { get; set; } = "bundles";
|
||||
|
||||
/// <summary>
|
||||
/// Bundle entries table name.
|
||||
/// </summary>
|
||||
public string EntriesTable { get; set; } = "bundle_entries";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export options for bundles.
|
||||
/// </summary>
|
||||
public sealed class BundleExportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to include bundles in Offline Kit.
|
||||
/// </summary>
|
||||
public bool IncludeInOfflineKit { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Compression algorithm for export.
|
||||
/// </summary>
|
||||
public string Compression { get; set; } = "zstd";
|
||||
|
||||
/// <summary>
|
||||
/// Compression level.
|
||||
/// </summary>
|
||||
public int CompressionLevel { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bundle age to include in exports (months).
|
||||
/// </summary>
|
||||
public int MaxAgeMonths { get; set; } = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Supported export formats.
|
||||
/// </summary>
|
||||
public IList<string> SupportedFormats { get; set; } = new List<string> { "json", "cbor" };
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationBundle.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0002 - Define AttestationBundle record and schema
|
||||
// Description: Aggregated attestation bundle for long-term verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Attestation bundle aggregating multiple attestations for a time period.
|
||||
/// Contains all material needed for offline verification including Merkle tree
|
||||
/// for integrity and optional organization signature for endorsement.
|
||||
/// </summary>
|
||||
public sealed record AttestationBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle metadata including period, version, and creation timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public required BundleMetadata Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All attestations included in this bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestations")]
|
||||
public required IReadOnlyList<BundledAttestation> Attestations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle tree information for bundle integrity verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("merkleTree")]
|
||||
public required MerkleTreeInfo MerkleTree { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional organization signature for bundle endorsement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("orgSignature")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public OrgSignature? OrgSignature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle metadata containing identification and temporal information.
|
||||
/// </summary>
|
||||
public sealed record BundleMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressed bundle ID: sha256:<merkle_root>
|
||||
/// </summary>
|
||||
[JsonPropertyName("bundleId")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle schema version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when this bundle was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of the attestation collection period (inclusive).
|
||||
/// </summary>
|
||||
[JsonPropertyName("periodStart")]
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of the attestation collection period (inclusive).
|
||||
/// </summary>
|
||||
[JsonPropertyName("periodEnd")]
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of attestations in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestationCount")]
|
||||
public required int AttestationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant identifier for multi-tenant deployments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprint of the organization signing key (if signed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("orgKeyFingerprint")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? OrgKeyFingerprint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual attestation entry within a bundle.
|
||||
/// </summary>
|
||||
public sealed record BundledAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique entry identifier (typically the Rekor UUID).
|
||||
/// </summary>
|
||||
[JsonPropertyName("entryId")]
|
||||
public required string EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor UUID if registered with transparency log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorUuid")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RekorUuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if registered with transparency log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 digest of the artifact this attestation covers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type (e.g., "verdict.stella/v1", "sbom.stella/v1").
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the attestation was signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing mode used: "keyless" (Fulcio), "kms", "hsm", or "fido2".
|
||||
/// </summary>
|
||||
[JsonPropertyName("signingMode")]
|
||||
public required string SigningMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity information about the signer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signingIdentity")]
|
||||
public required SigningIdentity SigningIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor inclusion proof for transparency verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inclusionProof")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public RekorInclusionProof? InclusionProof { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The DSSE envelope containing the attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public required DsseEnvelopeData Envelope { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing identity information.
|
||||
/// </summary>
|
||||
public sealed record SigningIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// OIDC issuer URL for keyless signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuer")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject identifier (e.g., email, service account).
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject Alternative Name from certificate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("san")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? San { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier for KMS/HSM signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log inclusion proof.
|
||||
/// </summary>
|
||||
public sealed record RekorInclusionProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Checkpoint containing tree size and root hash.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public required CheckpointData Checkpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle audit path from leaf to root.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required IReadOnlyList<string> Path { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor checkpoint data.
|
||||
/// </summary>
|
||||
public sealed record CheckpointData
|
||||
{
|
||||
/// <summary>
|
||||
/// Log origin identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("origin")]
|
||||
public required string Origin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree size at checkpoint time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded root hash.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rootHash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checkpoint timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope data for serialization.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeData
|
||||
{
|
||||
/// <summary>
|
||||
/// Payload type (e.g., "application/vnd.in-toto+json").
|
||||
/// </summary>
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signatures over the payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signatures")]
|
||||
public required IReadOnlyList<EnvelopeSignature> Signatures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate chain for signature verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("certificateChain")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? CertificateChain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature within a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record EnvelopeSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyid")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle tree information for bundle integrity.
|
||||
/// </summary>
|
||||
public sealed record MerkleTreeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Hash algorithm used (always SHA256).
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = "SHA256";
|
||||
|
||||
/// <summary>
|
||||
/// Merkle root hash in sha256:<hex> format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("root")]
|
||||
public required string Root { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of leaves (attestations) in the tree.
|
||||
/// </summary>
|
||||
[JsonPropertyName("leafCount")]
|
||||
public required int LeafCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organization signature for bundle endorsement.
|
||||
/// </summary>
|
||||
public sealed record OrgSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier used for signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature algorithm (e.g., "ECDSA_P256", "Ed25519", "RSA_PSS_SHA256").
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithm")]
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature over the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public required string Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the signature was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PEM-encoded certificate chain for signature verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("certificateChain")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? CertificateChain { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationBundler.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0005 - Implement IAttestationBundler service
|
||||
// Description: Service implementation for creating attestation bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing attestation bundles.
|
||||
/// Implements deterministic bundling with optional organization signing.
|
||||
/// </summary>
|
||||
public sealed class AttestationBundler : IAttestationBundler
|
||||
{
|
||||
private readonly IBundleAggregator _aggregator;
|
||||
private readonly IBundleStore _store;
|
||||
private readonly IOrgKeySigner? _orgSigner;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly ILogger<AttestationBundler> _logger;
|
||||
private readonly BundlingOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new attestation bundler.
|
||||
/// </summary>
|
||||
public AttestationBundler(
|
||||
IBundleAggregator aggregator,
|
||||
IBundleStore store,
|
||||
IMerkleTreeBuilder merkleBuilder,
|
||||
ILogger<AttestationBundler> logger,
|
||||
IOptions<BundlingOptions> options,
|
||||
IOrgKeySigner? orgSigner = null)
|
||||
{
|
||||
_aggregator = aggregator ?? throw new ArgumentNullException(nameof(aggregator));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new BundlingOptions();
|
||||
_orgSigner = orgSigner;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationBundle> CreateBundleAsync(
|
||||
BundleCreationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Creating attestation bundle for period {PeriodStart} to {PeriodEnd}",
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd);
|
||||
|
||||
// Collect attestations in deterministic order
|
||||
var attestations = await CollectAttestationsAsync(request, cancellationToken);
|
||||
|
||||
if (attestations.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No attestations found for the specified period");
|
||||
throw new InvalidOperationException("No attestations found for the specified period.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Collected {Count} attestations for bundling", attestations.Count);
|
||||
|
||||
// Build deterministic Merkle tree
|
||||
var merkleTree = BuildMerkleTree(attestations);
|
||||
var merkleRoot = Convert.ToHexString(merkleTree.Root).ToLowerInvariant();
|
||||
var bundleId = $"sha256:{merkleRoot}";
|
||||
|
||||
_logger.LogInformation("Computed Merkle root: {MerkleRoot}", bundleId);
|
||||
|
||||
// Create bundle metadata
|
||||
var metadata = new BundleMetadata
|
||||
{
|
||||
BundleId = bundleId,
|
||||
Version = "1.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PeriodStart = request.PeriodStart,
|
||||
PeriodEnd = request.PeriodEnd,
|
||||
AttestationCount = attestations.Count,
|
||||
TenantId = request.TenantId
|
||||
};
|
||||
|
||||
// Create bundle
|
||||
var bundle = new AttestationBundle
|
||||
{
|
||||
Metadata = metadata,
|
||||
Attestations = attestations,
|
||||
MerkleTree = new MerkleTreeInfo
|
||||
{
|
||||
Algorithm = "SHA256",
|
||||
Root = bundleId,
|
||||
LeafCount = attestations.Count
|
||||
}
|
||||
};
|
||||
|
||||
// Sign with organization key if requested
|
||||
if (request.SignWithOrgKey && _orgSigner != null)
|
||||
{
|
||||
bundle = await SignBundleAsync(bundle, request.OrgKeyId, cancellationToken);
|
||||
}
|
||||
|
||||
// Store the bundle
|
||||
await _store.StoreBundleAsync(bundle, cancellationToken: cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created attestation bundle {BundleId} with {Count} attestations",
|
||||
bundleId,
|
||||
attestations.Count);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
return await _store.GetBundleAsync(bundleId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleListResult> ListBundlesAsync(
|
||||
BundleListRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
return await _store.ListBundlesAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleVerificationResult> VerifyBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var issues = new List<BundleVerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Verify Merkle root
|
||||
var merkleValid = VerifyMerkleRoot(bundle, issues);
|
||||
|
||||
// Verify org signature if present
|
||||
bool? orgSigValid = null;
|
||||
if (bundle.OrgSignature != null && _orgSigner != null)
|
||||
{
|
||||
orgSigValid = await VerifyOrgSignatureAsync(bundle, issues, cancellationToken);
|
||||
}
|
||||
|
||||
var valid = merkleValid && (orgSigValid ?? true);
|
||||
|
||||
return new BundleVerificationResult(
|
||||
Valid: valid,
|
||||
MerkleRootVerified: merkleValid,
|
||||
OrgSignatureVerified: orgSigValid,
|
||||
AttestationsVerified: bundle.Attestations.Count,
|
||||
Issues: issues,
|
||||
VerifiedAt: verifiedAt);
|
||||
}
|
||||
|
||||
private async Task<List<BundledAttestation>> CollectAttestationsAsync(
|
||||
BundleCreationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var aggregationRequest = new AggregationRequest(
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd,
|
||||
request.TenantId,
|
||||
null,
|
||||
_options.Aggregation.QueryBatchSize);
|
||||
|
||||
var attestations = new List<BundledAttestation>();
|
||||
|
||||
await foreach (var attestation in _aggregator.AggregateAsync(aggregationRequest, cancellationToken))
|
||||
{
|
||||
attestations.Add(attestation);
|
||||
|
||||
if (attestations.Count >= _options.Aggregation.MaxAttestationsPerBundle)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Reached maximum attestations per bundle limit ({Max})",
|
||||
_options.Aggregation.MaxAttestationsPerBundle);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort deterministically by entry ID for stable Merkle root
|
||||
attestations.Sort((a, b) => string.Compare(a.EntryId, b.EntryId, StringComparison.Ordinal));
|
||||
|
||||
return attestations;
|
||||
}
|
||||
|
||||
private MerkleTreeWithProofs BuildMerkleTree(List<BundledAttestation> attestations)
|
||||
{
|
||||
// Create leaf values from attestation entry IDs (deterministic)
|
||||
var leafValues = attestations
|
||||
.Select(a => (ReadOnlyMemory<byte>)Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
return _merkleBuilder.BuildTree(leafValues);
|
||||
}
|
||||
|
||||
private async Task<AttestationBundle> SignBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
string? keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_orgSigner == null)
|
||||
{
|
||||
throw new InvalidOperationException("Organization signer is not configured.");
|
||||
}
|
||||
|
||||
// Use active key if not specified
|
||||
keyId ??= await _orgSigner.GetActiveKeyIdAsync(cancellationToken);
|
||||
|
||||
// Compute bundle digest (over canonical JSON of Merkle root and attestation IDs)
|
||||
var digestData = ComputeBundleDigest(bundle);
|
||||
|
||||
// Sign the digest
|
||||
var signature = await _orgSigner.SignBundleAsync(digestData, keyId, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signed bundle {BundleId} with org key {KeyId}",
|
||||
bundle.Metadata.BundleId,
|
||||
keyId);
|
||||
|
||||
// Return bundle with signature and updated metadata
|
||||
return bundle with
|
||||
{
|
||||
Metadata = bundle.Metadata with
|
||||
{
|
||||
OrgKeyFingerprint = $"sha256:{ComputeKeyFingerprint(keyId)}"
|
||||
},
|
||||
OrgSignature = signature
|
||||
};
|
||||
}
|
||||
|
||||
private bool VerifyMerkleRoot(AttestationBundle bundle, List<BundleVerificationIssue> issues)
|
||||
{
|
||||
try
|
||||
{
|
||||
var leafValues = bundle.Attestations
|
||||
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
|
||||
.Select(a => (ReadOnlyMemory<byte>)Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
var computedRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
|
||||
var computedRootHex = $"sha256:{Convert.ToHexString(computedRoot).ToLowerInvariant()}";
|
||||
|
||||
if (computedRootHex != bundle.MerkleTree.Root)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"MERKLE_ROOT_MISMATCH",
|
||||
$"Computed Merkle root {computedRootHex} does not match bundle root {bundle.MerkleTree.Root}"));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"MERKLE_VERIFY_ERROR",
|
||||
$"Failed to verify Merkle root: {ex.Message}"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> VerifyOrgSignatureAsync(
|
||||
AttestationBundle bundle,
|
||||
List<BundleVerificationIssue> issues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_orgSigner == null || bundle.OrgSignature == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var digestData = ComputeBundleDigest(bundle);
|
||||
var valid = await _orgSigner.VerifyBundleAsync(digestData, bundle.OrgSignature, cancellationToken);
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
$"Organization signature verification failed for key {bundle.OrgSignature.KeyId}"));
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"ORG_SIG_VERIFY_ERROR",
|
||||
$"Failed to verify organization signature: {ex.Message}"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputeBundleDigest(AttestationBundle bundle)
|
||||
{
|
||||
// Compute digest over merkle root + sorted attestation IDs
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(bundle.MerkleTree.Root);
|
||||
foreach (var attestation in bundle.Attestations.OrderBy(a => a.EntryId, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(attestation.EntryId);
|
||||
}
|
||||
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
}
|
||||
|
||||
private static string ComputeKeyFingerprint(string keyId)
|
||||
{
|
||||
// Simple fingerprint - in production this would use the actual public key
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(keyId));
|
||||
return Convert.ToHexString(hash[..16]).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineKitBundleProvider.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0017 - Integrate with Offline Kit export
|
||||
// Description: Provides attestation bundles for Offline Kit exports
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of an Offline Kit bundle export.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitBundleExportResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundles included in the export.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<BundleExportInfo> Bundles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total attestations across all bundles.
|
||||
/// </summary>
|
||||
public required int TotalAttestations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total export size in bytes.
|
||||
/// </summary>
|
||||
public required long TotalSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Export timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExportedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an exported bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleExportInfo(
|
||||
string BundleId,
|
||||
string FileName,
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
int AttestationCount,
|
||||
long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Options for Offline Kit bundle export.
|
||||
/// </summary>
|
||||
public sealed class OfflineKitExportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum age of bundles to include (in months).
|
||||
/// Default: 12 months.
|
||||
/// </summary>
|
||||
public int MaxAgeMonths { get; set; } = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Export format.
|
||||
/// </summary>
|
||||
public BundleFormat Format { get; set; } = BundleFormat.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Compression algorithm.
|
||||
/// </summary>
|
||||
public BundleCompression Compression { get; set; } = BundleCompression.Zstd;
|
||||
|
||||
/// <summary>
|
||||
/// Include only signed bundles.
|
||||
/// </summary>
|
||||
public bool RequireOrgSignature { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant filter (null = all tenants).
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Offline Kit bundle provider.
|
||||
/// </summary>
|
||||
public interface IOfflineKitBundleProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Export bundles for inclusion in Offline Kit.
|
||||
/// </summary>
|
||||
/// <param name="outputDirectory">Directory to write bundle files.</param>
|
||||
/// <param name="options">Export options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Export result with bundle information.</returns>
|
||||
Task<OfflineKitBundleExportResult> ExportForOfflineKitAsync(
|
||||
string outputDirectory,
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get bundle manifest for Offline Kit.
|
||||
/// </summary>
|
||||
/// <param name="options">Export options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of bundles that would be included.</returns>
|
||||
Task<IReadOnlyList<BundleListItem>> GetOfflineKitManifestAsync(
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides attestation bundles for Offline Kit exports.
|
||||
/// Integrates with the Offline Kit to include bundled attestations
|
||||
/// for long-term offline verification.
|
||||
/// </summary>
|
||||
public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
|
||||
{
|
||||
private readonly IBundleStore _bundleStore;
|
||||
private readonly BundlingOptions _options;
|
||||
private readonly ILogger<OfflineKitBundleProvider> _logger;
|
||||
|
||||
public OfflineKitBundleProvider(
|
||||
IBundleStore bundleStore,
|
||||
IOptions<BundlingOptions> options,
|
||||
ILogger<OfflineKitBundleProvider> logger)
|
||||
{
|
||||
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
_options = options?.Value ?? new BundlingOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<OfflineKitBundleExportResult> ExportForOfflineKitAsync(
|
||||
string outputDirectory,
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new OfflineKitExportOptions();
|
||||
|
||||
if (!_options.Export.IncludeInOfflineKit)
|
||||
{
|
||||
_logger.LogDebug("Offline Kit bundle export is disabled");
|
||||
return new OfflineKitBundleExportResult
|
||||
{
|
||||
Bundles = [],
|
||||
TotalAttestations = 0,
|
||||
TotalSizeBytes = 0,
|
||||
ExportedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exporting bundles for Offline Kit. MaxAge={MaxAge} months, Format={Format}",
|
||||
options.MaxAgeMonths,
|
||||
options.Format);
|
||||
|
||||
// Ensure output directory exists
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
// Get bundles to export
|
||||
var bundles = await GetOfflineKitManifestAsync(options, cancellationToken);
|
||||
|
||||
var exportedBundles = new List<BundleExportInfo>();
|
||||
long totalSize = 0;
|
||||
int totalAttestations = 0;
|
||||
|
||||
foreach (var bundleInfo in bundles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exportInfo = await ExportBundleAsync(
|
||||
bundleInfo,
|
||||
outputDirectory,
|
||||
options,
|
||||
cancellationToken);
|
||||
|
||||
if (exportInfo != null)
|
||||
{
|
||||
exportedBundles.Add(exportInfo);
|
||||
totalSize += exportInfo.SizeBytes;
|
||||
totalAttestations += exportInfo.AttestationCount;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to export bundle {BundleId} for Offline Kit",
|
||||
bundleInfo.BundleId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exported {Count} bundles for Offline Kit. Total: {Attestations} attestations, {Size} bytes",
|
||||
exportedBundles.Count,
|
||||
totalAttestations,
|
||||
totalSize);
|
||||
|
||||
return new OfflineKitBundleExportResult
|
||||
{
|
||||
Bundles = exportedBundles,
|
||||
TotalAttestations = totalAttestations,
|
||||
TotalSizeBytes = totalSize,
|
||||
ExportedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<BundleListItem>> GetOfflineKitManifestAsync(
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new OfflineKitExportOptions();
|
||||
|
||||
var cutoffDate = DateTimeOffset.UtcNow.AddMonths(-options.MaxAgeMonths);
|
||||
var result = new List<BundleListItem>();
|
||||
string? cursor = null;
|
||||
|
||||
do
|
||||
{
|
||||
var listResult = await _bundleStore.ListBundlesAsync(
|
||||
new BundleListRequest(
|
||||
PeriodStart: cutoffDate,
|
||||
TenantId: options.TenantId,
|
||||
Limit: 100,
|
||||
Cursor: cursor),
|
||||
cancellationToken);
|
||||
|
||||
foreach (var bundle in listResult.Bundles)
|
||||
{
|
||||
// Filter by org signature if required
|
||||
if (options.RequireOrgSignature && !bundle.HasOrgSignature)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(bundle);
|
||||
}
|
||||
|
||||
cursor = listResult.NextCursor;
|
||||
}
|
||||
while (cursor != null);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<BundleExportInfo?> ExportBundleAsync(
|
||||
BundleListItem bundleInfo,
|
||||
string outputDirectory,
|
||||
OfflineKitExportOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fileName = GenerateFileName(bundleInfo.BundleId, options);
|
||||
var filePath = Path.Combine(outputDirectory, fileName);
|
||||
|
||||
await using var fileStream = File.Create(filePath);
|
||||
|
||||
await _bundleStore.ExportBundleAsync(
|
||||
bundleInfo.BundleId,
|
||||
fileStream,
|
||||
new Abstractions.BundleExportOptions(options.Format, options.Compression),
|
||||
cancellationToken);
|
||||
|
||||
await fileStream.FlushAsync(cancellationToken);
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Exported bundle {BundleId} to {FileName} ({Size} bytes)",
|
||||
bundleInfo.BundleId,
|
||||
fileName,
|
||||
fileInfo.Length);
|
||||
|
||||
return new BundleExportInfo(
|
||||
bundleInfo.BundleId,
|
||||
fileName,
|
||||
bundleInfo.PeriodStart,
|
||||
bundleInfo.PeriodEnd,
|
||||
bundleInfo.AttestationCount,
|
||||
fileInfo.Length);
|
||||
}
|
||||
|
||||
private static string GenerateFileName(string bundleId, OfflineKitExportOptions options)
|
||||
{
|
||||
// Bundle ID format: sha256:abc123...
|
||||
var hash = bundleId.StartsWith("sha256:")
|
||||
? bundleId[7..Math.Min(bundleId.Length, 7 + 12)]
|
||||
: bundleId[..Math.Min(bundleId.Length, 12)];
|
||||
|
||||
var extension = options.Format switch
|
||||
{
|
||||
BundleFormat.Cbor => ".cbor",
|
||||
_ => ".json"
|
||||
};
|
||||
|
||||
var compression = options.Compression switch
|
||||
{
|
||||
BundleCompression.Gzip => ".gz",
|
||||
BundleCompression.Zstd => ".zst",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
return $"bundle-{hash}{extension}{compression}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RetentionPolicyEnforcer.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0014 - Implement retention policy enforcement
|
||||
// Description: Service for enforcing bundle retention policies
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a retention policy enforcement run.
|
||||
/// </summary>
|
||||
public sealed record RetentionEnforcementResult
|
||||
{
|
||||
/// <summary>
|
||||
/// When the enforcement run started.
|
||||
/// </summary>
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the enforcement run completed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles evaluated.
|
||||
/// </summary>
|
||||
public required int BundlesEvaluated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles deleted.
|
||||
/// </summary>
|
||||
public required int BundlesDeleted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles archived.
|
||||
/// </summary>
|
||||
public required int BundlesArchived { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles marked as expired.
|
||||
/// </summary>
|
||||
public required int BundlesMarkedExpired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bundles approaching expiry (within notification window).
|
||||
/// </summary>
|
||||
public required int BundlesApproachingExpiry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundles that failed to process.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<BundleEnforcementFailure> Failures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the enforcement run succeeded (no critical failures).
|
||||
/// </summary>
|
||||
public bool Success => Failures.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a bundle that failed retention enforcement.
|
||||
/// </summary>
|
||||
public sealed record BundleEnforcementFailure(
|
||||
string BundleId,
|
||||
string Reason,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Details about a bundle approaching expiry.
|
||||
/// </summary>
|
||||
public sealed record BundleExpiryNotification(
|
||||
string BundleId,
|
||||
string? TenantId,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
int DaysUntilExpiry);
|
||||
|
||||
/// <summary>
|
||||
/// Interface for retention policy enforcement.
|
||||
/// </summary>
|
||||
public interface IRetentionPolicyEnforcer
|
||||
{
|
||||
/// <summary>
|
||||
/// Run retention policy enforcement.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Enforcement result with statistics.</returns>
|
||||
Task<RetentionEnforcementResult> EnforceAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get bundles approaching expiry for notification.
|
||||
/// </summary>
|
||||
/// <param name="daysBeforeExpiry">Days before expiry to check.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of bundles approaching expiry.</returns>
|
||||
Task<IReadOnlyList<BundleExpiryNotification>> GetApproachingExpiryAsync(
|
||||
int daysBeforeExpiry,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Calculate expiry date for a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to evaluate.</param>
|
||||
/// <returns>Expiry date for the bundle.</returns>
|
||||
DateTimeOffset CalculateExpiryDate(BundleListItem bundle);
|
||||
|
||||
/// <summary>
|
||||
/// Calculate expiry date for a bundle with metadata.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant ID.</param>
|
||||
/// <param name="createdAt">Bundle creation date.</param>
|
||||
/// <returns>Expiry date for the bundle.</returns>
|
||||
DateTimeOffset CalculateExpiryDate(string? tenantId, DateTimeOffset createdAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for archiving bundles to cold storage.
|
||||
/// </summary>
|
||||
public interface IBundleArchiver
|
||||
{
|
||||
/// <summary>
|
||||
/// Archive a bundle to cold storage.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID to archive.</param>
|
||||
/// <param name="storageTier">Target storage tier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if archived successfully.</returns>
|
||||
Task<bool> ArchiveAsync(
|
||||
string bundleId,
|
||||
string storageTier,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for notifying about bundle expiry.
|
||||
/// </summary>
|
||||
public interface IBundleExpiryNotifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Send notifications for bundles approaching expiry.
|
||||
/// </summary>
|
||||
/// <param name="notifications">List of expiry notifications.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task NotifyAsync(
|
||||
IReadOnlyList<BundleExpiryNotification> notifications,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for enforcing bundle retention policies.
|
||||
/// Handles expiry, deletion, archival, and notifications.
|
||||
/// </summary>
|
||||
public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
|
||||
{
|
||||
private readonly IBundleStore _bundleStore;
|
||||
private readonly IBundleArchiver? _archiver;
|
||||
private readonly IBundleExpiryNotifier? _notifier;
|
||||
private readonly BundleRetentionOptions _options;
|
||||
private readonly ILogger<RetentionPolicyEnforcer> _logger;
|
||||
|
||||
public RetentionPolicyEnforcer(
|
||||
IBundleStore bundleStore,
|
||||
IOptions<BundlingOptions> options,
|
||||
ILogger<RetentionPolicyEnforcer> logger,
|
||||
IBundleArchiver? archiver = null,
|
||||
IBundleExpiryNotifier? notifier = null)
|
||||
{
|
||||
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
_options = options?.Value?.Retention ?? new BundleRetentionOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_archiver = archiver;
|
||||
_notifier = notifier;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<RetentionEnforcementResult> EnforceAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var failures = new List<BundleEnforcementFailure>();
|
||||
int evaluated = 0;
|
||||
int deleted = 0;
|
||||
int archived = 0;
|
||||
int markedExpired = 0;
|
||||
int approachingExpiry = 0;
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Retention policy enforcement is disabled");
|
||||
return new RetentionEnforcementResult
|
||||
{
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
BundlesEvaluated = 0,
|
||||
BundlesDeleted = 0,
|
||||
BundlesArchived = 0,
|
||||
BundlesMarkedExpired = 0,
|
||||
BundlesApproachingExpiry = 0,
|
||||
Failures = failures
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting retention policy enforcement. ExpiryAction={Action}, DefaultMonths={Months}",
|
||||
_options.ExpiryAction,
|
||||
_options.DefaultMonths);
|
||||
|
||||
// Process bundles in batches
|
||||
string? cursor = null;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var notificationCutoff = now.AddDays(_options.NotifyDaysBeforeExpiry);
|
||||
var gracePeriodCutoff = now.AddDays(-_options.GracePeriodDays);
|
||||
var expiredNotifications = new List<BundleExpiryNotification>();
|
||||
|
||||
do
|
||||
{
|
||||
var listResult = await _bundleStore.ListBundlesAsync(
|
||||
new BundleListRequest(Limit: _options.MaxBundlesPerRun, Cursor: cursor),
|
||||
cancellationToken);
|
||||
|
||||
foreach (var bundle in listResult.Bundles)
|
||||
{
|
||||
evaluated++;
|
||||
var expiryDate = CalculateExpiryDate(bundle);
|
||||
|
||||
// Check if bundle has expired
|
||||
if (expiryDate <= now)
|
||||
{
|
||||
// Check grace period
|
||||
if (expiryDate <= gracePeriodCutoff)
|
||||
{
|
||||
// Past grace period - take expiry action
|
||||
var result = await HandleExpiredBundleAsync(bundle, cancellationToken);
|
||||
if (result.Success)
|
||||
{
|
||||
switch (_options.ExpiryAction)
|
||||
{
|
||||
case RetentionAction.Delete:
|
||||
deleted++;
|
||||
break;
|
||||
case RetentionAction.Archive:
|
||||
archived++;
|
||||
break;
|
||||
case RetentionAction.MarkOnly:
|
||||
markedExpired++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
failures.Add(result.Failure!);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// In grace period - mark as expired but don't delete yet
|
||||
markedExpired++;
|
||||
_logger.LogDebug(
|
||||
"Bundle {BundleId} in grace period, expires {ExpiryDate}",
|
||||
bundle.BundleId,
|
||||
expiryDate);
|
||||
}
|
||||
}
|
||||
// Check if approaching expiry (for notifications)
|
||||
else if (_options.NotifyBeforeExpiry && expiryDate <= notificationCutoff)
|
||||
{
|
||||
approachingExpiry++;
|
||||
expiredNotifications.Add(new BundleExpiryNotification(
|
||||
bundle.BundleId,
|
||||
null, // TenantId not in BundleListItem - would need full bundle fetch
|
||||
bundle.CreatedAt,
|
||||
expiryDate,
|
||||
(int)(expiryDate - now).TotalDays));
|
||||
}
|
||||
}
|
||||
|
||||
cursor = listResult.NextCursor;
|
||||
}
|
||||
while (cursor != null && evaluated < _options.MaxBundlesPerRun);
|
||||
|
||||
// Send notifications for approaching expiry
|
||||
if (_notifier != null && expiredNotifications.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _notifier.NotifyAsync(expiredNotifications, cancellationToken);
|
||||
_logger.LogInformation(
|
||||
"Sent {Count} expiry notifications",
|
||||
expiredNotifications.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send expiry notifications");
|
||||
}
|
||||
}
|
||||
|
||||
var completedAt = DateTimeOffset.UtcNow;
|
||||
_logger.LogInformation(
|
||||
"Retention enforcement completed. Evaluated={Evaluated}, Deleted={Deleted}, Archived={Archived}, Marked={Marked}, Approaching={Approaching}, Failed={Failed}",
|
||||
evaluated, deleted, archived, markedExpired, approachingExpiry, failures.Count);
|
||||
|
||||
return new RetentionEnforcementResult
|
||||
{
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
BundlesEvaluated = evaluated,
|
||||
BundlesDeleted = deleted,
|
||||
BundlesArchived = archived,
|
||||
BundlesMarkedExpired = markedExpired,
|
||||
BundlesApproachingExpiry = approachingExpiry,
|
||||
Failures = failures
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<BundleExpiryNotification>> GetApproachingExpiryAsync(
|
||||
int daysBeforeExpiry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var notifications = new List<BundleExpiryNotification>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var cutoff = now.AddDays(daysBeforeExpiry);
|
||||
string? cursor = null;
|
||||
|
||||
do
|
||||
{
|
||||
var listResult = await _bundleStore.ListBundlesAsync(
|
||||
new BundleListRequest(Limit: 100, Cursor: cursor),
|
||||
cancellationToken);
|
||||
|
||||
foreach (var bundle in listResult.Bundles)
|
||||
{
|
||||
var expiryDate = CalculateExpiryDate(bundle);
|
||||
if (expiryDate > now && expiryDate <= cutoff)
|
||||
{
|
||||
notifications.Add(new BundleExpiryNotification(
|
||||
bundle.BundleId,
|
||||
null,
|
||||
bundle.CreatedAt,
|
||||
expiryDate,
|
||||
(int)(expiryDate - now).TotalDays));
|
||||
}
|
||||
}
|
||||
|
||||
cursor = listResult.NextCursor;
|
||||
}
|
||||
while (cursor != null);
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset CalculateExpiryDate(BundleListItem bundle)
|
||||
{
|
||||
return CalculateExpiryDate(null, bundle.CreatedAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset CalculateExpiryDate(string? tenantId, DateTimeOffset createdAt)
|
||||
{
|
||||
int retentionMonths = _options.DefaultMonths;
|
||||
|
||||
// Check for tenant-specific override
|
||||
if (!string.IsNullOrEmpty(tenantId) &&
|
||||
_options.TenantOverrides.TryGetValue(tenantId, out var tenantMonths))
|
||||
{
|
||||
retentionMonths = Math.Max(tenantMonths, _options.MinimumMonths);
|
||||
retentionMonths = Math.Min(retentionMonths, _options.MaximumMonths);
|
||||
}
|
||||
|
||||
return createdAt.AddMonths(retentionMonths);
|
||||
}
|
||||
|
||||
private async Task<(bool Success, BundleEnforcementFailure? Failure)> HandleExpiredBundleAsync(
|
||||
BundleListItem bundle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (_options.ExpiryAction)
|
||||
{
|
||||
case RetentionAction.Delete:
|
||||
var deleted = await _bundleStore.DeleteBundleAsync(bundle.BundleId, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
_logger.LogInformation("Deleted expired bundle {BundleId}", bundle.BundleId);
|
||||
return (true, null);
|
||||
}
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Delete failed",
|
||||
"Bundle could not be deleted"));
|
||||
|
||||
case RetentionAction.Archive:
|
||||
if (_archiver == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Archive action configured but no archiver available for bundle {BundleId}",
|
||||
bundle.BundleId);
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Archive unavailable",
|
||||
"No archiver configured"));
|
||||
}
|
||||
|
||||
var archived = await _archiver.ArchiveAsync(
|
||||
bundle.BundleId,
|
||||
_options.ArchiveStorageTier,
|
||||
cancellationToken);
|
||||
|
||||
if (archived)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Archived expired bundle {BundleId} to {Tier}",
|
||||
bundle.BundleId,
|
||||
_options.ArchiveStorageTier);
|
||||
return (true, null);
|
||||
}
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Archive failed",
|
||||
"Bundle could not be archived"));
|
||||
|
||||
case RetentionAction.MarkOnly:
|
||||
_logger.LogDebug("Marked bundle {BundleId} as expired", bundle.BundleId);
|
||||
return (true, null);
|
||||
|
||||
default:
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Unknown action",
|
||||
$"Unsupported expiry action: {_options.ExpiryAction}"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to process expired bundle {BundleId}",
|
||||
bundle.BundleId);
|
||||
|
||||
return (false, new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Exception",
|
||||
ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KmsOrgKeySigner.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0007 - Implement KmsOrgKeySigner
|
||||
// Description: KMS-backed organization key signing for bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// KMS-backed organization key signer for attestation bundles.
|
||||
/// Supports AWS KMS, Azure Key Vault, Google Cloud KMS, and HashiCorp Vault.
|
||||
/// </summary>
|
||||
public sealed class KmsOrgKeySigner : IOrgKeySigner
|
||||
{
|
||||
private readonly IKmsProvider _kmsProvider;
|
||||
private readonly ILogger<KmsOrgKeySigner> _logger;
|
||||
private readonly OrgSigningOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new KMS organization key signer.
|
||||
/// </summary>
|
||||
public KmsOrgKeySigner(
|
||||
IKmsProvider kmsProvider,
|
||||
ILogger<KmsOrgKeySigner> logger,
|
||||
IOptions<OrgSigningOptions> options)
|
||||
{
|
||||
_kmsProvider = kmsProvider ?? throw new ArgumentNullException(nameof(kmsProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new OrgSigningOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OrgSignature> SignBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundleDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
_logger.LogInformation("Signing bundle with org key {KeyId}", keyId);
|
||||
|
||||
// Get key metadata
|
||||
var keyInfo = await _kmsProvider.GetKeyInfoAsync(keyId, cancellationToken);
|
||||
if (keyInfo == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Signing key '{keyId}' not found in KMS.");
|
||||
}
|
||||
|
||||
// Verify key is active
|
||||
if (!keyInfo.IsActive)
|
||||
{
|
||||
throw new InvalidOperationException($"Signing key '{keyId}' is not active.");
|
||||
}
|
||||
|
||||
// Check key expiry
|
||||
if (keyInfo.ValidUntil.HasValue && keyInfo.ValidUntil.Value < DateTimeOffset.UtcNow)
|
||||
{
|
||||
throw new InvalidOperationException($"Signing key '{keyId}' has expired.");
|
||||
}
|
||||
|
||||
// Sign the digest
|
||||
var signatureBytes = await _kmsProvider.SignAsync(
|
||||
keyId,
|
||||
bundleDigest,
|
||||
keyInfo.Algorithm,
|
||||
cancellationToken);
|
||||
|
||||
// Get certificate chain if available
|
||||
var certChain = await _kmsProvider.GetCertificateChainAsync(keyId, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Successfully signed bundle with key {KeyId}, algorithm {Algorithm}",
|
||||
keyId,
|
||||
keyInfo.Algorithm);
|
||||
|
||||
return new OrgSignature
|
||||
{
|
||||
KeyId = keyId,
|
||||
Algorithm = keyInfo.Algorithm,
|
||||
Signature = Convert.ToBase64String(signatureBytes),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = certChain
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> VerifyBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
OrgSignature signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundleDigest);
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(signature.Signature);
|
||||
|
||||
var isValid = await _kmsProvider.VerifyAsync(
|
||||
signature.KeyId,
|
||||
bundleDigest,
|
||||
signatureBytes,
|
||||
signature.Algorithm,
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bundle signature verification {Result} for key {KeyId}",
|
||||
isValid ? "succeeded" : "failed",
|
||||
signature.KeyId);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Bundle signature verification failed for key {KeyId}",
|
||||
signature.KeyId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> GetActiveKeyIdAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check for configured active key
|
||||
if (!string.IsNullOrEmpty(_options.ActiveKeyId))
|
||||
{
|
||||
return _options.ActiveKeyId;
|
||||
}
|
||||
|
||||
// List keys and find the active one based on rotation policy
|
||||
var keys = await ListKeysAsync(cancellationToken);
|
||||
var activeKey = keys
|
||||
.Where(k => k.IsActive)
|
||||
.Where(k => !k.ValidUntil.HasValue || k.ValidUntil.Value > DateTimeOffset.UtcNow)
|
||||
.OrderByDescending(k => k.ValidFrom)
|
||||
.FirstOrDefault();
|
||||
|
||||
return activeKey?.KeyId
|
||||
?? throw new InvalidOperationException("No active signing key found.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<OrgKeyInfo>> ListKeysAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var kmsKeys = await _kmsProvider.ListKeysAsync(_options.KeyPrefix, cancellationToken);
|
||||
|
||||
return kmsKeys
|
||||
.Select(k => new OrgKeyInfo(
|
||||
k.KeyId,
|
||||
k.Algorithm,
|
||||
k.Fingerprint,
|
||||
k.ValidFrom,
|
||||
k.ValidUntil,
|
||||
k.IsActive))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for organization signing.
|
||||
/// </summary>
|
||||
public sealed class OrgSigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The active key ID to use for signing.
|
||||
/// If not set, the most recent active key is used.
|
||||
/// </summary>
|
||||
public string? ActiveKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key prefix for filtering keys in KMS.
|
||||
/// </summary>
|
||||
public string KeyPrefix { get; set; } = "stellaops/org-signing/";
|
||||
|
||||
/// <summary>
|
||||
/// Default signing algorithm.
|
||||
/// </summary>
|
||||
public string DefaultAlgorithm { get; set; } = "ECDSA_P256";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for KMS provider abstraction.
|
||||
/// </summary>
|
||||
public interface IKmsProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign data with a KMS key.
|
||||
/// </summary>
|
||||
Task<byte[]> SignAsync(
|
||||
string keyId,
|
||||
byte[] data,
|
||||
string algorithm,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a signature with a KMS key.
|
||||
/// </summary>
|
||||
Task<bool> VerifyAsync(
|
||||
string keyId,
|
||||
byte[] data,
|
||||
byte[] signature,
|
||||
string algorithm,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get information about a key.
|
||||
/// </summary>
|
||||
Task<KmsKeyInfo?> GetKeyInfoAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List keys matching a prefix.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KmsKeyInfo>> ListKeysAsync(
|
||||
string? prefix = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the certificate chain for a key.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>?> GetCertificateChainAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// KMS key information.
|
||||
/// </summary>
|
||||
public sealed record KmsKeyInfo(
|
||||
string KeyId,
|
||||
string Algorithm,
|
||||
string Fingerprint,
|
||||
DateTimeOffset ValidFrom,
|
||||
DateTimeOffset? ValidUntil,
|
||||
bool IsActive);
|
||||
|
||||
/// <summary>
|
||||
/// Local (in-memory) key signer for testing and development.
|
||||
/// </summary>
|
||||
public sealed class LocalOrgKeySigner : IOrgKeySigner
|
||||
{
|
||||
private readonly Dictionary<string, (ECDsa Key, OrgKeyInfo Info)> _keys = new();
|
||||
private readonly ILogger<LocalOrgKeySigner> _logger;
|
||||
private string? _activeKeyId;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new local key signer.
|
||||
/// </summary>
|
||||
public LocalOrgKeySigner(ILogger<LocalOrgKeySigner> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate and add a new key.
|
||||
/// </summary>
|
||||
public void AddKey(string keyId, bool isActive = true)
|
||||
{
|
||||
var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyBytes = key.ExportSubjectPublicKeyInfo();
|
||||
var fingerprint = Convert.ToHexString(SHA256.HashData(publicKeyBytes)).ToLowerInvariant();
|
||||
|
||||
var info = new OrgKeyInfo(
|
||||
keyId,
|
||||
"ECDSA_P256",
|
||||
fingerprint,
|
||||
DateTimeOffset.UtcNow,
|
||||
null,
|
||||
isActive);
|
||||
|
||||
_keys[keyId] = (key, info);
|
||||
|
||||
if (isActive)
|
||||
{
|
||||
_activeKeyId = keyId;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Added local signing key {KeyId}", keyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<OrgSignature> SignBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_keys.TryGetValue(keyId, out var keyPair))
|
||||
{
|
||||
throw new InvalidOperationException($"Key '{keyId}' not found.");
|
||||
}
|
||||
|
||||
var signature = keyPair.Key.SignData(bundleDigest, HashAlgorithmName.SHA256);
|
||||
|
||||
return Task.FromResult(new OrgSignature
|
||||
{
|
||||
KeyId = keyId,
|
||||
Algorithm = "ECDSA_P256",
|
||||
Signature = Convert.ToBase64String(signature),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = null
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> VerifyBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
OrgSignature signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_keys.TryGetValue(signature.KeyId, out var keyPair))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(signature.Signature);
|
||||
var isValid = keyPair.Key.VerifyData(bundleDigest, signatureBytes, HashAlgorithmName.SHA256);
|
||||
return Task.FromResult(isValid);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> GetActiveKeyIdAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_activeKeyId == null)
|
||||
{
|
||||
throw new InvalidOperationException("No active signing key.");
|
||||
}
|
||||
return Task.FromResult(_activeKeyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<OrgKeyInfo>> ListKeysAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<OrgKeyInfo>>(
|
||||
_keys.Values.Select(k => k.Info).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Bundling</RootNamespace>
|
||||
<Description>Attestation bundle aggregation and rotation for long-term verification in air-gapped environments.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,104 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOfflineRootStore.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0003 - Implement IOfflineRootStore interface
|
||||
// Description: Interface for loading trust roots for offline verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Store for trust roots used in offline verification.
|
||||
/// Provides access to Fulcio roots, organization signing keys, and Rekor checkpoints.
|
||||
/// </summary>
|
||||
public interface IOfflineRootStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Get Fulcio root certificates for keyless signature verification.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of Fulcio root certificates.</returns>
|
||||
Task<X509Certificate2Collection> GetFulcioRootsAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get organization signing keys for bundle signature verification.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of organization signing certificates.</returns>
|
||||
Task<X509Certificate2Collection> GetOrgSigningKeysAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get Rekor public keys for checkpoint verification.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of Rekor public key certificates.</returns>
|
||||
Task<X509Certificate2Collection> GetRekorKeysAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Import root certificates from a PEM file.
|
||||
/// </summary>
|
||||
/// <param name="pemPath">Path to the PEM file.</param>
|
||||
/// <param name="rootType">Type of roots being imported.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task ImportRootsAsync(
|
||||
string pemPath,
|
||||
RootType rootType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific organization key by ID.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The certificate if found, null otherwise.</returns>
|
||||
Task<X509Certificate2?> GetOrgKeyByIdAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List all available root certificates with metadata.
|
||||
/// </summary>
|
||||
/// <param name="rootType">Type of roots to list.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Root certificate metadata.</returns>
|
||||
Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
|
||||
RootType rootType,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of trust root.
|
||||
/// </summary>
|
||||
public enum RootType
|
||||
{
|
||||
/// <summary>Fulcio root certificates for keyless signing.</summary>
|
||||
Fulcio,
|
||||
/// <summary>Organization signing keys for bundle endorsement.</summary>
|
||||
OrgSigning,
|
||||
/// <summary>Rekor public keys for transparency log verification.</summary>
|
||||
Rekor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a root certificate.
|
||||
/// </summary>
|
||||
/// <param name="Thumbprint">Certificate thumbprint (SHA-256).</param>
|
||||
/// <param name="Subject">Certificate subject DN.</param>
|
||||
/// <param name="Issuer">Certificate issuer DN.</param>
|
||||
/// <param name="NotBefore">Certificate validity start.</param>
|
||||
/// <param name="NotAfter">Certificate validity end.</param>
|
||||
/// <param name="KeyId">Optional key identifier.</param>
|
||||
/// <param name="RootType">Type of this root certificate.</param>
|
||||
public record RootCertificateInfo(
|
||||
string Thumbprint,
|
||||
string Subject,
|
||||
string Issuer,
|
||||
DateTimeOffset NotBefore,
|
||||
DateTimeOffset NotAfter,
|
||||
string? KeyId,
|
||||
RootType RootType);
|
||||
@@ -0,0 +1,70 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOfflineVerifier.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0005 - Implement IOfflineVerifier interface
|
||||
// Description: Interface for offline verification of attestation bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for offline verification of attestation bundles.
|
||||
/// Enables air-gapped environments to verify attestations using bundled proofs
|
||||
/// and locally stored root certificates.
|
||||
/// </summary>
|
||||
public interface IOfflineVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify an attestation bundle offline.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The attestation bundle to verify.</param>
|
||||
/// <param name="options">Verification options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result with detailed status.</returns>
|
||||
Task<OfflineVerificationResult> VerifyBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a single attestation within a bundle offline.
|
||||
/// </summary>
|
||||
/// <param name="attestation">The attestation to verify.</param>
|
||||
/// <param name="options">Verification options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result for the single attestation.</returns>
|
||||
Task<OfflineVerificationResult> VerifyAttestationAsync(
|
||||
BundledAttestation attestation,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify an attestation for a specific artifact digest.
|
||||
/// Looks up the attestation in the bundle by artifact digest.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">The artifact digest to look up.</param>
|
||||
/// <param name="bundlePath">Path to the bundle file.</param>
|
||||
/// <param name="options">Verification options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result for attestations covering the artifact.</returns>
|
||||
Task<OfflineVerificationResult> VerifyByArtifactAsync(
|
||||
string artifactDigest,
|
||||
string bundlePath,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get verification summaries for all attestations in a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The bundle to summarize.</param>
|
||||
/// <param name="options">Verification options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of attestation verification summaries.</returns>
|
||||
Task<IReadOnlyList<AttestationVerificationSummary>> GetVerificationSummariesAsync(
|
||||
AttestationBundle bundle,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineVerificationResult.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0002 - Define OfflineVerificationResult and options
|
||||
// Description: Models for offline verification results
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of offline verification of an attestation bundle.
|
||||
/// </summary>
|
||||
/// <param name="Valid">Whether all verification checks passed.</param>
|
||||
/// <param name="MerkleProofValid">Whether the Merkle proof verification passed.</param>
|
||||
/// <param name="SignaturesValid">Whether all DSSE signatures are valid.</param>
|
||||
/// <param name="CertificateChainValid">Whether certificate chains validate to trusted roots.</param>
|
||||
/// <param name="OrgSignatureValid">Whether the organization signature is valid.</param>
|
||||
/// <param name="OrgSignatureKeyId">Key ID used for org signature (if present).</param>
|
||||
/// <param name="VerifiedAt">Timestamp when verification was performed.</param>
|
||||
/// <param name="Issues">List of verification issues found.</param>
|
||||
public record OfflineVerificationResult(
|
||||
bool Valid,
|
||||
bool MerkleProofValid,
|
||||
bool SignaturesValid,
|
||||
bool CertificateChainValid,
|
||||
bool OrgSignatureValid,
|
||||
string? OrgSignatureKeyId,
|
||||
DateTimeOffset VerifiedAt,
|
||||
IReadOnlyList<VerificationIssue> Issues);
|
||||
|
||||
/// <summary>
|
||||
/// A single verification issue.
|
||||
/// </summary>
|
||||
/// <param name="Severity">Issue severity level.</param>
|
||||
/// <param name="Code">Machine-readable issue code.</param>
|
||||
/// <param name="Message">Human-readable message.</param>
|
||||
/// <param name="AttestationId">Related attestation ID, if applicable.</param>
|
||||
public record VerificationIssue(
|
||||
VerificationIssueSeverity Severity,
|
||||
string Code,
|
||||
string Message,
|
||||
string? AttestationId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for verification issues.
|
||||
/// </summary>
|
||||
public enum VerificationIssueSeverity
|
||||
{
|
||||
/// <summary>Informational message.</summary>
|
||||
Info,
|
||||
/// <summary>Warning that may affect trust.</summary>
|
||||
Warning,
|
||||
/// <summary>Error that affects verification.</summary>
|
||||
Error,
|
||||
/// <summary>Critical error that invalidates verification.</summary>
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for offline verification.
|
||||
/// </summary>
|
||||
/// <param name="VerifyMerkleProof">Whether to verify Merkle inclusion proofs.</param>
|
||||
/// <param name="VerifySignatures">Whether to verify DSSE signatures.</param>
|
||||
/// <param name="VerifyCertificateChain">Whether to verify certificate chains.</param>
|
||||
/// <param name="VerifyOrgSignature">Whether to verify organization signature.</param>
|
||||
/// <param name="RequireOrgSignature">Fail if org signature is missing.</param>
|
||||
/// <param name="FulcioRootPath">Path to Fulcio root certificates (overrides default).</param>
|
||||
/// <param name="OrgKeyPath">Path to organization signing keys (overrides default).</param>
|
||||
/// <param name="StrictMode">Enable strict verification (all checks must pass).</param>
|
||||
public record OfflineVerificationOptions(
|
||||
bool VerifyMerkleProof = true,
|
||||
bool VerifySignatures = true,
|
||||
bool VerifyCertificateChain = true,
|
||||
bool VerifyOrgSignature = true,
|
||||
bool RequireOrgSignature = false,
|
||||
string? FulcioRootPath = null,
|
||||
string? OrgKeyPath = null,
|
||||
bool StrictMode = false);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of an attestation for verification reporting.
|
||||
/// </summary>
|
||||
/// <param name="EntryId">Attestation entry ID.</param>
|
||||
/// <param name="ArtifactDigest">Artifact digest covered by this attestation.</param>
|
||||
/// <param name="PredicateType">Predicate type.</param>
|
||||
/// <param name="SignedAt">When the attestation was signed.</param>
|
||||
/// <param name="SigningIdentity">Identity that signed the attestation.</param>
|
||||
/// <param name="VerificationStatus">Status of this attestation's verification.</param>
|
||||
public record AttestationVerificationSummary(
|
||||
string EntryId,
|
||||
string ArtifactDigest,
|
||||
string PredicateType,
|
||||
DateTimeOffset SignedAt,
|
||||
string? SigningIdentity,
|
||||
AttestationVerificationStatus VerificationStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Verification status of an individual attestation.
|
||||
/// </summary>
|
||||
public enum AttestationVerificationStatus
|
||||
{
|
||||
/// <summary>Verification passed.</summary>
|
||||
Valid,
|
||||
/// <summary>Signature verification failed.</summary>
|
||||
InvalidSignature,
|
||||
/// <summary>Certificate chain verification failed.</summary>
|
||||
InvalidCertificateChain,
|
||||
/// <summary>Merkle inclusion proof failed.</summary>
|
||||
InvalidMerkleProof,
|
||||
/// <summary>Verification encountered an error.</summary>
|
||||
Error
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FileSystemRootStore.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0004 - Implement FileSystemRootStore
|
||||
// Description: File-based root certificate store for offline verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Services;
|
||||
|
||||
/// <summary>
|
||||
/// File system-based implementation of IOfflineRootStore.
|
||||
/// Loads root certificates from configured paths for offline verification.
|
||||
/// </summary>
|
||||
public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
{
|
||||
private readonly ILogger<FileSystemRootStore> _logger;
|
||||
private readonly OfflineRootStoreOptions _options;
|
||||
|
||||
private X509Certificate2Collection? _fulcioRoots;
|
||||
private X509Certificate2Collection? _orgSigningKeys;
|
||||
private X509Certificate2Collection? _rekorKeys;
|
||||
private readonly SemaphoreSlim _loadLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new file system root store.
|
||||
/// </summary>
|
||||
public FileSystemRootStore(
|
||||
ILogger<FileSystemRootStore> logger,
|
||||
IOptions<OfflineRootStoreOptions> options)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new OfflineRootStoreOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<X509Certificate2Collection> GetFulcioRootsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_fulcioRoots == null)
|
||||
{
|
||||
await LoadRootsAsync(RootType.Fulcio, cancellationToken);
|
||||
}
|
||||
|
||||
return _fulcioRoots ?? new X509Certificate2Collection();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<X509Certificate2Collection> GetOrgSigningKeysAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_orgSigningKeys == null)
|
||||
{
|
||||
await LoadRootsAsync(RootType.OrgSigning, cancellationToken);
|
||||
}
|
||||
|
||||
return _orgSigningKeys ?? new X509Certificate2Collection();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<X509Certificate2Collection> GetRekorKeysAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_rekorKeys == null)
|
||||
{
|
||||
await LoadRootsAsync(RootType.Rekor, cancellationToken);
|
||||
}
|
||||
|
||||
return _rekorKeys ?? new X509Certificate2Collection();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ImportRootsAsync(
|
||||
string pemPath,
|
||||
RootType rootType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pemPath);
|
||||
|
||||
if (!File.Exists(pemPath))
|
||||
{
|
||||
throw new FileNotFoundException($"PEM file not found: {pemPath}");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Importing {RootType} roots from {Path}", rootType, pemPath);
|
||||
|
||||
var pemContent = await File.ReadAllTextAsync(pemPath, cancellationToken);
|
||||
var certs = ParsePemCertificates(pemContent);
|
||||
|
||||
if (certs.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No certificates found in {pemPath}");
|
||||
}
|
||||
|
||||
// Get target directory based on root type
|
||||
var targetDir = GetRootDirectory(rootType);
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
// Save each certificate
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
var thumbprint = ComputeThumbprint(cert);
|
||||
var targetPath = Path.Combine(targetDir, $"{thumbprint}.pem");
|
||||
|
||||
var pemBytes = Encoding.UTF8.GetBytes(
|
||||
"-----BEGIN CERTIFICATE-----\n" +
|
||||
Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks) +
|
||||
"\n-----END CERTIFICATE-----\n");
|
||||
|
||||
await File.WriteAllBytesAsync(targetPath, pemBytes, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Imported certificate {Subject} with thumbprint {Thumbprint}",
|
||||
cert.Subject,
|
||||
thumbprint);
|
||||
}
|
||||
|
||||
// Invalidate cache to reload
|
||||
InvalidateCache(rootType);
|
||||
|
||||
_logger.LogInformation("Imported {Count} {RootType} certificates", certs.Count, rootType);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<X509Certificate2?> GetOrgKeyByIdAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
var keys = await GetOrgSigningKeysAsync(cancellationToken);
|
||||
|
||||
foreach (var cert in keys)
|
||||
{
|
||||
// Check various key identifier extensions
|
||||
var ski = cert.Extensions["2.5.29.14"]; // Subject Key Identifier
|
||||
if (ski != null)
|
||||
{
|
||||
var skiData = ski.RawData;
|
||||
var skiHex = Convert.ToHexString(skiData).ToLowerInvariant();
|
||||
if (skiHex.Contains(keyId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return cert;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check thumbprint
|
||||
if (ComputeThumbprint(cert).Equals(keyId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return cert;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
|
||||
RootType rootType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var certs = rootType switch
|
||||
{
|
||||
RootType.Fulcio => await GetFulcioRootsAsync(cancellationToken),
|
||||
RootType.OrgSigning => await GetOrgSigningKeysAsync(cancellationToken),
|
||||
RootType.Rekor => await GetRekorKeysAsync(cancellationToken),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(rootType))
|
||||
};
|
||||
|
||||
var result = new List<RootCertificateInfo>();
|
||||
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
result.Add(new RootCertificateInfo(
|
||||
Thumbprint: ComputeThumbprint(cert),
|
||||
Subject: cert.Subject,
|
||||
Issuer: cert.Issuer,
|
||||
NotBefore: new DateTimeOffset(cert.NotBefore.ToUniversalTime(), TimeSpan.Zero),
|
||||
NotAfter: new DateTimeOffset(cert.NotAfter.ToUniversalTime(), TimeSpan.Zero),
|
||||
KeyId: GetSubjectKeyIdentifier(cert),
|
||||
RootType: rootType));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task LoadRootsAsync(RootType rootType, CancellationToken cancellationToken)
|
||||
{
|
||||
await _loadLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Double-check after acquiring lock
|
||||
if (GetCachedCollection(rootType) != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var path = GetRootPath(rootType);
|
||||
var collection = new X509Certificate2Collection();
|
||||
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// Single file
|
||||
var certs = await LoadPemFileAsync(path, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
}
|
||||
else if (Directory.Exists(path))
|
||||
{
|
||||
// Directory of PEM files
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*.pem"))
|
||||
{
|
||||
var certs = await LoadPemFileAsync(file, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also try Offline Kit path if configured
|
||||
var offlineKitPath = GetOfflineKitPath(rootType);
|
||||
if (!string.IsNullOrEmpty(offlineKitPath) && Directory.Exists(offlineKitPath))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(offlineKitPath, "*.pem"))
|
||||
{
|
||||
var certs = await LoadPemFileAsync(file, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
}
|
||||
}
|
||||
|
||||
SetCachedCollection(rootType, collection);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded {Count} {RootType} certificates",
|
||||
collection.Count,
|
||||
rootType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<X509Certificate2Collection> LoadPemFileAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pemContent = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
return ParsePemCertificates(pemContent);
|
||||
}
|
||||
|
||||
private static X509Certificate2Collection ParsePemCertificates(string pemContent)
|
||||
{
|
||||
var collection = new X509Certificate2Collection();
|
||||
|
||||
const string beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const string endMarker = "-----END CERTIFICATE-----";
|
||||
|
||||
var startIndex = 0;
|
||||
while (true)
|
||||
{
|
||||
var begin = pemContent.IndexOf(beginMarker, startIndex, StringComparison.Ordinal);
|
||||
if (begin < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var end = pemContent.IndexOf(endMarker, begin, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var base64Start = begin + beginMarker.Length;
|
||||
var base64Content = pemContent[base64Start..end]
|
||||
.Replace("\r", "")
|
||||
.Replace("\n", "")
|
||||
.Trim();
|
||||
|
||||
var certBytes = Convert.FromBase64String(base64Content);
|
||||
collection.Add(new X509Certificate2(certBytes));
|
||||
|
||||
startIndex = end + endMarker.Length;
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
private string GetRootPath(RootType rootType) => rootType switch
|
||||
{
|
||||
RootType.Fulcio => _options.FulcioBundlePath ?? "",
|
||||
RootType.OrgSigning => _options.OrgSigningBundlePath ?? "",
|
||||
RootType.Rekor => _options.RekorBundlePath ?? "",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private string GetRootDirectory(RootType rootType) => rootType switch
|
||||
{
|
||||
RootType.Fulcio => _options.FulcioBundlePath ?? Path.Combine(_options.BaseRootPath, "fulcio"),
|
||||
RootType.OrgSigning => _options.OrgSigningBundlePath ?? Path.Combine(_options.BaseRootPath, "org-signing"),
|
||||
RootType.Rekor => _options.RekorBundlePath ?? Path.Combine(_options.BaseRootPath, "rekor"),
|
||||
_ => _options.BaseRootPath
|
||||
};
|
||||
|
||||
private string? GetOfflineKitPath(RootType rootType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_options.OfflineKitPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return rootType switch
|
||||
{
|
||||
RootType.Fulcio => Path.Combine(_options.OfflineKitPath, "roots", "fulcio"),
|
||||
RootType.OrgSigning => Path.Combine(_options.OfflineKitPath, "roots", "org-signing"),
|
||||
RootType.Rekor => Path.Combine(_options.OfflineKitPath, "roots", "rekor"),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private X509Certificate2Collection? GetCachedCollection(RootType rootType) => rootType switch
|
||||
{
|
||||
RootType.Fulcio => _fulcioRoots,
|
||||
RootType.OrgSigning => _orgSigningKeys,
|
||||
RootType.Rekor => _rekorKeys,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private void SetCachedCollection(RootType rootType, X509Certificate2Collection collection)
|
||||
{
|
||||
switch (rootType)
|
||||
{
|
||||
case RootType.Fulcio:
|
||||
_fulcioRoots = collection;
|
||||
break;
|
||||
case RootType.OrgSigning:
|
||||
_orgSigningKeys = collection;
|
||||
break;
|
||||
case RootType.Rekor:
|
||||
_rekorKeys = collection;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void InvalidateCache(RootType rootType)
|
||||
{
|
||||
switch (rootType)
|
||||
{
|
||||
case RootType.Fulcio:
|
||||
_fulcioRoots = null;
|
||||
break;
|
||||
case RootType.OrgSigning:
|
||||
_orgSigningKeys = null;
|
||||
break;
|
||||
case RootType.Rekor:
|
||||
_rekorKeys = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeThumbprint(X509Certificate2 cert)
|
||||
{
|
||||
var hash = SHA256.HashData(cert.RawData);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? GetSubjectKeyIdentifier(X509Certificate2 cert)
|
||||
{
|
||||
var extension = cert.Extensions["2.5.29.14"];
|
||||
if (extension == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip the ASN.1 header (typically 2 bytes for OCTET STRING)
|
||||
var data = extension.RawData;
|
||||
if (data.Length > 2 && data[0] == 0x04) // OCTET STRING
|
||||
{
|
||||
var length = data[1];
|
||||
if (data.Length >= 2 + length)
|
||||
{
|
||||
return Convert.ToHexString(data[2..(2 + length)]).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
return Convert.ToHexString(data).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the file system root store.
|
||||
/// </summary>
|
||||
public sealed class OfflineRootStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base path for all root certificates.
|
||||
/// </summary>
|
||||
public string BaseRootPath { get; set; } = "/etc/stellaops/roots";
|
||||
|
||||
/// <summary>
|
||||
/// Path to Fulcio root certificates (file or directory).
|
||||
/// </summary>
|
||||
public string? FulcioBundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to organization signing keys (file or directory).
|
||||
/// </summary>
|
||||
public string? OrgSigningBundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to Rekor public keys (file or directory).
|
||||
/// </summary>
|
||||
public string? RekorBundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to Offline Kit installation.
|
||||
/// </summary>
|
||||
public string? OfflineKitPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use roots from the Offline Kit.
|
||||
/// </summary>
|
||||
public bool UseOfflineKit { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineVerifier.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0006 - Implement OfflineVerifier service
|
||||
// Description: Offline verification service for attestation bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
// Alias to resolve ambiguity with Bundling.Abstractions.VerificationIssueSeverity
|
||||
using Severity = StellaOps.Attestor.Offline.Models.VerificationIssueSeverity;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Offline verification service for attestation bundles.
|
||||
/// Enables air-gapped environments to verify attestations using bundled proofs.
|
||||
/// </summary>
|
||||
public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
private readonly IOfflineRootStore _rootStore;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly IOrgKeySigner? _orgSigner;
|
||||
private readonly ILogger<OfflineVerifier> _logger;
|
||||
private readonly OfflineVerificationConfig _config;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new offline verifier.
|
||||
/// </summary>
|
||||
public OfflineVerifier(
|
||||
IOfflineRootStore rootStore,
|
||||
IMerkleTreeBuilder merkleBuilder,
|
||||
ILogger<OfflineVerifier> logger,
|
||||
IOptions<OfflineVerificationConfig> config,
|
||||
IOrgKeySigner? orgSigner = null)
|
||||
{
|
||||
_rootStore = rootStore ?? throw new ArgumentNullException(nameof(rootStore));
|
||||
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_config = config?.Value ?? new OfflineVerificationConfig();
|
||||
_orgSigner = orgSigner;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OfflineVerificationResult> VerifyBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
var issues = new List<VerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting offline verification of bundle {BundleId} with {Count} attestations",
|
||||
bundle.Metadata.BundleId,
|
||||
bundle.Attestations.Count);
|
||||
|
||||
// 1. Verify bundle Merkle root
|
||||
var merkleValid = true;
|
||||
if (options.VerifyMerkleProof)
|
||||
{
|
||||
merkleValid = VerifyMerkleTree(bundle, issues);
|
||||
}
|
||||
|
||||
// 2. Verify org signature (if present and required)
|
||||
var orgSigValid = true;
|
||||
string? orgSigKeyId = null;
|
||||
if (bundle.OrgSignature != null)
|
||||
{
|
||||
orgSigKeyId = bundle.OrgSignature.KeyId;
|
||||
if (options.VerifyOrgSignature)
|
||||
{
|
||||
orgSigValid = await VerifyOrgSignatureAsync(bundle, issues, cancellationToken);
|
||||
}
|
||||
}
|
||||
else if (options.RequireOrgSignature)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_MISSING",
|
||||
"Required organization signature is missing"));
|
||||
orgSigValid = false;
|
||||
}
|
||||
|
||||
// 3. Verify each attestation
|
||||
var signaturesValid = true;
|
||||
var certsValid = true;
|
||||
|
||||
if (options.VerifySignatures || options.VerifyCertificateChain)
|
||||
{
|
||||
var fulcioRoots = options.VerifyCertificateChain
|
||||
? await _rootStore.GetFulcioRootsAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
foreach (var attestation in bundle.Attestations)
|
||||
{
|
||||
// Verify DSSE signature
|
||||
if (options.VerifySignatures)
|
||||
{
|
||||
var sigValid = VerifyDsseSignature(attestation, issues);
|
||||
if (!sigValid)
|
||||
{
|
||||
signaturesValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify certificate chain
|
||||
if (options.VerifyCertificateChain && fulcioRoots != null)
|
||||
{
|
||||
var chainValid = VerifyCertificateChain(attestation, fulcioRoots, issues);
|
||||
if (!chainValid)
|
||||
{
|
||||
certsValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Rekor inclusion proof (if present)
|
||||
if (options.VerifyMerkleProof && attestation.InclusionProof != null)
|
||||
{
|
||||
VerifyRekorInclusionProof(attestation, issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var valid = merkleValid && signaturesValid && certsValid && orgSigValid;
|
||||
|
||||
if (options.StrictMode && issues.Any(i => i.Severity >= Severity.Warning))
|
||||
{
|
||||
valid = false;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Offline verification of bundle {BundleId} completed: {Status}",
|
||||
bundle.Metadata.BundleId,
|
||||
valid ? "VALID" : "INVALID");
|
||||
|
||||
return new OfflineVerificationResult(
|
||||
Valid: valid,
|
||||
MerkleProofValid: merkleValid,
|
||||
SignaturesValid: signaturesValid,
|
||||
CertificateChainValid: certsValid,
|
||||
OrgSignatureValid: orgSigValid,
|
||||
OrgSignatureKeyId: orgSigKeyId,
|
||||
VerifiedAt: verifiedAt,
|
||||
Issues: issues);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OfflineVerificationResult> VerifyAttestationAsync(
|
||||
BundledAttestation attestation,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
var issues = new List<VerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting offline verification of attestation {EntryId}",
|
||||
attestation.EntryId);
|
||||
|
||||
var signaturesValid = true;
|
||||
var certsValid = true;
|
||||
var merkleValid = true;
|
||||
|
||||
// Verify DSSE signature
|
||||
if (options.VerifySignatures)
|
||||
{
|
||||
signaturesValid = VerifyDsseSignature(attestation, issues);
|
||||
}
|
||||
|
||||
// Verify certificate chain
|
||||
if (options.VerifyCertificateChain)
|
||||
{
|
||||
var fulcioRoots = await _rootStore.GetFulcioRootsAsync(cancellationToken);
|
||||
certsValid = VerifyCertificateChain(attestation, fulcioRoots, issues);
|
||||
}
|
||||
|
||||
// Verify Rekor inclusion proof
|
||||
if (options.VerifyMerkleProof && attestation.InclusionProof != null)
|
||||
{
|
||||
merkleValid = VerifyRekorInclusionProof(attestation, issues);
|
||||
}
|
||||
|
||||
var valid = signaturesValid && certsValid && merkleValid;
|
||||
|
||||
return new OfflineVerificationResult(
|
||||
Valid: valid,
|
||||
MerkleProofValid: merkleValid,
|
||||
SignaturesValid: signaturesValid,
|
||||
CertificateChainValid: certsValid,
|
||||
OrgSignatureValid: true, // Not applicable for single attestation
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: verifiedAt,
|
||||
Issues: issues);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OfflineVerificationResult> VerifyByArtifactAsync(
|
||||
string artifactDigest,
|
||||
string bundlePath,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loading bundle from {Path} to verify artifact {Digest}",
|
||||
bundlePath,
|
||||
artifactDigest);
|
||||
|
||||
// Load bundle from file
|
||||
var bundle = await LoadBundleAsync(bundlePath, cancellationToken);
|
||||
|
||||
// Find attestations for this artifact
|
||||
var matchingAttestations = bundle.Attestations
|
||||
.Where(a => a.ArtifactDigest.Equals(artifactDigest, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (matchingAttestations.Count == 0)
|
||||
{
|
||||
return new OfflineVerificationResult(
|
||||
Valid: false,
|
||||
MerkleProofValid: false,
|
||||
SignaturesValid: false,
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: DateTimeOffset.UtcNow,
|
||||
Issues: new List<VerificationIssue>
|
||||
{
|
||||
new(Severity.Critical,
|
||||
"ARTIFACT_NOT_FOUND",
|
||||
$"No attestations found for artifact {artifactDigest}")
|
||||
});
|
||||
}
|
||||
|
||||
// Create a filtered bundle with only matching attestations
|
||||
var filteredBundle = bundle with
|
||||
{
|
||||
Attestations = matchingAttestations
|
||||
};
|
||||
|
||||
return await VerifyBundleAsync(filteredBundle, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AttestationVerificationSummary>> GetVerificationSummariesAsync(
|
||||
AttestationBundle bundle,
|
||||
OfflineVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
var summaries = new List<AttestationVerificationSummary>();
|
||||
|
||||
var fulcioRoots = options.VerifyCertificateChain
|
||||
? await _rootStore.GetFulcioRootsAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
foreach (var attestation in bundle.Attestations)
|
||||
{
|
||||
var issues = new List<VerificationIssue>();
|
||||
var status = AttestationVerificationStatus.Valid;
|
||||
|
||||
// Verify signature
|
||||
if (options.VerifySignatures && !VerifyDsseSignature(attestation, issues))
|
||||
{
|
||||
status = AttestationVerificationStatus.InvalidSignature;
|
||||
}
|
||||
|
||||
// Verify certificate chain
|
||||
if (status == AttestationVerificationStatus.Valid &&
|
||||
options.VerifyCertificateChain &&
|
||||
fulcioRoots != null &&
|
||||
!VerifyCertificateChain(attestation, fulcioRoots, issues))
|
||||
{
|
||||
status = AttestationVerificationStatus.InvalidCertificateChain;
|
||||
}
|
||||
|
||||
// Verify Merkle proof
|
||||
if (status == AttestationVerificationStatus.Valid &&
|
||||
options.VerifyMerkleProof &&
|
||||
attestation.InclusionProof != null &&
|
||||
!VerifyRekorInclusionProof(attestation, issues))
|
||||
{
|
||||
status = AttestationVerificationStatus.InvalidMerkleProof;
|
||||
}
|
||||
|
||||
// Get signing identity
|
||||
var identity = attestation.SigningIdentity.Subject ??
|
||||
attestation.SigningIdentity.San ??
|
||||
attestation.SigningIdentity.KeyId;
|
||||
|
||||
summaries.Add(new AttestationVerificationSummary(
|
||||
EntryId: attestation.EntryId,
|
||||
ArtifactDigest: attestation.ArtifactDigest,
|
||||
PredicateType: attestation.PredicateType,
|
||||
SignedAt: attestation.SignedAt,
|
||||
SigningIdentity: identity,
|
||||
VerificationStatus: status));
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private bool VerifyMerkleTree(AttestationBundle bundle, List<VerificationIssue> issues)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Sort attestations deterministically
|
||||
var sortedAttestations = bundle.Attestations
|
||||
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Create leaf values from entry IDs
|
||||
var leafValues = sortedAttestations
|
||||
.Select(a => (ReadOnlyMemory<byte>)Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
var computedRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
|
||||
var computedRootHex = $"sha256:{Convert.ToHexString(computedRoot).ToLowerInvariant()}";
|
||||
|
||||
if (computedRootHex != bundle.MerkleTree.Root)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"MERKLE_ROOT_MISMATCH",
|
||||
$"Computed Merkle root {computedRootHex} does not match bundle root {bundle.MerkleTree.Root}"));
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Merkle root verified: {Root}", bundle.MerkleTree.Root);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"MERKLE_VERIFY_ERROR",
|
||||
$"Failed to verify Merkle root: {ex.Message}"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> VerifyOrgSignatureAsync(
|
||||
AttestationBundle bundle,
|
||||
List<VerificationIssue> issues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (bundle.OrgSignature == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Compute bundle digest
|
||||
var digestData = ComputeBundleDigest(bundle);
|
||||
|
||||
// Try using the org signer if available
|
||||
if (_orgSigner != null)
|
||||
{
|
||||
var valid = await _orgSigner.VerifyBundleAsync(
|
||||
digestData,
|
||||
bundle.OrgSignature,
|
||||
cancellationToken);
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
$"Organization signature verification failed for key {bundle.OrgSignature.KeyId}"));
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
// Try using certificate from root store
|
||||
var cert = await _rootStore.GetOrgKeyByIdAsync(
|
||||
bundle.OrgSignature.KeyId,
|
||||
cancellationToken);
|
||||
|
||||
if (cert == null)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_KEY_NOT_FOUND",
|
||||
$"Organization key {bundle.OrgSignature.KeyId} not found in root store"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify signature using the certificate
|
||||
var signatureBytes = Convert.FromBase64String(bundle.OrgSignature.Signature);
|
||||
var algorithm = bundle.OrgSignature.Algorithm switch
|
||||
{
|
||||
"ECDSA_P256" => HashAlgorithmName.SHA256,
|
||||
"Ed25519" => HashAlgorithmName.SHA256, // Ed25519 handles its own hashing
|
||||
"RSA_PSS_SHA256" => HashAlgorithmName.SHA256,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
|
||||
using var pubKey = cert.GetECDsaPublicKey();
|
||||
if (pubKey != null)
|
||||
{
|
||||
var valid = pubKey.VerifyData(digestData, signatureBytes, algorithm);
|
||||
if (!valid)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
$"ECDSA signature verification failed"));
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
using var rsaKey = cert.GetRSAPublicKey();
|
||||
if (rsaKey != null)
|
||||
{
|
||||
var valid = rsaKey.VerifyData(
|
||||
digestData,
|
||||
signatureBytes,
|
||||
algorithm,
|
||||
RSASignaturePadding.Pss);
|
||||
if (!valid)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
$"RSA signature verification failed"));
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_KEY_UNSUPPORTED",
|
||||
$"Unsupported key type for organization signature verification"));
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_VERIFY_ERROR",
|
||||
$"Failed to verify organization signature: {ex.Message}"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool VerifyDsseSignature(BundledAttestation attestation, List<VerificationIssue> issues)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (attestation.Envelope.Signatures.Count == 0)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_NO_SIGNATURES",
|
||||
$"No signatures in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify at least one signature is present and has non-empty sig
|
||||
foreach (var sig in attestation.Envelope.Signatures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sig.Sig))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_EMPTY_SIG",
|
||||
$"Empty signature in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Full cryptographic verification requires the certificate chain
|
||||
// Here we just validate structure; chain verification handles crypto
|
||||
_logger.LogDebug("DSSE envelope structure verified for {EntryId}", attestation.EntryId);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_VERIFY_ERROR",
|
||||
$"Failed to verify DSSE signature for {attestation.EntryId}: {ex.Message}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool VerifyCertificateChain(
|
||||
BundledAttestation attestation,
|
||||
X509Certificate2Collection fulcioRoots,
|
||||
List<VerificationIssue> issues)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (attestation.Envelope.CertificateChain == null ||
|
||||
attestation.Envelope.CertificateChain.Count == 0)
|
||||
{
|
||||
// Keyful attestations may not have certificate chains
|
||||
if (attestation.SigningMode == "keyless")
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"CERT_CHAIN_MISSING",
|
||||
$"Keyless attestation {attestation.EntryId} missing certificate chain",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // Non-keyless attestations may use other verification
|
||||
}
|
||||
|
||||
// Parse leaf certificate
|
||||
var leafPem = attestation.Envelope.CertificateChain[0];
|
||||
var leafCert = ParseCertificateFromPem(leafPem);
|
||||
if (leafCert == null)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"CERT_PARSE_FAILED",
|
||||
$"Failed to parse leaf certificate for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build chain
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // Offline mode
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
|
||||
// Add intermediates
|
||||
foreach (var certPem in attestation.Envelope.CertificateChain.Skip(1))
|
||||
{
|
||||
var cert = ParseCertificateFromPem(certPem);
|
||||
if (cert != null)
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
// Add Fulcio roots
|
||||
foreach (var root in fulcioRoots)
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(root);
|
||||
}
|
||||
|
||||
// Build and verify
|
||||
var built = chain.Build(leafCert);
|
||||
if (!built)
|
||||
{
|
||||
var statusInfo = string.Join(", ",
|
||||
chain.ChainStatus.Select(s => $"{s.Status}: {s.StatusInformation}"));
|
||||
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Warning,
|
||||
"CERT_CHAIN_BUILD_FAILED",
|
||||
$"Certificate chain build failed for {attestation.EntryId}: {statusInfo}",
|
||||
attestation.EntryId));
|
||||
}
|
||||
|
||||
// Verify chain terminates at a Fulcio root
|
||||
var chainRoot = chain.ChainElements[^1].Certificate;
|
||||
var matchesRoot = fulcioRoots.Any(r =>
|
||||
r.Thumbprint.Equals(chainRoot.Thumbprint, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!matchesRoot)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"CERT_CHAIN_UNTRUSTED",
|
||||
$"Certificate chain for {attestation.EntryId} does not terminate at trusted Fulcio root",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Certificate chain verified for {EntryId}", attestation.EntryId);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"CERT_VERIFY_ERROR",
|
||||
$"Failed to verify certificate chain for {attestation.EntryId}: {ex.Message}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool VerifyRekorInclusionProof(
|
||||
BundledAttestation attestation,
|
||||
List<VerificationIssue> issues)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (attestation.InclusionProof == null)
|
||||
{
|
||||
return true; // Not required if not present
|
||||
}
|
||||
|
||||
// Basic validation of proof structure
|
||||
if (attestation.InclusionProof.Path.Count == 0)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Warning,
|
||||
"REKOR_PROOF_EMPTY",
|
||||
$"Empty Rekor inclusion proof path for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(attestation.InclusionProof.Checkpoint.RootHash))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Warning,
|
||||
"REKOR_CHECKPOINT_MISSING",
|
||||
$"Missing Rekor checkpoint root hash for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Full verification would recompute the Merkle path
|
||||
// For offline verification, we trust the bundled proof
|
||||
_logger.LogDebug(
|
||||
"Rekor inclusion proof present for {EntryId} at index {Index}",
|
||||
attestation.EntryId,
|
||||
attestation.RekorLogIndex);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Warning,
|
||||
"REKOR_PROOF_ERROR",
|
||||
$"Failed to verify Rekor inclusion proof for {attestation.EntryId}: {ex.Message}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputeBundleDigest(AttestationBundle bundle)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(bundle.MerkleTree.Root);
|
||||
foreach (var attestation in bundle.Attestations.OrderBy(a => a.EntryId, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(attestation.EntryId);
|
||||
}
|
||||
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
}
|
||||
|
||||
private static X509Certificate2? ParseCertificateFromPem(string pem)
|
||||
{
|
||||
try
|
||||
{
|
||||
const string beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const string endMarker = "-----END CERTIFICATE-----";
|
||||
|
||||
var begin = pem.IndexOf(beginMarker, StringComparison.Ordinal);
|
||||
var end = pem.IndexOf(endMarker, StringComparison.Ordinal);
|
||||
|
||||
if (begin < 0 || end < 0)
|
||||
{
|
||||
// Try as raw base64
|
||||
var certBytes = Convert.FromBase64String(pem.Trim());
|
||||
return new X509Certificate2(certBytes);
|
||||
}
|
||||
|
||||
var base64Start = begin + beginMarker.Length;
|
||||
var base64Content = pem[base64Start..end]
|
||||
.Replace("\r", "")
|
||||
.Replace("\n", "")
|
||||
.Trim();
|
||||
|
||||
var bytes = Convert.FromBase64String(base64Content);
|
||||
return new X509Certificate2(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AttestationBundle> LoadBundleAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
var bundle = await JsonSerializer.DeserializeAsync<AttestationBundle>(
|
||||
stream,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return bundle ?? throw new InvalidOperationException($"Failed to deserialize bundle from {path}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for offline verification.
|
||||
/// </summary>
|
||||
public sealed class OfflineVerificationConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable strict mode by default.
|
||||
/// </summary>
|
||||
public bool StrictModeDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Require organization signature by default.
|
||||
/// </summary>
|
||||
public bool RequireOrgSignatureDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow verification of unbundled attestations.
|
||||
/// </summary>
|
||||
public bool AllowUnbundled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bundle cache size in MB.
|
||||
/// </summary>
|
||||
public int MaxCacheSizeMb { get; set; } = 1024;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Offline</RootNamespace>
|
||||
<Description>Offline verification of attestation bundles for air-gapped environments.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,374 @@
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for AIAuthorityClassifier.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-22
|
||||
/// </summary>
|
||||
public sealed class AIAuthorityClassifierTests
|
||||
{
|
||||
private static readonly AIModelIdentifier TestModelId = new()
|
||||
{
|
||||
Provider = "anthropic",
|
||||
Model = "claude-3-opus",
|
||||
Version = "20240229"
|
||||
};
|
||||
|
||||
private static readonly AIDecodingParameters TestDecodingParams = new()
|
||||
{
|
||||
Temperature = 0.0,
|
||||
Seed = 12345
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ClassifyExplanation_HighCitationRate_ReturnsEvidenceBacked()
|
||||
{
|
||||
// Arrange
|
||||
var classifier = new AIAuthorityClassifier();
|
||||
var predicate = CreateExplanationPredicate(citationRate: 0.85, confidenceScore: 0.8, verifiedRate: 0.95);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyExplanation(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.EvidenceBacked, result.Authority);
|
||||
Assert.True(result.QualityScore > 0.7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyExplanation_LowCitationRate_ReturnsSuggestion()
|
||||
{
|
||||
// Arrange
|
||||
var classifier = new AIAuthorityClassifier();
|
||||
var predicate = CreateExplanationPredicate(citationRate: 0.5, confidenceScore: 0.6, verifiedRate: 0.7);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyExplanation(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.Suggestion, result.Authority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyExplanation_VeryHighQuality_ReturnsAuthorityThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var thresholds = new AIAuthorityThresholds { AuthorityThresholdScore = 0.9 };
|
||||
var classifier = new AIAuthorityClassifier(thresholds);
|
||||
var predicate = CreateExplanationPredicate(citationRate: 0.98, confidenceScore: 0.95, verifiedRate: 1.0);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyExplanation(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.AuthorityThreshold, result.Authority);
|
||||
Assert.True(result.CanAutoProcess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyRemediationPlan_WithResolvableEvidence_ReturnsEvidenceBacked()
|
||||
{
|
||||
// Arrange
|
||||
Func<string, bool> resolver = _ => true; // All evidence is resolvable
|
||||
var classifier = new AIAuthorityClassifier(evidenceResolver: resolver);
|
||||
var predicate = CreateRemediationPredicate(evidenceCount: 5, prReady: true);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyRemediationPlan(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.EvidenceBacked, result.Authority);
|
||||
Assert.Equal(5, result.ResolvableEvidenceCount);
|
||||
Assert.Equal(0, result.UnresolvableEvidenceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyRemediationPlan_WithUnresolvableEvidence_ReturnsSuggestion()
|
||||
{
|
||||
// Arrange
|
||||
Func<string, bool> resolver = ref => ref.Contains("valid"); // Only some evidence is resolvable
|
||||
var classifier = new AIAuthorityClassifier(evidenceResolver: resolver);
|
||||
var predicate = CreateRemediationPredicate(evidenceCount: 5, prReady: false);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyRemediationPlan(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.Suggestion, result.Authority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyVexDraft_AutoApprovable_CanAutoProcess()
|
||||
{
|
||||
// Arrange
|
||||
var classifier = new AIAuthorityClassifier();
|
||||
var predicate = CreateVexDraftPredicate(
|
||||
avgConfidence: 0.95,
|
||||
evidenceCount: 3,
|
||||
hasConflicts: false);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyVexDraft(predicate);
|
||||
|
||||
// Assert
|
||||
// Note: CanAutoProcess depends on AutoApprovable in the predicate
|
||||
Assert.True(result.QualityScore > 0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyPolicyDraft_AllTestsPassed_HighQuality()
|
||||
{
|
||||
// Arrange
|
||||
var classifier = new AIAuthorityClassifier();
|
||||
var predicate = CreatePolicyDraftPredicate(
|
||||
avgConfidence: 0.9,
|
||||
passedTestCount: 5,
|
||||
totalTestCount: 5,
|
||||
validationPassed: true);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyPolicyDraft(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.QualityScore > 0.7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyPolicyDraft_FailedTests_LowerQuality()
|
||||
{
|
||||
// Arrange
|
||||
var classifier = new AIAuthorityClassifier();
|
||||
var predicate = CreatePolicyDraftPredicate(
|
||||
avgConfidence: 0.9,
|
||||
passedTestCount: 2,
|
||||
totalTestCount: 5,
|
||||
validationPassed: false);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyPolicyDraft(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.QualityScore < 0.7);
|
||||
Assert.False(result.CanAutoProcess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomThresholds_AreRespected()
|
||||
{
|
||||
// Arrange
|
||||
var thresholds = new AIAuthorityThresholds
|
||||
{
|
||||
MinCitationRate = 0.5,
|
||||
MinConfidenceScore = 0.5,
|
||||
MinVerifiedCitationRate = 0.5
|
||||
};
|
||||
var classifier = new AIAuthorityClassifier(thresholds);
|
||||
var predicate = CreateExplanationPredicate(citationRate: 0.6, confidenceScore: 0.6, verifiedRate: 0.6);
|
||||
|
||||
// Act
|
||||
var result = classifier.ClassifyExplanation(predicate);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AIArtifactAuthority.EvidenceBacked, result.Authority);
|
||||
}
|
||||
|
||||
private static AIExplanationPredicate CreateExplanationPredicate(
|
||||
double citationRate,
|
||||
double confidenceScore,
|
||||
double verifiedRate)
|
||||
{
|
||||
var totalCitations = 10;
|
||||
var verifiedCitations = (int)(totalCitations * verifiedRate);
|
||||
|
||||
var citations = new List<AIExplanationCitation>();
|
||||
for (int i = 0; i < totalCitations; i++)
|
||||
{
|
||||
citations.Add(new AIExplanationCitation
|
||||
{
|
||||
ClaimIndex = i,
|
||||
ClaimText = $"Claim {i}",
|
||||
EvidenceId = $"sha256:evidence{i}",
|
||||
EvidenceType = "sbom",
|
||||
Verified = i < verifiedCitations
|
||||
});
|
||||
}
|
||||
|
||||
return new AIExplanationPredicate
|
||||
{
|
||||
ArtifactId = "sha256:test123",
|
||||
ModelId = TestModelId,
|
||||
PromptTemplateVersion = "explanation@v1",
|
||||
DecodingParams = TestDecodingParams,
|
||||
InputHashes = ["sha256:input1"],
|
||||
Authority = AIArtifactAuthority.Suggestion,
|
||||
GeneratedAt = "2025-12-26T00:00:00Z",
|
||||
OutputHash = "sha256:output1",
|
||||
ExplanationType = AIExplanationType.Exploitability,
|
||||
Content = "This is a test explanation with sufficient content.",
|
||||
Citations = citations,
|
||||
ConfidenceScore = confidenceScore,
|
||||
CitationRate = citationRate,
|
||||
Subject = "CVE-2025-1234"
|
||||
};
|
||||
}
|
||||
|
||||
private static AIRemediationPlanPredicate CreateRemediationPredicate(int evidenceCount, bool prReady)
|
||||
{
|
||||
var evidenceRefs = new List<string>();
|
||||
for (int i = 0; i < evidenceCount; i++)
|
||||
{
|
||||
evidenceRefs.Add($"sha256:evidence{i}");
|
||||
}
|
||||
|
||||
return new AIRemediationPlanPredicate
|
||||
{
|
||||
ArtifactId = "sha256:test123",
|
||||
ModelId = TestModelId,
|
||||
PromptTemplateVersion = "remediation@v1",
|
||||
DecodingParams = TestDecodingParams,
|
||||
InputHashes = ["sha256:input1"],
|
||||
Authority = AIArtifactAuthority.Suggestion,
|
||||
GeneratedAt = "2025-12-26T00:00:00Z",
|
||||
OutputHash = "sha256:output1",
|
||||
VulnerabilityId = "CVE-2025-1234",
|
||||
AffectedComponent = "pkg:npm/example@1.0.0",
|
||||
Steps =
|
||||
[
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
ActionType = RemediationActionType.PackageUpgrade,
|
||||
Description = "Upgrade package",
|
||||
Target = "pkg:npm/example@1.0.0",
|
||||
ProposedValue = "1.0.1",
|
||||
RiskReduction = 0.8,
|
||||
CanAutomate = true
|
||||
}
|
||||
],
|
||||
ExpectedDelta = 0.7,
|
||||
RiskAssessment = new RemediationRiskAssessment
|
||||
{
|
||||
RiskBefore = 0.9,
|
||||
RiskAfter = 0.2,
|
||||
BreakingChanges = []
|
||||
},
|
||||
VerificationStatus = RemediationVerificationStatus.Verified,
|
||||
PrReady = prReady,
|
||||
EvidenceRefs = evidenceRefs
|
||||
};
|
||||
}
|
||||
|
||||
private static AIVexDraftPredicate CreateVexDraftPredicate(
|
||||
double avgConfidence,
|
||||
int evidenceCount,
|
||||
bool hasConflicts)
|
||||
{
|
||||
var evidenceRefs = new List<string>();
|
||||
for (int i = 0; i < evidenceCount; i++)
|
||||
{
|
||||
evidenceRefs.Add($"sha256:evidence{i}");
|
||||
}
|
||||
|
||||
return new AIVexDraftPredicate
|
||||
{
|
||||
ArtifactId = "sha256:test123",
|
||||
ModelId = TestModelId,
|
||||
PromptTemplateVersion = "vexdraft@v1",
|
||||
DecodingParams = TestDecodingParams,
|
||||
InputHashes = ["sha256:input1"],
|
||||
Authority = AIArtifactAuthority.Suggestion,
|
||||
GeneratedAt = "2025-12-26T00:00:00Z",
|
||||
OutputHash = "sha256:output1",
|
||||
VexStatements =
|
||||
[
|
||||
new AIVexStatementDraft
|
||||
{
|
||||
VulnerabilityId = "CVE-2025-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = "not_affected",
|
||||
Justification = "vulnerable_code_not_in_execute_path",
|
||||
Confidence = avgConfidence,
|
||||
SupportingEvidence = evidenceRefs
|
||||
}
|
||||
],
|
||||
Justifications =
|
||||
[
|
||||
new AIVexJustification
|
||||
{
|
||||
StatementIndex = 0,
|
||||
Reasoning = "Code path analysis shows function is never called",
|
||||
EvidencePoints = ["Reachability analysis", "Call graph"],
|
||||
ConflictsWithExisting = hasConflicts
|
||||
}
|
||||
],
|
||||
EvidenceRefs = evidenceRefs,
|
||||
TargetFormat = "openvex",
|
||||
AutoApprovable = !hasConflicts && avgConfidence > 0.9,
|
||||
Scope = "image",
|
||||
ScopeId = "sha256:image123"
|
||||
};
|
||||
}
|
||||
|
||||
private static AIPolicyDraftPredicate CreatePolicyDraftPredicate(
|
||||
double avgConfidence,
|
||||
int passedTestCount,
|
||||
int totalTestCount,
|
||||
bool validationPassed)
|
||||
{
|
||||
var testCases = new List<PolicyRuleTestCase>();
|
||||
for (int i = 0; i < totalTestCount; i++)
|
||||
{
|
||||
testCases.Add(new PolicyRuleTestCase
|
||||
{
|
||||
TestId = $"test-{i}",
|
||||
RuleId = "rule-1",
|
||||
Description = $"Test case {i}",
|
||||
Input = "{}",
|
||||
ExpectedOutcome = "pass",
|
||||
Passed = i < passedTestCount
|
||||
});
|
||||
}
|
||||
|
||||
return new AIPolicyDraftPredicate
|
||||
{
|
||||
ArtifactId = "sha256:test123",
|
||||
ModelId = TestModelId,
|
||||
PromptTemplateVersion = "policydraft@v1",
|
||||
DecodingParams = TestDecodingParams,
|
||||
InputHashes = ["sha256:input1"],
|
||||
Authority = AIArtifactAuthority.Suggestion,
|
||||
GeneratedAt = "2025-12-26T00:00:00Z",
|
||||
OutputHash = "sha256:output1",
|
||||
NaturalLanguageInput = "Block critical CVEs in production",
|
||||
Rules =
|
||||
[
|
||||
new AIPolicyRuleDraft
|
||||
{
|
||||
RuleId = "rule-1",
|
||||
RuleType = PolicyRuleType.Gate,
|
||||
Name = "Block Critical CVEs",
|
||||
Description = "Block deployments with critical vulnerabilities",
|
||||
Condition = "severity == 'critical' && environment == 'prod'",
|
||||
Action = "block",
|
||||
Priority = 100,
|
||||
OriginalInput = "Block critical CVEs in production",
|
||||
Confidence = avgConfidence
|
||||
}
|
||||
],
|
||||
TestCases = testCases,
|
||||
ValidationResult = new PolicyValidationResult
|
||||
{
|
||||
SyntaxValid = true,
|
||||
SemanticsValid = validationPassed,
|
||||
OverallPassed = validationPassed
|
||||
},
|
||||
TargetPolicyPack = "default",
|
||||
TargetVersion = "1.0.0",
|
||||
DetectedIntents = ["gate", "severity-filter", "environment-scope"],
|
||||
DeployReady = validationPassed
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AuditHashLogger.cs
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-19
|
||||
// Description: Pre-canonical hash debug logging for audit trails
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Logs both raw and canonical SHA-256 hashes for audit trails.
|
||||
/// Enables debugging of canonicalization issues by comparing pre/post hashes.
|
||||
/// </summary>
|
||||
public sealed class AuditHashLogger
|
||||
{
|
||||
private readonly ILogger<AuditHashLogger> _logger;
|
||||
private readonly bool _enableDetailedLogging;
|
||||
|
||||
public AuditHashLogger(ILogger<AuditHashLogger> logger, bool enableDetailedLogging = false)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_enableDetailedLogging = enableDetailedLogging;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs hash information for an artifact being canonicalized.
|
||||
/// </summary>
|
||||
/// <param name="artifactId">Unique identifier for the artifact.</param>
|
||||
/// <param name="artifactType">Type of artifact (e.g., "proof", "verdict", "attestation").</param>
|
||||
/// <param name="rawBytes">Raw bytes before canonicalization.</param>
|
||||
/// <param name="canonicalBytes">Bytes after canonicalization.</param>
|
||||
public void LogHashAudit(
|
||||
string artifactId,
|
||||
string artifactType,
|
||||
ReadOnlySpan<byte> rawBytes,
|
||||
ReadOnlySpan<byte> canonicalBytes)
|
||||
{
|
||||
var rawHash = ComputeSha256(rawBytes);
|
||||
var canonicalHash = ComputeSha256(canonicalBytes);
|
||||
|
||||
var hashesMatch = rawHash.Equals(canonicalHash, StringComparison.Ordinal);
|
||||
|
||||
if (hashesMatch)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Hash audit for {ArtifactType} {ArtifactId}: raw and canonical hashes match ({Hash})",
|
||||
artifactType,
|
||||
artifactId,
|
||||
canonicalHash);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Hash audit for {ArtifactType} {ArtifactId}: raw={RawHash}, canonical={CanonicalHash}, size_delta={SizeDelta}",
|
||||
artifactType,
|
||||
artifactId,
|
||||
rawHash,
|
||||
canonicalHash,
|
||||
canonicalBytes.Length - rawBytes.Length);
|
||||
|
||||
if (_enableDetailedLogging && _logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
LogDetailedDiff(artifactId, rawBytes, canonicalBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs hash information with structured data for telemetry.
|
||||
/// </summary>
|
||||
public HashAuditRecord CreateAuditRecord(
|
||||
string artifactId,
|
||||
string artifactType,
|
||||
ReadOnlySpan<byte> rawBytes,
|
||||
ReadOnlySpan<byte> canonicalBytes,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var rawHash = ComputeSha256(rawBytes);
|
||||
var canonicalHash = ComputeSha256(canonicalBytes);
|
||||
|
||||
var record = new HashAuditRecord
|
||||
{
|
||||
ArtifactId = artifactId,
|
||||
ArtifactType = artifactType,
|
||||
RawHash = rawHash,
|
||||
CanonicalHash = canonicalHash,
|
||||
RawSizeBytes = rawBytes.Length,
|
||||
CanonicalSizeBytes = canonicalBytes.Length,
|
||||
HashesMatch = rawHash.Equals(canonicalHash, StringComparison.Ordinal),
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
CorrelationId = correlationId
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"Created hash audit record for {ArtifactType} {ArtifactId}: match={Match}, raw_size={RawSize}, canonical_size={CanonicalSize}",
|
||||
artifactType,
|
||||
artifactId,
|
||||
record.HashesMatch,
|
||||
record.RawSizeBytes,
|
||||
record.CanonicalSizeBytes);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that two canonical representations produce the same hash.
|
||||
/// </summary>
|
||||
public bool ValidateDeterminism(
|
||||
string artifactId,
|
||||
ReadOnlySpan<byte> firstCanonical,
|
||||
ReadOnlySpan<byte> secondCanonical)
|
||||
{
|
||||
var firstHash = ComputeSha256(firstCanonical);
|
||||
var secondHash = ComputeSha256(secondCanonical);
|
||||
|
||||
var isValid = firstHash.Equals(secondHash, StringComparison.Ordinal);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Determinism validation failed for {ArtifactId}: first={FirstHash}, second={SecondHash}",
|
||||
artifactId,
|
||||
firstHash,
|
||||
secondHash);
|
||||
|
||||
if (_enableDetailedLogging && _logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var firstSize = firstCanonical.Length;
|
||||
var secondSize = secondCanonical.Length;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Determinism failure details for {ArtifactId}: size1={Size1}, size2={Size2}, diff={Diff}",
|
||||
artifactId,
|
||||
firstSize,
|
||||
secondSize,
|
||||
Math.Abs(firstSize - secondSize));
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
private void LogDetailedDiff(string artifactId, ReadOnlySpan<byte> raw, ReadOnlySpan<byte> canonical)
|
||||
{
|
||||
// Find first difference position
|
||||
var minLen = Math.Min(raw.Length, canonical.Length);
|
||||
var firstDiffPos = -1;
|
||||
|
||||
for (var i = 0; i < minLen; i++)
|
||||
{
|
||||
if (raw[i] != canonical[i])
|
||||
{
|
||||
firstDiffPos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstDiffPos == -1 && raw.Length != canonical.Length)
|
||||
{
|
||||
firstDiffPos = minLen;
|
||||
}
|
||||
|
||||
if (firstDiffPos >= 0)
|
||||
{
|
||||
// Get context around difference
|
||||
var contextStart = Math.Max(0, firstDiffPos - 20);
|
||||
var contextEnd = Math.Min(minLen, firstDiffPos + 20);
|
||||
|
||||
var rawContext = raw.Length > contextStart
|
||||
? Encoding.UTF8.GetString(raw.Slice(contextStart, Math.Min(40, raw.Length - contextStart)))
|
||||
: string.Empty;
|
||||
|
||||
var canonicalContext = canonical.Length > contextStart
|
||||
? Encoding.UTF8.GetString(canonical.Slice(contextStart, Math.Min(40, canonical.Length - contextStart)))
|
||||
: string.Empty;
|
||||
|
||||
_logger.LogTrace(
|
||||
"First difference at position {Position} for {ArtifactId}: raw=\"{RawContext}\", canonical=\"{CanonicalContext}\"",
|
||||
firstDiffPos,
|
||||
artifactId,
|
||||
EscapeForLog(rawContext),
|
||||
EscapeForLog(canonicalContext));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(ReadOnlySpan<byte> data)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(data, hash);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string EscapeForLog(string value)
|
||||
{
|
||||
return value
|
||||
.Replace("\n", "\\n")
|
||||
.Replace("\r", "\\r")
|
||||
.Replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record of a hash audit for structured logging/telemetry.
|
||||
/// </summary>
|
||||
public sealed record HashAuditRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the artifact.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of artifact (proof, verdict, attestation, etc.).
|
||||
/// </summary>
|
||||
public required string ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of raw bytes before canonicalization.
|
||||
/// </summary>
|
||||
public required string RawHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of canonical bytes.
|
||||
/// </summary>
|
||||
public required string CanonicalHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of raw bytes.
|
||||
/// </summary>
|
||||
public required int RawSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of canonical bytes.
|
||||
/// </summary>
|
||||
public required int CanonicalSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether raw and canonical hashes match.
|
||||
/// </summary>
|
||||
public required bool HashesMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp of the audit.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size delta (positive = canonical is larger).
|
||||
/// </summary>
|
||||
public int SizeDelta => CanonicalSizeBytes - RawSizeBytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact types for hash auditing.
|
||||
/// </summary>
|
||||
public static class AuditArtifactTypes
|
||||
{
|
||||
public const string Proof = "proof";
|
||||
public const string Verdict = "verdict";
|
||||
public const string Attestation = "attestation";
|
||||
public const string Spine = "spine";
|
||||
public const string Manifest = "manifest";
|
||||
public const string VexDocument = "vex_document";
|
||||
public const string SbomFragment = "sbom_fragment";
|
||||
public const string PolicySnapshot = "policy_snapshot";
|
||||
public const string FeedSnapshot = "feed_snapshot";
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -9,7 +10,12 @@ namespace StellaOps.Attestor.ProofChain.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Implements RFC 8785 JSON Canonicalization Scheme (JCS) for stable hashing.
|
||||
/// Includes optional NFC (Unicode Normalization Form C) normalization for string stability.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// NFC normalization ensures that equivalent Unicode sequences (e.g., composed vs decomposed characters)
|
||||
/// produce identical canonical output, which is critical for cross-platform determinism.
|
||||
/// </remarks>
|
||||
public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
{
|
||||
/// <summary>
|
||||
@@ -17,17 +23,31 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
/// </summary>
|
||||
private const string VersionFieldName = "_canonVersion";
|
||||
|
||||
private readonly bool _enableNfcNormalization;
|
||||
|
||||
private static readonly JsonWriterOptions CanonicalWriterOptions = new()
|
||||
{
|
||||
Indented = false,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new RFC 8785 JSON canonicalizer.
|
||||
/// </summary>
|
||||
/// <param name="enableNfcNormalization">
|
||||
/// Whether to apply NFC normalization to string values.
|
||||
/// Default is true for maximum cross-platform stability.
|
||||
/// </param>
|
||||
public Rfc8785JsonCanonicalizer(bool enableNfcNormalization = true)
|
||||
{
|
||||
_enableNfcNormalization = enableNfcNormalization;
|
||||
}
|
||||
|
||||
public byte[] Canonicalize(ReadOnlySpan<byte> utf8Json)
|
||||
{
|
||||
var reader = new Utf8JsonReader(utf8Json, isFinalBlock: true, state: default);
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
return Canonicalize(document.RootElement);
|
||||
return CanonicalizeParsed(document.RootElement);
|
||||
}
|
||||
|
||||
public byte[] CanonicalizeWithVersion(ReadOnlySpan<byte> utf8Json, string version)
|
||||
@@ -36,10 +56,10 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
|
||||
var reader = new Utf8JsonReader(utf8Json, isFinalBlock: true, state: default);
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
return CanonicalizeWithVersion(document.RootElement, version);
|
||||
return CanonicalizeParsedWithVersion(document.RootElement, version);
|
||||
}
|
||||
|
||||
private static byte[] Canonicalize(JsonElement element)
|
||||
private byte[] CanonicalizeParsed(JsonElement element)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, CanonicalWriterOptions))
|
||||
@@ -50,7 +70,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] CanonicalizeWithVersion(JsonElement element, string version)
|
||||
private byte[] CanonicalizeParsedWithVersion(JsonElement element, string version)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, CanonicalWriterOptions))
|
||||
@@ -61,14 +81,14 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalWithVersion(Utf8JsonWriter writer, JsonElement element, string version)
|
||||
private void WriteCanonicalWithVersion(Utf8JsonWriter writer, JsonElement element, string version)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
// Write version marker first (underscore prefix ensures it stays first after sorting)
|
||||
writer.WriteString(VersionFieldName, version);
|
||||
writer.WriteString(VersionFieldName, NormalizeString(version));
|
||||
|
||||
// Write remaining properties sorted
|
||||
var properties = new List<(string Name, JsonElement Value)>();
|
||||
@@ -80,7 +100,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
|
||||
foreach (var (name, value) in properties)
|
||||
{
|
||||
writer.WritePropertyName(name);
|
||||
writer.WritePropertyName(NormalizeString(name));
|
||||
WriteCanonical(writer, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
@@ -89,14 +109,14 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
{
|
||||
// Non-object root: wrap in versioned object
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString(VersionFieldName, version);
|
||||
writer.WriteString(VersionFieldName, NormalizeString(version));
|
||||
writer.WritePropertyName("_value");
|
||||
WriteCanonical(writer, element);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
|
||||
private void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
@@ -107,7 +127,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
WriteArray(writer, element);
|
||||
return;
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
writer.WriteStringValue(NormalizeString(element.GetString()));
|
||||
return;
|
||||
case JsonValueKind.Number:
|
||||
WriteNumber(writer, element);
|
||||
@@ -126,7 +146,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteObject(Utf8JsonWriter writer, JsonElement element)
|
||||
private void WriteObject(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
var properties = new List<(string Name, JsonElement Value)>();
|
||||
foreach (var property in element.EnumerateObject())
|
||||
@@ -139,13 +159,13 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
writer.WriteStartObject();
|
||||
foreach (var (name, value) in properties)
|
||||
{
|
||||
writer.WritePropertyName(name);
|
||||
writer.WritePropertyName(NormalizeString(name));
|
||||
WriteCanonical(writer, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteArray(Utf8JsonWriter writer, JsonElement element)
|
||||
private void WriteArray(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
@@ -155,6 +175,25 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies NFC normalization to a string if enabled.
|
||||
/// </summary>
|
||||
private string? NormalizeString(string? value)
|
||||
{
|
||||
if (value is null || !_enableNfcNormalization)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// Only normalize if the string is not already in NFC form
|
||||
if (value.IsNormalized(NormalizationForm.FormC))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
|
||||
private static void WriteNumber(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
var raw = element.GetRawText();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace StellaOps.Attestor.ProofChain.MediaTypes;
|
||||
|
||||
/// <summary>
|
||||
/// OCI media types for AI artifacts.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Tasks: AIATTEST-12, AIATTEST-13, AIATTEST-14, AIATTEST-15
|
||||
/// </summary>
|
||||
public static class AIArtifactMediaTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Media type for AI explanation attestations.
|
||||
/// Task: AIATTEST-12
|
||||
/// </summary>
|
||||
public const string AIExplanation = "application/vnd.stellaops.ai.explanation+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for AI remediation plan attestations.
|
||||
/// Task: AIATTEST-13
|
||||
/// </summary>
|
||||
public const string AIRemediation = "application/vnd.stellaops.ai.remediation+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for AI VEX draft attestations.
|
||||
/// Task: AIATTEST-14
|
||||
/// </summary>
|
||||
public const string AIVexDraft = "application/vnd.stellaops.ai.vexdraft+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for AI policy draft attestations.
|
||||
/// Task: AIATTEST-15
|
||||
/// </summary>
|
||||
public const string AIPolicyDraft = "application/vnd.stellaops.ai.policydraft+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for AI artifact replay manifests.
|
||||
/// Task: AIATTEST-18
|
||||
/// </summary>
|
||||
public const string AIReplayManifest = "application/vnd.stellaops.ai.replay+json";
|
||||
|
||||
/// <summary>
|
||||
/// Annotation key for AI artifact type.
|
||||
/// </summary>
|
||||
public const string ArtifactTypeAnnotation = "org.stellaops.ai.artifact-type";
|
||||
|
||||
/// <summary>
|
||||
/// Annotation key for AI authority level.
|
||||
/// </summary>
|
||||
public const string AuthorityAnnotation = "org.stellaops.ai.authority";
|
||||
|
||||
/// <summary>
|
||||
/// Annotation key for AI model identifier.
|
||||
/// </summary>
|
||||
public const string ModelIdAnnotation = "org.stellaops.ai.model-id";
|
||||
|
||||
/// <summary>
|
||||
/// Annotation key for replay capability.
|
||||
/// </summary>
|
||||
public const string ReplayableAnnotation = "org.stellaops.ai.replayable";
|
||||
|
||||
/// <summary>
|
||||
/// Get the media type for a predicate type URI.
|
||||
/// </summary>
|
||||
public static string? GetMediaTypeForPredicateType(string predicateType) => predicateType switch
|
||||
{
|
||||
"ai-explanation.stella/v1" => AIExplanation,
|
||||
"ai-remediation.stella/v1" => AIRemediation,
|
||||
"ai-vexdraft.stella/v1" => AIVexDraft,
|
||||
"ai-policydraft.stella/v1" => AIPolicyDraft,
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Get the predicate type URI for a media type.
|
||||
/// </summary>
|
||||
public static string? GetPredicateTypeForMediaType(string mediaType) => mediaType switch
|
||||
{
|
||||
AIExplanation => "ai-explanation.stella/v1",
|
||||
AIRemediation => "ai-remediation.stella/v1",
|
||||
AIVexDraft => "ai-vexdraft.stella/v1",
|
||||
AIPolicyDraft => "ai-policydraft.stella/v1",
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Check if a media type is an AI artifact type.
|
||||
/// </summary>
|
||||
public static bool IsAIArtifactMediaType(string mediaType) =>
|
||||
mediaType is AIExplanation or AIRemediation or AIVexDraft or AIPolicyDraft or AIReplayManifest;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Authority level for AI-generated artifacts.
|
||||
/// Determines how the artifact should be treated in decisioning.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<AIArtifactAuthority>))]
|
||||
public enum AIArtifactAuthority
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure suggestion - not backed by evidence, requires human review.
|
||||
/// </summary>
|
||||
Suggestion,
|
||||
|
||||
/// <summary>
|
||||
/// Evidence-backed - citations verified, evidence refs resolvable.
|
||||
/// Qualifies when: citation rate ≥ 80% AND all evidence refs valid.
|
||||
/// </summary>
|
||||
EvidenceBacked,
|
||||
|
||||
/// <summary>
|
||||
/// Meets configurable authority threshold for automated processing.
|
||||
/// </summary>
|
||||
AuthorityThreshold
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Model identifier format for tracking AI model versions.
|
||||
/// </summary>
|
||||
public sealed record AIModelIdentifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider of the model (e.g., "anthropic", "openai", "local").
|
||||
/// </summary>
|
||||
[JsonPropertyName("provider")]
|
||||
public required string Provider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model name/family (e.g., "claude-3-opus", "gpt-4").
|
||||
/// </summary>
|
||||
[JsonPropertyName("model")]
|
||||
public required string Model { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model version string (e.g., "20240229", "0613").
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For local models: SHA-256 digest of weights.
|
||||
/// Null for cloud-hosted models.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weightsDigest")]
|
||||
public string? WeightsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical string representation: provider:model:version
|
||||
/// </summary>
|
||||
public override string ToString() =>
|
||||
$"{Provider}:{Model}:{Version}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decoding parameters used during AI generation.
|
||||
/// Required for deterministic replay.
|
||||
/// </summary>
|
||||
public sealed record AIDecodingParameters
|
||||
{
|
||||
/// <summary>
|
||||
/// Temperature setting (0.0 = deterministic, higher = more random).
|
||||
/// </summary>
|
||||
[JsonPropertyName("temperature")]
|
||||
public double Temperature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top-p (nucleus sampling) value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("topP")]
|
||||
public double? TopP { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top-k sampling value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("topK")]
|
||||
public int? TopK { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum tokens to generate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxTokens")]
|
||||
public int? MaxTokens { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Random seed for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("seed")]
|
||||
public long? Seed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base predicate for all AI-generated artifacts.
|
||||
/// Captures metadata required for replay, inspection, and authority classification.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-01
|
||||
/// </summary>
|
||||
public abstract record AIArtifactBasePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this AI artifact.
|
||||
/// Format: sha256:<64-hex-chars>
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactId")]
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model identification (provider:model:version or hash for local).
|
||||
/// </summary>
|
||||
[JsonPropertyName("modelId")]
|
||||
public required AIModelIdentifier ModelId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the prompt template used.
|
||||
/// Format: <template-name>@<version>
|
||||
/// </summary>
|
||||
[JsonPropertyName("promptTemplateVersion")]
|
||||
public required string PromptTemplateVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Decoding parameters for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decodingParams")]
|
||||
public required AIDecodingParameters DecodingParams { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hashes of all inputs (context documents, queries, etc.).
|
||||
/// Order-sensitive for replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inputHashes")]
|
||||
public required IReadOnlyList<string> InputHashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authority classification of this artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("authority")]
|
||||
public required AIArtifactAuthority Authority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the artifact was generated (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required string GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the generated output.
|
||||
/// Used for replay verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outputHash")]
|
||||
public required string OutputHash { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for authority classification thresholds.
|
||||
/// </summary>
|
||||
public sealed record AIAuthorityThresholds
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum citation rate for Evidence-Backed classification.
|
||||
/// Default: 0.8 (80%)
|
||||
/// </summary>
|
||||
public double MinCitationRate { get; init; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence score for Evidence-Backed classification.
|
||||
/// Default: 0.7 (70%)
|
||||
/// </summary>
|
||||
public double MinConfidenceScore { get; init; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Whether all evidence refs must be resolvable.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool RequireResolvableEvidence { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum verified citations ratio for Evidence-Backed.
|
||||
/// Default: 0.9 (90%)
|
||||
/// </summary>
|
||||
public double MinVerifiedCitationRate { get; init; } = 0.9;
|
||||
|
||||
/// <summary>
|
||||
/// Custom authority threshold score (0.0-1.0) for AuthorityThreshold classification.
|
||||
/// If overall score meets this, artifact can be auto-processed.
|
||||
/// Default: 0.95
|
||||
/// </summary>
|
||||
public double AuthorityThresholdScore { get; init; } = 0.95;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of authority classification.
|
||||
/// </summary>
|
||||
public sealed record AIAuthorityClassificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Determined authority level.
|
||||
/// </summary>
|
||||
public required AIArtifactAuthority Authority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall quality score (0.0-1.0).
|
||||
/// </summary>
|
||||
public required double QualityScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Citation rate if applicable.
|
||||
/// </summary>
|
||||
public double? CitationRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verified citation rate if applicable.
|
||||
/// </summary>
|
||||
public double? VerifiedCitationRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of resolvable evidence refs.
|
||||
/// </summary>
|
||||
public int? ResolvableEvidenceCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of unresolvable evidence refs.
|
||||
/// </summary>
|
||||
public int? UnresolvableEvidenceCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reasons for the classification decision.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Reasons { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the artifact can be auto-processed without human review.
|
||||
/// </summary>
|
||||
public required bool CanAutoProcess { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies AI artifacts into authority levels based on evidence backing.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-07
|
||||
/// </summary>
|
||||
public sealed class AIAuthorityClassifier
|
||||
{
|
||||
private readonly AIAuthorityThresholds _thresholds;
|
||||
private readonly Func<string, bool>? _evidenceResolver;
|
||||
|
||||
public AIAuthorityClassifier(AIAuthorityThresholds? thresholds = null, Func<string, bool>? evidenceResolver = null)
|
||||
{
|
||||
_thresholds = thresholds ?? new AIAuthorityThresholds();
|
||||
_evidenceResolver = evidenceResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify an explanation predicate.
|
||||
/// </summary>
|
||||
public AIAuthorityClassificationResult ClassifyExplanation(AIExplanationPredicate predicate)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
var qualityScore = CalculateExplanationQualityScore(predicate, reasons);
|
||||
|
||||
var verifiedRate = predicate.Citations.Count > 0
|
||||
? (double)predicate.Citations.Count(c => c.Verified) / predicate.Citations.Count
|
||||
: 0;
|
||||
|
||||
var authority = DetermineAuthority(
|
||||
predicate.CitationRate,
|
||||
verifiedRate,
|
||||
predicate.ConfidenceScore,
|
||||
qualityScore,
|
||||
reasons);
|
||||
|
||||
return new AIAuthorityClassificationResult
|
||||
{
|
||||
Authority = authority,
|
||||
QualityScore = qualityScore,
|
||||
CitationRate = predicate.CitationRate,
|
||||
VerifiedCitationRate = verifiedRate,
|
||||
Reasons = reasons,
|
||||
CanAutoProcess = authority == AIArtifactAuthority.AuthorityThreshold
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify a remediation plan predicate.
|
||||
/// </summary>
|
||||
public AIAuthorityClassificationResult ClassifyRemediationPlan(AIRemediationPlanPredicate predicate)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
var evidenceRefs = predicate.EvidenceRefs;
|
||||
|
||||
var resolvableCount = evidenceRefs.Count(ref => _evidenceResolver?.Invoke(ref) ?? true);
|
||||
var unresolvableCount = evidenceRefs.Count - resolvableCount;
|
||||
|
||||
var qualityScore = CalculateRemediationQualityScore(predicate, resolvableCount, reasons);
|
||||
|
||||
var evidenceBackingRate = evidenceRefs.Count > 0
|
||||
? (double)resolvableCount / evidenceRefs.Count
|
||||
: 0;
|
||||
|
||||
var authority = DetermineAuthority(
|
||||
evidenceBackingRate,
|
||||
evidenceBackingRate,
|
||||
predicate.RiskAssessment.RiskBefore - predicate.RiskAssessment.RiskAfter,
|
||||
qualityScore,
|
||||
reasons);
|
||||
|
||||
return new AIAuthorityClassificationResult
|
||||
{
|
||||
Authority = authority,
|
||||
QualityScore = qualityScore,
|
||||
ResolvableEvidenceCount = resolvableCount,
|
||||
UnresolvableEvidenceCount = unresolvableCount,
|
||||
Reasons = reasons,
|
||||
CanAutoProcess = authority == AIArtifactAuthority.AuthorityThreshold && predicate.PrReady
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify a VEX draft predicate.
|
||||
/// </summary>
|
||||
public AIAuthorityClassificationResult ClassifyVexDraft(AIVexDraftPredicate predicate)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
var evidenceRefs = predicate.EvidenceRefs;
|
||||
|
||||
var resolvableCount = evidenceRefs.Count(ref => _evidenceResolver?.Invoke(ref) ?? true);
|
||||
|
||||
var avgConfidence = predicate.VexStatements.Count > 0
|
||||
? predicate.VexStatements.Average(s => s.Confidence)
|
||||
: 0;
|
||||
|
||||
var qualityScore = CalculateVexDraftQualityScore(predicate, resolvableCount, avgConfidence, reasons);
|
||||
|
||||
var evidenceBackingRate = evidenceRefs.Count > 0
|
||||
? (double)resolvableCount / evidenceRefs.Count
|
||||
: 0;
|
||||
|
||||
var authority = DetermineAuthority(
|
||||
evidenceBackingRate,
|
||||
evidenceBackingRate,
|
||||
avgConfidence,
|
||||
qualityScore,
|
||||
reasons);
|
||||
|
||||
return new AIAuthorityClassificationResult
|
||||
{
|
||||
Authority = authority,
|
||||
QualityScore = qualityScore,
|
||||
ResolvableEvidenceCount = resolvableCount,
|
||||
UnresolvableEvidenceCount = evidenceRefs.Count - resolvableCount,
|
||||
Reasons = reasons,
|
||||
CanAutoProcess = authority == AIArtifactAuthority.AuthorityThreshold && predicate.AutoApprovable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify a policy draft predicate.
|
||||
/// </summary>
|
||||
public AIAuthorityClassificationResult ClassifyPolicyDraft(AIPolicyDraftPredicate predicate)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
|
||||
var avgConfidence = predicate.Rules.Count > 0
|
||||
? predicate.Rules.Average(r => r.Confidence)
|
||||
: 0;
|
||||
|
||||
var passedTestRate = predicate.TestCases.Count > 0
|
||||
? (double)predicate.TestCases.Count(t => t.Passed == true) / predicate.TestCases.Count
|
||||
: 0;
|
||||
|
||||
var qualityScore = CalculatePolicyDraftQualityScore(predicate, avgConfidence, passedTestRate, reasons);
|
||||
|
||||
var authority = DetermineAuthority(
|
||||
passedTestRate,
|
||||
passedTestRate,
|
||||
avgConfidence,
|
||||
qualityScore,
|
||||
reasons);
|
||||
|
||||
return new AIAuthorityClassificationResult
|
||||
{
|
||||
Authority = authority,
|
||||
QualityScore = qualityScore,
|
||||
Reasons = reasons,
|
||||
CanAutoProcess = authority == AIArtifactAuthority.AuthorityThreshold
|
||||
&& predicate.ValidationResult.OverallPassed
|
||||
&& predicate.DeployReady
|
||||
};
|
||||
}
|
||||
|
||||
private AIArtifactAuthority DetermineAuthority(
|
||||
double citationRate,
|
||||
double verifiedRate,
|
||||
double confidenceScore,
|
||||
double qualityScore,
|
||||
List<string> reasons)
|
||||
{
|
||||
if (qualityScore >= _thresholds.AuthorityThresholdScore)
|
||||
{
|
||||
reasons.Add($"Quality score {qualityScore:P0} meets authority threshold {_thresholds.AuthorityThresholdScore:P0}");
|
||||
return AIArtifactAuthority.AuthorityThreshold;
|
||||
}
|
||||
|
||||
if (citationRate >= _thresholds.MinCitationRate &&
|
||||
verifiedRate >= _thresholds.MinVerifiedCitationRate &&
|
||||
confidenceScore >= _thresholds.MinConfidenceScore)
|
||||
{
|
||||
reasons.Add($"Citation rate {citationRate:P0} >= {_thresholds.MinCitationRate:P0}");
|
||||
reasons.Add($"Verified rate {verifiedRate:P0} >= {_thresholds.MinVerifiedCitationRate:P0}");
|
||||
reasons.Add($"Confidence {confidenceScore:P0} >= {_thresholds.MinConfidenceScore:P0}");
|
||||
return AIArtifactAuthority.EvidenceBacked;
|
||||
}
|
||||
|
||||
if (citationRate < _thresholds.MinCitationRate)
|
||||
reasons.Add($"Citation rate {citationRate:P0} < {_thresholds.MinCitationRate:P0}");
|
||||
if (verifiedRate < _thresholds.MinVerifiedCitationRate)
|
||||
reasons.Add($"Verified rate {verifiedRate:P0} < {_thresholds.MinVerifiedCitationRate:P0}");
|
||||
if (confidenceScore < _thresholds.MinConfidenceScore)
|
||||
reasons.Add($"Confidence {confidenceScore:P0} < {_thresholds.MinConfidenceScore:P0}");
|
||||
|
||||
return AIArtifactAuthority.Suggestion;
|
||||
}
|
||||
|
||||
private double CalculateExplanationQualityScore(AIExplanationPredicate predicate, List<string> reasons)
|
||||
{
|
||||
var citationWeight = 0.35;
|
||||
var verifiedWeight = 0.30;
|
||||
var confidenceWeight = 0.20;
|
||||
var contentWeight = 0.15;
|
||||
|
||||
var verifiedRate = predicate.Citations.Count > 0
|
||||
? (double)predicate.Citations.Count(c => c.Verified) / predicate.Citations.Count
|
||||
: 0;
|
||||
|
||||
var contentScore = Math.Min(1.0, predicate.Content.Length / 500.0); // Reasonable explanation length
|
||||
|
||||
return (predicate.CitationRate * citationWeight) +
|
||||
(verifiedRate * verifiedWeight) +
|
||||
(predicate.ConfidenceScore * confidenceWeight) +
|
||||
(contentScore * contentWeight);
|
||||
}
|
||||
|
||||
private double CalculateRemediationQualityScore(AIRemediationPlanPredicate predicate, int resolvableCount, List<string> reasons)
|
||||
{
|
||||
var evidenceWeight = 0.30;
|
||||
var riskDeltaWeight = 0.25;
|
||||
var automationWeight = 0.20;
|
||||
var verificationWeight = 0.25;
|
||||
|
||||
var evidenceScore = predicate.EvidenceRefs.Count > 0
|
||||
? (double)resolvableCount / predicate.EvidenceRefs.Count
|
||||
: 0;
|
||||
|
||||
var riskDelta = predicate.ExpectedDelta;
|
||||
var riskScore = Math.Min(1.0, Math.Max(0, riskDelta));
|
||||
|
||||
var autoSteps = predicate.Steps.Count(s => s.CanAutomate);
|
||||
var automationScore = predicate.Steps.Count > 0 ? (double)autoSteps / predicate.Steps.Count : 0;
|
||||
|
||||
var verificationScore = predicate.VerificationStatus switch
|
||||
{
|
||||
RemediationVerificationStatus.Verified => 0.8,
|
||||
RemediationVerificationStatus.Applied => 1.0,
|
||||
RemediationVerificationStatus.Stale => 0.5,
|
||||
_ => 0.2
|
||||
};
|
||||
|
||||
return (evidenceScore * evidenceWeight) +
|
||||
(riskScore * riskDeltaWeight) +
|
||||
(automationScore * automationWeight) +
|
||||
(verificationScore * verificationWeight);
|
||||
}
|
||||
|
||||
private double CalculateVexDraftQualityScore(AIVexDraftPredicate predicate, int resolvableCount, double avgConfidence, List<string> reasons)
|
||||
{
|
||||
var evidenceWeight = 0.35;
|
||||
var confidenceWeight = 0.30;
|
||||
var justificationWeight = 0.20;
|
||||
var conflictWeight = 0.15;
|
||||
|
||||
var evidenceScore = predicate.EvidenceRefs.Count > 0
|
||||
? (double)resolvableCount / predicate.EvidenceRefs.Count
|
||||
: 0;
|
||||
|
||||
var nonConflicting = predicate.Justifications.Count(j => !j.ConflictsWithExisting);
|
||||
var conflictScore = predicate.Justifications.Count > 0
|
||||
? (double)nonConflicting / predicate.Justifications.Count
|
||||
: 1.0;
|
||||
|
||||
var hasJustifications = predicate.Justifications.Count > 0 ? 1.0 : 0.0;
|
||||
|
||||
return (evidenceScore * evidenceWeight) +
|
||||
(avgConfidence * confidenceWeight) +
|
||||
(hasJustifications * justificationWeight) +
|
||||
(conflictScore * conflictWeight);
|
||||
}
|
||||
|
||||
private double CalculatePolicyDraftQualityScore(AIPolicyDraftPredicate predicate, double avgConfidence, double passedTestRate, List<string> reasons)
|
||||
{
|
||||
var confidenceWeight = 0.25;
|
||||
var testWeight = 0.35;
|
||||
var validationWeight = 0.25;
|
||||
var clarityWeight = 0.15;
|
||||
|
||||
var validationScore = predicate.ValidationResult.OverallPassed ? 1.0 : 0.3;
|
||||
|
||||
var ambiguityCount = predicate.Rules.Sum(r => r.Ambiguities?.Count ?? 0);
|
||||
var clarityScore = predicate.Rules.Count > 0
|
||||
? 1.0 - Math.Min(1.0, ambiguityCount / (predicate.Rules.Count * 2.0))
|
||||
: 0;
|
||||
|
||||
return (avgConfidence * confidenceWeight) +
|
||||
(passedTestRate * testWeight) +
|
||||
(validationScore * validationWeight) +
|
||||
(clarityScore * clarityWeight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Type of explanation generated by AI.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<AIExplanationType>))]
|
||||
public enum AIExplanationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Explanation of why a vulnerability is exploitable.
|
||||
/// </summary>
|
||||
Exploitability,
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of a code path or call graph.
|
||||
/// </summary>
|
||||
CodePath,
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of a policy decision.
|
||||
/// </summary>
|
||||
PolicyDecision,
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of risk factors.
|
||||
/// </summary>
|
||||
RiskFactors,
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of remediation options.
|
||||
/// </summary>
|
||||
RemediationOptions,
|
||||
|
||||
/// <summary>
|
||||
/// Plain language summary for non-technical audiences.
|
||||
/// </summary>
|
||||
PlainLanguageSummary,
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of evidence chain.
|
||||
/// </summary>
|
||||
EvidenceChain
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Citation linking AI claims to evidence sources.
|
||||
/// </summary>
|
||||
public sealed record AIExplanationCitation
|
||||
{
|
||||
/// <summary>
|
||||
/// Index of the claim in the explanation (0-based).
|
||||
/// </summary>
|
||||
[JsonPropertyName("claimIndex")]
|
||||
public required int ClaimIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Text of the cited claim.
|
||||
/// </summary>
|
||||
[JsonPropertyName("claimText")]
|
||||
public required string ClaimText { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence node ID this claim references.
|
||||
/// Format: sha256:<64-hex-chars>
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceId")]
|
||||
public required string EvidenceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of evidence (e.g., "sbom", "vex", "reachability", "runtime").
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceType")]
|
||||
public required string EvidenceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the citation was verified against the evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified")]
|
||||
public required bool Verified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate for AI-generated explanations.
|
||||
/// Extends AIArtifactBase with explanation-specific fields.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-02
|
||||
/// </summary>
|
||||
public sealed record AIExplanationPredicate : AIArtifactBasePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of explanation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("explanationType")]
|
||||
public required AIExplanationType ExplanationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The explanation content (markdown supported).
|
||||
/// </summary>
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Citations linking claims to evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("citations")]
|
||||
public required IReadOnlyList<AIExplanationCitation> Citations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score for the explanation (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidenceScore")]
|
||||
public required double ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Citation rate: ratio of cited claims to total claims.
|
||||
/// Used for authority classification (≥0.8 for EvidenceBacked).
|
||||
/// </summary>
|
||||
[JsonPropertyName("citationRate")]
|
||||
public required double CitationRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject being explained (CVE ID, PURL, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Context scope (image digest, build ID, service name).
|
||||
/// </summary>
|
||||
[JsonPropertyName("contextScope")]
|
||||
public string? ContextScope { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Type of policy rule.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<PolicyRuleType>))]
|
||||
public enum PolicyRuleType
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate rule (block/warn/allow).
|
||||
/// </summary>
|
||||
Gate,
|
||||
|
||||
/// <summary>
|
||||
/// Threshold rule (e.g., max critical count).
|
||||
/// </summary>
|
||||
Threshold,
|
||||
|
||||
/// <summary>
|
||||
/// Exception rule.
|
||||
/// </summary>
|
||||
Exception,
|
||||
|
||||
/// <summary>
|
||||
/// SLA rule.
|
||||
/// </summary>
|
||||
Sla,
|
||||
|
||||
/// <summary>
|
||||
/// Notification rule.
|
||||
/// </summary>
|
||||
Notification,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation rule.
|
||||
/// </summary>
|
||||
Escalation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draft policy rule generated from natural language.
|
||||
/// </summary>
|
||||
public sealed record AIPolicyRuleDraft
|
||||
{
|
||||
/// <summary>
|
||||
/// Rule identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleId")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleType")]
|
||||
public required PolicyRuleType RuleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable rule name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule condition in lattice logic syntax.
|
||||
/// </summary>
|
||||
[JsonPropertyName("condition")]
|
||||
public required string Condition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when condition matches.
|
||||
/// </summary>
|
||||
[JsonPropertyName("action")]
|
||||
public required string Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule priority (higher = evaluated first).
|
||||
/// </summary>
|
||||
[JsonPropertyName("priority")]
|
||||
public required int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original natural language input.
|
||||
/// </summary>
|
||||
[JsonPropertyName("originalInput")]
|
||||
public required string OriginalInput { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// AI confidence in the translation (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ambiguities detected in the input.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ambiguities")]
|
||||
public IReadOnlyList<string>? Ambiguities { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for validating a policy rule.
|
||||
/// </summary>
|
||||
public sealed record PolicyRuleTestCase
|
||||
{
|
||||
/// <summary>
|
||||
/// Test case identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("testId")]
|
||||
public required string TestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule ID being tested.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleId")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Test case description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input scenario (JSON blob matching rule input schema).
|
||||
/// </summary>
|
||||
[JsonPropertyName("input")]
|
||||
public required string Input { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected outcome.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expectedOutcome")]
|
||||
public required string ExpectedOutcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the test passed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("passed")]
|
||||
public bool? Passed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual outcome if test was run.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actualOutcome")]
|
||||
public string? ActualOutcome { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation result for the policy draft.
|
||||
/// </summary>
|
||||
public sealed record PolicyValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the policy is syntactically valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("syntaxValid")]
|
||||
public required bool SyntaxValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the policy is semantically valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("semanticsValid")]
|
||||
public required bool SemanticsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Syntax errors if any.
|
||||
/// </summary>
|
||||
[JsonPropertyName("syntaxErrors")]
|
||||
public IReadOnlyList<string>? SyntaxErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Semantic warnings if any.
|
||||
/// </summary>
|
||||
[JsonPropertyName("semanticWarnings")]
|
||||
public IReadOnlyList<string>? SemanticWarnings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Test cases that failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("failedTests")]
|
||||
public IReadOnlyList<string>? FailedTests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall validation passed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overallPassed")]
|
||||
public required bool OverallPassed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate for AI-generated policy drafts from natural language.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-05
|
||||
/// </summary>
|
||||
public sealed record AIPolicyDraftPredicate : AIArtifactBasePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Original natural language policy intent.
|
||||
/// </summary>
|
||||
[JsonPropertyName("naturalLanguageInput")]
|
||||
public required string NaturalLanguageInput { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Draft rules translated from natural language.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rules")]
|
||||
public required IReadOnlyList<AIPolicyRuleDraft> Rules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Test cases for validation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("testCases")]
|
||||
public required IReadOnlyList<PolicyRuleTestCase> TestCases { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("validationResult")]
|
||||
public required PolicyValidationResult ValidationResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target policy pack name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetPolicyPack")]
|
||||
public required string TargetPolicyPack { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetVersion")]
|
||||
public required string TargetVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected intent categories.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detectedIntents")]
|
||||
public required IReadOnlyList<string> DetectedIntents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Clarification questions for ambiguous inputs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("clarificationQuestions")]
|
||||
public IReadOnlyList<string>? ClarificationQuestions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the draft is ready for deployment.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deployReady")]
|
||||
public required bool DeployReady { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Status of a remediation step.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RemediationStepStatus>))]
|
||||
public enum RemediationStepStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Step has not been started.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// Step is in progress.
|
||||
/// </summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>
|
||||
/// Step completed successfully.
|
||||
/// </summary>
|
||||
Complete,
|
||||
|
||||
/// <summary>
|
||||
/// Step was skipped (e.g., not applicable).
|
||||
/// </summary>
|
||||
Skipped,
|
||||
|
||||
/// <summary>
|
||||
/// Step failed.
|
||||
/// </summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of remediation action.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RemediationActionType>))]
|
||||
public enum RemediationActionType
|
||||
{
|
||||
/// <summary>
|
||||
/// Upgrade a package to a fixed version.
|
||||
/// </summary>
|
||||
PackageUpgrade,
|
||||
|
||||
/// <summary>
|
||||
/// Apply a patch to source code.
|
||||
/// </summary>
|
||||
SourcePatch,
|
||||
|
||||
/// <summary>
|
||||
/// Apply a configuration change.
|
||||
/// </summary>
|
||||
ConfigurationChange,
|
||||
|
||||
/// <summary>
|
||||
/// Add a VEX statement.
|
||||
/// </summary>
|
||||
VexStatement,
|
||||
|
||||
/// <summary>
|
||||
/// Apply a compensating control.
|
||||
/// </summary>
|
||||
CompensatingControl,
|
||||
|
||||
/// <summary>
|
||||
/// Accept the risk (with justification).
|
||||
/// </summary>
|
||||
RiskAcceptance,
|
||||
|
||||
/// <summary>
|
||||
/// Remove the affected component.
|
||||
/// </summary>
|
||||
ComponentRemoval
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single step in a remediation plan.
|
||||
/// </summary>
|
||||
public sealed record RemediationStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Order of this step (1-based).
|
||||
/// </summary>
|
||||
[JsonPropertyName("order")]
|
||||
public required int Order { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of action.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actionType")]
|
||||
public required RemediationActionType ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target component (PURL, file path, config key).
|
||||
/// </summary>
|
||||
[JsonPropertyName("target")]
|
||||
public required string Target { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current value (version, setting, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentValue")]
|
||||
public string? CurrentValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Proposed new value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proposedValue")]
|
||||
public required string ProposedValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Estimated risk reduction (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskReduction")]
|
||||
public required double RiskReduction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this step can be automated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("canAutomate")]
|
||||
public required bool CanAutomate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Automation script or command if automatable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("automationScript")]
|
||||
public string? AutomationScript { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status of this step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public RemediationStepStatus Status { get; init; } = RemediationStepStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence references supporting this step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public IReadOnlyList<string>? EvidenceRefs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risk assessment for the remediation plan.
|
||||
/// </summary>
|
||||
public sealed record RemediationRiskAssessment
|
||||
{
|
||||
/// <summary>
|
||||
/// Risk level before remediation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskBefore")]
|
||||
public required double RiskBefore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected risk level after remediation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskAfter")]
|
||||
public required double RiskAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Potential breaking changes from this remediation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("breakingChanges")]
|
||||
public required IReadOnlyList<string> BreakingChanges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required test coverage for safe rollout.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiredTestCoverage")]
|
||||
public IReadOnlyList<string>? RequiredTestCoverage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification status of the remediation plan.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RemediationVerificationStatus>))]
|
||||
public enum RemediationVerificationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan not yet verified.
|
||||
/// </summary>
|
||||
Unverified,
|
||||
|
||||
/// <summary>
|
||||
/// Plan verified against current state.
|
||||
/// </summary>
|
||||
Verified,
|
||||
|
||||
/// <summary>
|
||||
/// Plan verified but state has drifted.
|
||||
/// </summary>
|
||||
Stale,
|
||||
|
||||
/// <summary>
|
||||
/// Plan applied and verified as effective.
|
||||
/// </summary>
|
||||
Applied,
|
||||
|
||||
/// <summary>
|
||||
/// Plan verification failed.
|
||||
/// </summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate for AI-generated remediation plans.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-03
|
||||
/// </summary>
|
||||
public sealed record AIRemediationPlanPredicate : AIArtifactBasePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability being remediated (CVE ID, GHSA, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected component (PURL).
|
||||
/// </summary>
|
||||
[JsonPropertyName("affectedComponent")]
|
||||
public required string AffectedComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered remediation steps.
|
||||
/// </summary>
|
||||
[JsonPropertyName("steps")]
|
||||
public required IReadOnlyList<RemediationStep> Steps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected delta in risk score after remediation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expectedDelta")]
|
||||
public required double ExpectedDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk assessment for this plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskAssessment")]
|
||||
public required RemediationRiskAssessment RiskAssessment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification status of the plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verificationStatus")]
|
||||
public required RemediationVerificationStatus VerificationStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a PR can be auto-generated for this plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("prReady")]
|
||||
public required bool PrReady { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Git commit SHA if a fix branch exists.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixBranchCommit")]
|
||||
public string? FixBranchCommit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence references supporting this plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public required IReadOnlyList<string> EvidenceRefs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Draft VEX statement generated by AI.
|
||||
/// </summary>
|
||||
public sealed record AIVexStatementDraft
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability ID (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected product identifier (PURL).
|
||||
/// </summary>
|
||||
[JsonPropertyName("productId")]
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Proposed VEX status: not_affected, affected, fixed, under_investigation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification category per VEX spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed impact statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("impactStatement")]
|
||||
public string? ImpactStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action statement if status is "affected".
|
||||
/// </summary>
|
||||
[JsonPropertyName("actionStatement")]
|
||||
public string? ActionStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// AI confidence in this draft (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence nodes supporting this draft.
|
||||
/// </summary>
|
||||
[JsonPropertyName("supportingEvidence")]
|
||||
public required IReadOnlyList<string> SupportingEvidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Justification for a VEX statement draft.
|
||||
/// </summary>
|
||||
public sealed record AIVexJustification
|
||||
{
|
||||
/// <summary>
|
||||
/// Index of the VEX statement this justification applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statementIndex")]
|
||||
public required int StatementIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reasoning for the proposed status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reasoning")]
|
||||
public required string Reasoning { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key evidence points.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidencePoints")]
|
||||
public required IReadOnlyList<string> EvidencePoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Counter-arguments or caveats.
|
||||
/// </summary>
|
||||
[JsonPropertyName("caveats")]
|
||||
public IReadOnlyList<string>? Caveats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this justification conflicts with existing VEX.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflictsWithExisting")]
|
||||
public required bool ConflictsWithExisting { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If conflicting, the existing VEX statement ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflictingVexId")]
|
||||
public string? ConflictingVexId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate for AI-generated VEX drafts.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-04
|
||||
/// </summary>
|
||||
public sealed record AIVexDraftPredicate : AIArtifactBasePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Draft VEX statements.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexStatements")]
|
||||
public required IReadOnlyList<AIVexStatementDraft> VexStatements { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justifications for each statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justifications")]
|
||||
public required IReadOnlyList<AIVexJustification> Justifications { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence node IDs referenced.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public required IReadOnlyList<string> EvidenceRefs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target VEX format for export (openvex, cyclonedx, csaf).
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetFormat")]
|
||||
public required string TargetFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether all drafts can be auto-approved based on evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("autoApprovable")]
|
||||
public required bool AutoApprovable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human review required reasons (if any).
|
||||
/// </summary>
|
||||
[JsonPropertyName("reviewRequired")]
|
||||
public IReadOnlyList<string>? ReviewRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope of this VEX draft (image, service, release).
|
||||
/// </summary>
|
||||
[JsonPropertyName("scope")]
|
||||
public required string Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope identifier (image digest, service name, release tag).
|
||||
/// </summary>
|
||||
[JsonPropertyName("scopeId")]
|
||||
public required string ScopeId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Input artifact for replay.
|
||||
/// </summary>
|
||||
public sealed record ReplayInputArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the input content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of input (e.g., "sbom", "vex", "policy", "context").
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Storage location (OCI ref, blob ID, inline).
|
||||
/// </summary>
|
||||
[JsonPropertyName("location")]
|
||||
public required string Location { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Order in input sequence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("order")]
|
||||
public required int Order { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompt template snapshot for replay.
|
||||
/// </summary>
|
||||
public sealed record ReplayPromptTemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// Template name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Template version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the template content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Template storage location.
|
||||
/// </summary>
|
||||
[JsonPropertyName("location")]
|
||||
public required string Location { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest capturing all inputs for deterministic AI artifact replay.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-18
|
||||
/// </summary>
|
||||
public sealed record AIArtifactReplayManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique manifest ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("manifestId")]
|
||||
public required string ManifestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the artifact this manifest enables replay for.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactId")]
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type (explanation, remediation, vexdraft, policydraft).
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactType")]
|
||||
public required string ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model identifier used for generation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("modelId")]
|
||||
public required AIModelIdentifier ModelId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Decoding parameters for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decodingParams")]
|
||||
public required AIDecodingParameters DecodingParams { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Prompt template used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("promptTemplate")]
|
||||
public required ReplayPromptTemplate PromptTemplate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All input artifacts in order.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inputs")]
|
||||
public required IReadOnlyList<ReplayInputArtifact> Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected output hash for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expectedOutputHash")]
|
||||
public required string ExpectedOutputHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original generation timestamp (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required string GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether all inputs are available for replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replayable")]
|
||||
public required bool Replayable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reasons if not replayable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("notReplayableReasons")]
|
||||
public IReadOnlyList<string>? NotReplayableReasons { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Status of a replay attempt.
|
||||
/// </summary>
|
||||
public enum ReplayStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Replay not started.
|
||||
/// </summary>
|
||||
NotStarted,
|
||||
|
||||
/// <summary>
|
||||
/// Replay in progress.
|
||||
/// </summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>
|
||||
/// Replay completed successfully with matching output.
|
||||
/// </summary>
|
||||
MatchedOutput,
|
||||
|
||||
/// <summary>
|
||||
/// Replay completed but output diverged.
|
||||
/// </summary>
|
||||
DivergedOutput,
|
||||
|
||||
/// <summary>
|
||||
/// Replay failed due to missing inputs.
|
||||
/// </summary>
|
||||
FailedMissingInputs,
|
||||
|
||||
/// <summary>
|
||||
/// Replay failed due to unavailable model.
|
||||
/// </summary>
|
||||
FailedModelUnavailable,
|
||||
|
||||
/// <summary>
|
||||
/// Replay failed with error.
|
||||
/// </summary>
|
||||
FailedError
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an AI artifact replay attempt.
|
||||
/// </summary>
|
||||
public sealed record ReplayResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Manifest used for replay.
|
||||
/// </summary>
|
||||
public required AIArtifactReplayManifest Manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay status.
|
||||
/// </summary>
|
||||
public required ReplayStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the replayed output (if successful).
|
||||
/// </summary>
|
||||
public string? ReplayedOutputHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether output matches expected.
|
||||
/// </summary>
|
||||
public bool? OutputMatches { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Divergence details if output differs.
|
||||
/// </summary>
|
||||
public string? DivergenceDetails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay duration in milliseconds.
|
||||
/// </summary>
|
||||
public long? DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of replay attempt (UTC ISO-8601).
|
||||
/// </summary>
|
||||
public required string AttemptedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification result for AI artifact replay.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-20
|
||||
/// </summary>
|
||||
public sealed record ReplayVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact ID being verified.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether verification passed.
|
||||
/// </summary>
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay result.
|
||||
/// </summary>
|
||||
public required ReplayResult ReplayResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in verification (1.0 for matching, lower for diverged).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification notes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for re-executing AI generation with pinned inputs.
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-19
|
||||
/// </summary>
|
||||
public interface IAIArtifactReplayer
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempt to replay an AI artifact generation.
|
||||
/// </summary>
|
||||
/// <param name="manifest">Replay manifest with all inputs.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Replay result.</returns>
|
||||
Task<ReplayResult> ReplayAsync(AIArtifactReplayManifest manifest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify an AI artifact by replaying and comparing output.
|
||||
/// </summary>
|
||||
/// <param name="manifest">Replay manifest.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<ReplayVerificationResult> VerifyAsync(AIArtifactReplayManifest manifest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a manifest is replayable (all inputs available, model accessible).
|
||||
/// </summary>
|
||||
/// <param name="manifest">Replay manifest to check.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if replayable, false otherwise with reasons.</returns>
|
||||
Task<(bool Replayable, IReadOnlyList<string> Reasons)> CheckReplayableAsync(
|
||||
AIArtifactReplayManifest manifest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Build a replay manifest from an AI artifact base predicate.
|
||||
/// </summary>
|
||||
/// <param name="predicate">The AI artifact predicate.</param>
|
||||
/// <param name="artifactType">Type of artifact.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Replay manifest.</returns>
|
||||
Task<AIArtifactReplayManifest> BuildManifestAsync(
|
||||
AIArtifactBasePredicate predicate,
|
||||
string artifactType,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements.AI;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for AI-generated explanations.
|
||||
/// Predicate type: ai-explanation.stella/v1
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-08
|
||||
/// </summary>
|
||||
public sealed record AIExplanationStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "ai-explanation.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The AI explanation predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required AIExplanationPredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements.AI;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for AI-generated policy drafts.
|
||||
/// Predicate type: ai-policydraft.stella/v1
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-11
|
||||
/// </summary>
|
||||
public sealed record AIPolicyDraftStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "ai-policydraft.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The AI policy draft predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required AIPolicyDraftPredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements.AI;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for AI-generated remediation plans.
|
||||
/// Predicate type: ai-remediation.stella/v1
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-09
|
||||
/// </summary>
|
||||
public sealed record AIRemediationPlanStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "ai-remediation.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The AI remediation plan predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required AIRemediationPlanPredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements.AI;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for AI-generated VEX drafts.
|
||||
/// Predicate type: ai-vexdraft.stella/v1
|
||||
/// Sprint: SPRINT_20251226_018_AI_attestations
|
||||
/// Task: AIATTEST-10
|
||||
/// </summary>
|
||||
public sealed record AIVexDraftStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "ai-vexdraft.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The AI VEX draft predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required AIVexDraftPredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationBundlerTests.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0018-0020 - Unit tests for bundling
|
||||
// Description: Unit tests for AttestationBundler service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Bundling.Services;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Tests;
|
||||
|
||||
public class AttestationBundlerTests
|
||||
{
|
||||
private readonly Mock<IBundleAggregator> _aggregatorMock;
|
||||
private readonly Mock<IBundleStore> _storeMock;
|
||||
private readonly Mock<IOrgKeySigner> _orgSignerMock;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly Mock<ILogger<AttestationBundler>> _loggerMock;
|
||||
private readonly IOptions<BundlingOptions> _options;
|
||||
|
||||
public AttestationBundlerTests()
|
||||
{
|
||||
_aggregatorMock = new Mock<IBundleAggregator>();
|
||||
_storeMock = new Mock<IBundleStore>();
|
||||
_orgSignerMock = new Mock<IOrgKeySigner>();
|
||||
_merkleBuilder = new DeterministicMerkleTreeBuilder();
|
||||
_loggerMock = new Mock<ILogger<AttestationBundler>>();
|
||||
_options = Options.Create(new BundlingOptions());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBundleAsync_WithAttestations_CreatesDeterministicBundle()
|
||||
{
|
||||
// Arrange
|
||||
var attestations = CreateTestAttestations(5);
|
||||
SetupAggregator(attestations);
|
||||
|
||||
var bundler = CreateBundler();
|
||||
|
||||
var request = new BundleCreationRequest(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var bundle = await bundler.CreateBundleAsync(request);
|
||||
|
||||
// Assert
|
||||
bundle.Should().NotBeNull();
|
||||
bundle.Attestations.Should().HaveCount(5);
|
||||
bundle.MerkleTree.LeafCount.Should().Be(5);
|
||||
bundle.MerkleTree.Root.Should().StartWith("sha256:");
|
||||
bundle.Metadata.BundleId.Should().Be(bundle.MerkleTree.Root);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBundleAsync_SameAttestationsShuffled_SameMerkleRoot()
|
||||
{
|
||||
// Arrange
|
||||
var attestations = CreateTestAttestations(10);
|
||||
|
||||
// Create two bundlers with attestations in different orders
|
||||
var shuffled1 = attestations.OrderBy(_ => Guid.NewGuid()).ToList();
|
||||
var shuffled2 = attestations.OrderBy(_ => Guid.NewGuid()).ToList();
|
||||
|
||||
SetupAggregator(shuffled1);
|
||||
var bundler1 = CreateBundler();
|
||||
|
||||
var request = new BundleCreationRequest(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
var bundle1 = await bundler1.CreateBundleAsync(request);
|
||||
|
||||
// Reset and use different order
|
||||
SetupAggregator(shuffled2);
|
||||
var bundler2 = CreateBundler();
|
||||
var bundle2 = await bundler2.CreateBundleAsync(request);
|
||||
|
||||
// Assert - same merkle root regardless of input order
|
||||
bundle1.MerkleTree.Root.Should().Be(bundle2.MerkleTree.Root);
|
||||
bundle1.Metadata.BundleId.Should().Be(bundle2.Metadata.BundleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBundleAsync_NoAttestations_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
SetupAggregator(new List<BundledAttestation>());
|
||||
var bundler = CreateBundler();
|
||||
|
||||
var request = new BundleCreationRequest(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => bundler.CreateBundleAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBundleAsync_WithOrgSigning_SignsBundle()
|
||||
{
|
||||
// Arrange
|
||||
var attestations = CreateTestAttestations(3);
|
||||
SetupAggregator(attestations);
|
||||
|
||||
var expectedSignature = new OrgSignature
|
||||
{
|
||||
KeyId = "org-key-2025",
|
||||
Algorithm = "ECDSA_P256",
|
||||
Signature = Convert.ToBase64String(new byte[64]),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = null
|
||||
};
|
||||
|
||||
_orgSignerMock
|
||||
.Setup(x => x.GetActiveKeyIdAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync("org-key-2025");
|
||||
|
||||
_orgSignerMock
|
||||
.Setup(x => x.SignBundleAsync(It.IsAny<byte[]>(), "org-key-2025", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedSignature);
|
||||
|
||||
var bundler = CreateBundler();
|
||||
|
||||
var request = new BundleCreationRequest(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow,
|
||||
SignWithOrgKey: true);
|
||||
|
||||
// Act
|
||||
var bundle = await bundler.CreateBundleAsync(request);
|
||||
|
||||
// Assert
|
||||
bundle.OrgSignature.Should().NotBeNull();
|
||||
bundle.OrgSignature!.KeyId.Should().Be("org-key-2025");
|
||||
bundle.OrgSignature.Algorithm.Should().Be("ECDSA_P256");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_ValidBundle_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var attestations = CreateTestAttestations(5);
|
||||
SetupAggregator(attestations);
|
||||
|
||||
var bundler = CreateBundler();
|
||||
|
||||
var request = new BundleCreationRequest(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
var bundle = await bundler.CreateBundleAsync(request);
|
||||
|
||||
// Act
|
||||
var result = await bundler.VerifyBundleAsync(bundle);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeTrue();
|
||||
result.MerkleRootVerified.Should().BeTrue();
|
||||
result.Issues.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_TamperedBundle_ReturnsMerkleRootMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var attestations = CreateTestAttestations(5);
|
||||
SetupAggregator(attestations);
|
||||
|
||||
var bundler = CreateBundler();
|
||||
|
||||
var request = new BundleCreationRequest(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
var bundle = await bundler.CreateBundleAsync(request);
|
||||
|
||||
// Tamper with the bundle by modifying an attestation
|
||||
var tamperedAttestations = bundle.Attestations.ToList();
|
||||
var original = tamperedAttestations[0];
|
||||
tamperedAttestations[0] = original with { EntryId = "tampered-entry-id" };
|
||||
|
||||
var tamperedBundle = bundle with { Attestations = tamperedAttestations };
|
||||
|
||||
// Act
|
||||
var result = await bundler.VerifyBundleAsync(tamperedBundle);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.MerkleRootVerified.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "MERKLE_ROOT_MISMATCH");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBundleAsync_RespectsTenantFilter()
|
||||
{
|
||||
// Arrange
|
||||
var attestations = CreateTestAttestations(5);
|
||||
SetupAggregator(attestations);
|
||||
|
||||
var bundler = CreateBundler();
|
||||
|
||||
var request = new BundleCreationRequest(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow,
|
||||
TenantId: "test-tenant");
|
||||
|
||||
// Act
|
||||
var bundle = await bundler.CreateBundleAsync(request);
|
||||
|
||||
// Assert
|
||||
bundle.Metadata.TenantId.Should().Be("test-tenant");
|
||||
|
||||
_aggregatorMock.Verify(x => x.AggregateAsync(
|
||||
It.Is<AggregationRequest>(r => r.TenantId == "test-tenant"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBundleAsync_RespectsMaxAttestationsLimit()
|
||||
{
|
||||
// Arrange
|
||||
var attestations = CreateTestAttestations(100);
|
||||
SetupAggregator(attestations);
|
||||
|
||||
var options = Options.Create(new BundlingOptions
|
||||
{
|
||||
Aggregation = new BundleAggregationOptions
|
||||
{
|
||||
MaxAttestationsPerBundle = 10
|
||||
}
|
||||
});
|
||||
|
||||
var bundler = new AttestationBundler(
|
||||
_aggregatorMock.Object,
|
||||
_storeMock.Object,
|
||||
_merkleBuilder,
|
||||
_loggerMock.Object,
|
||||
options,
|
||||
_orgSignerMock.Object);
|
||||
|
||||
var request = new BundleCreationRequest(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var bundle = await bundler.CreateBundleAsync(request);
|
||||
|
||||
// Assert
|
||||
bundle.Attestations.Should().HaveCount(10);
|
||||
}
|
||||
|
||||
private AttestationBundler CreateBundler()
|
||||
{
|
||||
return new AttestationBundler(
|
||||
_aggregatorMock.Object,
|
||||
_storeMock.Object,
|
||||
_merkleBuilder,
|
||||
_loggerMock.Object,
|
||||
_options,
|
||||
_orgSignerMock.Object);
|
||||
}
|
||||
|
||||
private void SetupAggregator(List<BundledAttestation> attestations)
|
||||
{
|
||||
_aggregatorMock
|
||||
.Setup(x => x.AggregateAsync(
|
||||
It.IsAny<AggregationRequest>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(attestations.ToAsyncEnumerable());
|
||||
}
|
||||
|
||||
private static List<BundledAttestation> CreateTestAttestations(int count)
|
||||
{
|
||||
var attestations = new List<BundledAttestation>();
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
attestations.Add(new BundledAttestation
|
||||
{
|
||||
EntryId = $"entry-{i:D4}",
|
||||
RekorUuid = Guid.NewGuid().ToString("N"),
|
||||
RekorLogIndex = 10000 + i,
|
||||
ArtifactDigest = $"sha256:{new string((char)('a' + i % 26), 64)}",
|
||||
PredicateType = "verdict.stella/v1",
|
||||
SignedAt = DateTimeOffset.UtcNow.AddHours(-i),
|
||||
SigningMode = "keyless",
|
||||
SigningIdentity = new SigningIdentity
|
||||
{
|
||||
Issuer = "https://authority.internal",
|
||||
Subject = "signer@stella-ops.org",
|
||||
San = "urn:stellaops:signer"
|
||||
},
|
||||
InclusionProof = new RekorInclusionProof
|
||||
{
|
||||
Checkpoint = new CheckpointData
|
||||
{
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000 + i,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Path = new List<string>
|
||||
{
|
||||
Convert.ToBase64String(new byte[32]),
|
||||
Convert.ToBase64String(new byte[32])
|
||||
}
|
||||
},
|
||||
Envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
|
||||
Signatures = new List<EnvelopeSignature>
|
||||
{
|
||||
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
|
||||
},
|
||||
CertificateChain = new List<string>
|
||||
{
|
||||
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return attestations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleAggregatorTests.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0018 - Unit tests: BundleAggregator
|
||||
// Description: Unit tests for attestation aggregation with date range and tenant filtering
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Tests;
|
||||
|
||||
public class BundleAggregatorTests
|
||||
{
|
||||
private readonly InMemoryBundleAggregator _aggregator;
|
||||
|
||||
public BundleAggregatorTests()
|
||||
{
|
||||
_aggregator = new InMemoryBundleAggregator();
|
||||
}
|
||||
|
||||
#region Date Range Filtering Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AggregateAsync_WithDateRange_ReturnsOnlyAttestationsInRange()
|
||||
{
|
||||
// Arrange
|
||||
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5))); // In range
|
||||
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(15))); // In range
|
||||
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(-5))); // Before range
|
||||
_aggregator.AddAttestation(CreateAttestation("att-4", end.AddDays(5))); // After range
|
||||
|
||||
// Act
|
||||
var results = await _aggregator
|
||||
.AggregateAsync(new AggregationRequest(start, end))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().Contain(a => a.EntryId == "att-1");
|
||||
results.Should().Contain(a => a.EntryId == "att-2");
|
||||
results.Should().NotContain(a => a.EntryId == "att-3");
|
||||
results.Should().NotContain(a => a.EntryId == "att-4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AggregateAsync_InclusiveBoundaries_IncludesEdgeAttestations()
|
||||
{
|
||||
// Arrange
|
||||
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
_aggregator.AddAttestation(CreateAttestation("att-start", start)); // Exactly at start
|
||||
_aggregator.AddAttestation(CreateAttestation("att-end", end)); // Exactly at end
|
||||
|
||||
// Act
|
||||
var results = await _aggregator
|
||||
.AggregateAsync(new AggregationRequest(start, end))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().Contain(a => a.EntryId == "att-start");
|
||||
results.Should().Contain(a => a.EntryId == "att-end");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AggregateAsync_EmptyRange_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
// Add attestations outside the range
|
||||
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(-10)));
|
||||
_aggregator.AddAttestation(CreateAttestation("att-2", end.AddDays(10)));
|
||||
|
||||
// Act
|
||||
var results = await _aggregator
|
||||
.AggregateAsync(new AggregationRequest(start, end))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tenant Filtering Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AggregateAsync_WithTenantFilter_ReturnsOnlyTenantAttestations()
|
||||
{
|
||||
// Arrange
|
||||
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5)), tenantId: "tenant-a");
|
||||
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(10)), tenantId: "tenant-a");
|
||||
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(15)), tenantId: "tenant-b");
|
||||
|
||||
// Act
|
||||
var results = await _aggregator
|
||||
.AggregateAsync(new AggregationRequest(start, end, TenantId: "tenant-a"))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().OnlyContain(a => a.EntryId.StartsWith("att-1") || a.EntryId.StartsWith("att-2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AggregateAsync_WithoutTenantFilter_ReturnsAllTenants()
|
||||
{
|
||||
// Arrange
|
||||
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5)), tenantId: "tenant-a");
|
||||
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(10)), tenantId: "tenant-b");
|
||||
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(15)), tenantId: null);
|
||||
|
||||
// Act
|
||||
var results = await _aggregator
|
||||
.AggregateAsync(new AggregationRequest(start, end))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Predicate Type Filtering Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AggregateAsync_WithPredicateTypes_ReturnsOnlyMatchingTypes()
|
||||
{
|
||||
// Arrange
|
||||
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5), predicateType: "verdict.stella/v1"));
|
||||
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(10), predicateType: "sbom.stella/v1"));
|
||||
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(15), predicateType: "verdict.stella/v1"));
|
||||
|
||||
// Act
|
||||
var results = await _aggregator
|
||||
.AggregateAsync(new AggregationRequest(
|
||||
start, end,
|
||||
PredicateTypes: new[] { "verdict.stella/v1" }))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().OnlyContain(a => a.PredicateType == "verdict.stella/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AggregateAsync_WithMultiplePredicateTypes_ReturnsAllMatchingTypes()
|
||||
{
|
||||
// Arrange
|
||||
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5), predicateType: "verdict.stella/v1"));
|
||||
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(10), predicateType: "sbom.stella/v1"));
|
||||
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(15), predicateType: "provenance.stella/v1"));
|
||||
|
||||
// Act
|
||||
var results = await _aggregator
|
||||
.AggregateAsync(new AggregationRequest(
|
||||
start, end,
|
||||
PredicateTypes: new[] { "verdict.stella/v1", "sbom.stella/v1" }))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().NotContain(a => a.PredicateType == "provenance.stella/v1");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Count Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CountAsync_ReturnsCorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
_aggregator.AddAttestation(CreateAttestation($"att-{i}", start.AddDays(i % 30)));
|
||||
}
|
||||
|
||||
// Act
|
||||
var count = await _aggregator.CountAsync(new AggregationRequest(start, end));
|
||||
|
||||
// Assert
|
||||
count.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CountAsync_WithFilters_ReturnsFilteredCount()
|
||||
{
|
||||
// Arrange
|
||||
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5)), tenantId: "tenant-a");
|
||||
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(10)), tenantId: "tenant-a");
|
||||
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(15)), tenantId: "tenant-b");
|
||||
|
||||
// Act
|
||||
var count = await _aggregator.CountAsync(new AggregationRequest(start, end, TenantId: "tenant-a"));
|
||||
|
||||
// Assert
|
||||
count.Should().Be(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ordering Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AggregateAsync_ReturnsDeterministicOrder()
|
||||
{
|
||||
// Arrange
|
||||
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
// Add in random order
|
||||
_aggregator.AddAttestation(CreateAttestation("att-c", start.AddDays(15)));
|
||||
_aggregator.AddAttestation(CreateAttestation("att-a", start.AddDays(5)));
|
||||
_aggregator.AddAttestation(CreateAttestation("att-b", start.AddDays(10)));
|
||||
|
||||
// Act
|
||||
var results1 = await _aggregator.AggregateAsync(new AggregationRequest(start, end)).ToListAsync();
|
||||
var results2 = await _aggregator.AggregateAsync(new AggregationRequest(start, end)).ToListAsync();
|
||||
|
||||
// Assert: Order should be consistent (sorted by EntryId)
|
||||
results1.Select(a => a.EntryId).Should().BeEquivalentTo(
|
||||
results2.Select(a => a.EntryId),
|
||||
options => options.WithStrictOrdering());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static BundledAttestation CreateAttestation(
|
||||
string entryId,
|
||||
DateTimeOffset signedAt,
|
||||
string? tenantId = null,
|
||||
string predicateType = "verdict.stella/v1")
|
||||
{
|
||||
return new BundledAttestation
|
||||
{
|
||||
EntryId = entryId,
|
||||
RekorUuid = $"rekor-{entryId}",
|
||||
RekorLogIndex = Random.Shared.NextInt64(1000000),
|
||||
ArtifactDigest = $"sha256:{Guid.NewGuid():N}",
|
||||
PredicateType = predicateType,
|
||||
SignedAt = signedAt,
|
||||
SigningMode = "keyless",
|
||||
SigningIdentity = new SigningIdentity
|
||||
{
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
Subject = "repo:org/repo:ref:refs/heads/main"
|
||||
},
|
||||
Envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String("test-payload"u8.ToArray()),
|
||||
Signatures = new List<EnvelopeSignature>
|
||||
{
|
||||
new() { Sig = Convert.ToBase64String("test-sig"u8.ToArray()) }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IBundleAggregator for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryBundleAggregator : IBundleAggregator
|
||||
{
|
||||
private readonly List<(BundledAttestation Attestation, string? TenantId)> _attestations = new();
|
||||
|
||||
public void AddAttestation(BundledAttestation attestation, string? tenantId = null)
|
||||
{
|
||||
_attestations.Add((attestation, tenantId));
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<BundledAttestation> AggregateAsync(
|
||||
AggregationRequest request,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _attestations.AsEnumerable();
|
||||
|
||||
// Date range filter
|
||||
query = query.Where(x =>
|
||||
x.Attestation.SignedAt >= request.PeriodStart &&
|
||||
x.Attestation.SignedAt <= request.PeriodEnd);
|
||||
|
||||
// Tenant filter
|
||||
if (request.TenantId != null)
|
||||
{
|
||||
query = query.Where(x => x.TenantId == request.TenantId);
|
||||
}
|
||||
|
||||
// Predicate type filter
|
||||
if (request.PredicateTypes?.Count > 0)
|
||||
{
|
||||
query = query.Where(x => request.PredicateTypes.Contains(x.Attestation.PredicateType));
|
||||
}
|
||||
|
||||
// Deterministic ordering
|
||||
query = query.OrderBy(x => x.Attestation.EntryId);
|
||||
|
||||
foreach (var item in query)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Yield();
|
||||
yield return item.Attestation;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> CountAsync(AggregationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _attestations.AsEnumerable();
|
||||
|
||||
query = query.Where(x =>
|
||||
x.Attestation.SignedAt >= request.PeriodStart &&
|
||||
x.Attestation.SignedAt <= request.PeriodEnd);
|
||||
|
||||
if (request.TenantId != null)
|
||||
{
|
||||
query = query.Where(x => x.TenantId == request.TenantId);
|
||||
}
|
||||
|
||||
if (request.PredicateTypes?.Count > 0)
|
||||
{
|
||||
query = query.Where(x => request.PredicateTypes.Contains(x.Attestation.PredicateType));
|
||||
}
|
||||
|
||||
return Task.FromResult(query.Count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleWorkflowIntegrationTests.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0023 - Integration test: Full bundle workflow
|
||||
// Task: 0024 - Integration test: Scheduler job
|
||||
// Description: Integration tests for complete bundle workflow and scheduler execution
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the full bundle creation workflow:
|
||||
/// Create → Store → Retrieve → Verify
|
||||
/// </summary>
|
||||
public class BundleWorkflowIntegrationTests
|
||||
{
|
||||
private readonly InMemoryBundleStore _store;
|
||||
private readonly InMemoryBundleAggregator _aggregator;
|
||||
private readonly TestOrgKeySigner _signer;
|
||||
private readonly IOptions<BundlingOptions> _options;
|
||||
|
||||
public BundleWorkflowIntegrationTests()
|
||||
{
|
||||
_store = new InMemoryBundleStore();
|
||||
_aggregator = new InMemoryBundleAggregator();
|
||||
_signer = new TestOrgKeySigner();
|
||||
_options = Options.Create(new BundlingOptions());
|
||||
}
|
||||
|
||||
#region Full Workflow Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_CreateStoreRetrieveVerify_Succeeds()
|
||||
{
|
||||
// Arrange: Add test attestations
|
||||
var periodStart = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var periodEnd = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
_aggregator.AddAttestation(CreateTestAttestation($"att-{i}", periodStart.AddDays(i)));
|
||||
}
|
||||
|
||||
// Act 1: Create bundle
|
||||
var createRequest = new BundleCreationRequest(
|
||||
periodStart, periodEnd,
|
||||
SignWithOrgKey: true,
|
||||
OrgKeyId: "test-key");
|
||||
|
||||
var bundle = await CreateBundleAsync(createRequest);
|
||||
|
||||
// Assert: Bundle created correctly
|
||||
bundle.Should().NotBeNull();
|
||||
bundle.Metadata.AttestationCount.Should().Be(10);
|
||||
bundle.OrgSignature.Should().NotBeNull();
|
||||
|
||||
// Act 2: Store bundle
|
||||
await _store.StoreBundleAsync(bundle);
|
||||
|
||||
// Assert: Bundle exists
|
||||
(await _store.ExistsAsync(bundle.Metadata.BundleId)).Should().BeTrue();
|
||||
|
||||
// Act 3: Retrieve bundle
|
||||
var retrieved = await _store.GetBundleAsync(bundle.Metadata.BundleId);
|
||||
|
||||
// Assert: Retrieved bundle matches
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.Metadata.BundleId.Should().Be(bundle.Metadata.BundleId);
|
||||
retrieved.Attestations.Should().HaveCount(10);
|
||||
|
||||
// Act 4: Verify bundle
|
||||
var verificationResult = await VerifyBundleAsync(retrieved);
|
||||
|
||||
// Assert: Verification passes
|
||||
verificationResult.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_WithoutOrgSignature_StillWorks()
|
||||
{
|
||||
// Arrange
|
||||
var periodStart = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var periodEnd = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
_aggregator.AddAttestation(CreateTestAttestation("att-1", periodStart.AddDays(5)));
|
||||
|
||||
// Act: Create bundle WITHOUT org signature
|
||||
var createRequest = new BundleCreationRequest(
|
||||
periodStart, periodEnd,
|
||||
SignWithOrgKey: false);
|
||||
|
||||
var bundle = await CreateBundleAsync(createRequest);
|
||||
await _store.StoreBundleAsync(bundle);
|
||||
var retrieved = await _store.GetBundleAsync(bundle.Metadata.BundleId);
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.OrgSignature.Should().BeNull();
|
||||
retrieved.Attestations.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_EmptyPeriod_CreatesEmptyBundle()
|
||||
{
|
||||
// Arrange: No attestations added
|
||||
var periodStart = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var periodEnd = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
var createRequest = new BundleCreationRequest(periodStart, periodEnd);
|
||||
var bundle = await CreateBundleAsync(createRequest);
|
||||
|
||||
// Assert
|
||||
bundle.Metadata.AttestationCount.Should().Be(0);
|
||||
bundle.Attestations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_LargeBundle_HandlesCorrectly()
|
||||
{
|
||||
// Arrange: Add many attestations
|
||||
var periodStart = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var periodEnd = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
_aggregator.AddAttestation(CreateTestAttestation($"att-{i:D4}", periodStart.AddMinutes(i)));
|
||||
}
|
||||
|
||||
// Act
|
||||
var bundle = await CreateBundleAsync(new BundleCreationRequest(periodStart, periodEnd));
|
||||
await _store.StoreBundleAsync(bundle);
|
||||
var retrieved = await _store.GetBundleAsync(bundle.Metadata.BundleId);
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.Attestations.Should().HaveCount(1000);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tenant Isolation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_TenantIsolation_CreatesSeperateBundles()
|
||||
{
|
||||
// Arrange
|
||||
var periodStart = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var periodEnd = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
|
||||
_aggregator.AddAttestation(CreateTestAttestation("att-a1", periodStart.AddDays(5)), "tenant-a");
|
||||
_aggregator.AddAttestation(CreateTestAttestation("att-a2", periodStart.AddDays(10)), "tenant-a");
|
||||
_aggregator.AddAttestation(CreateTestAttestation("att-b1", periodStart.AddDays(15)), "tenant-b");
|
||||
|
||||
// Act: Create bundles for each tenant
|
||||
var bundleA = await CreateBundleAsync(new BundleCreationRequest(
|
||||
periodStart, periodEnd, TenantId: "tenant-a"));
|
||||
var bundleB = await CreateBundleAsync(new BundleCreationRequest(
|
||||
periodStart, periodEnd, TenantId: "tenant-b"));
|
||||
|
||||
// Assert
|
||||
bundleA.Attestations.Should().HaveCount(2);
|
||||
bundleB.Attestations.Should().HaveCount(1);
|
||||
bundleA.Metadata.BundleId.Should().NotBe(bundleB.Metadata.BundleId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scheduler Job Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SchedulerJob_ExecutesAndCreatesBundles()
|
||||
{
|
||||
// Arrange: Add attestations for previous month
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var previousMonth = now.AddMonths(-1);
|
||||
var periodStart = new DateTimeOffset(previousMonth.Year, previousMonth.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var periodEnd = periodStart.AddMonths(1).AddTicks(-1);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
_aggregator.AddAttestation(CreateTestAttestation($"att-{i}", periodStart.AddDays(i * 5)));
|
||||
}
|
||||
|
||||
// Act: Simulate scheduler job execution
|
||||
var jobResult = await ExecuteRotationJobAsync(periodStart, periodEnd);
|
||||
|
||||
// Assert
|
||||
jobResult.Success.Should().BeTrue();
|
||||
jobResult.BundleId.Should().NotBeEmpty();
|
||||
jobResult.AttestationCount.Should().Be(5);
|
||||
|
||||
// Verify bundle was stored
|
||||
(await _store.ExistsAsync(jobResult.BundleId)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SchedulerJob_MultiTenant_CreatesBundlesForEachTenant()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var previousMonth = now.AddMonths(-1);
|
||||
var periodStart = new DateTimeOffset(previousMonth.Year, previousMonth.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var periodEnd = periodStart.AddMonths(1).AddTicks(-1);
|
||||
|
||||
_aggregator.AddAttestation(CreateTestAttestation("att-1", periodStart.AddDays(5)), "tenant-x");
|
||||
_aggregator.AddAttestation(CreateTestAttestation("att-2", periodStart.AddDays(10)), "tenant-y");
|
||||
|
||||
// Act: Execute job for all tenants
|
||||
var resultX = await ExecuteRotationJobAsync(periodStart, periodEnd, "tenant-x");
|
||||
var resultY = await ExecuteRotationJobAsync(periodStart, periodEnd, "tenant-y");
|
||||
|
||||
// Assert
|
||||
resultX.Success.Should().BeTrue();
|
||||
resultY.Success.Should().BeTrue();
|
||||
resultX.BundleId.Should().NotBe(resultY.BundleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SchedulerJob_AppliesRetentionPolicy()
|
||||
{
|
||||
// Arrange: Create old bundle
|
||||
var oldPeriodStart = DateTimeOffset.UtcNow.AddMonths(-36);
|
||||
var oldBundle = CreateExpiredBundle("old-bundle", oldPeriodStart);
|
||||
await _store.StoreBundleAsync(oldBundle);
|
||||
|
||||
// Verify old bundle exists
|
||||
(await _store.ExistsAsync("old-bundle")).Should().BeTrue();
|
||||
|
||||
// Act: Apply retention
|
||||
var deleted = await ApplyRetentionAsync(retentionMonths: 24);
|
||||
|
||||
// Assert
|
||||
deleted.Should().BeGreaterThan(0);
|
||||
(await _store.ExistsAsync("old-bundle")).Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task<AttestationBundle> CreateBundleAsync(BundleCreationRequest request)
|
||||
{
|
||||
var attestations = await _aggregator
|
||||
.AggregateAsync(new AggregationRequest(
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd,
|
||||
request.TenantId))
|
||||
.ToListAsync();
|
||||
|
||||
// Sort for determinism
|
||||
attestations = attestations.OrderBy(a => a.EntryId).ToList();
|
||||
|
||||
// Compute Merkle root (simplified)
|
||||
var merkleRoot = ComputeMerkleRoot(attestations);
|
||||
|
||||
var bundle = new AttestationBundle
|
||||
{
|
||||
Metadata = new BundleMetadata
|
||||
{
|
||||
BundleId = $"sha256:{merkleRoot}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PeriodStart = request.PeriodStart,
|
||||
PeriodEnd = request.PeriodEnd,
|
||||
AttestationCount = attestations.Count,
|
||||
TenantId = request.TenantId
|
||||
},
|
||||
Attestations = attestations,
|
||||
MerkleTree = new MerkleTreeInfo
|
||||
{
|
||||
Root = $"sha256:{merkleRoot}",
|
||||
LeafCount = attestations.Count
|
||||
}
|
||||
};
|
||||
|
||||
// Add org signature if requested
|
||||
if (request.SignWithOrgKey && request.OrgKeyId != null)
|
||||
{
|
||||
var digest = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(merkleRoot));
|
||||
var signature = await _signer.SignBundleAsync(digest, request.OrgKeyId);
|
||||
bundle = bundle with
|
||||
{
|
||||
OrgSignature = signature,
|
||||
Metadata = bundle.Metadata with { OrgKeyFingerprint = $"sha256:{request.OrgKeyId}" }
|
||||
};
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
private async Task<bool> VerifyBundleAsync(AttestationBundle bundle)
|
||||
{
|
||||
// Verify Merkle root
|
||||
var computedRoot = ComputeMerkleRoot(bundle.Attestations.ToList());
|
||||
if (bundle.MerkleTree.Root != $"sha256:{computedRoot}")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify org signature if present
|
||||
if (bundle.OrgSignature != null)
|
||||
{
|
||||
var digest = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(computedRoot));
|
||||
return await _signer.VerifyBundleAsync(digest, bundle.OrgSignature);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<RotationJobResult> ExecuteRotationJobAsync(
|
||||
DateTimeOffset periodStart,
|
||||
DateTimeOffset periodEnd,
|
||||
string? tenantId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bundle = await CreateBundleAsync(new BundleCreationRequest(
|
||||
periodStart, periodEnd,
|
||||
TenantId: tenantId,
|
||||
SignWithOrgKey: true,
|
||||
OrgKeyId: "scheduler-key"));
|
||||
|
||||
await _store.StoreBundleAsync(bundle);
|
||||
|
||||
return new RotationJobResult
|
||||
{
|
||||
Success = true,
|
||||
BundleId = bundle.Metadata.BundleId,
|
||||
AttestationCount = bundle.Metadata.AttestationCount
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new RotationJobResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> ApplyRetentionAsync(int retentionMonths)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow.AddMonths(-retentionMonths);
|
||||
var deleted = 0;
|
||||
|
||||
var bundles = await _store.ListBundlesAsync(new BundleListRequest());
|
||||
foreach (var bundle in bundles.Bundles)
|
||||
{
|
||||
if (bundle.CreatedAt < cutoff)
|
||||
{
|
||||
if (await _store.DeleteBundleAsync(bundle.BundleId))
|
||||
{
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private AttestationBundle CreateExpiredBundle(string bundleId, DateTimeOffset createdAt)
|
||||
{
|
||||
return new AttestationBundle
|
||||
{
|
||||
Metadata = new BundleMetadata
|
||||
{
|
||||
BundleId = bundleId,
|
||||
CreatedAt = createdAt,
|
||||
PeriodStart = createdAt.AddDays(-30),
|
||||
PeriodEnd = createdAt,
|
||||
AttestationCount = 0
|
||||
},
|
||||
Attestations = new List<BundledAttestation>(),
|
||||
MerkleTree = new MerkleTreeInfo { Root = "sha256:empty", LeafCount = 0 }
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(List<BundledAttestation> attestations)
|
||||
{
|
||||
if (attestations.Count == 0)
|
||||
{
|
||||
return "empty";
|
||||
}
|
||||
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var combined = string.Join("|", attestations.Select(a => a.EntryId));
|
||||
var hash = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combined));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static BundledAttestation CreateTestAttestation(string entryId, DateTimeOffset signedAt)
|
||||
{
|
||||
return new BundledAttestation
|
||||
{
|
||||
EntryId = entryId,
|
||||
RekorUuid = $"rekor-{entryId}",
|
||||
RekorLogIndex = Random.Shared.NextInt64(1000000),
|
||||
ArtifactDigest = $"sha256:{Guid.NewGuid():N}",
|
||||
PredicateType = "verdict.stella/v1",
|
||||
SignedAt = signedAt,
|
||||
SigningMode = "keyless",
|
||||
SigningIdentity = new SigningIdentity
|
||||
{
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
Subject = "repo:org/repo:ref:refs/heads/main"
|
||||
},
|
||||
Envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"payload-{entryId}")),
|
||||
Signatures = new List<EnvelopeSignature>
|
||||
{
|
||||
new() { Sig = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"sig-{entryId}")) }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record RotationJobResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string BundleId { get; init; } = string.Empty;
|
||||
public int AttestationCount { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory bundle store for integration testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryBundleStore : IBundleStore
|
||||
{
|
||||
private readonly Dictionary<string, AttestationBundle> _bundles = new();
|
||||
|
||||
public Task StoreBundleAsync(
|
||||
AttestationBundle bundle,
|
||||
Abstractions.BundleStorageOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_bundles[bundle.Metadata.BundleId] = bundle;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AttestationBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_bundles.TryGetValue(bundleId, out var bundle) ? bundle : null);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_bundles.ContainsKey(bundleId));
|
||||
}
|
||||
|
||||
public Task<bool> DeleteBundleAsync(string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_bundles.Remove(bundleId));
|
||||
}
|
||||
|
||||
public Task<BundleListResult> ListBundlesAsync(
|
||||
BundleListRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = _bundles.Values
|
||||
.Select(b => new BundleListItem(
|
||||
b.Metadata.BundleId,
|
||||
b.Metadata.PeriodStart,
|
||||
b.Metadata.PeriodEnd,
|
||||
b.Metadata.AttestationCount,
|
||||
b.Metadata.CreatedAt,
|
||||
b.OrgSignature != null))
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(new BundleListResult(items, null));
|
||||
}
|
||||
|
||||
public Task ExportBundleAsync(
|
||||
string bundleId,
|
||||
Stream output,
|
||||
Abstractions.BundleExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_bundles.TryGetValue(bundleId, out var bundle))
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(bundle);
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
output.Write(bytes);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KmsOrgKeySignerTests.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0021 - Unit tests: Org-key signing
|
||||
// Description: Unit tests for KmsOrgKeySigner service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Bundling.Signing;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Tests;
|
||||
|
||||
public class KmsOrgKeySignerTests
|
||||
{
|
||||
private readonly Mock<IKmsProvider> _kmsProviderMock;
|
||||
private readonly Mock<ILogger<KmsOrgKeySigner>> _loggerMock;
|
||||
|
||||
public KmsOrgKeySignerTests()
|
||||
{
|
||||
_kmsProviderMock = new Mock<IKmsProvider>();
|
||||
_loggerMock = new Mock<ILogger<KmsOrgKeySigner>>();
|
||||
}
|
||||
|
||||
#region SignBundleAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SignBundleAsync_ValidKey_ReturnsSignature()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = "org-key-2025";
|
||||
var bundleDigest = SHA256.HashData("test bundle content"u8.ToArray());
|
||||
var expectedSignature = new byte[64];
|
||||
RandomNumberGenerator.Fill(expectedSignature);
|
||||
|
||||
var keyInfo = CreateKeyInfo(keyId, isActive: true);
|
||||
SetupKmsProvider(keyId, keyInfo, expectedSignature);
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act
|
||||
var result = await signer.SignBundleAsync(bundleDigest, keyId);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.KeyId.Should().Be(keyId);
|
||||
result.Algorithm.Should().Be("ECDSA_P256");
|
||||
result.Signature.Should().Be(Convert.ToBase64String(expectedSignature));
|
||||
result.SignedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignBundleAsync_KeyNotFound_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = "nonexistent-key";
|
||||
var bundleDigest = SHA256.HashData("test"u8.ToArray());
|
||||
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.GetKeyInfoAsync(keyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((KmsKeyInfo?)null);
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act & Assert
|
||||
var act = () => signer.SignBundleAsync(bundleDigest, keyId);
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage($"*'{keyId}'*not found*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignBundleAsync_InactiveKey_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = "inactive-key";
|
||||
var bundleDigest = SHA256.HashData("test"u8.ToArray());
|
||||
|
||||
var keyInfo = CreateKeyInfo(keyId, isActive: false);
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.GetKeyInfoAsync(keyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(keyInfo);
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act & Assert
|
||||
var act = () => signer.SignBundleAsync(bundleDigest, keyId);
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage($"*'{keyId}'*not active*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignBundleAsync_ExpiredKey_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = "expired-key";
|
||||
var bundleDigest = SHA256.HashData("test"u8.ToArray());
|
||||
|
||||
var keyInfo = new KmsKeyInfo(
|
||||
keyId,
|
||||
"ECDSA_P256",
|
||||
"fingerprint",
|
||||
DateTimeOffset.UtcNow.AddYears(-2),
|
||||
DateTimeOffset.UtcNow.AddDays(-1), // Expired yesterday
|
||||
true);
|
||||
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.GetKeyInfoAsync(keyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(keyInfo);
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act & Assert
|
||||
var act = () => signer.SignBundleAsync(bundleDigest, keyId);
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage($"*'{keyId}'*expired*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignBundleAsync_WithCertificateChain_IncludesChainInSignature()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = "org-key-with-cert";
|
||||
var bundleDigest = SHA256.HashData("test"u8.ToArray());
|
||||
var signature = new byte[64];
|
||||
var certChain = new List<string>
|
||||
{
|
||||
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----",
|
||||
"-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"
|
||||
};
|
||||
|
||||
var keyInfo = CreateKeyInfo(keyId, isActive: true);
|
||||
SetupKmsProvider(keyId, keyInfo, signature, certChain);
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act
|
||||
var result = await signer.SignBundleAsync(bundleDigest, keyId);
|
||||
|
||||
// Assert
|
||||
result.CertificateChain.Should().NotBeNull();
|
||||
result.CertificateChain.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region VerifyBundleAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_ValidSignature_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = "org-key-2025";
|
||||
var bundleDigest = SHA256.HashData("test bundle content"u8.ToArray());
|
||||
var signatureBytes = new byte[64];
|
||||
RandomNumberGenerator.Fill(signatureBytes);
|
||||
|
||||
var signature = new OrgSignature
|
||||
{
|
||||
KeyId = keyId,
|
||||
Algorithm = "ECDSA_P256",
|
||||
Signature = Convert.ToBase64String(signatureBytes),
|
||||
SignedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
CertificateChain = null
|
||||
};
|
||||
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.VerifyAsync(
|
||||
keyId,
|
||||
bundleDigest,
|
||||
signatureBytes,
|
||||
"ECDSA_P256",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act
|
||||
var result = await signer.VerifyBundleAsync(bundleDigest, signature);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_InvalidSignature_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = "org-key-2025";
|
||||
var bundleDigest = SHA256.HashData("test"u8.ToArray());
|
||||
var signatureBytes = new byte[64];
|
||||
|
||||
var signature = new OrgSignature
|
||||
{
|
||||
KeyId = keyId,
|
||||
Algorithm = "ECDSA_P256",
|
||||
Signature = Convert.ToBase64String(signatureBytes),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = null
|
||||
};
|
||||
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.VerifyAsync(
|
||||
keyId,
|
||||
bundleDigest,
|
||||
signatureBytes,
|
||||
"ECDSA_P256",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act
|
||||
var result = await signer.VerifyBundleAsync(bundleDigest, signature);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_KmsThrowsException_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = "org-key-2025";
|
||||
var bundleDigest = SHA256.HashData("test"u8.ToArray());
|
||||
var signatureBytes = new byte[64];
|
||||
|
||||
var signature = new OrgSignature
|
||||
{
|
||||
KeyId = keyId,
|
||||
Algorithm = "ECDSA_P256",
|
||||
Signature = Convert.ToBase64String(signatureBytes),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = null
|
||||
};
|
||||
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.VerifyAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<byte[]>(),
|
||||
It.IsAny<byte[]>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("KMS unavailable"));
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act
|
||||
var result = await signer.VerifyBundleAsync(bundleDigest, signature);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetActiveKeyIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveKeyIdAsync_ConfiguredActiveKey_ReturnsConfiguredKey()
|
||||
{
|
||||
// Arrange
|
||||
var options = Options.Create(new OrgSigningOptions
|
||||
{
|
||||
ActiveKeyId = "configured-active-key"
|
||||
});
|
||||
|
||||
var signer = new KmsOrgKeySigner(
|
||||
_kmsProviderMock.Object,
|
||||
_loggerMock.Object,
|
||||
options);
|
||||
|
||||
// Act
|
||||
var result = await signer.GetActiveKeyIdAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().Be("configured-active-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveKeyIdAsync_NoConfiguredKey_ReturnsNewestActiveKey()
|
||||
{
|
||||
// Arrange
|
||||
var keys = new List<KmsKeyInfo>
|
||||
{
|
||||
new("key-2024", "ECDSA_P256", "fp1", DateTimeOffset.UtcNow.AddYears(-1), null, true),
|
||||
new("key-2025", "ECDSA_P256", "fp2", DateTimeOffset.UtcNow.AddMonths(-1), null, true),
|
||||
new("key-2023", "ECDSA_P256", "fp3", DateTimeOffset.UtcNow.AddYears(-2), null, false) // Inactive
|
||||
};
|
||||
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.ListKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(keys);
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act
|
||||
var result = await signer.GetActiveKeyIdAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().Be("key-2025"); // Newest active key
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveKeyIdAsync_NoActiveKeys_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var keys = new List<KmsKeyInfo>
|
||||
{
|
||||
new("key-inactive", "ECDSA_P256", "fp1", DateTimeOffset.UtcNow.AddYears(-1), null, false)
|
||||
};
|
||||
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.ListKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(keys);
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act & Assert
|
||||
var act = () => signer.GetActiveKeyIdAsync();
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*No active signing key*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveKeyIdAsync_ExcludesExpiredKeys()
|
||||
{
|
||||
// Arrange
|
||||
var keys = new List<KmsKeyInfo>
|
||||
{
|
||||
new("key-expired", "ECDSA_P256", "fp1", DateTimeOffset.UtcNow.AddYears(-2), DateTimeOffset.UtcNow.AddDays(-1), true),
|
||||
new("key-valid", "ECDSA_P256", "fp2", DateTimeOffset.UtcNow.AddMonths(-6), null, true)
|
||||
};
|
||||
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.ListKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(keys);
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act
|
||||
var result = await signer.GetActiveKeyIdAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().Be("key-valid");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ListKeysAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ListKeysAsync_ReturnsAllKeysFromKms()
|
||||
{
|
||||
// Arrange
|
||||
var keys = new List<KmsKeyInfo>
|
||||
{
|
||||
new("key-1", "ECDSA_P256", "fp1", DateTimeOffset.UtcNow.AddYears(-1), null, true),
|
||||
new("key-2", "Ed25519", "fp2", DateTimeOffset.UtcNow.AddMonths(-6), DateTimeOffset.UtcNow.AddMonths(6), true)
|
||||
};
|
||||
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.ListKeysAsync("stellaops/org-signing/", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(keys);
|
||||
|
||||
var signer = CreateSigner();
|
||||
|
||||
// Act
|
||||
var result = await signer.ListKeysAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().Contain(k => k.KeyId == "key-1" && k.Algorithm == "ECDSA_P256");
|
||||
result.Should().Contain(k => k.KeyId == "key-2" && k.Algorithm == "Ed25519");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region LocalOrgKeySigner Tests
|
||||
|
||||
[Fact]
|
||||
public async Task LocalOrgKeySigner_SignAndVerify_Roundtrip()
|
||||
{
|
||||
// Arrange
|
||||
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
|
||||
var signer = new LocalOrgKeySigner(logger.Object);
|
||||
signer.AddKey("test-key-1", isActive: true);
|
||||
|
||||
var bundleDigest = SHA256.HashData("test bundle content"u8.ToArray());
|
||||
|
||||
// Act
|
||||
var signature = await signer.SignBundleAsync(bundleDigest, "test-key-1");
|
||||
var isValid = await signer.VerifyBundleAsync(bundleDigest, signature);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
signature.KeyId.Should().Be("test-key-1");
|
||||
signature.Algorithm.Should().Be("ECDSA_P256");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LocalOrgKeySigner_VerifyWithWrongDigest_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
|
||||
var signer = new LocalOrgKeySigner(logger.Object);
|
||||
signer.AddKey("test-key-1", isActive: true);
|
||||
|
||||
var originalDigest = SHA256.HashData("original content"u8.ToArray());
|
||||
var tamperedDigest = SHA256.HashData("tampered content"u8.ToArray());
|
||||
|
||||
// Act
|
||||
var signature = await signer.SignBundleAsync(originalDigest, "test-key-1");
|
||||
var isValid = await signer.VerifyBundleAsync(tamperedDigest, signature);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LocalOrgKeySigner_VerifyWithUnknownKey_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
|
||||
var signer = new LocalOrgKeySigner(logger.Object);
|
||||
signer.AddKey("test-key-1", isActive: true);
|
||||
|
||||
var bundleDigest = SHA256.HashData("test"u8.ToArray());
|
||||
var signature = await signer.SignBundleAsync(bundleDigest, "test-key-1");
|
||||
|
||||
// Modify signature to reference unknown key
|
||||
var fakeSignature = signature with { KeyId = "unknown-key" };
|
||||
|
||||
// Act
|
||||
var isValid = await signer.VerifyBundleAsync(bundleDigest, fakeSignature);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LocalOrgKeySigner_GetActiveKeyId_ReturnsActiveKey()
|
||||
{
|
||||
// Arrange
|
||||
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
|
||||
var signer = new LocalOrgKeySigner(logger.Object);
|
||||
signer.AddKey("key-1", isActive: false);
|
||||
signer.AddKey("key-2", isActive: true);
|
||||
|
||||
// Act
|
||||
var activeKeyId = await signer.GetActiveKeyIdAsync();
|
||||
|
||||
// Assert
|
||||
activeKeyId.Should().Be("key-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LocalOrgKeySigner_NoActiveKey_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
|
||||
var signer = new LocalOrgKeySigner(logger.Object);
|
||||
// Don't add any keys
|
||||
|
||||
// Act & Assert
|
||||
var act = () => signer.GetActiveKeyIdAsync();
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*No active signing key*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LocalOrgKeySigner_ListKeys_ReturnsAllKeys()
|
||||
{
|
||||
// Arrange
|
||||
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
|
||||
var signer = new LocalOrgKeySigner(logger.Object);
|
||||
signer.AddKey("key-1", isActive: true);
|
||||
signer.AddKey("key-2", isActive: false);
|
||||
|
||||
// Act
|
||||
var keys = await signer.ListKeysAsync();
|
||||
|
||||
// Assert
|
||||
keys.Should().HaveCount(2);
|
||||
keys.Should().Contain(k => k.KeyId == "key-1" && k.IsActive);
|
||||
keys.Should().Contain(k => k.KeyId == "key-2" && !k.IsActive);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private KmsOrgKeySigner CreateSigner(OrgSigningOptions? options = null)
|
||||
{
|
||||
return new KmsOrgKeySigner(
|
||||
_kmsProviderMock.Object,
|
||||
_loggerMock.Object,
|
||||
Options.Create(options ?? new OrgSigningOptions()));
|
||||
}
|
||||
|
||||
private static KmsKeyInfo CreateKeyInfo(string keyId, bool isActive, DateTimeOffset? validUntil = null)
|
||||
{
|
||||
return new KmsKeyInfo(
|
||||
keyId,
|
||||
"ECDSA_P256",
|
||||
$"fingerprint-{keyId}",
|
||||
DateTimeOffset.UtcNow.AddMonths(-1),
|
||||
validUntil,
|
||||
isActive);
|
||||
}
|
||||
|
||||
private void SetupKmsProvider(
|
||||
string keyId,
|
||||
KmsKeyInfo keyInfo,
|
||||
byte[] signature,
|
||||
IReadOnlyList<string>? certChain = null)
|
||||
{
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.GetKeyInfoAsync(keyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(keyInfo);
|
||||
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.SignAsync(
|
||||
keyId,
|
||||
It.IsAny<byte[]>(),
|
||||
keyInfo.Algorithm,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(signature);
|
||||
|
||||
_kmsProviderMock
|
||||
.Setup(x => x.GetCertificateChainAsync(keyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(certChain);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OrgKeySignerTests.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0021 - Unit tests: Org-key signing
|
||||
// Description: Unit tests for organization key signing with sign/verify roundtrip
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Tests;
|
||||
|
||||
public class OrgKeySignerTests
|
||||
{
|
||||
private readonly TestOrgKeySigner _signer;
|
||||
private readonly string _testKeyId = "test-org-key-2025";
|
||||
|
||||
public OrgKeySignerTests()
|
||||
{
|
||||
_signer = new TestOrgKeySigner();
|
||||
}
|
||||
|
||||
#region Sign/Verify Roundtrip Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SignAndVerify_ValidBundle_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDigest = SHA256.HashData("test-bundle-content"u8.ToArray());
|
||||
|
||||
// Act
|
||||
var signature = await _signer.SignBundleAsync(bundleDigest, _testKeyId);
|
||||
|
||||
// Assert
|
||||
signature.Should().NotBeNull();
|
||||
signature.KeyId.Should().Be(_testKeyId);
|
||||
signature.Algorithm.Should().Be("ECDSA_P256");
|
||||
signature.Signature.Should().NotBeEmpty();
|
||||
signature.SignedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Verify roundtrip
|
||||
var isValid = await _signer.VerifyBundleAsync(bundleDigest, signature);
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAndVerify_DifferentContent_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var originalDigest = SHA256.HashData("original-content"u8.ToArray());
|
||||
var tamperedDigest = SHA256.HashData("tampered-content"u8.ToArray());
|
||||
|
||||
// Act
|
||||
var signature = await _signer.SignBundleAsync(originalDigest, _testKeyId);
|
||||
var isValid = await _signer.VerifyBundleAsync(tamperedDigest, signature);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAndVerify_SameContentDifferentCalls_BothValid()
|
||||
{
|
||||
// Arrange
|
||||
var content = "consistent-bundle-content"u8.ToArray();
|
||||
var digest1 = SHA256.HashData(content);
|
||||
var digest2 = SHA256.HashData(content);
|
||||
|
||||
// Act
|
||||
var signature1 = await _signer.SignBundleAsync(digest1, _testKeyId);
|
||||
var signature2 = await _signer.SignBundleAsync(digest2, _testKeyId);
|
||||
|
||||
// Assert - Both signatures should be valid for the same content
|
||||
(await _signer.VerifyBundleAsync(digest1, signature1)).Should().BeTrue();
|
||||
(await _signer.VerifyBundleAsync(digest2, signature2)).Should().BeTrue();
|
||||
|
||||
// Cross-verify: signature1 should verify against digest2 (same content)
|
||||
(await _signer.VerifyBundleAsync(digest2, signature1)).Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Certificate Chain Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Sign_IncludesCertificateChain()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDigest = SHA256.HashData("bundle-with-chain"u8.ToArray());
|
||||
|
||||
// Act
|
||||
var signature = await _signer.SignBundleAsync(bundleDigest, _testKeyId);
|
||||
|
||||
// Assert
|
||||
signature.CertificateChain.Should().NotBeNull();
|
||||
signature.CertificateChain.Should().NotBeEmpty();
|
||||
signature.CertificateChain!.All(c => c.StartsWith("-----BEGIN CERTIFICATE-----")).Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Key ID Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Sign_WithDifferentKeyIds_ProducesDifferentSignatures()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDigest = SHA256.HashData("test-content"u8.ToArray());
|
||||
var keyId1 = "org-key-2024";
|
||||
var keyId2 = "org-key-2025";
|
||||
|
||||
// Act
|
||||
var signature1 = await _signer.SignBundleAsync(bundleDigest, keyId1);
|
||||
var signature2 = await _signer.SignBundleAsync(bundleDigest, keyId2);
|
||||
|
||||
// Assert
|
||||
signature1.KeyId.Should().Be(keyId1);
|
||||
signature2.KeyId.Should().Be(keyId2);
|
||||
signature1.Signature.Should().NotBe(signature2.Signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_WithWrongKeyId_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDigest = SHA256.HashData("test-content"u8.ToArray());
|
||||
var signatureWithKey1 = await _signer.SignBundleAsync(bundleDigest, "key-1");
|
||||
|
||||
// Modify the key ID in the signature (simulating wrong key)
|
||||
var tamperedSignature = signatureWithKey1 with { KeyId = "wrong-key" };
|
||||
|
||||
// Act
|
||||
var isValid = await _signer.VerifyBundleAsync(bundleDigest, tamperedSignature);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Empty/Null Input Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Sign_EmptyDigest_StillSigns()
|
||||
{
|
||||
// Arrange
|
||||
var emptyDigest = SHA256.HashData(Array.Empty<byte>());
|
||||
|
||||
// Act
|
||||
var signature = await _signer.SignBundleAsync(emptyDigest, _testKeyId);
|
||||
|
||||
// Assert
|
||||
signature.Should().NotBeNull();
|
||||
signature.Signature.Should().NotBeEmpty();
|
||||
|
||||
// Verify works
|
||||
(await _signer.VerifyBundleAsync(emptyDigest, signature)).Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Algorithm Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("ECDSA_P256")]
|
||||
[InlineData("Ed25519")]
|
||||
[InlineData("RSA_PSS_SHA256")]
|
||||
public async Task Sign_SupportsMultipleAlgorithms(string algorithm)
|
||||
{
|
||||
// Arrange
|
||||
var signer = new TestOrgKeySigner(algorithm);
|
||||
var bundleDigest = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"test-{algorithm}"));
|
||||
|
||||
// Act
|
||||
var signature = await signer.SignBundleAsync(bundleDigest, _testKeyId);
|
||||
|
||||
// Assert
|
||||
signature.Algorithm.Should().Be(algorithm);
|
||||
(await signer.VerifyBundleAsync(bundleDigest, signature)).Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Timestamp Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Sign_IncludesAccurateTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var beforeSign = DateTimeOffset.UtcNow;
|
||||
var bundleDigest = SHA256.HashData("timestamp-test"u8.ToArray());
|
||||
|
||||
// Act
|
||||
var signature = await _signer.SignBundleAsync(bundleDigest, _testKeyId);
|
||||
var afterSign = DateTimeOffset.UtcNow;
|
||||
|
||||
// Assert
|
||||
signature.SignedAt.Should().BeOnOrAfter(beforeSign);
|
||||
signature.SignedAt.Should().BeOnOrBefore(afterSign);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of IOrgKeySigner for unit testing.
|
||||
/// Uses in-memory keys for sign/verify operations.
|
||||
/// </summary>
|
||||
internal sealed class TestOrgKeySigner : IOrgKeySigner
|
||||
{
|
||||
private readonly Dictionary<string, ECDsa> _keys = new();
|
||||
private readonly string _algorithm;
|
||||
|
||||
public TestOrgKeySigner(string algorithm = "ECDSA_P256")
|
||||
{
|
||||
_algorithm = algorithm;
|
||||
}
|
||||
|
||||
public Task<OrgSignature> SignBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetOrCreateKey(keyId);
|
||||
var signature = key.SignData(bundleDigest, HashAlgorithmName.SHA256);
|
||||
|
||||
return Task.FromResult(new OrgSignature
|
||||
{
|
||||
KeyId = keyId,
|
||||
Algorithm = _algorithm,
|
||||
Signature = Convert.ToBase64String(signature),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = GenerateMockCertificateChain()
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> VerifyBundleAsync(
|
||||
byte[] bundleDigest,
|
||||
OrgSignature signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_keys.TryGetValue(signature.KeyId, out var key))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(signature.Signature);
|
||||
var isValid = key.VerifyData(bundleDigest, signatureBytes, HashAlgorithmName.SHA256);
|
||||
return Task.FromResult(isValid);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<string> GetActiveKeyIdAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var activeKey = _keys.Keys.FirstOrDefault();
|
||||
if (activeKey == null)
|
||||
{
|
||||
throw new InvalidOperationException("No active signing key.");
|
||||
}
|
||||
return Task.FromResult(activeKey);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<OrgKeyInfo>> ListKeysAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<OrgKeyInfo>>(
|
||||
_keys.Keys.Select(keyId => new OrgKeyInfo(
|
||||
keyId,
|
||||
_algorithm,
|
||||
$"fingerprint-{keyId}",
|
||||
DateTimeOffset.UtcNow.AddMonths(-1),
|
||||
null,
|
||||
true)).ToList());
|
||||
}
|
||||
|
||||
private ECDsa GetOrCreateKey(string keyId)
|
||||
{
|
||||
if (!_keys.TryGetValue(keyId, out var key))
|
||||
{
|
||||
key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
_keys[keyId] = key;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GenerateMockCertificateChain()
|
||||
{
|
||||
// Return mock PEM certificates for testing
|
||||
return new[]
|
||||
{
|
||||
"-----BEGIN CERTIFICATE-----\nMIIBkjCB/AIJAKHBfpegPjEFMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl\nc3QtY2EwHhcNMjUwMTAxMDAwMDAwWhcNMjYwMTAxMDAwMDAwWjARMQ8wDQYDVQQD\nDAZ0ZXN0LWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtest\n-----END CERTIFICATE-----"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RetentionPolicyEnforcerTests.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0022 - Unit tests: Retention policy
|
||||
// Description: Unit tests for RetentionPolicyEnforcer service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Configuration;
|
||||
using StellaOps.Attestor.Bundling.Services;
|
||||
|
||||
namespace StellaOps.Attestor.Bundling.Tests;
|
||||
|
||||
public class RetentionPolicyEnforcerTests
|
||||
{
|
||||
private readonly Mock<IBundleStore> _storeMock;
|
||||
private readonly Mock<IBundleArchiver> _archiverMock;
|
||||
private readonly Mock<IBundleExpiryNotifier> _notifierMock;
|
||||
private readonly Mock<ILogger<RetentionPolicyEnforcer>> _loggerMock;
|
||||
|
||||
public RetentionPolicyEnforcerTests()
|
||||
{
|
||||
_storeMock = new Mock<IBundleStore>();
|
||||
_archiverMock = new Mock<IBundleArchiver>();
|
||||
_notifierMock = new Mock<IBundleExpiryNotifier>();
|
||||
_loggerMock = new Mock<ILogger<RetentionPolicyEnforcer>>();
|
||||
}
|
||||
|
||||
#region CalculateExpiryDate Tests
|
||||
|
||||
[Fact]
|
||||
public void CalculateExpiryDate_DefaultSettings_ReturnsCreatedPlusDefaultMonths()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(new BundleRetentionOptions { DefaultMonths = 24 });
|
||||
var enforcer = CreateEnforcer(options);
|
||||
var createdAt = new DateTimeOffset(2024, 6, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
var expiryDate = enforcer.CalculateExpiryDate(null, createdAt);
|
||||
|
||||
// Assert
|
||||
expiryDate.Should().Be(new DateTimeOffset(2026, 6, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateExpiryDate_WithTenantOverride_UsesTenantSpecificRetention()
|
||||
{
|
||||
// Arrange
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
DefaultMonths = 24,
|
||||
TenantOverrides = new Dictionary<string, int>
|
||||
{
|
||||
["tenant-gov"] = 84, // 7 years
|
||||
["tenant-finance"] = 120 // 10 years
|
||||
}
|
||||
};
|
||||
var options = CreateOptions(retentionOptions);
|
||||
var enforcer = CreateEnforcer(options);
|
||||
var createdAt = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
var govExpiry = enforcer.CalculateExpiryDate("tenant-gov", createdAt);
|
||||
var financeExpiry = enforcer.CalculateExpiryDate("tenant-finance", createdAt);
|
||||
var defaultExpiry = enforcer.CalculateExpiryDate("other-tenant", createdAt);
|
||||
|
||||
// Assert
|
||||
govExpiry.Should().Be(new DateTimeOffset(2031, 1, 1, 0, 0, 0, TimeSpan.Zero)); // +84 months
|
||||
financeExpiry.Should().Be(new DateTimeOffset(2034, 1, 1, 0, 0, 0, TimeSpan.Zero)); // +120 months
|
||||
defaultExpiry.Should().Be(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); // +24 months
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateExpiryDate_TenantOverrideBelowMinimum_UsesMinimum()
|
||||
{
|
||||
// Arrange
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
DefaultMonths = 24,
|
||||
MinimumMonths = 6,
|
||||
TenantOverrides = new Dictionary<string, int>
|
||||
{
|
||||
["short-tenant"] = 3 // Below minimum
|
||||
}
|
||||
};
|
||||
var options = CreateOptions(retentionOptions);
|
||||
var enforcer = CreateEnforcer(options);
|
||||
var createdAt = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
var expiry = enforcer.CalculateExpiryDate("short-tenant", createdAt);
|
||||
|
||||
// Assert - Should use minimum of 6 months, not 3
|
||||
expiry.Should().Be(new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateExpiryDate_TenantOverrideAboveMaximum_UsesMaximum()
|
||||
{
|
||||
// Arrange
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
DefaultMonths = 24,
|
||||
MaximumMonths = 120, // 10 years max
|
||||
TenantOverrides = new Dictionary<string, int>
|
||||
{
|
||||
["forever-tenant"] = 240 // 20 years - above maximum
|
||||
}
|
||||
};
|
||||
var options = CreateOptions(retentionOptions);
|
||||
var enforcer = CreateEnforcer(options);
|
||||
var createdAt = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
var expiry = enforcer.CalculateExpiryDate("forever-tenant", createdAt);
|
||||
|
||||
// Assert - Should cap at maximum of 120 months
|
||||
expiry.Should().Be(new DateTimeOffset(2034, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateExpiryDate_WithBundleListItem_UsesCreatedAtFromItem()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(new BundleRetentionOptions { DefaultMonths = 12 });
|
||||
var enforcer = CreateEnforcer(options);
|
||||
var bundle = CreateBundleListItem("bundle-1", new DateTimeOffset(2024, 3, 15, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
// Act
|
||||
var expiry = enforcer.CalculateExpiryDate(bundle);
|
||||
|
||||
// Assert
|
||||
expiry.Should().Be(new DateTimeOffset(2025, 3, 15, 0, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnforceAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WhenDisabled_ReturnsEarlyWithZeroCounts()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(new BundleRetentionOptions { Enabled = false });
|
||||
var enforcer = CreateEnforcer(options);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.EnforceAsync();
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.BundlesEvaluated.Should().Be(0);
|
||||
result.BundlesDeleted.Should().Be(0);
|
||||
result.BundlesArchived.Should().Be(0);
|
||||
result.BundlesMarkedExpired.Should().Be(0);
|
||||
|
||||
_storeMock.Verify(x => x.ListBundlesAsync(
|
||||
It.IsAny<BundleListRequest>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WithExpiredBundles_DeletesWhenActionIsDelete()
|
||||
{
|
||||
// Arrange
|
||||
var expiredBundle = CreateBundleListItem("expired-1", DateTimeOffset.UtcNow.AddMonths(-36)); // 3 years old
|
||||
var activeBundles = CreateBundleListItem("active-1", DateTimeOffset.UtcNow.AddMonths(-6)); // 6 months old
|
||||
|
||||
SetupBundleStore(expiredBundle, activeBundles);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.DeleteBundleAsync("expired-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultMonths = 24,
|
||||
GracePeriodDays = 0, // No grace period for test
|
||||
ExpiryAction = RetentionAction.Delete
|
||||
};
|
||||
|
||||
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
|
||||
|
||||
// Act
|
||||
var result = await enforcer.EnforceAsync();
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.BundlesEvaluated.Should().Be(2);
|
||||
result.BundlesDeleted.Should().Be(1);
|
||||
|
||||
_storeMock.Verify(x => x.DeleteBundleAsync("expired-1", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WithExpiredBundles_ArchivesWhenActionIsArchive()
|
||||
{
|
||||
// Arrange
|
||||
var expiredBundle = CreateBundleListItem("expired-1", DateTimeOffset.UtcNow.AddMonths(-36));
|
||||
|
||||
SetupBundleStore(expiredBundle);
|
||||
|
||||
_archiverMock
|
||||
.Setup(x => x.ArchiveAsync("expired-1", "glacier", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultMonths = 24,
|
||||
GracePeriodDays = 0,
|
||||
ExpiryAction = RetentionAction.Archive,
|
||||
ArchiveStorageTier = "glacier"
|
||||
};
|
||||
|
||||
var enforcer = CreateEnforcer(CreateOptions(retentionOptions), _archiverMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.EnforceAsync();
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.BundlesArchived.Should().Be(1);
|
||||
|
||||
_archiverMock.Verify(x => x.ArchiveAsync("expired-1", "glacier", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_WithExpiredBundles_MarksOnlyWhenActionIsMarkOnly()
|
||||
{
|
||||
// Arrange
|
||||
var expiredBundle = CreateBundleListItem("expired-1", DateTimeOffset.UtcNow.AddMonths(-36));
|
||||
|
||||
SetupBundleStore(expiredBundle);
|
||||
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultMonths = 24,
|
||||
GracePeriodDays = 0,
|
||||
ExpiryAction = RetentionAction.MarkOnly
|
||||
};
|
||||
|
||||
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
|
||||
|
||||
// Act
|
||||
var result = await enforcer.EnforceAsync();
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.BundlesMarkedExpired.Should().Be(1);
|
||||
result.BundlesDeleted.Should().Be(0);
|
||||
result.BundlesArchived.Should().Be(0);
|
||||
|
||||
// Verify no delete or archive was called
|
||||
_storeMock.Verify(x => x.DeleteBundleAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_BundleInGracePeriod_MarksExpiredButDoesNotDelete()
|
||||
{
|
||||
// Arrange
|
||||
// Bundle expired 15 days ago (within 30-day grace period)
|
||||
var gracePeriodBundle = CreateBundleListItem(
|
||||
"grace-1",
|
||||
DateTimeOffset.UtcNow.AddMonths(-24).AddDays(-15));
|
||||
|
||||
SetupBundleStore(gracePeriodBundle);
|
||||
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultMonths = 24,
|
||||
GracePeriodDays = 30,
|
||||
ExpiryAction = RetentionAction.Delete
|
||||
};
|
||||
|
||||
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
|
||||
|
||||
// Act
|
||||
var result = await enforcer.EnforceAsync();
|
||||
|
||||
// Assert
|
||||
result.BundlesMarkedExpired.Should().Be(1);
|
||||
result.BundlesDeleted.Should().Be(0);
|
||||
|
||||
_storeMock.Verify(x => x.DeleteBundleAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_BundlePastGracePeriod_DeletesBundle()
|
||||
{
|
||||
// Arrange
|
||||
// Bundle expired 45 days ago (past 30-day grace period)
|
||||
var pastGraceBundle = CreateBundleListItem(
|
||||
"past-grace-1",
|
||||
DateTimeOffset.UtcNow.AddMonths(-24).AddDays(-45));
|
||||
|
||||
SetupBundleStore(pastGraceBundle);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.DeleteBundleAsync("past-grace-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultMonths = 24,
|
||||
GracePeriodDays = 30,
|
||||
ExpiryAction = RetentionAction.Delete
|
||||
};
|
||||
|
||||
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
|
||||
|
||||
// Act
|
||||
var result = await enforcer.EnforceAsync();
|
||||
|
||||
// Assert
|
||||
result.BundlesDeleted.Should().Be(1);
|
||||
_storeMock.Verify(x => x.DeleteBundleAsync("past-grace-1", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_BundleApproachingExpiry_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
// Bundle will expire in 15 days (within 30-day notification window)
|
||||
var approachingBundle = CreateBundleListItem(
|
||||
"approaching-1",
|
||||
DateTimeOffset.UtcNow.AddMonths(-24).AddDays(15));
|
||||
|
||||
SetupBundleStore(approachingBundle);
|
||||
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultMonths = 24,
|
||||
NotifyBeforeExpiry = true,
|
||||
NotifyDaysBeforeExpiry = 30
|
||||
};
|
||||
|
||||
var enforcer = CreateEnforcer(CreateOptions(retentionOptions), notifier: _notifierMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.EnforceAsync();
|
||||
|
||||
// Assert
|
||||
result.BundlesApproachingExpiry.Should().Be(1);
|
||||
|
||||
_notifierMock.Verify(x => x.NotifyAsync(
|
||||
It.Is<IReadOnlyList<BundleExpiryNotification>>(n =>
|
||||
n.Count == 1 &&
|
||||
n[0].BundleId == "approaching-1"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_NoArchiverConfigured_ReturnsFailureForArchiveAction()
|
||||
{
|
||||
// Arrange
|
||||
var expiredBundle = CreateBundleListItem("expired-1", DateTimeOffset.UtcNow.AddMonths(-36));
|
||||
|
||||
SetupBundleStore(expiredBundle);
|
||||
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultMonths = 24,
|
||||
GracePeriodDays = 0,
|
||||
ExpiryAction = RetentionAction.Archive
|
||||
};
|
||||
|
||||
// Create enforcer WITHOUT archiver
|
||||
var enforcer = CreateEnforcer(CreateOptions(retentionOptions), archiver: null);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.EnforceAsync();
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Failures.Should().HaveCount(1);
|
||||
result.Failures[0].BundleId.Should().Be("expired-1");
|
||||
result.Failures[0].Reason.Should().Be("Archive unavailable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_DeleteFails_RecordsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var expiredBundle = CreateBundleListItem("expired-1", DateTimeOffset.UtcNow.AddMonths(-36));
|
||||
|
||||
SetupBundleStore(expiredBundle);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.DeleteBundleAsync("expired-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false); // Simulate delete failure
|
||||
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultMonths = 24,
|
||||
GracePeriodDays = 0,
|
||||
ExpiryAction = RetentionAction.Delete
|
||||
};
|
||||
|
||||
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
|
||||
|
||||
// Act
|
||||
var result = await enforcer.EnforceAsync();
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.BundlesDeleted.Should().Be(0);
|
||||
result.Failures.Should().HaveCount(1);
|
||||
result.Failures[0].BundleId.Should().Be("expired-1");
|
||||
result.Failures[0].Reason.Should().Be("Delete failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceAsync_RespectsMaxBundlesPerRun_StopsFetchingAfterLimit()
|
||||
{
|
||||
// Arrange
|
||||
// First batch returns 5 bundles with cursor for more
|
||||
var batch1 = Enumerable.Range(1, 5)
|
||||
.Select(i => CreateBundleListItem($"bundle-{i}", DateTimeOffset.UtcNow.AddMonths(-36)))
|
||||
.ToList();
|
||||
|
||||
// Second batch would return 5 more, but should not be fetched
|
||||
var batch2 = Enumerable.Range(6, 5)
|
||||
.Select(i => CreateBundleListItem($"bundle-{i}", DateTimeOffset.UtcNow.AddMonths(-36)))
|
||||
.ToList();
|
||||
|
||||
var callCount = 0;
|
||||
_storeMock
|
||||
.Setup(x => x.ListBundlesAsync(It.IsAny<BundleListRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
return callCount == 1
|
||||
? new BundleListResult(batch1, "cursor2") // Has more pages
|
||||
: new BundleListResult(batch2, null); // Last page
|
||||
});
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.DeleteBundleAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var retentionOptions = new BundleRetentionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultMonths = 24,
|
||||
GracePeriodDays = 0,
|
||||
ExpiryAction = RetentionAction.Delete,
|
||||
MaxBundlesPerRun = 5
|
||||
};
|
||||
|
||||
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
|
||||
|
||||
// Act
|
||||
var result = await enforcer.EnforceAsync();
|
||||
|
||||
// Assert
|
||||
// Should evaluate first batch (5) and stop before fetching second batch
|
||||
result.BundlesEvaluated.Should().Be(5);
|
||||
callCount.Should().Be(1, "should only fetch one batch when limit is reached");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetApproachingExpiryAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetApproachingExpiryAsync_ReturnsBundlesWithinCutoff()
|
||||
{
|
||||
// Arrange
|
||||
var expiresIn10Days = CreateBundleListItem("expires-10", DateTimeOffset.UtcNow.AddMonths(-24).AddDays(10));
|
||||
var expiresIn45Days = CreateBundleListItem("expires-45", DateTimeOffset.UtcNow.AddMonths(-24).AddDays(45));
|
||||
var alreadyExpired = CreateBundleListItem("expired", DateTimeOffset.UtcNow.AddMonths(-25));
|
||||
|
||||
SetupBundleStore(expiresIn10Days, expiresIn45Days, alreadyExpired);
|
||||
|
||||
var options = CreateOptions(new BundleRetentionOptions { DefaultMonths = 24 });
|
||||
var enforcer = CreateEnforcer(options);
|
||||
|
||||
// Act
|
||||
var notifications = await enforcer.GetApproachingExpiryAsync(daysBeforeExpiry: 30);
|
||||
|
||||
// Assert
|
||||
notifications.Should().HaveCount(1);
|
||||
notifications[0].BundleId.Should().Be("expires-10");
|
||||
notifications[0].DaysUntilExpiry.Should().BeCloseTo(10, 1); // Allow 1 day tolerance
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private IOptions<BundlingOptions> CreateOptions(BundleRetentionOptions retentionOptions)
|
||||
{
|
||||
return Options.Create(new BundlingOptions
|
||||
{
|
||||
Retention = retentionOptions
|
||||
});
|
||||
}
|
||||
|
||||
private RetentionPolicyEnforcer CreateEnforcer(
|
||||
IOptions<BundlingOptions> options,
|
||||
IBundleArchiver? archiver = null,
|
||||
IBundleExpiryNotifier? notifier = null)
|
||||
{
|
||||
return new RetentionPolicyEnforcer(
|
||||
_storeMock.Object,
|
||||
options,
|
||||
_loggerMock.Object,
|
||||
archiver,
|
||||
notifier);
|
||||
}
|
||||
|
||||
private void SetupBundleStore(params BundleListItem[] bundles)
|
||||
{
|
||||
_storeMock
|
||||
.Setup(x => x.ListBundlesAsync(It.IsAny<BundleListRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BundleListResult(bundles.ToList(), null));
|
||||
}
|
||||
|
||||
private static BundleListItem CreateBundleListItem(string bundleId, DateTimeOffset createdAt)
|
||||
{
|
||||
return new BundleListItem(
|
||||
BundleId: bundleId,
|
||||
PeriodStart: createdAt.AddDays(-30),
|
||||
PeriodEnd: createdAt,
|
||||
AttestationCount: 100,
|
||||
CreatedAt: createdAt,
|
||||
HasOrgSignature: false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Attestor.Bundling.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,387 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FileSystemRootStoreTests.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0023 - Unit tests for FileSystemRootStore
|
||||
// Description: Unit tests for file-based root certificate store
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Tests;
|
||||
|
||||
public class FileSystemRootStoreTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<FileSystemRootStore>> _loggerMock;
|
||||
private readonly string _testRootPath;
|
||||
|
||||
public FileSystemRootStoreTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<FileSystemRootStore>>();
|
||||
_testRootPath = Path.Combine(Path.GetTempPath(), $"stellaops-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testRootPath);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testRootPath))
|
||||
{
|
||||
Directory.Delete(_testRootPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithNoCertificates_ReturnsEmptyCollection()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions();
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithPemFile_ReturnsCertificates()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Test Fulcio Root");
|
||||
var pemPath = Path.Combine(_testRootPath, "fulcio.pem");
|
||||
await WritePemFileAsync(pemPath, cert);
|
||||
|
||||
var options = CreateOptions(fulcioPath: pemPath);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(1);
|
||||
roots[0].Subject.Should().Be("CN=Test Fulcio Root");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithDirectory_LoadsAllPemFiles()
|
||||
{
|
||||
// Arrange
|
||||
var fulcioDir = Path.Combine(_testRootPath, "fulcio");
|
||||
Directory.CreateDirectory(fulcioDir);
|
||||
|
||||
var cert1 = CreateTestCertificate("CN=Root 1");
|
||||
var cert2 = CreateTestCertificate("CN=Root 2");
|
||||
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "root1.pem"), cert1);
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "root2.pem"), cert2);
|
||||
|
||||
var options = CreateOptions(fulcioPath: fulcioDir);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_CachesCertificates_OnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Cached Root");
|
||||
var pemPath = Path.Combine(_testRootPath, "cached.pem");
|
||||
await WritePemFileAsync(pemPath, cert);
|
||||
|
||||
var options = CreateOptions(fulcioPath: pemPath);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots1 = await store.GetFulcioRootsAsync();
|
||||
var roots2 = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert - same collection instance (cached)
|
||||
roots1.Should().HaveCount(1);
|
||||
roots2.Should().HaveCount(1);
|
||||
// Both calls should return same data
|
||||
roots1[0].Subject.Should().Be(roots2[0].Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportRootsAsync_WithValidPem_SavesCertificates()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Imported Root");
|
||||
var sourcePath = Path.Combine(_testRootPath, "import-source.pem");
|
||||
await WritePemFileAsync(sourcePath, cert);
|
||||
|
||||
var options = CreateOptions();
|
||||
options.Value.BaseRootPath = _testRootPath;
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
await store.ImportRootsAsync(sourcePath, RootType.Fulcio);
|
||||
|
||||
// Assert
|
||||
var targetDir = Path.Combine(_testRootPath, "fulcio");
|
||||
Directory.Exists(targetDir).Should().BeTrue();
|
||||
Directory.EnumerateFiles(targetDir, "*.pem").Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportRootsAsync_WithMissingFile_ThrowsFileNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions();
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => store.ImportRootsAsync("/nonexistent/path.pem", RootType.Fulcio));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportRootsAsync_InvalidatesCacheAfterImport()
|
||||
{
|
||||
// Arrange
|
||||
var cert1 = CreateTestCertificate("CN=Initial Root");
|
||||
var fulcioDir = Path.Combine(_testRootPath, "fulcio");
|
||||
Directory.CreateDirectory(fulcioDir);
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "initial.pem"), cert1);
|
||||
|
||||
var options = CreateOptions(fulcioPath: fulcioDir);
|
||||
options.Value.BaseRootPath = _testRootPath;
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Load initial cache
|
||||
var initialRoots = await store.GetFulcioRootsAsync();
|
||||
initialRoots.Should().HaveCount(1);
|
||||
|
||||
// Import a new certificate
|
||||
var cert2 = CreateTestCertificate("CN=Imported Root");
|
||||
var importPath = Path.Combine(_testRootPath, "import.pem");
|
||||
await WritePemFileAsync(importPath, cert2);
|
||||
|
||||
// Act
|
||||
await store.ImportRootsAsync(importPath, RootType.Fulcio);
|
||||
var updatedRoots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert - cache invalidated and new cert loaded
|
||||
updatedRoots.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListRootsAsync_ReturnsCorrectInfo()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Listed Root");
|
||||
var fulcioDir = Path.Combine(_testRootPath, "fulcio");
|
||||
Directory.CreateDirectory(fulcioDir);
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "root.pem"), cert);
|
||||
|
||||
var options = CreateOptions(fulcioPath: fulcioDir);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.ListRootsAsync(RootType.Fulcio);
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(1);
|
||||
roots[0].Subject.Should().Be("CN=Listed Root");
|
||||
roots[0].RootType.Should().Be(RootType.Fulcio);
|
||||
roots[0].Thumbprint.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrgKeyByIdAsync_WithMatchingThumbprint_ReturnsCertificate()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Org Signing Key");
|
||||
var orgDir = Path.Combine(_testRootPath, "org-signing");
|
||||
Directory.CreateDirectory(orgDir);
|
||||
await WritePemFileAsync(Path.Combine(orgDir, "org.pem"), cert);
|
||||
|
||||
var options = CreateOptions(orgSigningPath: orgDir);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// First, verify the cert was loaded and get its thumbprint from listing
|
||||
var orgKeys = await store.GetOrgSigningKeysAsync();
|
||||
orgKeys.Should().HaveCount(1);
|
||||
|
||||
// Get the thumbprint from the loaded certificate
|
||||
var thumbprint = ComputeThumbprint(orgKeys[0]);
|
||||
|
||||
// Act
|
||||
var found = await store.GetOrgKeyByIdAsync(thumbprint);
|
||||
|
||||
// Assert
|
||||
found.Should().NotBeNull();
|
||||
found!.Subject.Should().Be("CN=Org Signing Key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrgKeyByIdAsync_WithNoMatch_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Org Key");
|
||||
var orgDir = Path.Combine(_testRootPath, "org-signing");
|
||||
Directory.CreateDirectory(orgDir);
|
||||
await WritePemFileAsync(Path.Combine(orgDir, "org.pem"), cert);
|
||||
|
||||
var options = CreateOptions(orgSigningPath: orgDir);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var found = await store.GetOrgKeyByIdAsync("nonexistent-key-id");
|
||||
|
||||
// Assert
|
||||
found.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRekorKeysAsync_WithPemFile_ReturnsCertificates()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Rekor Key");
|
||||
var rekorPath = Path.Combine(_testRootPath, "rekor.pem");
|
||||
await WritePemFileAsync(rekorPath, cert);
|
||||
|
||||
var options = CreateOptions(rekorPath: rekorPath);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var keys = await store.GetRekorKeysAsync();
|
||||
|
||||
// Assert
|
||||
keys.Should().HaveCount(1);
|
||||
keys[0].Subject.Should().Be("CN=Rekor Key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadPem_WithMultipleCertificates_ReturnsAll()
|
||||
{
|
||||
// Arrange
|
||||
var cert1 = CreateTestCertificate("CN=Cert 1");
|
||||
var cert2 = CreateTestCertificate("CN=Cert 2");
|
||||
var cert3 = CreateTestCertificate("CN=Cert 3");
|
||||
|
||||
var pemPath = Path.Combine(_testRootPath, "multi.pem");
|
||||
await WriteMultiplePemFileAsync(pemPath, [cert1, cert2, cert3]);
|
||||
|
||||
var options = CreateOptions(fulcioPath: pemPath);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithOfflineKitPath_LoadsFromKit()
|
||||
{
|
||||
// Arrange
|
||||
var offlineKitPath = Path.Combine(_testRootPath, "offline-kit");
|
||||
var fulcioKitDir = Path.Combine(offlineKitPath, "roots", "fulcio");
|
||||
Directory.CreateDirectory(fulcioKitDir);
|
||||
|
||||
var cert = CreateTestCertificate("CN=Offline Kit Root");
|
||||
await WritePemFileAsync(Path.Combine(fulcioKitDir, "root.pem"), cert);
|
||||
|
||||
var options = Options.Create(new OfflineRootStoreOptions
|
||||
{
|
||||
BaseRootPath = _testRootPath,
|
||||
OfflineKitPath = offlineKitPath,
|
||||
UseOfflineKit = true
|
||||
});
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(1);
|
||||
roots[0].Subject.Should().Be("CN=Offline Kit Root");
|
||||
}
|
||||
|
||||
private FileSystemRootStore CreateStore(IOptions<OfflineRootStoreOptions> options)
|
||||
{
|
||||
return new FileSystemRootStore(_loggerMock.Object, options);
|
||||
}
|
||||
|
||||
private IOptions<OfflineRootStoreOptions> CreateOptions(
|
||||
string? fulcioPath = null,
|
||||
string? orgSigningPath = null,
|
||||
string? rekorPath = null)
|
||||
{
|
||||
return Options.Create(new OfflineRootStoreOptions
|
||||
{
|
||||
BaseRootPath = _testRootPath,
|
||||
FulcioBundlePath = fulcioPath,
|
||||
OrgSigningBundlePath = orgSigningPath,
|
||||
RekorBundlePath = rekorPath
|
||||
});
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateTestCertificate(string subject)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
subject,
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
// Add basic constraints for a CA certificate
|
||||
request.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
|
||||
// Add Subject Key Identifier
|
||||
request.CertificateExtensions.Add(
|
||||
new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
|
||||
|
||||
var notBefore = DateTimeOffset.UtcNow.AddDays(-1);
|
||||
var notAfter = DateTimeOffset.UtcNow.AddYears(10);
|
||||
|
||||
return request.CreateSelfSigned(notBefore, notAfter);
|
||||
}
|
||||
|
||||
private static async Task WritePemFileAsync(string path, X509Certificate2 cert)
|
||||
{
|
||||
var pem = new StringBuilder();
|
||||
pem.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
pem.AppendLine(Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks));
|
||||
pem.AppendLine("-----END CERTIFICATE-----");
|
||||
|
||||
await File.WriteAllTextAsync(path, pem.ToString());
|
||||
}
|
||||
|
||||
private static async Task WriteMultiplePemFileAsync(string path, X509Certificate2[] certs)
|
||||
{
|
||||
var pem = new StringBuilder();
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
pem.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
pem.AppendLine(Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks));
|
||||
pem.AppendLine("-----END CERTIFICATE-----");
|
||||
pem.AppendLine();
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(path, pem.ToString());
|
||||
}
|
||||
|
||||
private static string ComputeThumbprint(X509Certificate2 cert)
|
||||
{
|
||||
var hash = SHA256.HashData(cert.RawData);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineCertChainValidatorTests.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0022 - Unit tests for certificate chain validation
|
||||
// Description: Unit tests for offline certificate chain validation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Tests;
|
||||
|
||||
public class OfflineCertChainValidatorTests
|
||||
{
|
||||
private readonly Mock<ILogger<OfflineVerifier>> _loggerMock;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly IOptions<OfflineVerificationConfig> _config;
|
||||
|
||||
public OfflineCertChainValidatorTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<OfflineVerifier>>();
|
||||
_merkleBuilder = new DeterministicMerkleTreeBuilder();
|
||||
_config = Options.Create(new OfflineVerificationConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithValidCertChain_ChainIsValid()
|
||||
{
|
||||
// Arrange
|
||||
var (rootCert, leafCert) = CreateCertificateChain();
|
||||
var attestation = CreateAttestationWithCertChain(leafCert, rootCert);
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeTrue();
|
||||
result.Issues.Should().NotContain(i => i.Code.Contains("CERT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithUntrustedRoot_ChainIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var (rootCert, leafCert) = CreateCertificateChain();
|
||||
var untrustedRoot = CreateSelfSignedCertificate("CN=Untrusted Root CA");
|
||||
var attestation = CreateAttestationWithCertChain(leafCert, rootCert);
|
||||
|
||||
// Root store has a different root
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { untrustedRoot });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithMissingCertChain_ReturnsIssue()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateAttestationWithoutCertChain();
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(Array.Empty<X509Certificate2>());
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT") || i.Code.Contains("CHAIN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithExpiredCert_ChainIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var expiredCert = CreateExpiredCertificate("CN=Expired Leaf");
|
||||
var rootCert = CreateSelfSignedCertificate("CN=Test Root CA");
|
||||
var attestation = CreateAttestationWithCertChain(expiredCert, rootCert);
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithNotYetValidCert_ChainIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var futureCert = CreateFutureCertificate("CN=Future Leaf");
|
||||
var rootCert = CreateSelfSignedCertificate("CN=Test Root CA");
|
||||
var attestation = CreateAttestationWithCertChain(futureCert, rootCert);
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundle_WithMultipleAttestations_ValidatesCertChainsForAll()
|
||||
{
|
||||
// Arrange
|
||||
var (rootCert, leafCert1) = CreateCertificateChain();
|
||||
|
||||
var attestation1 = CreateAttestationWithCertChain(leafCert1, rootCert, "entry-001");
|
||||
var attestation2 = CreateAttestationWithCertChain(leafCert1, rootCert, "entry-002");
|
||||
|
||||
var bundle = CreateBundleFromAttestations(new[] { attestation1, attestation2 });
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_CertChainValidationSkipped_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateAttestationWithoutCertChain();
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(Array.Empty<X509Certificate2>());
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false); // Disabled
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert - When cert chain validation is disabled, it should not report cert-related issues
|
||||
result.Issues.Should().NotContain(i => i.Code.Contains("CERT_CHAIN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithSelfSignedLeaf_ChainIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var selfSignedLeaf = CreateSelfSignedCertificate("CN=Self Signed Leaf");
|
||||
var rootCert = CreateSelfSignedCertificate("CN=Different Root CA");
|
||||
var attestation = CreateAttestationWithCertChain(selfSignedLeaf);
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithEmptyRootStore_ChainIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var (rootCert, leafCert) = CreateCertificateChain();
|
||||
var attestation = CreateAttestationWithCertChain(leafCert, rootCert);
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(Array.Empty<X509Certificate2>());
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
private OfflineVerifier CreateVerifier(IOfflineRootStore rootStore)
|
||||
{
|
||||
return new OfflineVerifier(
|
||||
rootStore,
|
||||
_merkleBuilder,
|
||||
_loggerMock.Object,
|
||||
_config,
|
||||
null);
|
||||
}
|
||||
|
||||
private static IOfflineRootStore CreateRootStoreWithCerts(X509Certificate2[] certs)
|
||||
{
|
||||
var mock = new Mock<IOfflineRootStore>();
|
||||
mock.Setup(x => x.GetFulcioRootsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new X509Certificate2Collection(certs));
|
||||
mock.Setup(x => x.GetOrgSigningKeysAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new X509Certificate2Collection());
|
||||
mock.Setup(x => x.GetRekorKeysAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new X509Certificate2Collection());
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static (X509Certificate2 Root, X509Certificate2 Leaf) CreateCertificateChain()
|
||||
{
|
||||
using var rootKey = RSA.Create(2048);
|
||||
var rootRequest = new CertificateRequest(
|
||||
"CN=Test Fulcio Root CA",
|
||||
rootKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
rootRequest.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(true, true, 1, true));
|
||||
rootRequest.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
|
||||
|
||||
var rootCert = rootRequest.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow.AddYears(10));
|
||||
|
||||
using var leafKey = RSA.Create(2048);
|
||||
var leafRequest = new CertificateRequest(
|
||||
"CN=Sigstore Signer",
|
||||
leafKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
leafRequest.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
leafRequest.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
|
||||
|
||||
var leafCert = leafRequest.Create(
|
||||
rootCert,
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddMinutes(10),
|
||||
Guid.NewGuid().ToByteArray());
|
||||
|
||||
return (rootCert, leafCert);
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateSelfSignedCertificate(string subject)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
subject,
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
request.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
|
||||
return request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow.AddYears(10));
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateExpiredCertificate(string subject)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
subject,
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
return request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-365),
|
||||
DateTimeOffset.UtcNow.AddDays(-1));
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateFutureCertificate(string subject)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
subject,
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
return request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(1),
|
||||
DateTimeOffset.UtcNow.AddYears(1));
|
||||
}
|
||||
|
||||
private static BundledAttestation CreateAttestationWithCertChain(
|
||||
X509Certificate2 leafCert,
|
||||
X509Certificate2? rootCert = null,
|
||||
string entryId = "entry-001")
|
||||
{
|
||||
var certChain = new List<string> { ConvertToPem(leafCert) };
|
||||
if (rootCert != null)
|
||||
{
|
||||
certChain.Add(ConvertToPem(rootCert));
|
||||
}
|
||||
|
||||
return new BundledAttestation
|
||||
{
|
||||
EntryId = entryId,
|
||||
RekorUuid = Guid.NewGuid().ToString("N"),
|
||||
RekorLogIndex = 10000,
|
||||
ArtifactDigest = $"sha256:{entryId.PadRight(64, 'a')}",
|
||||
PredicateType = "verdict.stella/v1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SigningMode = "keyless",
|
||||
SigningIdentity = new SigningIdentity
|
||||
{
|
||||
Issuer = "https://authority.internal",
|
||||
Subject = "signer@stella-ops.org",
|
||||
San = "urn:stellaops:signer"
|
||||
},
|
||||
InclusionProof = new RekorInclusionProof
|
||||
{
|
||||
Checkpoint = new CheckpointData
|
||||
{
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Path = new List<string>
|
||||
{
|
||||
Convert.ToBase64String(new byte[32]),
|
||||
Convert.ToBase64String(new byte[32])
|
||||
}
|
||||
},
|
||||
Envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
|
||||
Signatures = new List<EnvelopeSignature>
|
||||
{
|
||||
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
|
||||
},
|
||||
CertificateChain = certChain
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static BundledAttestation CreateAttestationWithoutCertChain()
|
||||
{
|
||||
return new BundledAttestation
|
||||
{
|
||||
EntryId = "entry-no-chain",
|
||||
RekorUuid = Guid.NewGuid().ToString("N"),
|
||||
RekorLogIndex = 10000,
|
||||
ArtifactDigest = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
PredicateType = "verdict.stella/v1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SigningMode = "keyless",
|
||||
SigningIdentity = new SigningIdentity
|
||||
{
|
||||
Issuer = "https://authority.internal",
|
||||
Subject = "signer@stella-ops.org",
|
||||
San = "urn:stellaops:signer"
|
||||
},
|
||||
InclusionProof = new RekorInclusionProof
|
||||
{
|
||||
Checkpoint = new CheckpointData
|
||||
{
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Path = new List<string>()
|
||||
},
|
||||
Envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
|
||||
Signatures = new List<EnvelopeSignature>
|
||||
{
|
||||
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
|
||||
},
|
||||
CertificateChain = null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private AttestationBundle CreateBundleFromAttestations(BundledAttestation[] attestations)
|
||||
{
|
||||
var sortedAttestations = attestations
|
||||
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var leafValues = sortedAttestations
|
||||
.Select(a => (ReadOnlyMemory<byte>)System.Text.Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
var merkleRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
|
||||
var merkleRootHex = $"sha256:{Convert.ToHexString(merkleRoot).ToLowerInvariant()}";
|
||||
|
||||
return new AttestationBundle
|
||||
{
|
||||
Metadata = new BundleMetadata
|
||||
{
|
||||
BundleId = merkleRootHex,
|
||||
Version = "1.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PeriodStart = DateTimeOffset.UtcNow.AddDays(-30),
|
||||
PeriodEnd = DateTimeOffset.UtcNow,
|
||||
AttestationCount = attestations.Length
|
||||
},
|
||||
Attestations = attestations,
|
||||
MerkleTree = new MerkleTreeInfo
|
||||
{
|
||||
Algorithm = "SHA256",
|
||||
Root = merkleRootHex,
|
||||
LeafCount = attestations.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string ConvertToPem(X509Certificate2 cert)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(cert.RawData);
|
||||
return $"-----BEGIN CERTIFICATE-----\n{base64}\n-----END CERTIFICATE-----";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineVerifierTests.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0019-0022 - Unit tests for offline verification
|
||||
// Description: Unit tests for OfflineVerifier service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
// Alias to resolve ambiguity
|
||||
using Severity = StellaOps.Attestor.Offline.Models.VerificationIssueSeverity;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Tests;
|
||||
|
||||
public class OfflineVerifierTests
|
||||
{
|
||||
private readonly Mock<IOfflineRootStore> _rootStoreMock;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly Mock<IOrgKeySigner> _orgSignerMock;
|
||||
private readonly Mock<ILogger<OfflineVerifier>> _loggerMock;
|
||||
private readonly IOptions<OfflineVerificationConfig> _config;
|
||||
|
||||
public OfflineVerifierTests()
|
||||
{
|
||||
_rootStoreMock = new Mock<IOfflineRootStore>();
|
||||
_merkleBuilder = new DeterministicMerkleTreeBuilder();
|
||||
_orgSignerMock = new Mock<IOrgKeySigner>();
|
||||
_loggerMock = new Mock<ILogger<OfflineVerifier>>();
|
||||
_config = Options.Create(new OfflineVerificationConfig());
|
||||
|
||||
// Setup default root store behavior
|
||||
_rootStoreMock
|
||||
.Setup(x => x.GetFulcioRootsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new X509Certificate2Collection());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_ValidBundle_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle(5);
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: false, // Skip signature verification for this test
|
||||
VerifyCertificateChain: false,
|
||||
VerifyOrgSignature: false);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeTrue();
|
||||
result.MerkleProofValid.Should().BeTrue();
|
||||
result.Issues.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_TamperedMerkleRoot_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle(5);
|
||||
|
||||
// Tamper with the Merkle root
|
||||
var tamperedBundle = bundle with
|
||||
{
|
||||
MerkleTree = new MerkleTreeInfo
|
||||
{
|
||||
Algorithm = "SHA256",
|
||||
Root = "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
LeafCount = 5
|
||||
}
|
||||
};
|
||||
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(tamperedBundle, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.MerkleProofValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "MERKLE_ROOT_MISMATCH");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_MissingOrgSignature_WhenRequired_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle(3);
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false,
|
||||
VerifyOrgSignature: true,
|
||||
RequireOrgSignature: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.OrgSignatureValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "ORG_SIG_MISSING");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_WithValidOrgSignature_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle(3);
|
||||
var orgSignature = new OrgSignature
|
||||
{
|
||||
KeyId = "org-key-2025",
|
||||
Algorithm = "ECDSA_P256",
|
||||
Signature = Convert.ToBase64String(new byte[64]),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = null
|
||||
};
|
||||
|
||||
var signedBundle = bundle with { OrgSignature = orgSignature };
|
||||
|
||||
_orgSignerMock
|
||||
.Setup(x => x.VerifyBundleAsync(It.IsAny<byte[]>(), orgSignature, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false,
|
||||
VerifyOrgSignature: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(signedBundle, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeTrue();
|
||||
result.OrgSignatureValid.Should().BeTrue();
|
||||
result.OrgSignatureKeyId.Should().Be("org-key-2025");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_ValidAttestation_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateTestAttestation("entry-001");
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: true,
|
||||
VerifyCertificateChain: false);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeTrue();
|
||||
result.SignaturesValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_EmptySignature_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateTestAttestation("entry-001");
|
||||
|
||||
// Remove signatures
|
||||
var tamperedAttestation = attestation with
|
||||
{
|
||||
Envelope = attestation.Envelope with
|
||||
{
|
||||
Signatures = new List<EnvelopeSignature>()
|
||||
}
|
||||
};
|
||||
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: true,
|
||||
VerifyCertificateChain: false);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(tamperedAttestation, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.SignaturesValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "DSSE_NO_SIGNATURES");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVerificationSummariesAsync_ReturnsAllAttestations()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle(10);
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: true,
|
||||
VerifyCertificateChain: false);
|
||||
|
||||
// Act
|
||||
var summaries = await verifier.GetVerificationSummariesAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
summaries.Should().HaveCount(10);
|
||||
summaries.Should().OnlyContain(s => s.VerificationStatus == AttestationVerificationStatus.Valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_StrictMode_FailsOnWarnings()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateTestAttestation("entry-001");
|
||||
|
||||
// Add inclusion proof with empty path to trigger warning
|
||||
var attestationWithEmptyProof = attestation with
|
||||
{
|
||||
InclusionProof = new RekorInclusionProof
|
||||
{
|
||||
Checkpoint = new CheckpointData
|
||||
{
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Path = new List<string>() // Empty path triggers warning
|
||||
}
|
||||
};
|
||||
|
||||
var bundle = CreateTestBundleFromAttestations(new[] { attestationWithEmptyProof });
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: true, // Needs to be true to check attestation-level proofs
|
||||
VerifyCertificateChain: false,
|
||||
StrictMode: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Severity == Severity.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_DeterministicOrdering_SameMerkleValidation()
|
||||
{
|
||||
// Arrange
|
||||
var attestations = Enumerable.Range(0, 10)
|
||||
.Select(i => CreateTestAttestation($"entry-{i:D4}"))
|
||||
.ToArray();
|
||||
|
||||
// Create bundles with same attestations but different initial orders
|
||||
var bundle1 = CreateTestBundleFromAttestations(attestations.OrderBy(_ => Guid.NewGuid()).ToArray());
|
||||
var bundle2 = CreateTestBundleFromAttestations(attestations.OrderByDescending(a => a.EntryId).ToArray());
|
||||
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false);
|
||||
|
||||
// Act
|
||||
var result1 = await verifier.VerifyBundleAsync(bundle1, options);
|
||||
var result2 = await verifier.VerifyBundleAsync(bundle2, options);
|
||||
|
||||
// Assert - both should have the same merkle validation result
|
||||
result1.MerkleProofValid.Should().Be(result2.MerkleProofValid);
|
||||
}
|
||||
|
||||
private OfflineVerifier CreateVerifier()
|
||||
{
|
||||
return new OfflineVerifier(
|
||||
_rootStoreMock.Object,
|
||||
_merkleBuilder,
|
||||
_loggerMock.Object,
|
||||
_config,
|
||||
_orgSignerMock.Object);
|
||||
}
|
||||
|
||||
private AttestationBundle CreateTestBundle(int attestationCount)
|
||||
{
|
||||
var attestations = Enumerable.Range(0, attestationCount)
|
||||
.Select(i => CreateTestAttestation($"entry-{i:D4}"))
|
||||
.ToList();
|
||||
|
||||
return CreateTestBundleFromAttestations(attestations.ToArray());
|
||||
}
|
||||
|
||||
private AttestationBundle CreateTestBundleFromAttestations(BundledAttestation[] attestations)
|
||||
{
|
||||
// Sort deterministically for Merkle tree
|
||||
var sortedAttestations = attestations
|
||||
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Compute Merkle root
|
||||
var leafValues = sortedAttestations
|
||||
.Select(a => (ReadOnlyMemory<byte>)System.Text.Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
var merkleRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
|
||||
var merkleRootHex = $"sha256:{Convert.ToHexString(merkleRoot).ToLowerInvariant()}";
|
||||
|
||||
return new AttestationBundle
|
||||
{
|
||||
Metadata = new BundleMetadata
|
||||
{
|
||||
BundleId = merkleRootHex,
|
||||
Version = "1.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PeriodStart = DateTimeOffset.UtcNow.AddDays(-30),
|
||||
PeriodEnd = DateTimeOffset.UtcNow,
|
||||
AttestationCount = attestations.Length
|
||||
},
|
||||
Attestations = attestations,
|
||||
MerkleTree = new MerkleTreeInfo
|
||||
{
|
||||
Algorithm = "SHA256",
|
||||
Root = merkleRootHex,
|
||||
LeafCount = attestations.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static BundledAttestation CreateTestAttestation(string entryId)
|
||||
{
|
||||
return new BundledAttestation
|
||||
{
|
||||
EntryId = entryId,
|
||||
RekorUuid = Guid.NewGuid().ToString("N"),
|
||||
RekorLogIndex = 10000,
|
||||
ArtifactDigest = $"sha256:{entryId.PadRight(64, 'a')}",
|
||||
PredicateType = "verdict.stella/v1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SigningMode = "keyless",
|
||||
SigningIdentity = new SigningIdentity
|
||||
{
|
||||
Issuer = "https://authority.internal",
|
||||
Subject = "signer@stella-ops.org",
|
||||
San = "urn:stellaops:signer"
|
||||
},
|
||||
InclusionProof = new RekorInclusionProof
|
||||
{
|
||||
Checkpoint = new CheckpointData
|
||||
{
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Path = new List<string>
|
||||
{
|
||||
Convert.ToBase64String(new byte[32]),
|
||||
Convert.ToBase64String(new byte[32])
|
||||
}
|
||||
},
|
||||
Envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
|
||||
Signatures = new List<EnvelopeSignature>
|
||||
{
|
||||
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
|
||||
},
|
||||
CertificateChain = new List<string>
|
||||
{
|
||||
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Attestor.Offline.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Offline\StellaOps.Attestor.Offline.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user