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; /// /// Publishes reachability witness attestations to CAS and Rekor. /// public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher { private readonly ReachabilityWitnessOptions _options; private readonly ReachabilityWitnessDsseBuilder _dsseBuilder; private readonly ICryptoHash _cryptoHash; private readonly ILogger _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 }; /// /// Creates a new reachability witness publisher. /// public ReachabilityWitnessPublisher( IOptions options, ICryptoHash cryptoHash, ILogger 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; } /// public async Task 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()); } 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 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 data) { Span 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; }