up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
This commit is contained in:
@@ -28,6 +28,8 @@ public sealed class ScannerWorkerOptions
|
||||
|
||||
public StellaOpsCryptoOptions Crypto { get; } = new();
|
||||
|
||||
public SigningOptions Signing { get; } = new();
|
||||
|
||||
public DeterminismOptions Determinism { get; } = new();
|
||||
|
||||
public sealed class QueueOptions
|
||||
@@ -208,4 +210,35 @@ public sealed class ScannerWorkerOptions
|
||||
/// </summary>
|
||||
public int? ConcurrencyLimit { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable DSSE signing for surface artifacts (composition recipe, layer fragments).
|
||||
/// When disabled, the worker will fall back to deterministic hash envelopes.
|
||||
/// </summary>
|
||||
public bool EnableDsseSigning { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifier recorded in DSSE signatures.
|
||||
/// </summary>
|
||||
public string KeyId { get; set; } = "scanner-hmac";
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret material for HMAC-based DSSE signatures (base64 or hex).
|
||||
/// Prefer <see cref=\"SharedSecretFile\"/> for file-based loading.
|
||||
/// </summary>
|
||||
public string? SharedSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to a file containing the shared secret (base64 or hex).
|
||||
/// </summary>
|
||||
public string? SharedSecretFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow deterministic fallback when signing is enabled but no secret is provided.
|
||||
/// Keeps offline determinism while avoiding hard failures in sealed-mode runs.
|
||||
/// </summary>
|
||||
public bool AllowDeterministicFallback { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Options;
|
||||
|
||||
@@ -89,11 +90,21 @@ public sealed class ScannerWorkerOptionsValidator : IValidateOptions<ScannerWork
|
||||
}
|
||||
}
|
||||
|
||||
if (options.Shutdown.Timeout < TimeSpan.FromSeconds(5))
|
||||
{
|
||||
failures.Add("Scanner.Worker:Shutdown:Timeout must be at least 5 seconds to allow lease completion.");
|
||||
}
|
||||
|
||||
if (options.Shutdown.Timeout < TimeSpan.FromSeconds(5))
|
||||
{
|
||||
failures.Add("Scanner.Worker:Shutdown:Timeout must be at least 5 seconds to allow lease completion.");
|
||||
}
|
||||
|
||||
if (options.Signing.EnableDsseSigning)
|
||||
{
|
||||
var hasSecret = !string.IsNullOrWhiteSpace(options.Signing.SharedSecret)
|
||||
|| (!string.IsNullOrWhiteSpace(options.Signing.SharedSecretFile) && File.Exists(options.Signing.SharedSecretFile));
|
||||
if (!hasSecret && !options.Signing.AllowDeterministicFallback)
|
||||
{
|
||||
failures.Add("Scanner.Worker:Signing requires SharedSecret or SharedSecretFile when EnableDsseSigning is true and AllowDeterministicFallback is false.");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.Telemetry.EnableTelemetry)
|
||||
{
|
||||
if (!options.Telemetry.EnableMetrics && !options.Telemetry.EnableTracing)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Buffers.Text;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing.Surface;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope signer that prefers an HMAC key (deterministic) and falls back to
|
||||
/// the deterministic hash-only signer when no key is configured.
|
||||
/// </summary>
|
||||
internal sealed class HmacDsseEnvelopeSigner : IDsseEnvelopeSigner, IDisposable
|
||||
{
|
||||
private readonly ILogger<HmacDsseEnvelopeSigner> _logger;
|
||||
private readonly ScannerWorkerOptions _options;
|
||||
private readonly DeterministicDsseEnvelopeSigner _deterministic = new();
|
||||
private readonly HMACSHA256? _hmac;
|
||||
private readonly string _keyId;
|
||||
|
||||
public HmacDsseEnvelopeSigner(
|
||||
IOptions<ScannerWorkerOptions> options,
|
||||
ILogger<HmacDsseEnvelopeSigner> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
var signing = _options.Signing;
|
||||
_keyId = string.IsNullOrWhiteSpace(signing.KeyId) ? "scanner-hmac" : signing.KeyId.Trim();
|
||||
|
||||
if (!signing.EnableDsseSigning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var secretBytes = LoadSecret(signing);
|
||||
if (secretBytes is not null && secretBytes.Length > 0)
|
||||
{
|
||||
_hmac = new HMACSHA256(secretBytes);
|
||||
_logger.LogInformation("DSSE signing enabled using HMAC-SHA256 with key id {KeyId}", _keyId);
|
||||
}
|
||||
else if (!signing.AllowDeterministicFallback)
|
||||
{
|
||||
throw new InvalidOperationException("DSSE signing enabled but no shared secret provided and deterministic fallback is disabled.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<DsseEnvelope> SignAsync(string payloadType, ReadOnlyMemory<byte> content, string suggestedKind, string merkleRoot, string? view, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_hmac is null)
|
||||
{
|
||||
return _deterministic.SignAsync(payloadType, content, suggestedKind, merkleRoot, view, cancellationToken);
|
||||
}
|
||||
|
||||
var pae = BuildPae(payloadType, content.Span);
|
||||
var signatureBytes = _hmac.ComputeHash(pae);
|
||||
var envelope = new
|
||||
{
|
||||
payloadType,
|
||||
payload = Base64UrlEncode(content.Span),
|
||||
signatures = new[]
|
||||
{
|
||||
new { keyid = _keyId, sig = Base64UrlEncode(signatureBytes) }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var digest = $"sha256:{ComputeSha256Hex(content.Span)}";
|
||||
var uri = $"cas://attestations/{suggestedKind}/{digest}.json";
|
||||
|
||||
return Task.FromResult(new DsseEnvelope("application/vnd.dsse+json", uri, digest, bytes));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hmac?.Dispose();
|
||||
}
|
||||
|
||||
private static byte[]? LoadSecret(ScannerWorkerOptions.SigningOptions signing)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signing.SharedSecretFile) && File.Exists(signing.SharedSecretFile))
|
||||
{
|
||||
var fileContent = File.ReadAllText(signing.SharedSecretFile).Trim();
|
||||
var fromFile = DecodeFlexible(fileContent);
|
||||
if (fromFile is not null)
|
||||
{
|
||||
return fromFile;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(signing.SharedSecret))
|
||||
{
|
||||
var inline = DecodeFlexible(signing.SharedSecret);
|
||||
if (inline is not null)
|
||||
{
|
||||
return inline;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? DecodeFlexible(string value)
|
||||
{
|
||||
// Try base64 (std)
|
||||
if (Convert.TryFromBase64String(value, Span<byte>.Empty, out var needed))
|
||||
{
|
||||
var buffer = new byte[needed];
|
||||
if (Convert.TryFromBase64String(value, buffer, out _))
|
||||
{
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
// Try base64url
|
||||
if (Base64UrlDecode(value) is { } base64Url)
|
||||
{
|
||||
return base64Url;
|
||||
}
|
||||
|
||||
// Try hex
|
||||
if (value.Length % 2 == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromHexString(value);
|
||||
return bytes;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to UTF-8 bytes as last resort (deterministic but not recommended)
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
const string prefix = "DSSEv1";
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var typeLen = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
|
||||
var payloadLen = Encoding.UTF8.GetBytes(payload.Length.ToString());
|
||||
|
||||
var total = prefix.Length + 1 + typeLen.Length + 1 + typeBytes.Length + 1 + payloadLen.Length + 1 + payload.Length;
|
||||
var buffer = new byte[total];
|
||||
var offset = 0;
|
||||
|
||||
Encoding.UTF8.GetBytes(prefix, buffer.AsSpan(offset));
|
||||
offset += prefix.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
typeLen.CopyTo(buffer.AsSpan(offset));
|
||||
offset += typeLen.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
typeBytes.CopyTo(buffer.AsSpan(offset));
|
||||
offset += typeBytes.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
payloadLen.CopyTo(buffer.AsSpan(offset));
|
||||
offset += payloadLen.Length;
|
||||
buffer[offset++] = 0x20;
|
||||
|
||||
payload.CopyTo(buffer.AsSpan(offset));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(ReadOnlySpan<byte> data)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(data, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var len = Base64.GetMaxEncodedToUtf8Length(data.Length);
|
||||
Span<byte> buffer = stackalloc byte[len];
|
||||
Base64.EncodeToUtf8(data, buffer, out _, out var written);
|
||||
var encoded = Encoding.UTF8.GetString(buffer[..written]);
|
||||
return encoded.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
private static byte[]? Base64UrlDecode(string value)
|
||||
{
|
||||
var normalized = value.Replace('-', '+').Replace('_', '/');
|
||||
while (normalized.Length % 4 != 0)
|
||||
{
|
||||
normalized += "=";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(normalized);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ if (!string.IsNullOrWhiteSpace(connectionString))
|
||||
builder.Services.AddSingleton<IConfigureOptions<ScannerStorageOptions>, ScannerStorageSurfaceSecretConfigurator>();
|
||||
builder.Services.AddSingleton<ISurfaceManifestPublisher, SurfaceManifestPublisher>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, SurfaceManifestStageExecutor>();
|
||||
builder.Services.AddSingleton<IDsseEnvelopeSigner, DeterministicDsseEnvelopeSigner>();
|
||||
builder.Services.AddSingleton<IDsseEnvelopeSigner, HmacDsseEnvelopeSigner>();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user