218 lines
8.3 KiB
C#
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;
|
|
}
|