consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -0,0 +1,32 @@
# StellaOps Provenance & Attestation Guild Charter
## Mission
Provide shared libraries and tooling for generating, signing, and verifying provenance attestations (DSSE/SLSA) used by evidence bundles, exports, and timeline verification flows.
## Scope
- DSSE statement builders with Merkle and digest utilities.
- Signer/validator abstractions for KMS, cosign, offline keys.
- Provenance schema definitions reused across services and CLI.
- Verification harnesses for evidence locker and export center integrations.
## Collaboration
- Partner with Evidence Locker, Exporter, Orchestrator, and CLI guilds for integration.
- Coordinate with Security Guild on key management policies and rotation logs.
- Ensure docs in `docs/modules/provenance/guides/provenance-attestation.md` stay aligned with implementation.
## Definition of Done
- Libraries ship with deterministic serialization tests.
- Threat model reviewed before each release.
- Sample statements and verification scripts committed under `samples/provenance/`.
## Required Reading
- `docs/modules/provenance/guides/provenance-attestation.md`
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -0,0 +1,148 @@
using StellaOps.Cryptography;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Provenance.Attestation;
public sealed record BuildDefinition(
string BuildType,
IReadOnlyDictionary<string, string>? ExternalParameters = null,
IReadOnlyDictionary<string, string>? ResolvedDependencies = null);
public sealed record BuildMetadata(
string? BuildInvocationId,
DateTimeOffset? BuildStartedOn,
DateTimeOffset? BuildFinishedOn,
bool? Reproducible = null,
IReadOnlyDictionary<string, bool>? Completeness = null,
IReadOnlyDictionary<string, string>? Environment = null);
public static class CanonicalJson
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = null,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
public static byte[] SerializeToUtf8Bytes<T>(T value)
{
var element = JsonSerializer.SerializeToElement(value, Options);
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false });
WriteCanonical(element, writer);
writer.Flush();
return stream.ToArray();
}
public static string SerializeToString<T>(T value) => Encoding.UTF8.GetString(SerializeToUtf8Bytes(value));
private static void WriteCanonical(JsonElement element, Utf8JsonWriter writer)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
writer.WriteStartObject();
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
{
writer.WritePropertyName(property.Name);
WriteCanonical(property.Value, writer);
}
writer.WriteEndObject();
break;
case JsonValueKind.Array:
writer.WriteStartArray();
foreach (var item in element.EnumerateArray())
{
WriteCanonical(item, writer);
}
writer.WriteEndArray();
break;
default:
element.WriteTo(writer);
break;
}
}
}
public static class MerkleTree
{
public static byte[] ComputeRoot(ICryptoHash cryptoHash, IEnumerable<byte[]> leaves)
{
ArgumentNullException.ThrowIfNull(cryptoHash);
var leafList = leaves?.ToList() ?? throw new ArgumentNullException(nameof(leaves));
if (leafList.Count == 0) throw new ArgumentException("At least one leaf required", nameof(leaves));
var level = leafList.Select(data => NormalizeLeaf(cryptoHash, data)).ToList();
while (level.Count > 1)
{
var next = new List<byte[]>((level.Count + 1) / 2);
for (var i = 0; i < level.Count; i += 2)
{
var left = level[i];
var right = i + 1 < level.Count ? level[i + 1] : left;
var combined = new byte[left.Length + right.Length];
Buffer.BlockCopy(left, 0, combined, 0, left.Length);
Buffer.BlockCopy(right, 0, combined, left.Length, right.Length);
next.Add(cryptoHash.ComputeHashForPurpose(combined, HashPurpose.Merkle));
}
level = next;
}
return level[0];
}
private static byte[] NormalizeLeaf(ICryptoHash cryptoHash, byte[] data)
{
if (data.Length == 32) return data;
return cryptoHash.ComputeHashForPurpose(data, HashPurpose.Merkle);
}
}
public sealed record BuildStatement(
BuildDefinition BuildDefinition,
BuildMetadata BuildMetadata);
public static class BuildStatementFactory
{
public static BuildStatement Create(BuildDefinition definition, BuildMetadata metadata) => new(definition, metadata);
}
public static class BuildStatementDigest
{
public static byte[] ComputeHash(ICryptoHash cryptoHash, BuildStatement statement)
{
ArgumentNullException.ThrowIfNull(cryptoHash);
ArgumentNullException.ThrowIfNull(statement);
var canonicalBytes = CanonicalJson.SerializeToUtf8Bytes(statement);
return cryptoHash.ComputeHashForPurpose(canonicalBytes, HashPurpose.Attestation);
}
public static string ComputeHashHex(ICryptoHash cryptoHash, BuildStatement statement)
{
return Convert.ToHexStringLower(ComputeHash(cryptoHash, statement));
}
public static byte[] ComputeMerkleRoot(ICryptoHash cryptoHash, IEnumerable<BuildStatement> statements)
{
ArgumentNullException.ThrowIfNull(cryptoHash);
ArgumentNullException.ThrowIfNull(statements);
var leaves = statements.Select(s => ComputeHash(cryptoHash, s)).ToArray();
if (leaves.Length == 0)
{
throw new ArgumentException("At least one build statement required", nameof(statements));
}
return MerkleTree.ComputeRoot(cryptoHash, leaves);
}
public static string ComputeMerkleRootHex(ICryptoHash cryptoHash, IEnumerable<BuildStatement> statements)
{
return Convert.ToHexStringLower(ComputeMerkleRoot(cryptoHash, statements));
}
}

View File

@@ -0,0 +1,20 @@
using System.Globalization;
namespace StellaOps.Provenance.Attestation;
public static class Hex
{
public static byte[] FromHex(string hex)
{
if (string.IsNullOrWhiteSpace(hex)) throw new ArgumentException("hex is required", nameof(hex));
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..];
if (hex.Length % 2 != 0) throw new FormatException("hex length must be even");
var bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = byte.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
return bytes;
}
}

View File

@@ -0,0 +1,56 @@
using System.Text.Json;
namespace StellaOps.Provenance.Attestation;
public sealed record PromotionPredicate(
string ImageDigest,
string SbomDigest,
string VexDigest,
string PromotionId,
string? RekorEntry = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public sealed record PromotionAttestation(
PromotionPredicate Predicate,
byte[] Payload,
SignResult Signature);
public static class PromotionAttestationBuilder
{
public const string PredicateType = "stella.ops/promotion@v1";
public const string ContentType = "application/vnd.stella.promotion+json";
public static byte[] CreateCanonicalJson(PromotionPredicate predicate)
{
if (predicate is null) throw new ArgumentNullException(nameof(predicate));
return CanonicalJson.SerializeToUtf8Bytes(predicate);
}
public static async Task<PromotionAttestation> BuildAsync(
PromotionPredicate predicate,
ISigner signer,
IReadOnlyDictionary<string, string>? claims = null,
CancellationToken cancellationToken = default)
{
if (predicate is null) throw new ArgumentNullException(nameof(predicate));
if (signer is null) throw new ArgumentNullException(nameof(signer));
var payload = CreateCanonicalJson(predicate);
// ensure predicate type claim is always present
var mergedClaims = claims is null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(claims, StringComparer.Ordinal);
mergedClaims["predicateType"] = PredicateType;
var request = new SignRequest(
Payload: payload,
ContentType: ContentType,
Claims: mergedClaims,
RequiredClaims: new[] { "predicateType" });
var signature = await signer.SignAsync(request, cancellationToken).ConfigureAwait(false);
return new PromotionAttestation(predicate, payload, signature);
}
}

View File

@@ -0,0 +1,253 @@
using StellaOps.Cryptography;
namespace StellaOps.Provenance.Attestation;
public sealed record SignRequest(
byte[] Payload,
string ContentType,
IReadOnlyDictionary<string, string>? Claims = null,
IReadOnlyCollection<string>? RequiredClaims = null);
public sealed record SignResult(
byte[] Signature,
string KeyId,
DateTimeOffset SignedAt,
IReadOnlyDictionary<string, string>? Claims);
public interface IKeyProvider
{
string KeyId { get; }
byte[] KeyMaterial { get; }
DateTimeOffset? NotAfter { get; }
}
public interface IAuditSink
{
void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt);
void LogMissingClaim(string keyId, string claimName);
void LogKeyRotation(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt);
}
public sealed class NullAuditSink : IAuditSink
{
public static readonly NullAuditSink Instance = new();
private NullAuditSink() { }
public void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt) { }
public void LogMissingClaim(string keyId, string claimName) { }
public void LogKeyRotation(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt) { }
}
public sealed class HmacSigner : ISigner
{
private readonly IKeyProvider _keyProvider;
private readonly ICryptoHmac _cryptoHmac;
private readonly IAuditSink _audit;
private readonly TimeProvider _timeProvider;
public HmacSigner(IKeyProvider keyProvider, ICryptoHmac cryptoHmac, IAuditSink? audit = null, TimeProvider? timeProvider = null)
{
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
_cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
_audit = audit ?? NullAuditSink.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default)
{
if (request is null) throw new ArgumentNullException(nameof(request));
if (request.RequiredClaims is not null)
{
foreach (var required in request.RequiredClaims)
{
if (request.Claims is null || !request.Claims.ContainsKey(required))
{
_audit.LogMissingClaim(_keyProvider.KeyId, required);
throw new InvalidOperationException($"Missing required claim {required}.");
}
}
}
else if (request.Claims is null || request.Claims.Count == 0)
{
// allow empty claims for legacy rotation tests and non-DSSE payloads
// (predicateType enforcement happens at PromotionAttestationBuilder layer)
}
var signature = _cryptoHmac.ComputeHmacForPurpose(_keyProvider.KeyMaterial, request.Payload, HmacPurpose.Signing);
var signedAt = _timeProvider.GetUtcNow();
_audit.LogSigned(_keyProvider.KeyId, request.ContentType, request.Claims, signedAt);
return Task.FromResult(new SignResult(
Signature: signature,
KeyId: _keyProvider.KeyId,
SignedAt: signedAt,
Claims: request.Claims));
}
}
public interface ISigner
{
Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default);
}
public sealed class InMemoryKeyProvider : IKeyProvider
{
public string KeyId { get; }
public byte[] KeyMaterial { get; }
public DateTimeOffset? NotAfter { get; }
public InMemoryKeyProvider(string keyId, byte[] keyMaterial, DateTimeOffset? notAfter = null)
{
KeyId = keyId ?? throw new ArgumentNullException(nameof(keyId));
KeyMaterial = keyMaterial ?? throw new ArgumentNullException(nameof(keyMaterial));
NotAfter = notAfter;
}
}
public sealed class InMemoryAuditSink : IAuditSink
{
public List<(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt)> Signed { get; } = new();
public List<(string keyId, string claim)> Missing { get; } = new();
public List<(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt)> Rotations { get; } = new();
public void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt)
=> Signed.Add((keyId, contentType, claims, signedAt));
public void LogMissingClaim(string keyId, string claimName)
=> Missing.Add((keyId, claimName));
public void LogKeyRotation(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt)
=> Rotations.Add((previousKeyId, nextKeyId, rotatedAt));
}
public sealed class RotatingKeyProvider : IKeyProvider
{
private readonly IReadOnlyList<IKeyProvider> _keys;
private readonly TimeProvider _timeProvider;
private readonly IAuditSink _audit;
private string _activeKeyId;
public RotatingKeyProvider(IEnumerable<IKeyProvider> keys, TimeProvider? timeProvider = null, IAuditSink? audit = null)
{
_keys = keys?.ToList() ?? throw new ArgumentNullException(nameof(keys));
if (_keys.Count == 0) throw new ArgumentException("At least one key is required", nameof(keys));
_timeProvider = timeProvider ?? TimeProvider.System;
_audit = audit ?? NullAuditSink.Instance;
_activeKeyId = _keys[0].KeyId;
}
private IKeyProvider ResolveActive()
{
var now = _timeProvider.GetUtcNow();
var next = _keys
.OrderByDescending(k => k.NotAfter ?? DateTimeOffset.MaxValue)
.First(k => !k.NotAfter.HasValue || k.NotAfter.Value >= now);
if (!string.Equals(next.KeyId, _activeKeyId, StringComparison.Ordinal))
{
_audit.LogKeyRotation(_activeKeyId, next.KeyId, now);
_activeKeyId = next.KeyId;
}
return next;
}
public string KeyId => ResolveActive().KeyId;
public byte[] KeyMaterial => ResolveActive().KeyMaterial;
public DateTimeOffset? NotAfter => ResolveActive().NotAfter;
}
public interface ICosignClient
{
Task<byte[]> SignAsync(byte[] payload, string contentType, string keyRef, CancellationToken cancellationToken);
}
public interface IKmsClient
{
Task<byte[]> SignAsync(byte[] payload, string contentType, string keyId, CancellationToken cancellationToken);
}
public sealed class CosignSigner : ISigner
{
private readonly string _keyRef;
private readonly ICosignClient _client;
private readonly IAuditSink _audit;
private readonly TimeProvider _timeProvider;
public CosignSigner(string keyRef, ICosignClient client, IAuditSink? audit = null, TimeProvider? timeProvider = null)
{
_keyRef = string.IsNullOrWhiteSpace(keyRef) ? throw new ArgumentException("Key reference required", nameof(keyRef)) : keyRef;
_client = client ?? throw new ArgumentNullException(nameof(client));
_audit = audit ?? NullAuditSink.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default)
{
if (request is null) throw new ArgumentNullException(nameof(request));
EnforceClaims(request);
var signature = await _client.SignAsync(request.Payload, request.ContentType, _keyRef, cancellationToken).ConfigureAwait(false);
var signedAt = _timeProvider.GetUtcNow();
_audit.LogSigned(_keyRef, request.ContentType, request.Claims, signedAt);
return new SignResult(signature, _keyRef, signedAt, request.Claims);
}
private void EnforceClaims(SignRequest request)
{
if (request.RequiredClaims is null)
{
return;
}
foreach (var required in request.RequiredClaims)
{
if (request.Claims is null || !request.Claims.ContainsKey(required))
{
_audit.LogMissingClaim(_keyRef, required);
throw new InvalidOperationException($"Missing required claim {required}.");
}
}
}
}
public sealed class KmsSigner : ISigner
{
private readonly IKmsClient _client;
private readonly IKeyProvider _keyProvider;
private readonly IAuditSink _audit;
private readonly TimeProvider _timeProvider;
public KmsSigner(IKmsClient client, IKeyProvider keyProvider, IAuditSink? audit = null, TimeProvider? timeProvider = null)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
_audit = audit ?? NullAuditSink.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default)
{
if (request is null) throw new ArgumentNullException(nameof(request));
if (request.RequiredClaims is not null)
{
foreach (var required in request.RequiredClaims)
{
if (request.Claims is null || !request.Claims.ContainsKey(required))
{
_audit.LogMissingClaim(_keyProvider.KeyId, required);
throw new InvalidOperationException($"Missing required claim {required}.");
}
}
}
var signature = await _client.SignAsync(request.Payload, request.ContentType, _keyProvider.KeyId, cancellationToken).ConfigureAwait(false);
var signedAt = _timeProvider.GetUtcNow();
_audit.LogSigned(_keyProvider.KeyId, request.ContentType, request.Claims, signedAt);
return new SignResult(signature, _keyProvider.KeyId, signedAt, request.Claims);
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.Provenance.Attestation Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Provenance/StellaOps.Provenance.Attestation/StellaOps.Provenance.Attestation.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,103 @@
using StellaOps.Cryptography;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.Provenance.Attestation;
public sealed record VerificationResult(bool IsValid, string Reason, DateTimeOffset VerifiedAt);
public interface IVerifier
{
Task<VerificationResult> VerifyAsync(SignRequest request, SignResult signature, CancellationToken cancellationToken = default);
}
public sealed class HmacVerifier : IVerifier
{
private readonly IKeyProvider _keyProvider;
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _maxClockSkew;
public HmacVerifier(IKeyProvider keyProvider, TimeProvider? timeProvider = null, TimeSpan? maxClockSkew = null)
{
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
_maxClockSkew = maxClockSkew ?? TimeSpan.FromMinutes(5);
}
public Task<VerificationResult> VerifyAsync(SignRequest request, SignResult signature, CancellationToken cancellationToken = default)
{
if (request is null) throw new ArgumentNullException(nameof(request));
if (signature is null) throw new ArgumentNullException(nameof(signature));
using var hmac = new HMACSHA256(_keyProvider.KeyMaterial);
var expected = hmac.ComputeHash(request.Payload);
var ok = CryptographicOperations.FixedTimeEquals(expected, signature.Signature) &&
string.Equals(_keyProvider.KeyId, signature.KeyId, StringComparison.Ordinal);
// enforce not-after validity and basic clock skew checks for offline verification
var now = _timeProvider.GetUtcNow();
if (_keyProvider.NotAfter.HasValue && signature.SignedAt > _keyProvider.NotAfter.Value)
{
ok = false;
}
if (signature.SignedAt - now > _maxClockSkew)
{
ok = false;
}
var result = new VerificationResult(
IsValid: ok,
Reason: ok ? "verified" : "signature or time invalid",
VerifiedAt: _timeProvider.GetUtcNow());
return Task.FromResult(result);
}
}
public static class MerkleRootVerifier
{
public static VerificationResult VerifyRoot(ICryptoHash cryptoHash, IEnumerable<byte[]> leaves, byte[] expectedRoot, TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(cryptoHash);
var provider = timeProvider ?? TimeProvider.System;
if (leaves is null) throw new ArgumentNullException(nameof(leaves));
if (expectedRoot is null) throw new ArgumentNullException(nameof(expectedRoot));
var leafList = leaves.ToList();
var computed = MerkleTree.ComputeRoot(cryptoHash, leafList);
var ok = CryptographicOperations.FixedTimeEquals(computed, expectedRoot);
return new VerificationResult(ok, ok ? "verified" : "merkle root mismatch", provider.GetUtcNow());
}
}
public static class ChainOfCustodyVerifier
{
/// <summary>
/// Verifies a simple chain-of-custody where each hop is hashed onto the previous aggregate.
/// head = Hash(hopN || ... || hop1) using the active compliance profile's attestation algorithm.
/// </summary>
public static VerificationResult Verify(ICryptoHash cryptoHash, IEnumerable<byte[]> hops, byte[] expectedHead, TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(cryptoHash);
var provider = timeProvider ?? TimeProvider.System;
if (hops is null) throw new ArgumentNullException(nameof(hops));
if (expectedHead is null) throw new ArgumentNullException(nameof(expectedHead));
var list = hops.ToList();
if (list.Count == 0)
{
return new VerificationResult(false, "no hops", provider.GetUtcNow());
}
byte[] aggregate = Array.Empty<byte>();
foreach (var hop in list)
{
aggregate = cryptoHash.ComputeHashForPurpose(aggregate.Concat(hop).ToArray(), HashPurpose.Attestation);
}
var ok = CryptographicOperations.FixedTimeEquals(aggregate, expectedHead);
return new VerificationResult(ok, ok ? "verified" : "chain mismatch", provider.GetUtcNow());
}
}