notify doctors work, audit work, new product advisory sprints

This commit is contained in:
master
2026-01-13 08:36:29 +02:00
parent b8868a5f13
commit 9ca7cb183e
343 changed files with 24492 additions and 3544 deletions

View File

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

View File

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

View File

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