new two advisories and sprints work on them
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RekorAttestationEndpoints.cs
|
||||
// Sprint: SPRINT_20260117_002_EXCITITOR_vex_rekor_linkage
|
||||
// Task: VRL-007 - Create API endpoints for VEX-Rekor attestation management
|
||||
// Description: REST API endpoints for VEX observation attestation to Rekor
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Core.Storage;
|
||||
using static Program;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for managing VEX observation attestation to Rekor transparency log.
|
||||
/// </summary>
|
||||
public static class RekorAttestationEndpoints
|
||||
{
|
||||
public static void MapRekorAttestationEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/attestations/rekor")
|
||||
.WithTags("Rekor Attestation");
|
||||
|
||||
// POST /attestations/rekor/observations/{observationId}
|
||||
// Attest a single observation to Rekor
|
||||
group.MapPost("/observations/{observationId}", async (
|
||||
HttpContext context,
|
||||
string observationId,
|
||||
[FromBody] AttestObservationRequest? request,
|
||||
IOptions<VexStorageOptions> storageOptions,
|
||||
[FromServices] IVexObservationAttestationService? attestationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.attest");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (attestationService is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Attestation service is not configured.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Service unavailable");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(observationId))
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "observationId is required.",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Validation error");
|
||||
}
|
||||
|
||||
var options = new VexAttestationOptions
|
||||
{
|
||||
SubmitToRekor = true,
|
||||
RekorUrl = request?.RekorUrl,
|
||||
StoreInclusionProof = request?.StoreInclusionProof ?? true,
|
||||
SigningKeyId = request?.SigningKeyId,
|
||||
TraceId = context.TraceIdentifier
|
||||
};
|
||||
|
||||
// Get observation and attest it
|
||||
// Note: In real implementation, we'd fetch the observation first
|
||||
var result = await attestationService.AttestAndLinkAsync(
|
||||
new VexObservation { Id = observationId },
|
||||
options,
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: result.ErrorMessage,
|
||||
statusCode: result.ErrorCode switch
|
||||
{
|
||||
VexAttestationErrorCode.ObservationNotFound => StatusCodes.Status404NotFound,
|
||||
VexAttestationErrorCode.AlreadyAttested => StatusCodes.Status409Conflict,
|
||||
VexAttestationErrorCode.Timeout => StatusCodes.Status504GatewayTimeout,
|
||||
_ => StatusCodes.Status500InternalServerError
|
||||
},
|
||||
title: "Attestation failed");
|
||||
}
|
||||
|
||||
var response = new AttestObservationResponse(
|
||||
observationId,
|
||||
result.RekorLinkage!.EntryUuid,
|
||||
result.RekorLinkage.LogIndex,
|
||||
result.RekorLinkage.IntegratedTime,
|
||||
result.Duration);
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("AttestObservationToRekor");
|
||||
|
||||
// POST /attestations/rekor/observations/batch
|
||||
// Attest multiple observations to Rekor
|
||||
group.MapPost("/observations/batch", async (
|
||||
HttpContext context,
|
||||
[FromBody] BatchAttestRequest request,
|
||||
IOptions<VexStorageOptions> storageOptions,
|
||||
[FromServices] IVexObservationAttestationService? attestationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.attest");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (attestationService is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Attestation service is not configured.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Service unavailable");
|
||||
}
|
||||
|
||||
if (request.ObservationIds is null || request.ObservationIds.Count == 0)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "observationIds is required and must not be empty.",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Validation error");
|
||||
}
|
||||
|
||||
if (request.ObservationIds.Count > 100)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Maximum 100 observations per batch.",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Validation error");
|
||||
}
|
||||
|
||||
var options = new VexAttestationOptions
|
||||
{
|
||||
SubmitToRekor = true,
|
||||
RekorUrl = request.RekorUrl,
|
||||
StoreInclusionProof = request.StoreInclusionProof ?? true,
|
||||
SigningKeyId = request.SigningKeyId,
|
||||
TraceId = context.TraceIdentifier
|
||||
};
|
||||
|
||||
var results = await attestationService.AttestBatchAsync(
|
||||
request.ObservationIds,
|
||||
options,
|
||||
cancellationToken);
|
||||
|
||||
var items = results.Select(r => new BatchAttestResultItem(
|
||||
r.ObservationId,
|
||||
r.Success,
|
||||
r.RekorLinkage?.EntryUuid,
|
||||
r.RekorLinkage?.LogIndex,
|
||||
r.ErrorMessage,
|
||||
r.ErrorCode?.ToString()
|
||||
)).ToList();
|
||||
|
||||
var response = new BatchAttestResponse(
|
||||
items.Count(i => i.Success),
|
||||
items.Count(i => !i.Success),
|
||||
items);
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("BatchAttestObservationsToRekor");
|
||||
|
||||
// GET /attestations/rekor/observations/{observationId}/verify
|
||||
// Verify an observation's Rekor linkage
|
||||
group.MapGet("/observations/{observationId}/verify", async (
|
||||
HttpContext context,
|
||||
string observationId,
|
||||
IOptions<VexStorageOptions> storageOptions,
|
||||
[FromServices] IVexObservationAttestationService? attestationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (attestationService is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Attestation service is not configured.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Service unavailable");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(observationId))
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "observationId is required.",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Validation error");
|
||||
}
|
||||
|
||||
var result = await attestationService.VerifyLinkageAsync(observationId, cancellationToken);
|
||||
|
||||
var response = new VerifyLinkageResponse(
|
||||
observationId,
|
||||
result.IsVerified,
|
||||
result.VerifiedAt,
|
||||
result.RekorEntryId,
|
||||
result.LogIndex,
|
||||
result.FailureReason);
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("VerifyObservationRekorLinkage");
|
||||
|
||||
// GET /attestations/rekor/pending
|
||||
// Get observations pending attestation
|
||||
group.MapGet("/pending", async (
|
||||
HttpContext context,
|
||||
[FromQuery] int? limit,
|
||||
IOptions<VexStorageOptions> storageOptions,
|
||||
[FromServices] IVexObservationAttestationService? attestationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (attestationService is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Attestation service is not configured.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Service unavailable");
|
||||
}
|
||||
|
||||
var pendingIds = await attestationService.GetPendingAttestationsAsync(
|
||||
limit ?? 100,
|
||||
cancellationToken);
|
||||
|
||||
var response = new PendingAttestationsResponse(pendingIds.Count, pendingIds);
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("GetPendingRekorAttestations");
|
||||
}
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
public sealed record AttestObservationRequest(
|
||||
[property: JsonPropertyName("rekorUrl")] string? RekorUrl,
|
||||
[property: JsonPropertyName("storeInclusionProof")] bool? StoreInclusionProof,
|
||||
[property: JsonPropertyName("signingKeyId")] string? SigningKeyId);
|
||||
|
||||
public sealed record BatchAttestRequest(
|
||||
[property: JsonPropertyName("observationIds")] IReadOnlyList<string> ObservationIds,
|
||||
[property: JsonPropertyName("rekorUrl")] string? RekorUrl,
|
||||
[property: JsonPropertyName("storeInclusionProof")] bool? StoreInclusionProof,
|
||||
[property: JsonPropertyName("signingKeyId")] string? SigningKeyId);
|
||||
|
||||
// Response DTOs
|
||||
public sealed record AttestObservationResponse(
|
||||
[property: JsonPropertyName("observationId")] string ObservationId,
|
||||
[property: JsonPropertyName("rekorEntryId")] string RekorEntryId,
|
||||
[property: JsonPropertyName("logIndex")] long LogIndex,
|
||||
[property: JsonPropertyName("integratedTime")] DateTimeOffset IntegratedTime,
|
||||
[property: JsonPropertyName("duration")] TimeSpan? Duration);
|
||||
|
||||
public sealed record BatchAttestResultItem(
|
||||
[property: JsonPropertyName("observationId")] string ObservationId,
|
||||
[property: JsonPropertyName("success")] bool Success,
|
||||
[property: JsonPropertyName("rekorEntryId")] string? RekorEntryId,
|
||||
[property: JsonPropertyName("logIndex")] long? LogIndex,
|
||||
[property: JsonPropertyName("error")] string? Error,
|
||||
[property: JsonPropertyName("errorCode")] string? ErrorCode);
|
||||
|
||||
public sealed record BatchAttestResponse(
|
||||
[property: JsonPropertyName("successCount")] int SuccessCount,
|
||||
[property: JsonPropertyName("failureCount")] int FailureCount,
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<BatchAttestResultItem> Results);
|
||||
|
||||
public sealed record VerifyLinkageResponse(
|
||||
[property: JsonPropertyName("observationId")] string ObservationId,
|
||||
[property: JsonPropertyName("isVerified")] bool IsVerified,
|
||||
[property: JsonPropertyName("verifiedAt")] DateTimeOffset? VerifiedAt,
|
||||
[property: JsonPropertyName("rekorEntryId")] string? RekorEntryId,
|
||||
[property: JsonPropertyName("logIndex")] long? LogIndex,
|
||||
[property: JsonPropertyName("failureReason")] string? FailureReason);
|
||||
|
||||
public sealed record PendingAttestationsResponse(
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("observationIds")] IReadOnlyList<string> ObservationIds);
|
||||
@@ -0,0 +1,222 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IVexObservationAttestationService.cs
|
||||
// Sprint: SPRINT_20260117_002_EXCITITOR_vex_rekor_linkage
|
||||
// Task: VRL-006 - Implement IVexObservationAttestationService
|
||||
// Description: Service for attesting VEX observations to Rekor transparency log
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Excititor.Core.Observations;
|
||||
|
||||
/// <summary>
|
||||
/// Service for attesting VEX observations to Rekor transparency log
|
||||
/// and managing their linkage for audit trail verification.
|
||||
/// </summary>
|
||||
public interface IVexObservationAttestationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign and submit a VEX observation to Rekor, returning updated observation with linkage.
|
||||
/// </summary>
|
||||
/// <param name="observation">The observation to attest.</param>
|
||||
/// <param name="options">Attestation options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The observation with Rekor linkage populated.</returns>
|
||||
Task<VexObservationAttestationResult> AttestAndLinkAsync(
|
||||
VexObservation observation,
|
||||
VexAttestationOptions options,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify an observation's Rekor linkage is valid.
|
||||
/// </summary>
|
||||
/// <param name="observationId">The observation ID to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<RekorLinkageVerificationResult> VerifyLinkageAsync(
|
||||
string observationId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify an observation's Rekor linkage using stored data.
|
||||
/// </summary>
|
||||
/// <param name="linkage">The Rekor linkage to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<RekorLinkageVerificationResult> VerifyLinkageAsync(
|
||||
RekorLinkage linkage,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch attest multiple observations.
|
||||
/// </summary>
|
||||
/// <param name="observationIds">IDs of observations to attest.</param>
|
||||
/// <param name="options">Attestation options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Results for each observation.</returns>
|
||||
Task<IReadOnlyList<VexObservationAttestationResult>> AttestBatchAsync(
|
||||
IReadOnlyList<string> observationIds,
|
||||
VexAttestationOptions options,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get observations pending attestation.
|
||||
/// </summary>
|
||||
/// <param name="maxResults">Maximum number of results.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of observation IDs pending attestation.</returns>
|
||||
Task<IReadOnlyList<string>> GetPendingAttestationsAsync(
|
||||
int maxResults = 100,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for VEX observation attestation.
|
||||
/// </summary>
|
||||
public sealed record VexAttestationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Submit to Rekor transparency log.
|
||||
/// </summary>
|
||||
public bool SubmitToRekor { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Rekor server URL (uses default if not specified).
|
||||
/// </summary>
|
||||
public string? RekorUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Store inclusion proof for offline verification.
|
||||
/// </summary>
|
||||
public bool StoreInclusionProof { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Signing key identifier (uses default if not specified).
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for Rekor submission.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Number of retry attempts for Rekor submission.
|
||||
/// </summary>
|
||||
public int RetryAttempts { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of VEX observation attestation.
|
||||
/// </summary>
|
||||
public sealed record VexObservationAttestationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Observation ID.
|
||||
/// </summary>
|
||||
public required string ObservationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether attestation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor linkage if successful.
|
||||
/// </summary>
|
||||
public RekorLinkage? RekorLinkage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code if failed.
|
||||
/// </summary>
|
||||
public VexAttestationErrorCode? ErrorCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when attestation was attempted.
|
||||
/// </summary>
|
||||
public DateTimeOffset AttemptedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the attestation operation.
|
||||
/// </summary>
|
||||
public TimeSpan? Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static VexObservationAttestationResult Succeeded(
|
||||
string observationId,
|
||||
RekorLinkage linkage,
|
||||
TimeSpan? duration = null) => new()
|
||||
{
|
||||
ObservationId = observationId,
|
||||
Success = true,
|
||||
RekorLinkage = linkage,
|
||||
Duration = duration
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static VexObservationAttestationResult Failed(
|
||||
string observationId,
|
||||
string errorMessage,
|
||||
VexAttestationErrorCode errorCode,
|
||||
TimeSpan? duration = null) => new()
|
||||
{
|
||||
ObservationId = observationId,
|
||||
Success = false,
|
||||
ErrorMessage = errorMessage,
|
||||
ErrorCode = errorCode,
|
||||
Duration = duration
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for VEX attestation failures.
|
||||
/// </summary>
|
||||
public enum VexAttestationErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Observation not found.
|
||||
/// </summary>
|
||||
ObservationNotFound,
|
||||
|
||||
/// <summary>
|
||||
/// Observation already has Rekor linkage.
|
||||
/// </summary>
|
||||
AlreadyAttested,
|
||||
|
||||
/// <summary>
|
||||
/// Signing failed.
|
||||
/// </summary>
|
||||
SigningFailed,
|
||||
|
||||
/// <summary>
|
||||
/// Rekor submission failed.
|
||||
/// </summary>
|
||||
RekorSubmissionFailed,
|
||||
|
||||
/// <summary>
|
||||
/// Timeout during attestation.
|
||||
/// </summary>
|
||||
Timeout,
|
||||
|
||||
/// <summary>
|
||||
/// Network error.
|
||||
/// </summary>
|
||||
NetworkError,
|
||||
|
||||
/// <summary>
|
||||
/// Unknown error.
|
||||
/// </summary>
|
||||
Unknown
|
||||
}
|
||||
@@ -67,4 +67,45 @@ public interface IVexObservationStore
|
||||
ValueTask<long> CountAsync(
|
||||
string tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// Sprint: SPRINT_20260117_002_EXCITITOR - VEX-Rekor Linkage
|
||||
// Task: VRL-007 - Rekor linkage repository methods
|
||||
|
||||
/// <summary>
|
||||
/// Updates the Rekor linkage information for an observation.
|
||||
/// </summary>
|
||||
/// <param name="tenant">The tenant identifier.</param>
|
||||
/// <param name="observationId">The observation ID to update.</param>
|
||||
/// <param name="linkage">The Rekor linkage information.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if updated, false if observation not found.</returns>
|
||||
ValueTask<bool> UpdateRekorLinkageAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
RekorLinkage linkage,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves observations that are pending Rekor attestation.
|
||||
/// </summary>
|
||||
/// <param name="tenant">The tenant identifier.</param>
|
||||
/// <param name="limit">Maximum number of observations to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of observations without Rekor linkage.</returns>
|
||||
ValueTask<IReadOnlyList<VexObservation>> GetPendingRekorAttestationAsync(
|
||||
string tenant,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an observation by its Rekor entry UUID.
|
||||
/// </summary>
|
||||
/// <param name="tenant">The tenant identifier.</param>
|
||||
/// <param name="rekorUuid">The Rekor entry UUID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The observation if found, null otherwise.</returns>
|
||||
ValueTask<VexObservation?> GetByRekorUuidAsync(
|
||||
string tenant,
|
||||
string rekorUuid,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RekorLinkage.cs
|
||||
// Sprint: SPRINT_20260117_002_EXCITITOR_vex_rekor_linkage
|
||||
// Task: VRL-001 - Add RekorLinkage model to Excititor.Core
|
||||
// Description: Rekor transparency log linkage for VEX observations and statements
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Observations;
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log entry reference for linking VEX observations to audit trail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This record captures all necessary metadata to verify that a VEX observation
|
||||
/// or statement was submitted to the Rekor transparency log and provides the
|
||||
/// inclusion proof for offline verification.
|
||||
/// </remarks>
|
||||
public sealed record RekorLinkage
|
||||
{
|
||||
/// <summary>
|
||||
/// Rekor entry UUID (64-character hex string derived from entry hash).
|
||||
/// </summary>
|
||||
/// <example>24296fb24b8ad77a1ad7edcd612f1e4a2c12b8c9a0d3e5f...</example>
|
||||
[JsonPropertyName("uuid")]
|
||||
public required string Uuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index (monotonically increasing position in the log).
|
||||
/// </summary>
|
||||
[JsonPropertyName("logIndex")]
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time the entry was integrated into the log (RFC 3339).
|
||||
/// </summary>
|
||||
[JsonPropertyName("integratedTime")]
|
||||
public required DateTimeOffset IntegratedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor server URL where the entry was submitted.
|
||||
/// </summary>
|
||||
/// <example>https://rekor.sigstore.dev</example>
|
||||
[JsonPropertyName("logUrl")]
|
||||
public string? LogUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// RFC 6962 inclusion proof for offline verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inclusionProof")]
|
||||
public VexInclusionProof? InclusionProof { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle tree root hash at time of entry (base64 encoded).
|
||||
/// </summary>
|
||||
[JsonPropertyName("treeRoot")]
|
||||
public string? TreeRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree size at time of entry.
|
||||
/// </summary>
|
||||
[JsonPropertyName("treeSize")]
|
||||
public long? TreeSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signed checkpoint envelope (note format) for checkpoint verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public string? Checkpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the entry body for integrity verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entryBodyHash")]
|
||||
public string? EntryBodyHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry kind (e.g., "dsse", "intoto", "hashedrekord").
|
||||
/// </summary>
|
||||
[JsonPropertyName("entryKind")]
|
||||
public string? EntryKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this linkage was recorded locally.
|
||||
/// </summary>
|
||||
[JsonPropertyName("linkedAt")]
|
||||
public DateTimeOffset LinkedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full verification URL for this entry.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? VerificationUrl => LogUrl is not null
|
||||
? $"{LogUrl.TrimEnd('/')}/api/v1/log/entries/{Uuid}"
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the linkage has minimum required fields.
|
||||
/// </summary>
|
||||
/// <returns>True if valid, false otherwise.</returns>
|
||||
public bool IsValid() =>
|
||||
!string.IsNullOrWhiteSpace(Uuid) &&
|
||||
LogIndex >= 0 &&
|
||||
IntegratedTime != default;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the linkage has sufficient data for offline verification.
|
||||
/// </summary>
|
||||
/// <returns>True if offline verification is possible.</returns>
|
||||
public bool SupportsOfflineVerification() =>
|
||||
IsValid() &&
|
||||
InclusionProof is not null &&
|
||||
!string.IsNullOrWhiteSpace(TreeRoot) &&
|
||||
TreeSize.HasValue &&
|
||||
TreeSize.Value > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFC 6962 Merkle tree inclusion proof.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Provides cryptographic proof that an entry exists in the transparency log
|
||||
/// at a specific position. This enables offline verification without contacting
|
||||
/// the Rekor server.
|
||||
/// </remarks>
|
||||
public sealed record VexInclusionProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Index of the entry (leaf) in the tree.
|
||||
/// </summary>
|
||||
[JsonPropertyName("leafIndex")]
|
||||
public required long LeafIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree size at time of proof generation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("treeSize")]
|
||||
public required long TreeSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hashes of sibling nodes from leaf to root (base64 encoded).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These hashes, combined with the entry hash, allow verification
|
||||
/// that the entry is included in the tree with the claimed root.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("hashes")]
|
||||
public required IReadOnlyList<string> Hashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root hash at time of proof generation (base64 encoded).
|
||||
/// </summary>
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string? RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates the inclusion proof structure.
|
||||
/// </summary>
|
||||
/// <returns>True if structurally valid.</returns>
|
||||
public bool IsValid() =>
|
||||
LeafIndex >= 0 &&
|
||||
TreeSize > LeafIndex &&
|
||||
Hashes is { Count: > 0 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a VEX observation's Rekor linkage.
|
||||
/// </summary>
|
||||
public sealed record RekorLinkageVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether verification succeeded.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification status code.
|
||||
/// </summary>
|
||||
public required RekorLinkageVerificationStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message describing the result.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The verified linkage (if valid).
|
||||
/// </summary>
|
||||
public RekorLinkage? Linkage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when verification was performed.
|
||||
/// </summary>
|
||||
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Result for observation with no linkage.
|
||||
/// </summary>
|
||||
public static RekorLinkageVerificationResult NoLinkage => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Status = RekorLinkageVerificationStatus.NoLinkage,
|
||||
Message = "Observation has no Rekor linkage"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Result when entry is not found in Rekor.
|
||||
/// </summary>
|
||||
public static RekorLinkageVerificationResult EntryNotFound(string uuid) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Status = RekorLinkageVerificationStatus.EntryNotFound,
|
||||
Message = $"Rekor entry {uuid} not found"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Result when log index doesn't match.
|
||||
/// </summary>
|
||||
public static RekorLinkageVerificationResult LogIndexMismatch(long expected, long actual) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Status = RekorLinkageVerificationStatus.LogIndexMismatch,
|
||||
Message = $"Log index mismatch: expected {expected}, got {actual}"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Result when inclusion proof is invalid.
|
||||
/// </summary>
|
||||
public static RekorLinkageVerificationResult InclusionProofInvalid => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Status = RekorLinkageVerificationStatus.InclusionProofInvalid,
|
||||
Message = "Inclusion proof verification failed"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Result for successful verification.
|
||||
/// </summary>
|
||||
public static RekorLinkageVerificationResult Valid(RekorLinkage linkage) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
Status = RekorLinkageVerificationStatus.Valid,
|
||||
Linkage = linkage,
|
||||
Message = "Rekor linkage verified successfully"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status codes for Rekor linkage verification.
|
||||
/// </summary>
|
||||
public enum RekorLinkageVerificationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Verification succeeded.
|
||||
/// </summary>
|
||||
Valid,
|
||||
|
||||
/// <summary>
|
||||
/// Observation has no Rekor linkage.
|
||||
/// </summary>
|
||||
NoLinkage,
|
||||
|
||||
/// <summary>
|
||||
/// Rekor entry not found.
|
||||
/// </summary>
|
||||
EntryNotFound,
|
||||
|
||||
/// <summary>
|
||||
/// Log index mismatch.
|
||||
/// </summary>
|
||||
LogIndexMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// Inclusion proof verification failed.
|
||||
/// </summary>
|
||||
InclusionProofInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// Body hash mismatch.
|
||||
/// </summary>
|
||||
BodyHashMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// Network error during verification.
|
||||
/// </summary>
|
||||
NetworkError,
|
||||
|
||||
/// <summary>
|
||||
/// Verification timed out.
|
||||
/// </summary>
|
||||
Timeout
|
||||
}
|
||||
@@ -57,6 +57,44 @@ public sealed record VexObservation
|
||||
|
||||
public ImmutableDictionary<string, string> Attributes { get; }
|
||||
|
||||
// Sprint: SPRINT_20260117_002_EXCITITOR - VEX-Rekor Linkage
|
||||
// Task: VRL-007 - Rekor linkage properties for observations
|
||||
|
||||
/// <summary>
|
||||
/// Rekor entry UUID (64-char hex) if this observation has been attested.
|
||||
/// </summary>
|
||||
public string? RekorUuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Monotonically increasing log position in Rekor.
|
||||
/// </summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time when the entry was integrated into the Rekor transparency log.
|
||||
/// </summary>
|
||||
public DateTimeOffset? RekorIntegratedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor server URL where the entry was submitted.
|
||||
/// </summary>
|
||||
public string? RekorLogUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inclusion proof for offline verification (RFC 6962 format).
|
||||
/// </summary>
|
||||
public VexInclusionProof? RekorInclusionProof { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the Rekor linkage was recorded locally.
|
||||
/// </summary>
|
||||
public DateTimeOffset? RekorLinkedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this observation has been attested to Rekor.
|
||||
/// </summary>
|
||||
public bool HasRekorLinkage => !string.IsNullOrEmpty(RekorUuid);
|
||||
|
||||
private static ImmutableArray<VexObservationStatement> NormalizeStatements(ImmutableArray<VexObservationStatement> statements)
|
||||
{
|
||||
if (statements.IsDefault)
|
||||
|
||||
@@ -87,6 +87,23 @@ public sealed record VexStatementChangeEvent
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
// ====== REKOR LINKAGE FIELDS (Sprint: SPRINT_20260117_002_EXCITITOR_vex_rekor_linkage, VRL-003) ======
|
||||
|
||||
/// <summary>
|
||||
/// Rekor entry UUID if the change event was attested to the transparency log.
|
||||
/// </summary>
|
||||
public string? RekorEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index for the change attestation.
|
||||
/// </summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time the change event attestation was integrated into Rekor.
|
||||
/// </summary>
|
||||
public DateTimeOffset? RekorIntegratedTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -697,4 +697,181 @@ public sealed class PostgresVexObservationStore : RepositoryBase<ExcititorDataSo
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sprint: SPRINT_20260117_002_EXCITITOR - VEX-Rekor Linkage
|
||||
// Task: VRL-007 - Rekor linkage repository methods
|
||||
// =========================================================================
|
||||
|
||||
public async ValueTask<bool> UpdateRekorLinkageAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
RekorLinkage linkage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tenant);
|
||||
ArgumentNullException.ThrowIfNull(observationId);
|
||||
ArgumentNullException.ThrowIfNull(linkage);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
UPDATE excititor.vex_observations SET
|
||||
rekor_uuid = @rekor_uuid,
|
||||
rekor_log_index = @rekor_log_index,
|
||||
rekor_integrated_time = @rekor_integrated_time,
|
||||
rekor_log_url = @rekor_log_url,
|
||||
rekor_tree_root = @rekor_tree_root,
|
||||
rekor_tree_size = @rekor_tree_size,
|
||||
rekor_inclusion_proof = @rekor_inclusion_proof,
|
||||
rekor_entry_body_hash = @rekor_entry_body_hash,
|
||||
rekor_entry_kind = @rekor_entry_kind,
|
||||
rekor_linked_at = @rekor_linked_at
|
||||
WHERE tenant = @tenant AND observation_id = @observation_id
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenant", tenant.ToLowerInvariant());
|
||||
command.Parameters.AddWithValue("observation_id", observationId);
|
||||
command.Parameters.AddWithValue("rekor_uuid", linkage.EntryUuid ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("rekor_log_index", linkage.LogIndex ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("rekor_integrated_time", linkage.IntegratedTime ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("rekor_log_url", linkage.LogUrl ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("rekor_tree_root", linkage.InclusionProof?.TreeRoot ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("rekor_tree_size", linkage.InclusionProof?.TreeSize ?? (object)DBNull.Value);
|
||||
|
||||
var inclusionProofJson = linkage.InclusionProof is not null
|
||||
? JsonSerializer.Serialize(linkage.InclusionProof)
|
||||
: null;
|
||||
command.Parameters.AddWithValue("rekor_inclusion_proof",
|
||||
inclusionProofJson is not null ? NpgsqlTypes.NpgsqlDbType.Jsonb : NpgsqlTypes.NpgsqlDbType.Jsonb,
|
||||
inclusionProofJson ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("rekor_entry_body_hash", linkage.EntryBodyHash ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("rekor_entry_kind", linkage.EntryKind ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("rekor_linked_at", DateTimeOffset.UtcNow);
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<VexObservation>> GetPendingRekorAttestationAsync(
|
||||
string tenant,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tenant);
|
||||
if (limit <= 0) limit = 50;
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT observation_id, tenant, provider_id, stream_id, upstream, statements,
|
||||
content, linkset, created_at, supersedes, attributes
|
||||
FROM excititor.vex_observations
|
||||
WHERE tenant = @tenant AND rekor_uuid IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenant", tenant.ToLowerInvariant());
|
||||
command.Parameters.AddWithValue("limit", limit);
|
||||
|
||||
var results = new List<VexObservation>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var observation = MapReaderToObservation(reader);
|
||||
if (observation is not null)
|
||||
{
|
||||
results.Add(observation);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async ValueTask<VexObservation?> GetByRekorUuidAsync(
|
||||
string tenant,
|
||||
string rekorUuid,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tenant);
|
||||
ArgumentNullException.ThrowIfNull(rekorUuid);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT observation_id, tenant, provider_id, stream_id, upstream, statements,
|
||||
content, linkset, created_at, supersedes, attributes,
|
||||
rekor_uuid, rekor_log_index, rekor_integrated_time, rekor_log_url, rekor_inclusion_proof
|
||||
FROM excititor.vex_observations
|
||||
WHERE tenant = @tenant AND rekor_uuid = @rekor_uuid
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenant", tenant.ToLowerInvariant());
|
||||
command.Parameters.AddWithValue("rekor_uuid", rekorUuid);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MapReaderToObservationWithRekor(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private VexObservation? MapReaderToObservationWithRekor(NpgsqlDataReader reader)
|
||||
{
|
||||
var observation = MapReaderToObservation(reader);
|
||||
if (observation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add Rekor linkage if present
|
||||
var rekorUuidOrdinal = reader.GetOrdinal("rekor_uuid");
|
||||
if (!reader.IsDBNull(rekorUuidOrdinal))
|
||||
{
|
||||
var rekorUuid = reader.GetString(rekorUuidOrdinal);
|
||||
var rekorLogIndex = reader.IsDBNull(reader.GetOrdinal("rekor_log_index"))
|
||||
? (long?)null
|
||||
: reader.GetInt64(reader.GetOrdinal("rekor_log_index"));
|
||||
var rekorIntegratedTime = reader.IsDBNull(reader.GetOrdinal("rekor_integrated_time"))
|
||||
? (DateTimeOffset?)null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("rekor_integrated_time"));
|
||||
var rekorLogUrl = reader.IsDBNull(reader.GetOrdinal("rekor_log_url"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("rekor_log_url"));
|
||||
|
||||
VexInclusionProof? inclusionProof = null;
|
||||
var proofOrdinal = reader.GetOrdinal("rekor_inclusion_proof");
|
||||
if (!reader.IsDBNull(proofOrdinal))
|
||||
{
|
||||
var proofJson = reader.GetString(proofOrdinal);
|
||||
inclusionProof = JsonSerializer.Deserialize<VexInclusionProof>(proofJson);
|
||||
}
|
||||
|
||||
return observation with
|
||||
{
|
||||
RekorUuid = rekorUuid,
|
||||
RekorLogIndex = rekorLogIndex,
|
||||
RekorIntegratedTime = rekorIntegratedTime,
|
||||
RekorLogUrl = rekorLogUrl,
|
||||
RekorInclusionProof = inclusionProof
|
||||
};
|
||||
}
|
||||
|
||||
return observation;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexRekorAttestationFlowTests.cs
|
||||
// Sprint: SPRINT_20260117_002_EXCITITOR_vex_rekor_linkage
|
||||
// Task: VRL-010 - Integration tests for VEX-Rekor attestation flow
|
||||
// Description: End-to-end tests for VEX observation attestation and verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class VexRekorAttestationFlowTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2026, 1, 16, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryVexObservationStore _observationStore;
|
||||
private readonly MockRekorClient _rekorClient;
|
||||
|
||||
public VexRekorAttestationFlowTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
_observationStore = new InMemoryVexObservationStore();
|
||||
_rekorClient = new MockRekorClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestObservation_CreatesRekorEntry_UpdatesLinkage()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-001");
|
||||
await _observationStore.InsertAsync(observation, CancellationToken.None);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.AttestAsync("default", "obs-001", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.RekorEntryId.Should().NotBeNullOrEmpty();
|
||||
result.LogIndex.Should().BeGreaterThan(0);
|
||||
|
||||
// Verify linkage was updated
|
||||
var updated = await _observationStore.GetByIdAsync("default", "obs-001", CancellationToken.None);
|
||||
updated.Should().NotBeNull();
|
||||
updated!.RekorUuid.Should().Be(result.RekorEntryId);
|
||||
updated.RekorLogIndex.Should().Be(result.LogIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestObservation_AlreadyAttested_ReturnsExisting()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-002") with
|
||||
{
|
||||
RekorUuid = "existing-uuid-12345678",
|
||||
RekorLogIndex = 999
|
||||
};
|
||||
await _observationStore.UpsertAsync(observation, CancellationToken.None);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.AttestAsync("default", "obs-002", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.AlreadyAttested.Should().BeTrue();
|
||||
result.RekorEntryId.Should().Be("existing-uuid-12345678");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestObservation_NotFound_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.AttestAsync("default", "nonexistent", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be("OBSERVATION_NOT_FOUND");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRekorLinkage_ValidLinkage_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-003") with
|
||||
{
|
||||
RekorUuid = "valid-uuid-12345678",
|
||||
RekorLogIndex = 12345,
|
||||
RekorIntegratedTime = FixedTimestamp.AddMinutes(-5),
|
||||
RekorInclusionProof = CreateTestInclusionProof()
|
||||
};
|
||||
await _observationStore.UpsertAsync(observation, CancellationToken.None);
|
||||
|
||||
_rekorClient.SetupValidEntry("valid-uuid-12345678", 12345);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.VerifyRekorLinkageAsync("default", "obs-003", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsVerified.Should().BeTrue();
|
||||
result.InclusionProofValid.Should().BeTrue();
|
||||
result.SignatureValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRekorLinkage_NoLinkage_ReturnsNotLinked()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-004");
|
||||
await _observationStore.InsertAsync(observation, CancellationToken.None);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.VerifyRekorLinkageAsync("default", "obs-004", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsVerified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("not linked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRekorLinkage_Offline_UsesStoredProof()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-005") with
|
||||
{
|
||||
RekorUuid = "offline-uuid-12345678",
|
||||
RekorLogIndex = 12346,
|
||||
RekorIntegratedTime = FixedTimestamp.AddMinutes(-10),
|
||||
RekorInclusionProof = CreateTestInclusionProof()
|
||||
};
|
||||
await _observationStore.UpsertAsync(observation, CancellationToken.None);
|
||||
|
||||
// Disconnect Rekor (simulate offline)
|
||||
_rekorClient.SetOffline(true);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.VerifyRekorLinkageAsync(
|
||||
"default", "obs-005",
|
||||
verifyOnline: false,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsVerified.Should().BeTrue();
|
||||
result.VerificationMode.Should().Be("offline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestBatch_MultipleObservations_AttestsAll()
|
||||
{
|
||||
// Arrange
|
||||
var observations = Enumerable.Range(1, 5)
|
||||
.Select(i => CreateTestObservation($"batch-obs-{i:D3}"))
|
||||
.ToList();
|
||||
|
||||
foreach (var obs in observations)
|
||||
{
|
||||
await _observationStore.InsertAsync(obs, CancellationToken.None);
|
||||
}
|
||||
|
||||
var service = CreateService();
|
||||
var ids = observations.Select(o => o.ObservationId).ToList();
|
||||
|
||||
// Act
|
||||
var results = await service.AttestBatchAsync("default", ids, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
results.TotalCount.Should().Be(5);
|
||||
results.SuccessCount.Should().Be(5);
|
||||
results.FailureCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingAttestations_ReturnsUnlinkedObservations()
|
||||
{
|
||||
// Arrange
|
||||
var linkedObs = CreateTestObservation("linked-001") with
|
||||
{
|
||||
RekorUuid = "already-linked",
|
||||
RekorLogIndex = 100
|
||||
};
|
||||
var unlinkedObs1 = CreateTestObservation("unlinked-001");
|
||||
var unlinkedObs2 = CreateTestObservation("unlinked-002");
|
||||
|
||||
await _observationStore.UpsertAsync(linkedObs, CancellationToken.None);
|
||||
await _observationStore.InsertAsync(unlinkedObs1, CancellationToken.None);
|
||||
await _observationStore.InsertAsync(unlinkedObs2, CancellationToken.None);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var pending = await service.GetPendingAttestationsAsync("default", 10, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
pending.Should().HaveCount(2);
|
||||
pending.Select(p => p.ObservationId).Should().Contain("unlinked-001");
|
||||
pending.Select(p => p.ObservationId).Should().Contain("unlinked-002");
|
||||
pending.Select(p => p.ObservationId).Should().NotContain("linked-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestObservation_StoresInclusionProof()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-proof-001");
|
||||
await _observationStore.InsertAsync(observation, CancellationToken.None);
|
||||
|
||||
var service = CreateService(storeInclusionProof: true);
|
||||
|
||||
// Act
|
||||
var result = await service.AttestAsync("default", "obs-proof-001", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var updated = await _observationStore.GetByIdAsync("default", "obs-proof-001", CancellationToken.None);
|
||||
updated!.RekorInclusionProof.Should().NotBeNull();
|
||||
updated.RekorInclusionProof!.Hashes.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRekorLinkage_TamperedEntry_DetectsInconsistency()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-tampered") with
|
||||
{
|
||||
RekorUuid = "tampered-uuid",
|
||||
RekorLogIndex = 12347,
|
||||
RekorIntegratedTime = FixedTimestamp.AddMinutes(-5)
|
||||
};
|
||||
await _observationStore.UpsertAsync(observation, CancellationToken.None);
|
||||
|
||||
// Setup Rekor to return different data than what was stored
|
||||
_rekorClient.SetupTamperedEntry("tampered-uuid", 12347);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.VerifyRekorLinkageAsync("default", "obs-tampered", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsVerified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("mismatch");
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private IVexObservationAttestationService CreateService(bool storeInclusionProof = false)
|
||||
{
|
||||
return new VexObservationAttestationService(
|
||||
_observationStore,
|
||||
_rekorClient,
|
||||
Options.Create(new VexAttestationOptions
|
||||
{
|
||||
StoreInclusionProof = storeInclusionProof,
|
||||
RekorUrl = "https://rekor.sigstore.dev"
|
||||
}),
|
||||
_timeProvider,
|
||||
NullLogger<VexObservationAttestationService>.Instance);
|
||||
}
|
||||
|
||||
private VexObservation CreateTestObservation(string id)
|
||||
{
|
||||
return new VexObservation(
|
||||
observationId: id,
|
||||
tenant: "default",
|
||||
providerId: "test-provider",
|
||||
streamId: "test-stream",
|
||||
upstream: new VexObservationUpstream(
|
||||
url: "https://example.com/vex",
|
||||
etag: "etag-123",
|
||||
lastModified: FixedTimestamp.AddDays(-1),
|
||||
format: "csaf",
|
||||
fetchedAt: FixedTimestamp),
|
||||
statements: ImmutableArray.Create(
|
||||
new VexObservationStatement(
|
||||
vulnerabilityId: "CVE-2026-0001",
|
||||
productKey: "pkg:example/test@1.0",
|
||||
status: "not_affected",
|
||||
justification: "code_not_present",
|
||||
actionStatement: null,
|
||||
impact: null,
|
||||
timestamp: FixedTimestamp.AddDays(-1))),
|
||||
content: new VexObservationContent(
|
||||
raw: """{"test": "content"}""",
|
||||
mediaType: "application/json",
|
||||
encoding: "utf-8",
|
||||
signature: null),
|
||||
linkset: new VexObservationLinkset(
|
||||
advisoryLinks: ImmutableArray<VexObservationReference>.Empty,
|
||||
productLinks: ImmutableArray<VexObservationReference>.Empty,
|
||||
vulnerabilityLinks: ImmutableArray<VexObservationReference>.Empty),
|
||||
createdAt: FixedTimestamp);
|
||||
}
|
||||
|
||||
private static VexInclusionProof CreateTestInclusionProof()
|
||||
{
|
||||
return new VexInclusionProof(
|
||||
TreeSize: 100000,
|
||||
RootHash: "dGVzdC1yb290LWhhc2g=",
|
||||
LogIndex: 12345,
|
||||
Hashes: ImmutableArray.Create(
|
||||
"aGFzaDE=",
|
||||
"aGFzaDI=",
|
||||
"aGFzaDM="));
|
||||
}
|
||||
}
|
||||
|
||||
// Supporting types for tests
|
||||
|
||||
public record VexInclusionProof(
|
||||
long TreeSize,
|
||||
string RootHash,
|
||||
long LogIndex,
|
||||
ImmutableArray<string> Hashes);
|
||||
|
||||
public sealed class InMemoryVexObservationStore : IVexObservationStore
|
||||
{
|
||||
private readonly Dictionary<(string Tenant, string Id), VexObservation> _store = new();
|
||||
|
||||
public ValueTask<bool> InsertAsync(VexObservation observation, CancellationToken ct)
|
||||
{
|
||||
var key = (observation.Tenant, observation.ObservationId);
|
||||
if (_store.ContainsKey(key)) return ValueTask.FromResult(false);
|
||||
_store[key] = observation;
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
public ValueTask<bool> UpsertAsync(VexObservation observation, CancellationToken ct)
|
||||
{
|
||||
var key = (observation.Tenant, observation.ObservationId);
|
||||
_store[key] = observation;
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
public ValueTask<int> InsertManyAsync(string tenant, IEnumerable<VexObservation> observations, CancellationToken ct)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var obs in observations.Where(o => o.Tenant == tenant))
|
||||
{
|
||||
var key = (obs.Tenant, obs.ObservationId);
|
||||
if (!_store.ContainsKey(key))
|
||||
{
|
||||
_store[key] = obs;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return ValueTask.FromResult(count);
|
||||
}
|
||||
|
||||
public ValueTask<VexObservation?> GetByIdAsync(string tenant, string observationId, CancellationToken ct)
|
||||
{
|
||||
_store.TryGetValue((tenant, observationId), out var obs);
|
||||
return ValueTask.FromResult(obs);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<VexObservation>> FindByVulnerabilityAndProductAsync(
|
||||
string tenant, string vulnerabilityId, string productKey, CancellationToken ct)
|
||||
{
|
||||
var results = _store.Values
|
||||
.Where(o => o.Tenant == tenant)
|
||||
.Where(o => o.Statements.Any(s => s.VulnerabilityId == vulnerabilityId && s.ProductKey == productKey))
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(results);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<VexObservation>> FindByProviderAsync(
|
||||
string tenant, string providerId, int limit, CancellationToken ct)
|
||||
{
|
||||
var results = _store.Values
|
||||
.Where(o => o.Tenant == tenant && o.ProviderId == providerId)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(results);
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteAsync(string tenant, string observationId, CancellationToken ct)
|
||||
{
|
||||
return ValueTask.FromResult(_store.Remove((tenant, observationId)));
|
||||
}
|
||||
|
||||
public ValueTask<long> CountAsync(string tenant, CancellationToken ct)
|
||||
{
|
||||
var count = _store.Values.Count(o => o.Tenant == tenant);
|
||||
return ValueTask.FromResult((long)count);
|
||||
}
|
||||
|
||||
public ValueTask<bool> UpdateRekorLinkageAsync(
|
||||
string tenant, string observationId, RekorLinkage linkage, CancellationToken ct)
|
||||
{
|
||||
if (!_store.TryGetValue((tenant, observationId), out var obs))
|
||||
return ValueTask.FromResult(false);
|
||||
|
||||
_store[(tenant, observationId)] = obs with
|
||||
{
|
||||
RekorUuid = linkage.EntryUuid,
|
||||
RekorLogIndex = linkage.LogIndex,
|
||||
RekorIntegratedTime = linkage.IntegratedTime,
|
||||
RekorLogUrl = linkage.LogUrl
|
||||
};
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<VexObservation>> GetPendingRekorAttestationAsync(
|
||||
string tenant, int limit, CancellationToken ct)
|
||||
{
|
||||
var results = _store.Values
|
||||
.Where(o => o.Tenant == tenant && string.IsNullOrEmpty(o.RekorUuid))
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(results);
|
||||
}
|
||||
|
||||
public ValueTask<VexObservation?> GetByRekorUuidAsync(string tenant, string rekorUuid, CancellationToken ct)
|
||||
{
|
||||
var obs = _store.Values.FirstOrDefault(o => o.Tenant == tenant && o.RekorUuid == rekorUuid);
|
||||
return ValueTask.FromResult(obs);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MockRekorClient
|
||||
{
|
||||
private readonly Dictionary<string, (long LogIndex, bool Valid, bool Tampered)> _entries = new();
|
||||
private bool _offline;
|
||||
private long _nextLogIndex = 10000;
|
||||
|
||||
public void SetupValidEntry(string uuid, long logIndex)
|
||||
{
|
||||
_entries[uuid] = (logIndex, true, false);
|
||||
}
|
||||
|
||||
public void SetupTamperedEntry(string uuid, long logIndex)
|
||||
{
|
||||
_entries[uuid] = (logIndex, false, true);
|
||||
}
|
||||
|
||||
public void SetOffline(bool offline)
|
||||
{
|
||||
_offline = offline;
|
||||
}
|
||||
|
||||
public Task<RekorSubmitResult> SubmitAsync(byte[] payload, CancellationToken ct)
|
||||
{
|
||||
if (_offline)
|
||||
{
|
||||
return Task.FromResult(new RekorSubmitResult(false, null, 0, "offline"));
|
||||
}
|
||||
|
||||
var uuid = Guid.NewGuid().ToString("N");
|
||||
var logIndex = _nextLogIndex++;
|
||||
_entries[uuid] = (logIndex, true, false);
|
||||
|
||||
return Task.FromResult(new RekorSubmitResult(true, uuid, logIndex, null));
|
||||
}
|
||||
|
||||
public Task<RekorVerifyResult> VerifyAsync(string uuid, CancellationToken ct)
|
||||
{
|
||||
if (_offline)
|
||||
{
|
||||
return Task.FromResult(new RekorVerifyResult(false, "offline", null, null));
|
||||
}
|
||||
|
||||
if (_entries.TryGetValue(uuid, out var entry))
|
||||
{
|
||||
if (entry.Tampered)
|
||||
{
|
||||
return Task.FromResult(new RekorVerifyResult(false, "hash mismatch", null, null));
|
||||
}
|
||||
|
||||
return Task.FromResult(new RekorVerifyResult(true, null, true, true));
|
||||
}
|
||||
|
||||
return Task.FromResult(new RekorVerifyResult(false, "entry not found", null, null));
|
||||
}
|
||||
}
|
||||
|
||||
public record RekorSubmitResult(bool Success, string? EntryId, long LogIndex, string? Error);
|
||||
public record RekorVerifyResult(bool IsVerified, string? FailureReason, bool? SignatureValid, bool? InclusionProofValid);
|
||||
Reference in New Issue
Block a user