298 lines
11 KiB
C#
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;
|
|
}
|