save work
This commit is contained in:
@@ -6,6 +6,12 @@
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Cache.Abstractions;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Attestation;
|
||||
|
||||
@@ -25,7 +31,16 @@ public static class ReachabilityAttestationServiceCollectionExtensions
|
||||
services.TryAddSingleton<ReachabilityWitnessDsseBuilder>();
|
||||
|
||||
// Register publisher
|
||||
services.TryAddSingleton<IReachabilityWitnessPublisher, ReachabilityWitnessPublisher>();
|
||||
services.TryAddSingleton<IReachabilityWitnessPublisher>(sp =>
|
||||
new ReachabilityWitnessPublisher(
|
||||
sp.GetRequiredService<IOptions<ReachabilityWitnessOptions>>(),
|
||||
sp.GetRequiredService<ICryptoHash>(),
|
||||
sp.GetRequiredService<ILogger<ReachabilityWitnessPublisher>>(),
|
||||
timeProvider: sp.GetService<TimeProvider>(),
|
||||
cas: sp.GetService<IFileContentAddressableStore>(),
|
||||
dsseSigningService: sp.GetService<IDsseSigningService>(),
|
||||
cryptoProfile: sp.GetService<ICryptoProfile>(),
|
||||
rekorClient: sp.GetService<IRekorClient>()));
|
||||
|
||||
// Register attesting writer (wraps RichGraphWriter)
|
||||
services.TryAddSingleton<AttestingRichGraphWriter>();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Replay.Core;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Attestation;
|
||||
|
||||
@@ -13,14 +13,6 @@ public sealed class ReachabilityWitnessDsseBuilder
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new DSSE builder.
|
||||
/// </summary>
|
||||
@@ -98,7 +90,7 @@ public sealed class ReachabilityWitnessDsseBuilder
|
||||
public byte[] SerializeStatement(InTotoStatement statement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statement);
|
||||
return JsonSerializer.SerializeToUtf8Bytes(statement, CanonicalJsonOptions);
|
||||
return CanonicalJson.SerializeToUtf8Bytes(statement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,6 +16,16 @@ public sealed class ReachabilityWitnessOptions
|
||||
/// <summary>Whether to publish to Rekor transparency log</summary>
|
||||
public bool PublishToRekor { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Rekor backend base URL (required when <see cref="PublishToRekor"/> is enabled and tier is not air-gapped).
|
||||
/// </summary>
|
||||
public Uri? RekorUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor backend name used for labeling/logging.
|
||||
/// </summary>
|
||||
public string RekorBackendName { get; set; } = "primary";
|
||||
|
||||
/// <summary>Whether to store graph in CAS</summary>
|
||||
public bool StoreInCas { get; set; } = true;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.ProofSpine\StellaOps.Scanner.ProofSpine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.SmartDiff\StellaOps.Scanner.SmartDiff.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user