new two advisories and sprints work on them

This commit is contained in:
master
2026-01-16 18:39:36 +02:00
parent 9daf619954
commit c3a6269d55
72 changed files with 15540 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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