676 lines
26 KiB
C#
676 lines
26 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace StellaOps.Provcache.Api;
|
|
|
|
/// <summary>
|
|
/// Marker class for logging in Provcache API endpoints.
|
|
/// </summary>
|
|
public sealed class ProvcacheApiEndpoints;
|
|
|
|
/// <summary>
|
|
/// Extension methods for mapping Provcache API endpoints.
|
|
/// </summary>
|
|
public static partial class ProvcacheEndpointExtensions
|
|
{
|
|
/// <summary>
|
|
/// Maps Provcache API endpoints to the specified route builder.
|
|
/// </summary>
|
|
/// <param name="endpoints">The endpoint route builder.</param>
|
|
/// <param name="prefix">The route prefix (default: "/v1/provcache").</param>
|
|
/// <returns>A route group builder for further customization.</returns>
|
|
public static RouteGroupBuilder MapProvcacheEndpoints(
|
|
this IEndpointRouteBuilder endpoints,
|
|
string prefix = "/v1/provcache")
|
|
{
|
|
var group = endpoints.MapGroup(prefix)
|
|
.WithTags("Provcache")
|
|
.WithOpenApi();
|
|
|
|
// GET /v1/provcache/{veriKey}
|
|
group.MapGet("/{veriKey}", GetByVeriKey)
|
|
.WithName("GetProvcacheEntry")
|
|
.WithSummary("Get a cached decision by VeriKey")
|
|
.WithDescription("Retrieves a cached evaluation decision by its VeriKey. Returns 200 if found, 204 if not cached, 410 if expired.")
|
|
.Produces<ProvcacheGetResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status410Gone)
|
|
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
|
|
|
// POST /v1/provcache
|
|
group.MapPost("/", CreateOrUpdate)
|
|
.WithName("CreateOrUpdateProvcacheEntry")
|
|
.WithSummary("Store a decision in the cache (idempotent)")
|
|
.WithDescription("Stores or updates a cached evaluation decision. This operation is idempotent - storing the same VeriKey multiple times is safe.")
|
|
.Accepts<ProvcacheCreateRequest>("application/json")
|
|
.Produces<ProvcacheCreateResponse>(StatusCodes.Status201Created)
|
|
.Produces<ProvcacheCreateResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
|
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
|
|
|
// POST /v1/provcache/invalidate
|
|
group.MapPost("/invalidate", Invalidate)
|
|
.WithName("InvalidateProvcacheEntries")
|
|
.WithSummary("Invalidate cache entries by key or pattern")
|
|
.WithDescription("Invalidates one or more cache entries. Can invalidate by exact VeriKey, policy hash, signer set hash, feed epoch, or pattern.")
|
|
.Accepts<ProvcacheInvalidateRequest>("application/json")
|
|
.Produces<ProvcacheInvalidateResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
|
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
|
|
|
// GET /v1/provcache/metrics
|
|
group.MapGet("/metrics", GetMetrics)
|
|
.WithName("GetProvcacheMetrics")
|
|
.WithSummary("Get cache performance metrics")
|
|
.WithDescription("Returns current cache metrics including hit rate, miss rate, latency percentiles, and entry counts.")
|
|
.Produces<ProvcacheMetricsResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
|
|
|
// GET /v1/provcache/{veriKey}/manifest
|
|
group.MapGet("/{veriKey}/manifest", GetInputManifest)
|
|
.WithName("GetInputManifest")
|
|
.WithSummary("Get input manifest for VeriKey components")
|
|
.WithDescription("Returns detailed information about the inputs (SBOM, VEX, policy, signers) that formed a cached decision. Use for transparency and debugging.")
|
|
.Produces<InputManifestResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
|
|
|
// Map evidence paging endpoints under /proofs
|
|
var proofsGroup = endpoints.MapGroup($"{prefix}/proofs")
|
|
.WithTags("Provcache Evidence")
|
|
.WithOpenApi();
|
|
|
|
// GET /v1/provcache/proofs/{proofRoot}
|
|
proofsGroup.MapGet("/{proofRoot}", GetEvidenceChunks)
|
|
.WithName("GetProofEvidence")
|
|
.WithSummary("Get evidence chunks by proof root")
|
|
.WithDescription("Retrieves evidence chunks for a proof root with pagination support. Use cursor parameter for subsequent pages.")
|
|
.Produces<ProofEvidenceResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
|
|
|
// GET /v1/provcache/proofs/{proofRoot}/manifest
|
|
proofsGroup.MapGet("/{proofRoot}/manifest", GetProofManifest)
|
|
.WithName("GetProofManifest")
|
|
.WithSummary("Get chunk manifest (metadata without data)")
|
|
.WithDescription("Retrieves the chunk manifest for lazy evidence fetching. Contains hashes and sizes but no blob data.")
|
|
.Produces<ProofManifestResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
|
|
|
// GET /v1/provcache/proofs/{proofRoot}/chunks/{chunkIndex}
|
|
proofsGroup.MapGet("/{proofRoot}/chunks/{chunkIndex:int}", GetSingleChunk)
|
|
.WithName("GetProofChunk")
|
|
.WithSummary("Get a single chunk by index")
|
|
.WithDescription("Retrieves a specific chunk by its index within the proof.")
|
|
.Produces<ProofChunkResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
|
|
|
// POST /v1/provcache/proofs/{proofRoot}/verify
|
|
proofsGroup.MapPost("/{proofRoot}/verify", VerifyProof)
|
|
.WithName("VerifyProof")
|
|
.WithSummary("Verify Merkle tree integrity")
|
|
.WithDescription("Verifies all chunk hashes and the Merkle tree for the proof root.")
|
|
.Produces<ProofVerificationResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
|
|
|
return group;
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /v1/provcache/{veriKey}
|
|
/// </summary>
|
|
private static async Task<IResult> GetByVeriKey(
|
|
string veriKey,
|
|
bool? bypassCache,
|
|
IProvcacheService provcacheService,
|
|
ILogger<ProvcacheApiEndpoints> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
logger.LogDebug("GET /v1/provcache/{VeriKey}", veriKey);
|
|
|
|
try
|
|
{
|
|
var result = await provcacheService.GetAsync(veriKey, bypassCache ?? false, cancellationToken);
|
|
|
|
return result.Status switch
|
|
{
|
|
ProvcacheResultStatus.CacheHit => Results.Ok(new ProvcacheGetResponse
|
|
{
|
|
VeriKey = result.Entry!.VeriKey,
|
|
Entry = result.Entry,
|
|
Source = result.Source,
|
|
ElapsedMs = result.ElapsedMs,
|
|
Status = "hit"
|
|
}),
|
|
ProvcacheResultStatus.Bypassed => Results.Ok(new ProvcacheGetResponse
|
|
{
|
|
VeriKey = veriKey,
|
|
Entry = null,
|
|
Source = null,
|
|
ElapsedMs = result.ElapsedMs,
|
|
Status = "bypassed"
|
|
}),
|
|
ProvcacheResultStatus.Expired => Results.StatusCode(StatusCodes.Status410Gone),
|
|
_ => Results.NoContent()
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error getting cache entry for VeriKey {VeriKey}", veriKey);
|
|
return Results.Problem(
|
|
detail: ex.Message,
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "Cache lookup failed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// POST /v1/provcache
|
|
/// </summary>
|
|
private static async Task<IResult> CreateOrUpdate(
|
|
ProvcacheCreateRequest request,
|
|
IProvcacheService provcacheService,
|
|
ILogger<ProvcacheApiEndpoints> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
logger.LogDebug("POST /v1/provcache for VeriKey {VeriKey}", request.Entry?.VeriKey);
|
|
|
|
if (request.Entry is null)
|
|
{
|
|
return Results.Problem(
|
|
detail: "Request body must contain a valid entry",
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "Invalid request");
|
|
}
|
|
|
|
try
|
|
{
|
|
var success = await provcacheService.SetAsync(request.Entry, cancellationToken);
|
|
|
|
if (!success)
|
|
{
|
|
return Results.Problem(
|
|
detail: "Failed to store cache entry",
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "Cache write failed");
|
|
}
|
|
|
|
return Results.Created($"/v1/provcache/{request.Entry.VeriKey}", new ProvcacheCreateResponse
|
|
{
|
|
VeriKey = request.Entry.VeriKey,
|
|
Success = true,
|
|
ExpiresAt = request.Entry.ExpiresAt
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error storing cache entry for VeriKey {VeriKey}", request.Entry?.VeriKey);
|
|
return Results.Problem(
|
|
detail: ex.Message,
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "Cache write failed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// POST /v1/provcache/invalidate
|
|
/// </summary>
|
|
private static async Task<IResult> Invalidate(
|
|
ProvcacheInvalidateRequest request,
|
|
IProvcacheService provcacheService,
|
|
ILogger<ProvcacheApiEndpoints> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
logger.LogDebug("POST /v1/provcache/invalidate type={Type} value={Value}", request.Type, request.Value);
|
|
|
|
try
|
|
{
|
|
// If single VeriKey invalidation (Type is null = single VeriKey mode)
|
|
if (request.Type is null)
|
|
{
|
|
var success = await provcacheService.InvalidateAsync(request.Value, request.Reason, cancellationToken);
|
|
return Results.Ok(new ProvcacheInvalidateResponse
|
|
{
|
|
EntriesAffected = success ? 1 : 0,
|
|
Type = "verikey",
|
|
Value = request.Value,
|
|
Reason = request.Reason
|
|
});
|
|
}
|
|
|
|
// Bulk invalidation
|
|
var invalidationRequest = new InvalidationRequest
|
|
{
|
|
Type = request.Type ?? InvalidationType.Pattern,
|
|
Value = request.Value,
|
|
Reason = request.Reason,
|
|
Actor = request.Actor
|
|
};
|
|
|
|
var result = await provcacheService.InvalidateByAsync(invalidationRequest, cancellationToken);
|
|
|
|
return Results.Ok(new ProvcacheInvalidateResponse
|
|
{
|
|
EntriesAffected = result.EntriesAffected,
|
|
Type = request.Type?.ToString() ?? "pattern",
|
|
Value = request.Value,
|
|
Reason = request.Reason
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error invalidating cache entries");
|
|
return Results.Problem(
|
|
detail: ex.Message,
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "Cache invalidation failed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /v1/provcache/metrics
|
|
/// </summary>
|
|
private static async Task<IResult> GetMetrics(
|
|
IProvcacheService provcacheService,
|
|
ILogger<ProvcacheApiEndpoints> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
logger.LogDebug("GET /v1/provcache/metrics");
|
|
|
|
try
|
|
{
|
|
var metrics = await provcacheService.GetMetricsAsync(cancellationToken);
|
|
|
|
var hitRate = metrics.TotalRequests > 0
|
|
? (double)metrics.TotalHits / metrics.TotalRequests
|
|
: 0;
|
|
|
|
return Results.Ok(new ProvcacheMetricsResponse
|
|
{
|
|
TotalRequests = metrics.TotalRequests,
|
|
TotalHits = metrics.TotalHits,
|
|
TotalMisses = metrics.TotalMisses,
|
|
TotalInvalidations = metrics.TotalInvalidations,
|
|
HitRate = hitRate,
|
|
CurrentEntryCount = metrics.CurrentEntryCount,
|
|
AvgLatencyMs = metrics.AvgLatencyMs,
|
|
P99LatencyMs = metrics.P99LatencyMs,
|
|
ValkeyCacheHealthy = metrics.ValkeyCacheHealthy,
|
|
PostgresRepositoryHealthy = metrics.PostgresRepositoryHealthy,
|
|
CollectedAt = metrics.CollectedAt
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error getting cache metrics");
|
|
return Results.Problem(
|
|
detail: ex.Message,
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "Metrics retrieval failed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /v1/provcache/{veriKey}/manifest
|
|
/// </summary>
|
|
private static async Task<IResult> GetInputManifest(
|
|
string veriKey,
|
|
IProvcacheService provcacheService,
|
|
TimeProvider timeProvider,
|
|
ILogger<ProvcacheApiEndpoints> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
logger.LogDebug("GET /v1/provcache/{VeriKey}/manifest", veriKey);
|
|
|
|
try
|
|
{
|
|
// First get the entry to verify it exists
|
|
var result = await provcacheService.GetAsync(veriKey, bypassCache: false, cancellationToken);
|
|
|
|
if (result.Status == ProvcacheResultStatus.CacheMiss)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Entry not found",
|
|
Detail = $"No cache entry found for VeriKey: {veriKey}",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
if (result.Status == ProvcacheResultStatus.Expired)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Entry expired",
|
|
Detail = $"Cache entry for VeriKey '{veriKey}' has expired",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
var entry = result.Entry;
|
|
if (entry is null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Entry not found",
|
|
Detail = $"No cache entry found for VeriKey: {veriKey}",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
// Build the input manifest from the entry metadata
|
|
// In a full implementation, we'd resolve these hashes to more detailed metadata
|
|
// from their respective stores (SBOM store, VEX store, policy registry, etc.)
|
|
var manifest = BuildInputManifest(entry, timeProvider);
|
|
|
|
return Results.Ok(manifest);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error getting input manifest for VeriKey {VeriKey}", veriKey);
|
|
return Results.Problem(
|
|
detail: ex.Message,
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "Manifest retrieval failed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds an InputManifestResponse from a ProvcacheEntry.
|
|
/// </summary>
|
|
private static InputManifestResponse BuildInputManifest(ProvcacheEntry entry, TimeProvider timeProvider)
|
|
{
|
|
// Build input manifest from the entry and its embedded DecisionDigest
|
|
// The DecisionDigest contains the VeriKey components as hashes
|
|
var decision = entry.Decision;
|
|
|
|
return new InputManifestResponse
|
|
{
|
|
VeriKey = entry.VeriKey,
|
|
SourceArtifact = new SourceArtifactInfo
|
|
{
|
|
// VeriKey includes source hash as first component
|
|
Digest = entry.VeriKey,
|
|
},
|
|
Sbom = new SbomInfoDto
|
|
{
|
|
// SBOM hash is embedded in VeriKey computation
|
|
// In a full implementation, we'd resolve this from the SBOM store
|
|
Hash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder - actual hash would come from VeriKey decomposition
|
|
},
|
|
Vex = new VexInfoDto
|
|
{
|
|
// VEX hash set is embedded in VeriKey computation
|
|
HashSetHash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder
|
|
StatementCount = 0, // Would be resolved from VEX store
|
|
},
|
|
Policy = new PolicyInfoDto
|
|
{
|
|
Hash = entry.PolicyHash,
|
|
},
|
|
Signers = new SignerInfoDto
|
|
{
|
|
SetHash = entry.SignerSetHash,
|
|
SignerCount = 0, // Would be resolved from signer registry
|
|
},
|
|
TimeWindow = new TimeWindowInfoDto
|
|
{
|
|
Bucket = entry.FeedEpoch,
|
|
StartsAt = entry.CreatedAt,
|
|
EndsAt = entry.ExpiresAt,
|
|
},
|
|
GeneratedAt = timeProvider.GetUtcNow(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Placeholder for problem details when ASP.NET Core's ProblemDetails isn't available.
|
|
/// </summary>
|
|
internal sealed class ProblemDetails
|
|
{
|
|
public string? Type { get; set; }
|
|
public string? Title { get; set; }
|
|
public int? Status { get; set; }
|
|
public string? Detail { get; set; }
|
|
public string? Instance { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marker class for logging in Proofs API endpoints.
|
|
/// </summary>
|
|
public sealed class ProofsApiEndpoints;
|
|
|
|
partial class ProvcacheEndpointExtensions
|
|
{
|
|
private const int DefaultPageSize = 10;
|
|
private const int MaxPageSize = 100;
|
|
|
|
/// <summary>
|
|
/// GET /v1/provcache/proofs/{proofRoot}
|
|
/// </summary>
|
|
private static async Task<IResult> GetEvidenceChunks(
|
|
string proofRoot,
|
|
int? offset,
|
|
int? limit,
|
|
bool? includeData,
|
|
[FromServices] IEvidenceChunkRepository chunkRepository,
|
|
ILogger<ProofsApiEndpoints> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
logger.LogDebug("GET /v1/provcache/proofs/{ProofRoot} offset={Offset} limit={Limit}", proofRoot, offset, limit);
|
|
|
|
try
|
|
{
|
|
var startIndex = offset ?? 0;
|
|
var pageSize = Math.Min(limit ?? DefaultPageSize, MaxPageSize);
|
|
|
|
// Get manifest for total count
|
|
var manifest = await chunkRepository.GetManifestAsync(proofRoot, cancellationToken);
|
|
if (manifest is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
// Get chunk range
|
|
var chunks = await chunkRepository.GetChunkRangeAsync(proofRoot, startIndex, pageSize, cancellationToken);
|
|
|
|
var chunkResponses = chunks.Select(c => new ProofChunkResponse
|
|
{
|
|
ChunkId = c.ChunkId,
|
|
Index = c.ChunkIndex,
|
|
Hash = c.ChunkHash,
|
|
Size = c.BlobSize,
|
|
ContentType = c.ContentType,
|
|
Data = includeData == true ? Convert.ToBase64String(c.Blob) : null
|
|
}).ToList();
|
|
|
|
var hasMore = startIndex + chunks.Count < manifest.TotalChunks;
|
|
var nextCursor = hasMore ? (startIndex + pageSize).ToString() : null;
|
|
|
|
return Results.Ok(new ProofEvidenceResponse
|
|
{
|
|
ProofRoot = proofRoot,
|
|
TotalChunks = manifest.TotalChunks,
|
|
TotalSize = manifest.TotalSize,
|
|
Chunks = chunkResponses,
|
|
NextCursor = nextCursor,
|
|
HasMore = hasMore
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error getting evidence chunks for proof root {ProofRoot}", proofRoot);
|
|
return Results.Problem(
|
|
detail: ex.Message,
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "Evidence retrieval failed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /v1/provcache/proofs/{proofRoot}/manifest
|
|
/// </summary>
|
|
private static async Task<IResult> GetProofManifest(
|
|
string proofRoot,
|
|
[FromServices] IEvidenceChunkRepository chunkRepository,
|
|
ILogger<ProofsApiEndpoints> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
logger.LogDebug("GET /v1/provcache/proofs/{ProofRoot}/manifest", proofRoot);
|
|
|
|
try
|
|
{
|
|
var manifest = await chunkRepository.GetManifestAsync(proofRoot, cancellationToken);
|
|
if (manifest is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var chunkMetadata = manifest.Chunks.Select(c => new ChunkMetadataResponse
|
|
{
|
|
ChunkId = c.ChunkId,
|
|
Index = c.Index,
|
|
Hash = c.Hash,
|
|
Size = c.Size,
|
|
ContentType = c.ContentType
|
|
}).ToList();
|
|
|
|
return Results.Ok(new ProofManifestResponse
|
|
{
|
|
ProofRoot = proofRoot,
|
|
TotalChunks = manifest.TotalChunks,
|
|
TotalSize = manifest.TotalSize,
|
|
Chunks = chunkMetadata,
|
|
GeneratedAt = manifest.GeneratedAt
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error getting manifest for proof root {ProofRoot}", proofRoot);
|
|
return Results.Problem(
|
|
detail: ex.Message,
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "Manifest retrieval failed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /v1/provcache/proofs/{proofRoot}/chunks/{chunkIndex}
|
|
/// </summary>
|
|
private static async Task<IResult> GetSingleChunk(
|
|
string proofRoot,
|
|
int chunkIndex,
|
|
[FromServices] IEvidenceChunkRepository chunkRepository,
|
|
ILogger<ProofsApiEndpoints> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
logger.LogDebug("GET /v1/provcache/proofs/{ProofRoot}/chunks/{ChunkIndex}", proofRoot, chunkIndex);
|
|
|
|
try
|
|
{
|
|
var chunk = await chunkRepository.GetChunkAsync(proofRoot, chunkIndex, cancellationToken);
|
|
if (chunk is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
return Results.Ok(new ProofChunkResponse
|
|
{
|
|
ChunkId = chunk.ChunkId,
|
|
Index = chunk.ChunkIndex,
|
|
Hash = chunk.ChunkHash,
|
|
Size = chunk.BlobSize,
|
|
ContentType = chunk.ContentType,
|
|
Data = Convert.ToBase64String(chunk.Blob)
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error getting chunk {ChunkIndex} for proof root {ProofRoot}", chunkIndex, proofRoot);
|
|
return Results.Problem(
|
|
detail: ex.Message,
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "Chunk retrieval failed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// POST /v1/provcache/proofs/{proofRoot}/verify
|
|
/// </summary>
|
|
private static async Task<IResult> VerifyProof(
|
|
string proofRoot,
|
|
[FromServices] IEvidenceChunkRepository chunkRepository,
|
|
[FromServices] IEvidenceChunker chunker,
|
|
ILogger<ProofsApiEndpoints> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
logger.LogDebug("POST /v1/provcache/proofs/{ProofRoot}/verify", proofRoot);
|
|
|
|
try
|
|
{
|
|
var chunks = await chunkRepository.GetChunksAsync(proofRoot, cancellationToken);
|
|
if (chunks.Count == 0)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var chunkResults = new List<ChunkVerificationResult>();
|
|
var allValid = true;
|
|
|
|
foreach (var chunk in chunks)
|
|
{
|
|
var isValid = chunker.VerifyChunk(chunk);
|
|
var computedHash = isValid ? chunk.ChunkHash : ComputeChunkHash(chunk.Blob);
|
|
|
|
chunkResults.Add(new ChunkVerificationResult
|
|
{
|
|
Index = chunk.ChunkIndex,
|
|
IsValid = isValid,
|
|
ExpectedHash = chunk.ChunkHash,
|
|
ComputedHash = isValid ? null : computedHash
|
|
});
|
|
|
|
if (!isValid)
|
|
{
|
|
allValid = false;
|
|
}
|
|
}
|
|
|
|
// Verify Merkle root
|
|
var chunkHashes = chunks.Select(c => c.ChunkHash).ToList();
|
|
var computedRoot = chunker.ComputeMerkleRoot(chunkHashes);
|
|
var rootMatches = string.Equals(computedRoot, proofRoot, StringComparison.OrdinalIgnoreCase);
|
|
|
|
return Results.Ok(new ProofVerificationResponse
|
|
{
|
|
ProofRoot = proofRoot,
|
|
IsValid = allValid && rootMatches,
|
|
ChunkResults = chunkResults,
|
|
Error = !rootMatches ? $"Merkle root mismatch. Expected: {proofRoot}, Computed: {computedRoot}" : null
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error verifying proof root {ProofRoot}", proofRoot);
|
|
return Results.Problem(
|
|
detail: ex.Message,
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "Proof verification failed");
|
|
}
|
|
}
|
|
|
|
private static string ComputeChunkHash(byte[] data)
|
|
{
|
|
var hash = System.Security.Cryptography.SHA256.HashData(data);
|
|
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
|
}
|
|
}
|