notify doctors work, audit work, new product advisory sprints
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Rekor;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for fetching proofs from Rekor v2 tile-based logs.
|
||||
/// Implements the Sunlight/C2SP tlog-tiles specification.
|
||||
/// </summary>
|
||||
internal sealed class HttpRekorTileClient : IRekorTileClient
|
||||
{
|
||||
private const int TileHeight = 8; // Standard tile height (2^8 = 256 entries per tile)
|
||||
private const int TileWidth = 1 << TileHeight; // 256 entries per full tile
|
||||
private const int HashSize = 32; // SHA-256
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<HttpRekorTileClient> _logger;
|
||||
|
||||
public HttpRekorTileClient(HttpClient httpClient, ILogger<HttpRekorTileClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RekorTileCheckpoint?> GetCheckpointAsync(
|
||||
RekorBackend backend,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(backend);
|
||||
|
||||
var checkpointUrl = new Uri(backend.GetEffectiveTileBaseUrl(), "../checkpoint");
|
||||
_logger.LogDebug("Fetching checkpoint from {Url}", checkpointUrl);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, checkpointUrl);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Checkpoint not found at {Url}", checkpointUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return ParseCheckpoint(content);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch checkpoint from {Url}", checkpointUrl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RekorTileData?> GetTileAsync(
|
||||
RekorBackend backend,
|
||||
int level,
|
||||
long index,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(backend);
|
||||
|
||||
// Tile path format: tile/{level}/{index...} where index is split into directories
|
||||
var tilePath = FormatTilePath(level, index);
|
||||
var tileUrl = new Uri(backend.GetEffectiveTileBaseUrl(), tilePath);
|
||||
|
||||
_logger.LogDebug("Fetching tile at level {Level} index {Index} from {Url}", level, index, tileUrl);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, tileUrl);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Tile not found at {Url}", tileUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var data = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
var width = data.Length / HashSize;
|
||||
|
||||
return new RekorTileData
|
||||
{
|
||||
Level = level,
|
||||
Index = index,
|
||||
Width = width,
|
||||
Hashes = data
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch tile from {Url}", tileUrl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RekorTileEntry?> GetEntryAsync(
|
||||
RekorBackend backend,
|
||||
long logIndex,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(backend);
|
||||
|
||||
// Entry path format: tile/entries/{index...}
|
||||
var entryPath = FormatEntryPath(logIndex);
|
||||
var entryUrl = new Uri(backend.GetEffectiveTileBaseUrl(), entryPath);
|
||||
|
||||
_logger.LogDebug("Fetching entry at index {Index} from {Url}", logIndex, entryUrl);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, entryUrl);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Entry not found at {Url}", entryUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var data = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new RekorTileEntry
|
||||
{
|
||||
LogIndex = logIndex,
|
||||
Data = data,
|
||||
IntegratedTime = null // Would need to parse from entry format
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch entry from {Url}", entryUrl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RekorTileInclusionProof?> ComputeInclusionProofAsync(
|
||||
RekorBackend backend,
|
||||
long logIndex,
|
||||
long treeSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(backend);
|
||||
|
||||
if (logIndex < 0 || logIndex >= treeSize)
|
||||
{
|
||||
_logger.LogWarning("Invalid log index {Index} for tree size {Size}", logIndex, treeSize);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Computing inclusion proof for index {Index} in tree of size {Size}", logIndex, treeSize);
|
||||
|
||||
try
|
||||
{
|
||||
// Fetch the leaf tile to get the leaf hash
|
||||
var leafTileIndex = logIndex / TileWidth;
|
||||
var leafTile = await GetTileAsync(backend, 0, leafTileIndex, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (leafTile is null)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch leaf tile for index {Index}", logIndex);
|
||||
return null;
|
||||
}
|
||||
|
||||
var positionInTile = (int)(logIndex % TileWidth);
|
||||
if (positionInTile >= leafTile.Width)
|
||||
{
|
||||
_logger.LogWarning("Position {Position} exceeds tile width {Width}", positionInTile, leafTile.Width);
|
||||
return null;
|
||||
}
|
||||
|
||||
var leafHash = leafTile.GetHash(positionInTile);
|
||||
|
||||
// Compute the proof path by fetching required tiles
|
||||
var path = await ComputeProofPathAsync(backend, logIndex, treeSize, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (path is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute expected root hash from path
|
||||
var rootHash = ComputeRootFromPath(leafHash, logIndex, treeSize, path);
|
||||
|
||||
return new RekorTileInclusionProof
|
||||
{
|
||||
LogIndex = logIndex,
|
||||
TreeSize = treeSize,
|
||||
LeafHash = leafHash,
|
||||
Path = path,
|
||||
RootHash = rootHash
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to compute inclusion proof for index {Index}", logIndex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<byte[]>?> ComputeProofPathAsync(
|
||||
RekorBackend backend,
|
||||
long logIndex,
|
||||
long treeSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = new List<byte[]>();
|
||||
var index = logIndex;
|
||||
var size = treeSize;
|
||||
var level = 0;
|
||||
|
||||
while (size > 1)
|
||||
{
|
||||
var siblingIndex = index ^ 1; // XOR to get sibling
|
||||
var tileIndex = siblingIndex / TileWidth;
|
||||
var positionInTile = (int)(siblingIndex % TileWidth);
|
||||
|
||||
// Only add sibling if it exists in the tree
|
||||
if (siblingIndex < size)
|
||||
{
|
||||
var tile = await GetTileAsync(backend, level, tileIndex, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (tile is null || positionInTile >= tile.Width)
|
||||
{
|
||||
// For partial trees, compute ephemeral hash if needed
|
||||
_logger.LogDebug("Sibling at level {Level} index {Index} not in tile, tree may be partial", level, siblingIndex);
|
||||
|
||||
// For now, return null if we can't get the sibling
|
||||
// A full implementation would handle partial tiles
|
||||
return null;
|
||||
}
|
||||
|
||||
path.Add(tile.GetHash(positionInTile));
|
||||
}
|
||||
|
||||
index /= 2;
|
||||
size = (size + 1) / 2;
|
||||
level++;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static byte[] ComputeRootFromPath(byte[] leafHash, long logIndex, long treeSize, IReadOnlyList<byte[]> path)
|
||||
{
|
||||
var current = leafHash;
|
||||
var index = logIndex;
|
||||
var size = treeSize;
|
||||
var pathIndex = 0;
|
||||
|
||||
while (size > 1 && pathIndex < path.Count)
|
||||
{
|
||||
var siblingIndex = index ^ 1;
|
||||
|
||||
if (siblingIndex < size)
|
||||
{
|
||||
var sibling = path[pathIndex++];
|
||||
|
||||
// Hash order depends on position
|
||||
current = (index & 1) == 0
|
||||
? HashPair(current, sibling)
|
||||
: HashPair(sibling, current);
|
||||
}
|
||||
|
||||
index /= 2;
|
||||
size = (size + 1) / 2;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private static byte[] HashPair(byte[] left, byte[] right)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
|
||||
// RFC 6962: H(0x01 || left || right)
|
||||
var input = new byte[1 + left.Length + right.Length];
|
||||
input[0] = 0x01;
|
||||
Array.Copy(left, 0, input, 1, left.Length);
|
||||
Array.Copy(right, 0, input, 1 + left.Length, right.Length);
|
||||
|
||||
return sha256.ComputeHash(input);
|
||||
}
|
||||
|
||||
private RekorTileCheckpoint? ParseCheckpoint(string content)
|
||||
{
|
||||
// Checkpoint format (Go signed note format):
|
||||
// <origin>
|
||||
// <tree_size>
|
||||
// <root_hash_base64>
|
||||
// [optional extension lines]
|
||||
//
|
||||
// <signature_line>...
|
||||
|
||||
var lines = content.Split('\n', StringSplitOptions.None);
|
||||
|
||||
if (lines.Length < 4)
|
||||
{
|
||||
_logger.LogWarning("Checkpoint has too few lines: {Count}", lines.Length);
|
||||
return null;
|
||||
}
|
||||
|
||||
var origin = lines[0];
|
||||
if (!long.TryParse(lines[1], NumberStyles.None, CultureInfo.InvariantCulture, out var treeSize))
|
||||
{
|
||||
_logger.LogWarning("Invalid tree size in checkpoint: {Line}", lines[1]);
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] rootHash;
|
||||
try
|
||||
{
|
||||
rootHash = Convert.FromBase64String(lines[2]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
_logger.LogWarning("Invalid root hash base64 in checkpoint: {Line}", lines[2]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the blank line that separates checkpoint from signatures
|
||||
var signatureStartIndex = -1;
|
||||
for (var i = 3; i < lines.Length; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lines[i]))
|
||||
{
|
||||
signatureStartIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var signatures = new List<RekorCheckpointSignature>();
|
||||
if (signatureStartIndex > 0)
|
||||
{
|
||||
for (var i = signatureStartIndex; i < lines.Length; i++)
|
||||
{
|
||||
var sigLine = lines[i];
|
||||
if (string.IsNullOrWhiteSpace(sigLine))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Signature format: <key_hint> <signature_base64>
|
||||
var parts = sigLine.Split(' ', 2);
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
try
|
||||
{
|
||||
signatures.Add(new RekorCheckpointSignature
|
||||
{
|
||||
KeyHint = parts[0],
|
||||
Signature = Convert.FromBase64String(parts[1])
|
||||
});
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
_logger.LogDebug("Skipping invalid signature line: {Line}", sigLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract raw checkpoint (everything before signatures)
|
||||
var rawCheckpointEnd = signatureStartIndex > 0 ? signatureStartIndex - 1 : lines.Length;
|
||||
var rawCheckpoint = string.Join('\n', lines[..rawCheckpointEnd]);
|
||||
|
||||
return new RekorTileCheckpoint
|
||||
{
|
||||
Origin = origin,
|
||||
TreeSize = treeSize,
|
||||
RootHash = rootHash,
|
||||
RawCheckpoint = rawCheckpoint,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatTilePath(int level, long index)
|
||||
{
|
||||
// Tile path uses base-1000 directory structure for scalability
|
||||
// e.g., tile/0/x001/234 for level 0, index 1234
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(level.ToString(CultureInfo.InvariantCulture));
|
||||
sb.Append('/');
|
||||
|
||||
if (index == 0)
|
||||
{
|
||||
sb.Append("000");
|
||||
}
|
||||
else
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var remaining = index;
|
||||
while (remaining > 0)
|
||||
{
|
||||
parts.Add((remaining % 1000).ToString("D3", CultureInfo.InvariantCulture));
|
||||
remaining /= 1000;
|
||||
}
|
||||
|
||||
parts.Reverse();
|
||||
// First part doesn't need leading zeros padding to 3 digits if it's the most significant
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
parts[0] = parts[0].TrimStart('0');
|
||||
if (string.IsNullOrEmpty(parts[0]))
|
||||
{
|
||||
parts[0] = "0";
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(string.Join('/', parts));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatEntryPath(long index)
|
||||
{
|
||||
// Entry path: entries/{index...}
|
||||
var sb = new StringBuilder("entries/");
|
||||
|
||||
if (index == 0)
|
||||
{
|
||||
sb.Append("000");
|
||||
}
|
||||
else
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var remaining = index;
|
||||
while (remaining > 0)
|
||||
{
|
||||
parts.Add((remaining % 1000).ToString("D3", CultureInfo.InvariantCulture));
|
||||
remaining /= 1000;
|
||||
}
|
||||
|
||||
parts.Reverse();
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
parts[0] = parts[0].TrimStart('0');
|
||||
if (string.IsNullOrEmpty(parts[0]))
|
||||
{
|
||||
parts[0] = "0";
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(string.Join('/', parts));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,43 @@ internal static class RekorBackendResolver
|
||||
{
|
||||
Name = name,
|
||||
Url = new Uri(options.Url, UriKind.Absolute),
|
||||
Version = ParseLogVersion(options.Version),
|
||||
TileBaseUrl = string.IsNullOrWhiteSpace(options.TileBaseUrl)
|
||||
? null
|
||||
: new Uri(options.TileBaseUrl, UriKind.Absolute),
|
||||
LogId = options.LogId,
|
||||
PreferTileProofs = options.PreferTileProofs,
|
||||
ProofTimeout = TimeSpan.FromMilliseconds(options.ProofTimeoutMs),
|
||||
PollInterval = TimeSpan.FromMilliseconds(options.PollIntervalMs),
|
||||
MaxAttempts = options.MaxAttempts
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the log version string to the enum value.
|
||||
/// </summary>
|
||||
private static RekorLogVersion ParseLogVersion(string? version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return RekorLogVersion.Auto;
|
||||
}
|
||||
|
||||
return version.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"AUTO" => RekorLogVersion.Auto,
|
||||
"V1" or "1" => RekorLogVersion.V1,
|
||||
"V2" or "2" => RekorLogVersion.V2,
|
||||
_ => RekorLogVersion.Auto
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the backend should use tile-based verification.
|
||||
/// </summary>
|
||||
public static bool ShouldUseTileProofs(RekorBackend backend)
|
||||
{
|
||||
return backend.Version == RekorLogVersion.V2 ||
|
||||
(backend.Version == RekorLogVersion.Auto && backend.PreferTileProofs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,20 @@ public static class ServiceCollectionExtensions
|
||||
});
|
||||
services.AddSingleton<IRekorClient>(sp => sp.GetRequiredService<HttpRekorClient>());
|
||||
|
||||
// Rekor v2 tile-based client for Sunlight/tile log format
|
||||
services.AddHttpClient<HttpRekorTileClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
var timeoutMs = options.Rekor.Primary.ProofTimeoutMs;
|
||||
if (timeoutMs <= 0)
|
||||
{
|
||||
timeoutMs = 15_000;
|
||||
}
|
||||
|
||||
client.Timeout = TimeSpan.FromMilliseconds(timeoutMs);
|
||||
});
|
||||
services.AddSingleton<IRekorTileClient>(sp => sp.GetRequiredService<HttpRekorTileClient>());
|
||||
|
||||
services.AddHttpClient<HttpTransparencyWitnessClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
|
||||
Reference in New Issue
Block a user