Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessPublisher.cs
2026-02-01 21:37:40 +02:00

298 lines
11 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Cryptography;
using StellaOps.Replay.Core;
using StellaOps.Scanner.Cache.Abstractions;
using StellaOps.Scanner.ProofSpine;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Publishes reachability witness attestations to CAS and Rekor.
/// </summary>
public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
{
private readonly ReachabilityWitnessOptions _options;
private readonly ReachabilityWitnessDsseBuilder _dsseBuilder;
private readonly ICryptoHash _cryptoHash;
private readonly ILogger<ReachabilityWitnessPublisher> _logger;
private readonly IFileContentAddressableStore? _cas;
private readonly IDsseSigningService? _dsseSigningService;
private readonly ICryptoProfile? _cryptoProfile;
private readonly IRekorClient? _rekorClient;
private static readonly JsonSerializerOptions DsseJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
/// <summary>
/// Creates a new reachability witness publisher.
/// </summary>
public ReachabilityWitnessPublisher(
IOptions<ReachabilityWitnessOptions> options,
ICryptoHash cryptoHash,
ILogger<ReachabilityWitnessPublisher> logger,
TimeProvider? timeProvider = null,
IFileContentAddressableStore? cas = null,
IDsseSigningService? dsseSigningService = null,
ICryptoProfile? cryptoProfile = null,
IRekorClient? rekorClient = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(cryptoHash);
ArgumentNullException.ThrowIfNull(logger);
_options = options.Value;
_cryptoHash = cryptoHash;
_logger = logger;
_dsseBuilder = new ReachabilityWitnessDsseBuilder(cryptoHash, timeProvider);
_cas = cas;
_dsseSigningService = dsseSigningService;
_cryptoProfile = cryptoProfile;
_rekorClient = rekorClient;
}
/// <inheritdoc />
public async Task<ReachabilityWitnessPublishResult> PublishAsync(
RichGraph graph,
byte[] graphBytes,
string graphHash,
string subjectDigest,
string? policyHash = null,
string? sourceCommit = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(graphBytes);
ArgumentException.ThrowIfNullOrWhiteSpace(graphHash);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest);
if (!_options.Enabled)
{
_logger.LogDebug("Reachability witness attestation is disabled");
return new ReachabilityWitnessPublishResult(
StatementHash: string.Empty,
GraphHash: graphHash,
CasUri: null,
RekorLogIndex: null,
RekorLogId: null,
DsseEnvelopeBytes: Array.Empty<byte>());
}
string? casUri = null;
string? casKey = null;
// Step 1: Store graph in CAS (if enabled)
if (_options.StoreInCas)
{
casKey = ExtractHashDigest(graphHash);
casUri = await StoreInCasAsync(graphBytes, casKey, cancellationToken).ConfigureAwait(false);
}
// Step 2: Build in-toto statement
var statement = _dsseBuilder.BuildStatement(
graph,
graphHash,
subjectDigest,
casUri,
policyHash,
sourceCommit);
var statementBytes = _dsseBuilder.SerializeStatement(statement);
var statementHash = _dsseBuilder.ComputeStatementHash(statementBytes);
_logger.LogInformation(
"Built reachability witness statement: hash={StatementHash}, nodes={NodeCount}, edges={EdgeCount}",
statementHash,
graph.Nodes.Count,
graph.Edges.Count);
// Step 3: Create DSSE envelope (signed where configured; deterministic fallback otherwise).
var (envelope, dsseEnvelopeBytes) = await CreateDsseEnvelopeAsync(statement, statementBytes, cancellationToken)
.ConfigureAwait(false);
if (_options.StoreInCas && casKey is not null)
{
await StoreDsseInCasAsync(dsseEnvelopeBytes, casKey, cancellationToken).ConfigureAwait(false);
}
// Step 4: Submit to Rekor (if enabled and not air-gapped)
long? rekorLogIndex = null;
string? rekorLogId = null;
if (_options.PublishToRekor && _options.Tier != AttestationTier.AirGapped)
{
(rekorLogIndex, rekorLogId) = await SubmitToRekorAsync(envelope, dsseEnvelopeBytes, cancellationToken).ConfigureAwait(false);
}
else if (_options.Tier == AttestationTier.AirGapped)
{
_logger.LogDebug("Skipping Rekor submission (air-gapped tier)");
}
return new ReachabilityWitnessPublishResult(
StatementHash: statementHash,
GraphHash: graphHash,
CasUri: casUri,
RekorLogIndex: rekorLogIndex,
RekorLogId: rekorLogId,
DsseEnvelopeBytes: dsseEnvelopeBytes);
}
private async Task<string?> StoreInCasAsync(byte[] graphBytes, string casKey, CancellationToken cancellationToken)
{
if (_cas is null)
{
_logger.LogWarning("CAS storage requested but no CAS store is configured; skipping graph CAS publication.");
return null;
}
var existing = await _cas.TryGetAsync(casKey, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await using var stream = new MemoryStream(graphBytes, writable: false);
await _cas.PutAsync(new FileCasPutRequest(casKey, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
}
var casUri = $"cas://reachability/graphs/{casKey}";
_logger.LogDebug("Stored graph in CAS: {CasUri}", casUri);
return casUri;
}
private async Task StoreDsseInCasAsync(byte[] dsseBytes, string casKey, CancellationToken cancellationToken)
{
if (_cas is null)
{
return;
}
var key = $"{casKey}.dsse";
var existing = await _cas.TryGetAsync(key, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
return;
}
await using var stream = new MemoryStream(dsseBytes, writable: false);
await _cas.PutAsync(new FileCasPutRequest(key, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
}
private async Task<(DsseEnvelope Envelope, byte[] EnvelopeBytes)> CreateDsseEnvelopeAsync(
InTotoStatement statement,
byte[] statementBytes,
CancellationToken cancellationToken)
{
const string payloadType = "application/vnd.in-toto+json";
if (_dsseSigningService is not null)
{
var profile = _cryptoProfile ?? new InlineCryptoProfile(_options.SigningKeyId ?? "scanner-deterministic", "hs256");
var signed = await _dsseSigningService.SignAsync(statement, payloadType, profile, cancellationToken).ConfigureAwait(false);
return (signed, SerializeDsseEnvelope(signed));
}
// Deterministic fallback signature: SHA-256 over the canonical statement bytes (no external key material).
var signature = SHA256.HashData(statementBytes);
var envelope = new DsseEnvelope(
payloadType,
Convert.ToBase64String(statementBytes),
new[] { new DsseSignature(_options.SigningKeyId ?? "scanner-deterministic", Convert.ToBase64String(signature)) });
return (envelope, SerializeDsseEnvelope(envelope));
}
private async Task<(long? logIndex, string? logId)> SubmitToRekorAsync(
DsseEnvelope envelope,
byte[] envelopeBytes,
CancellationToken cancellationToken)
{
if (_rekorClient is null)
{
_logger.LogWarning("Rekor submission requested but no Rekor client is configured; skipping.");
return (null, null);
}
if (_options.RekorUrl is null)
{
_logger.LogWarning("Rekor submission requested but no RekorUrl is configured; skipping.");
return (null, null);
}
var request = new AttestorSubmissionRequest();
request.Bundle.Dsse.PayloadType = envelope.PayloadType;
request.Bundle.Dsse.PayloadBase64 = envelope.Payload;
request.Bundle.Dsse.Signatures.Clear();
foreach (var signature in envelope.Signatures)
{
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
{
KeyId = signature.KeyId,
Signature = signature.Sig
});
}
request.Meta.BundleSha256 = ComputeSha256Hex(envelopeBytes);
request.Meta.LogPreference = _options.RekorBackendName;
var backend = new RekorBackend
{
Name = _options.RekorBackendName,
Url = _options.RekorUrl
};
try
{
var response = await _rekorClient.SubmitAsync(request, backend, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(response.Uuid))
{
_logger.LogInformation("Submitted reachability witness envelope to Rekor backend {Backend} as {Uuid}", backend.Name, response.Uuid);
}
return (response.Index, response.Uuid);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to submit reachability witness envelope to Rekor backend {Backend}", backend.Name);
return (null, null);
}
}
private static string ExtractHashDigest(string prefixedHash)
{
var colonIndex = prefixedHash.IndexOf(':');
return colonIndex >= 0 ? prefixedHash[(colonIndex + 1)..] : prefixedHash;
}
private static string ComputeSha256Hex(ReadOnlySpan<byte> data)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(data, hash);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static byte[] SerializeDsseEnvelope(DsseEnvelope envelope)
{
var signatures = envelope.Signatures
.OrderBy(static s => s.KeyId, StringComparer.Ordinal)
.ThenBy(static s => s.Sig, StringComparer.Ordinal)
.Select(static s => new { keyid = s.KeyId, sig = s.Sig })
.ToArray();
var dto = new
{
payloadType = envelope.PayloadType,
payload = envelope.Payload,
signatures
};
return JsonSerializer.SerializeToUtf8Bytes(dto, DsseJsonOptions);
}
private sealed record InlineCryptoProfile(string KeyId, string Algorithm) : ICryptoProfile;
}