consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
32
src/Attestor/StellaOps.Provenance.Attestation/AGENTS.md
Normal file
32
src/Attestor/StellaOps.Provenance.Attestation/AGENTS.md
Normal 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.
|
||||
|
||||
148
src/Attestor/StellaOps.Provenance.Attestation/BuildModels.cs
Normal file
148
src/Attestor/StellaOps.Provenance.Attestation/BuildModels.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
20
src/Attestor/StellaOps.Provenance.Attestation/Hex.cs
Normal file
20
src/Attestor/StellaOps.Provenance.Attestation/Hex.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
253
src/Attestor/StellaOps.Provenance.Attestation/Signers.cs
Normal file
253
src/Attestor/StellaOps.Provenance.Attestation/Signers.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
8
src/Attestor/StellaOps.Provenance.Attestation/TASKS.md
Normal file
8
src/Attestor/StellaOps.Provenance.Attestation/TASKS.md
Normal 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. |
|
||||
103
src/Attestor/StellaOps.Provenance.Attestation/Verification.cs
Normal file
103
src/Attestor/StellaOps.Provenance.Attestation/Verification.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user