using System.Security.Cryptography; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Attestor.ProofChain.Predicates; using StellaOps.Attestor.ProofChain.Statements; using StellaOps.Cryptography; using StellaOps.Replay.Core; using StellaOps.Scanner.Cache.Abstractions; using StellaOps.Scanner.ProofSpine; using StellaOps.Scanner.Reachability.Subgraph; namespace StellaOps.Scanner.Reachability.Attestation; public sealed class ReachabilitySubgraphPublisher : IReachabilitySubgraphPublisher { private static readonly JsonSerializerOptions DsseJsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = false }; private readonly ReachabilitySubgraphOptions _options; private readonly ICryptoHash _cryptoHash; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly IFileContentAddressableStore? _cas; private readonly IDsseSigningService? _dsseSigningService; private readonly ICryptoProfile? _cryptoProfile; public ReachabilitySubgraphPublisher( IOptions options, ICryptoHash cryptoHash, ILogger logger, TimeProvider? timeProvider = null, IFileContentAddressableStore? cas = null, IDsseSigningService? dsseSigningService = null, ICryptoProfile? cryptoProfile = null) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; _cas = cas; _dsseSigningService = dsseSigningService; _cryptoProfile = cryptoProfile; } public async Task PublishAsync( ReachabilitySubgraph subgraph, string subjectDigest, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(subgraph); ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest); if (!_options.Enabled) { _logger.LogDebug("Reachability subgraph attestation disabled"); return new ReachabilitySubgraphPublishResult( SubgraphDigest: string.Empty, CasUri: null, AttestationDigest: string.Empty, DsseEnvelopeBytes: Array.Empty()); } var normalized = subgraph.Normalize(); var subgraphBytes = CanonicalJson.SerializeToUtf8Bytes(normalized); var subgraphDigest = _cryptoHash.ComputePrefixedHashForPurpose(subgraphBytes, HashPurpose.Graph); string? casUri = null; if (_options.StoreInCas) { casUri = await StoreSubgraphAsync(subgraphBytes, subgraphDigest, cancellationToken).ConfigureAwait(false); } var statement = BuildStatement(normalized, subgraphDigest, casUri, subjectDigest); var statementBytes = CanonicalJson.SerializeToUtf8Bytes(statement); var (envelope, envelopeBytes) = await CreateDsseEnvelopeAsync(statement, statementBytes, cancellationToken) .ConfigureAwait(false); var attestationDigest = _cryptoHash.ComputePrefixedHashForPurpose(envelopeBytes, HashPurpose.Attestation); _logger.LogInformation( "Created reachability subgraph attestation: graphDigest={GraphDigest}, attestationDigest={AttestationDigest}", subgraphDigest, attestationDigest); return new ReachabilitySubgraphPublishResult( SubgraphDigest: subgraphDigest, CasUri: casUri, AttestationDigest: attestationDigest, DsseEnvelopeBytes: envelopeBytes); } private ReachabilitySubgraphStatement BuildStatement( ReachabilitySubgraph subgraph, string subgraphDigest, string? casUri, string subjectDigest) { var analysis = subgraph.AnalysisMetadata; var predicate = new ReachabilitySubgraphPredicate { SchemaVersion = subgraph.Version, GraphDigest = subgraphDigest, GraphCasUri = casUri, FindingKeys = subgraph.FindingKeys, Analysis = new ReachabilitySubgraphAnalysis { Analyzer = analysis?.Analyzer ?? "reachability", AnalyzerVersion = analysis?.AnalyzerVersion ?? "unknown", Confidence = analysis?.Confidence ?? 0.5, Completeness = analysis?.Completeness ?? "partial", GeneratedAt = analysis?.GeneratedAt ?? _timeProvider.GetUtcNow(), HashAlgorithm = _cryptoHash.GetAlgorithmForPurpose(HashPurpose.Graph) } }; return new ReachabilitySubgraphStatement { Subject = [ BuildSubject(subjectDigest) ], Predicate = predicate }; } private static Subject BuildSubject(string digest) { var (algorithm, value) = SplitDigest(digest); return new Subject { Name = digest, Digest = new Dictionary { [algorithm] = value } }; } private async Task StoreSubgraphAsync(byte[] subgraphBytes, string subgraphDigest, CancellationToken cancellationToken) { if (_cas is null) { _logger.LogWarning("CAS storage requested but no CAS store configured; skipping subgraph storage."); return null; } var key = ExtractHashDigest(subgraphDigest); var existing = await _cas.TryGetAsync(key, cancellationToken).ConfigureAwait(false); if (existing is null) { await using var stream = new MemoryStream(subgraphBytes, writable: false); await _cas.PutAsync(new FileCasPutRequest(key, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false); } return $"cas://reachability/subgraphs/{key}"; } private async Task<(DsseEnvelope Envelope, byte[] EnvelopeBytes)> CreateDsseEnvelopeAsync( ReachabilitySubgraphStatement 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)); } 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 static byte[] SerializeDsseEnvelope(DsseEnvelope envelope) { var signatures = envelope.Signatures .OrderBy(s => s.KeyId, StringComparer.Ordinal) .ThenBy(s => s.Sig, StringComparer.Ordinal) .Select(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 static string ExtractHashDigest(string prefixedHash) { var colonIndex = prefixedHash.IndexOf(':'); return colonIndex >= 0 ? prefixedHash[(colonIndex + 1)..] : prefixedHash; } private static (string Algorithm, string Value) SplitDigest(string digest) { var colonIndex = digest.IndexOf(':'); if (colonIndex <= 0 || colonIndex == digest.Length - 1) { return ("sha256", digest); } return (digest[..colonIndex], digest[(colonIndex + 1)..]); } private sealed record InlineCryptoProfile(string KeyId, string Algorithm) : ICryptoProfile; }