Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilitySubgraphPublisher.cs

218 lines
8.3 KiB
C#

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<ReachabilitySubgraphPublisher> _logger;
private readonly TimeProvider _timeProvider;
private readonly IFileContentAddressableStore? _cas;
private readonly IDsseSigningService? _dsseSigningService;
private readonly ICryptoProfile? _cryptoProfile;
public ReachabilitySubgraphPublisher(
IOptions<ReachabilitySubgraphOptions> options,
ICryptoHash cryptoHash,
ILogger<ReachabilitySubgraphPublisher> 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<ReachabilitySubgraphPublishResult> 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<byte>());
}
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<string, string> { [algorithm] = value }
};
}
private async Task<string?> 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;
}