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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user