|
|
|
|
@@ -1,6 +1,14 @@
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using StellaOps.Attestor.Core.Rekor;
|
|
|
|
|
using StellaOps.Attestor.Core.Submission;
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
using Microsoft.Extensions.Options;
|
|
|
|
|
using StellaOps.Cryptography;
|
|
|
|
|
using StellaOps.Replay.Core;
|
|
|
|
|
using StellaOps.Scanner.Cache.Abstractions;
|
|
|
|
|
using StellaOps.Scanner.ProofSpine;
|
|
|
|
|
|
|
|
|
|
namespace StellaOps.Scanner.Reachability.Attestation;
|
|
|
|
|
|
|
|
|
|
@@ -13,6 +21,14 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
|
|
|
|
|
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.
|
|
|
|
|
@@ -21,7 +37,11 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
|
|
|
|
|
IOptions<ReachabilityWitnessOptions> options,
|
|
|
|
|
ICryptoHash cryptoHash,
|
|
|
|
|
ILogger<ReachabilityWitnessPublisher> logger,
|
|
|
|
|
TimeProvider? timeProvider = null)
|
|
|
|
|
TimeProvider? timeProvider = null,
|
|
|
|
|
IFileContentAddressableStore? cas = null,
|
|
|
|
|
IDsseSigningService? dsseSigningService = null,
|
|
|
|
|
ICryptoProfile? cryptoProfile = null,
|
|
|
|
|
IRekorClient? rekorClient = null)
|
|
|
|
|
{
|
|
|
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
|
ArgumentNullException.ThrowIfNull(cryptoHash);
|
|
|
|
|
@@ -31,6 +51,10 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
|
|
|
|
|
_cryptoHash = cryptoHash;
|
|
|
|
|
_logger = logger;
|
|
|
|
|
_dsseBuilder = new ReachabilityWitnessDsseBuilder(cryptoHash, timeProvider);
|
|
|
|
|
_cas = cas;
|
|
|
|
|
_dsseSigningService = dsseSigningService;
|
|
|
|
|
_cryptoProfile = cryptoProfile;
|
|
|
|
|
_rekorClient = rekorClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <inheritdoc />
|
|
|
|
|
@@ -61,11 +85,13 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
string? casUri = null;
|
|
|
|
|
string? casKey = null;
|
|
|
|
|
|
|
|
|
|
// Step 1: Store graph in CAS (if enabled)
|
|
|
|
|
if (_options.StoreInCas)
|
|
|
|
|
{
|
|
|
|
|
casUri = await StoreInCasAsync(graphBytes, graphHash, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
casKey = ExtractHashDigest(graphHash);
|
|
|
|
|
casUri = await StoreInCasAsync(graphBytes, casKey, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 2: Build in-toto statement
|
|
|
|
|
@@ -86,8 +112,14 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
|
|
|
|
|
graph.Nodes.Count,
|
|
|
|
|
graph.Edges.Count);
|
|
|
|
|
|
|
|
|
|
// Step 3: Create DSSE envelope (placeholder - actual signing via Attestor service)
|
|
|
|
|
var dsseEnvelope = CreateDsseEnvelope(statementBytes);
|
|
|
|
|
// 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;
|
|
|
|
|
@@ -95,7 +127,7 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
|
|
|
|
|
|
|
|
|
|
if (_options.PublishToRekor && _options.Tier != AttestationTier.AirGapped)
|
|
|
|
|
{
|
|
|
|
|
(rekorLogIndex, rekorLogId) = await SubmitToRekorAsync(dsseEnvelope, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
(rekorLogIndex, rekorLogId) = await SubmitToRekorAsync(envelope, dsseEnvelopeBytes, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
else if (_options.Tier == AttestationTier.AirGapped)
|
|
|
|
|
{
|
|
|
|
|
@@ -108,40 +140,157 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
|
|
|
|
|
CasUri: casUri,
|
|
|
|
|
RekorLogIndex: rekorLogIndex,
|
|
|
|
|
RekorLogId: rekorLogId,
|
|
|
|
|
DsseEnvelopeBytes: dsseEnvelope);
|
|
|
|
|
DsseEnvelopeBytes: dsseEnvelopeBytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Task<string?> StoreInCasAsync(byte[] graphBytes, string graphHash, CancellationToken cancellationToken)
|
|
|
|
|
private async Task<string?> StoreInCasAsync(byte[] graphBytes, string casKey, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
// TODO: Integrate with actual CAS storage (BID-007)
|
|
|
|
|
// For now, return a placeholder CAS URI based on hash
|
|
|
|
|
var casUri = $"cas://local/{graphHash}";
|
|
|
|
|
_logger.LogDebug("Stored graph in CAS: {CasUri}", casUri);
|
|
|
|
|
return Task.FromResult<string?>(casUri);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private byte[] CreateDsseEnvelope(byte[] statementBytes)
|
|
|
|
|
{
|
|
|
|
|
// TODO: Integrate with Attestor DSSE signing service (RWD-008)
|
|
|
|
|
// For now, return unsigned envelope structure
|
|
|
|
|
// In production, this would call the Attestor service to sign the statement
|
|
|
|
|
|
|
|
|
|
// Minimal DSSE envelope structure (unsigned)
|
|
|
|
|
var envelope = new
|
|
|
|
|
if (_cas is null)
|
|
|
|
|
{
|
|
|
|
|
payloadType = "application/vnd.in-toto+json",
|
|
|
|
|
payload = Convert.ToBase64String(statementBytes),
|
|
|
|
|
signatures = Array.Empty<object>() // Will be populated by Attestor
|
|
|
|
|
_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
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(envelope);
|
|
|
|
|
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 Task<(long? logIndex, string? logId)> SubmitToRekorAsync(byte[] dsseEnvelope, CancellationToken cancellationToken)
|
|
|
|
|
private static string ExtractHashDigest(string prefixedHash)
|
|
|
|
|
{
|
|
|
|
|
// TODO: Integrate with Rekor backend (RWD-008)
|
|
|
|
|
// For now, return placeholder values
|
|
|
|
|
_logger.LogDebug("Rekor submission placeholder - actual integration pending");
|
|
|
|
|
return Task.FromResult<(long?, string?)>((null, null));
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|