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

This commit is contained in:
StellaOps Bot
2025-12-03 00:10:19 +02:00
parent ea1d58a89b
commit 37cba83708
158 changed files with 147438 additions and 867 deletions

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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;
}
}
}

View File

@@ -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
{