This commit is contained in:
StellaOps Bot
2025-12-14 23:20:14 +02:00
parent 3411e825cd
commit b058dbe031
356 changed files with 68310 additions and 1108 deletions

View File

@@ -40,8 +40,101 @@ Deliver the API, workers, and storage that power signing, verification, and life
## Required Reading
- `docs/modules/attestor/architecture.md`
- `docs/modules/attestor/rekor-verification-design.md`
- `docs/modules/platform/architecture-overview.md`
---
## Active Sprints — Rekor Verification Enhancement
### SPRINT_3000_0001_0001: Merkle Proof Verification (P0)
**Objective**: Implement cryptographic verification of Rekor inclusion proofs for offline/air-gap attestation validation.
**Key Contracts**:
```csharp
// IRekorClient.cs — New method
Task<RekorInclusionVerificationResult> VerifyInclusionAsync(
AttestorEntry entry,
byte[] payloadDigest,
byte[] rekorPublicKey,
CancellationToken cancellationToken = default);
// MerkleProofVerifier.cs — RFC 6962 implementation
public static bool VerifyInclusion(
byte[] leafHash,
long leafIndex,
long treeSize,
IReadOnlyList<byte[]> proofHashes,
byte[] expectedRootHash);
```
**New Files**:
- `StellaOps.Attestor.Core/Rekor/RekorInclusionVerificationResult.cs`
- `StellaOps.Attestor.Core/Verification/MerkleProofVerifier.cs`
- `StellaOps.Attestor.Core/Verification/CheckpointVerifier.cs`
### SPRINT_3000_0001_0002: Rekor Retry Queue & Metrics (P1)
**Objective**: Implement durable retry queue for failed Rekor submissions with operational metrics.
**Key Contracts**:
```csharp
// IRekorSubmissionQueue.cs
public interface IRekorSubmissionQueue
{
Task<Guid> EnqueueAsync(string tenantId, string bundleSha256, byte[] dssePayload, string backend, CancellationToken ct);
Task<IReadOnlyList<RekorQueueItem>> DequeueAsync(int batchSize, CancellationToken ct);
Task MarkSubmittedAsync(Guid id, string rekorUuid, long? logIndex, CancellationToken ct);
Task MarkRetryAsync(Guid id, string error, CancellationToken ct);
Task MarkDeadLetterAsync(Guid id, string error, CancellationToken ct);
Task<QueueDepthSnapshot> GetQueueDepthAsync(CancellationToken ct);
}
```
**New Metrics**:
- `attestor.rekor_queue_depth` (gauge)
- `attestor.rekor_retry_attempts_total` (counter)
- `attestor.rekor_submission_status_total` (counter)
**New Files**:
- `StellaOps.Attestor.Core/Queue/IRekorSubmissionQueue.cs`
- `StellaOps.Attestor.Infrastructure/Queue/PostgresRekorSubmissionQueue.cs`
- `StellaOps.Attestor.Infrastructure/Workers/RekorRetryWorker.cs`
- `Migrations/00X_rekor_submission_queue.sql`
### SPRINT_3000_0001_0003: Time Skew Validation (P2)
**Objective**: Validate Rekor `integrated_time` to detect backdated or anomalous entries.
**Key Contracts**:
```csharp
// ITimeSkewValidator.cs
public interface ITimeSkewValidator
{
TimeSkewResult Validate(DateTimeOffset integratedTime, DateTimeOffset localTime);
}
public sealed record TimeSkewResult(
TimeSkewSeverity Severity, // Ok, Warning, Rejected
TimeSpan Skew,
string? Message);
```
**Configuration** (`AttestorOptions.TimeSkewOptions`):
- `WarnThresholdSeconds`: 300 (5 min)
- `RejectThresholdSeconds`: 3600 (1 hour)
- `FutureToleranceSeconds`: 60
**New Files**:
- `StellaOps.Attestor.Core/Validation/ITimeSkewValidator.cs`
- `StellaOps.Attestor.Infrastructure/Validation/TimeSkewValidator.cs`
---
## 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.

View File

@@ -0,0 +1,57 @@
# Attestor · Sprint 3000-0001-0001 (Rekor Merkle Proof Verification)
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
| SPRINT_3000_0001_0001-T1 | DOING | Add `VerifyInclusionAsync` contract + wire initial verifier plumbing. | 2025-12-14 |
| SPRINT_3000_0001_0001-T2 | TODO | | |
| SPRINT_3000_0001_0001-T3 | TODO | | |
| SPRINT_3000_0001_0001-T4 | TODO | | |
| SPRINT_3000_0001_0001-T5 | TODO | | |
| SPRINT_3000_0001_0001-T6 | TODO | | |
| SPRINT_3000_0001_0001-T7 | TODO | | |
| SPRINT_3000_0001_0001-T8 | TODO | | |
| SPRINT_3000_0001_0001-T9 | TODO | | |
| SPRINT_3000_0001_0001-T10 | TODO | | |
| SPRINT_3000_0001_0001-T11 | TODO | | |
| SPRINT_3000_0001_0001-T12 | TODO | | |
# Attestor · Sprint 3000-0001-0002 (Rekor Durable Retry Queue & Metrics)
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
| SPRINT_3000_0001_0002-T1 | TODO | | |
| SPRINT_3000_0001_0002-T2 | TODO | | |
| SPRINT_3000_0001_0002-T3 | TODO | | |
| SPRINT_3000_0001_0002-T4 | TODO | | |
| SPRINT_3000_0001_0002-T5 | TODO | | |
| SPRINT_3000_0001_0002-T6 | TODO | | |
| SPRINT_3000_0001_0002-T7 | TODO | | |
| SPRINT_3000_0001_0002-T8 | TODO | | |
| SPRINT_3000_0001_0002-T9 | TODO | | |
| SPRINT_3000_0001_0002-T10 | TODO | | |
| SPRINT_3000_0001_0002-T11 | TODO | | |
| SPRINT_3000_0001_0002-T12 | TODO | | |
| SPRINT_3000_0001_0002-T13 | TODO | | |
| SPRINT_3000_0001_0002-T14 | TODO | | |
| SPRINT_3000_0001_0002-T15 | TODO | | |
# Attestor · Sprint 3000-0001-0003 (Rekor Integrated Time Skew Validation)
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
| SPRINT_3000_0001_0003-T1 | TODO | | |
| SPRINT_3000_0001_0003-T2 | TODO | | |
| SPRINT_3000_0001_0003-T3 | TODO | | |
| SPRINT_3000_0001_0003-T4 | TODO | | |
| SPRINT_3000_0001_0003-T5 | TODO | | |
| SPRINT_3000_0001_0003-T6 | TODO | | |
| SPRINT_3000_0001_0003-T7 | TODO | | |
| SPRINT_3000_0001_0003-T8 | TODO | | |
| SPRINT_3000_0001_0003-T9 | TODO | | |
| SPRINT_3000_0001_0003-T10 | TODO | | |
| SPRINT_3000_0001_0003-T11 | TODO | | |
Status changes must be mirrored in:
- `docs/implplan/SPRINT_3000_0001_0001_rekor_merkle_proof_verification.md`
- `docs/implplan/SPRINT_3000_0001_0002_rekor_retry_queue_metrics.md`
- `docs/implplan/SPRINT_3000_0001_0003_rekor_time_skew_validation.md`

View File

@@ -0,0 +1,23 @@
# StellaOps.Attestor.ProofChain — Local Agent Charter
## Scope
- This charter applies to `src/Attestor/__Libraries/StellaOps.Attestor.ProofChain/**`.
## Primary roles
- Backend engineer (C# / .NET 10).
- QA automation engineer (xUnit).
## Required reading (treat as read before edits)
- `docs/modules/attestor/architecture.md`
- `docs/product-advisories/14-Dec-2025 - Proof and Evidence Chain Technical Reference.md`
- RFC 8785 (JSON Canonicalization Scheme)
## Working agreements
- Determinism is mandatory: stable ordering, stable hashes, UTC timestamps only.
- No network dependence in library code paths; keep implementations offline-friendly.
- Prefer small, composable services with explicit interfaces (`I*`).
## Testing expectations
- Every behavior change must be covered by tests under `src/Attestor/__Tests/StellaOps.Attestor.ProofChain.Tests`.
- Include determinism tests (same inputs -> same IDs/hashes) and negative tests (invalid formats).

View File

@@ -0,0 +1,155 @@
using System;
using StellaOps.Attestor.ProofChain.Internal;
namespace StellaOps.Attestor.ProofChain.Identifiers;
public abstract record ContentAddressedId
{
protected ContentAddressedId(string algorithm, string digest)
{
Algorithm = NormalizeAlgorithm(algorithm, nameof(algorithm));
Digest = NormalizeDigest(Algorithm, digest);
}
public string Algorithm { get; }
public string Digest { get; }
public override string ToString() => $"{Algorithm}:{Digest}";
public static GenericContentAddressedId Parse(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (!TrySplit(value, out var algorithm, out var digest))
{
throw new FormatException($"Invalid content-addressed ID format: '{value}'.");
}
return new GenericContentAddressedId(algorithm, digest);
}
internal static bool TrySplit(string value, out string algorithm, out string digest)
{
algorithm = string.Empty;
digest = string.Empty;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var separator = value.IndexOf(':', StringComparison.Ordinal);
if (separator <= 0 || separator == value.Length - 1)
{
return false;
}
algorithm = value[..separator];
digest = value[(separator + 1)..];
if (string.IsNullOrWhiteSpace(algorithm) || string.IsNullOrWhiteSpace(digest))
{
return false;
}
try
{
algorithm = NormalizeAlgorithm(algorithm, nameof(algorithm));
digest = NormalizeDigest(algorithm, digest);
return true;
}
catch (FormatException)
{
return false;
}
}
internal static string NormalizeAlgorithm(string algorithm, string parameterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(algorithm, parameterName);
return algorithm.Trim().ToLowerInvariant();
}
internal static string NormalizeDigest(string algorithm, string digest)
{
ArgumentException.ThrowIfNullOrWhiteSpace(digest, nameof(digest));
digest = digest.Trim();
return algorithm switch
{
"sha256" => Hex.NormalizeLowerHex(digest, expectedLength: 64, nameof(digest)),
"sha512" => Hex.NormalizeLowerHex(digest, expectedLength: 128, nameof(digest)),
_ => throw new FormatException($"Unsupported digest algorithm '{algorithm}'.")
};
}
}
public sealed record GenericContentAddressedId(string Algorithm, string Digest) : ContentAddressedId(Algorithm, Digest);
public sealed record ArtifactId(string Digest) : ContentAddressedId("sha256", Digest)
{
public new static ArtifactId Parse(string value) => new(ParseSha256(value));
public static bool TryParse(string value, out ArtifactId? id) => TryParseSha256(value, out id);
private static string ParseSha256(string value)
{
if (!TryParseSha256(value, out var id))
{
throw new FormatException($"Invalid ArtifactID: '{value}'.");
}
return id!.Digest;
}
private static bool TryParseSha256(string value, out ArtifactId? id)
{
id = null;
if (!ContentAddressedId.TrySplit(value, out var algorithm, out var digest))
{
return false;
}
if (!string.Equals(algorithm, "sha256", StringComparison.Ordinal))
{
return false;
}
id = new ArtifactId(digest);
return true;
}
}
public sealed record EvidenceId(string Digest) : ContentAddressedId("sha256", Digest)
{
public new static EvidenceId Parse(string value) => new(Sha256IdParser.Parse(value, "EvidenceID"));
}
public sealed record ReasoningId(string Digest) : ContentAddressedId("sha256", Digest)
{
public new static ReasoningId Parse(string value) => new(Sha256IdParser.Parse(value, "ReasoningID"));
}
public sealed record VexVerdictId(string Digest) : ContentAddressedId("sha256", Digest)
{
public new static VexVerdictId Parse(string value) => new(Sha256IdParser.Parse(value, "VEXVerdictID"));
}
public sealed record ProofBundleId(string Digest) : ContentAddressedId("sha256", Digest)
{
public new static ProofBundleId Parse(string value) => new(Sha256IdParser.Parse(value, "ProofBundleID"));
}
internal static class Sha256IdParser
{
public static string Parse(string value, string kind)
{
if (!ContentAddressedId.TrySplit(value, out var algorithm, out var digest) ||
!string.Equals(algorithm, "sha256", StringComparison.Ordinal))
{
throw new FormatException($"Invalid {kind}: '{value}'.");
}
return digest;
}
}

View File

@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Attestor.ProofChain.Json;
using StellaOps.Attestor.ProofChain.Merkle;
using StellaOps.Attestor.ProofChain.Predicates;
namespace StellaOps.Attestor.ProofChain.Identifiers;
public sealed class ContentAddressedIdGenerator : IContentAddressedIdGenerator
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = null,
WriteIndented = false
};
private readonly IJsonCanonicalizer _canonicalizer;
private readonly IMerkleTreeBuilder _merkleTreeBuilder;
public ContentAddressedIdGenerator(IJsonCanonicalizer canonicalizer, IMerkleTreeBuilder merkleTreeBuilder)
{
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
_merkleTreeBuilder = merkleTreeBuilder ?? throw new ArgumentNullException(nameof(merkleTreeBuilder));
}
public EvidenceId ComputeEvidenceId(EvidencePredicate predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
var canonical = Canonicalize(predicate with { EvidenceId = null });
return new EvidenceId(HashSha256Hex(canonical));
}
public ReasoningId ComputeReasoningId(ReasoningPredicate predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
var canonical = Canonicalize(predicate with { ReasoningId = null });
return new ReasoningId(HashSha256Hex(canonical));
}
public VexVerdictId ComputeVexVerdictId(VexPredicate predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
var canonical = Canonicalize(predicate with { VexVerdictId = null });
return new VexVerdictId(HashSha256Hex(canonical));
}
public ProofBundleId ComputeProofBundleId(
SbomEntryId sbomEntryId,
IReadOnlyList<EvidenceId> evidenceIds,
ReasoningId reasoningId,
VexVerdictId vexVerdictId)
{
ArgumentNullException.ThrowIfNull(sbomEntryId);
ArgumentNullException.ThrowIfNull(evidenceIds);
ArgumentNullException.ThrowIfNull(reasoningId);
ArgumentNullException.ThrowIfNull(vexVerdictId);
if (evidenceIds.Count == 0)
{
throw new ArgumentException("At least one EvidenceID is required.", nameof(evidenceIds));
}
var sortedEvidence = new List<string>(evidenceIds.Count);
for (var i = 0; i < evidenceIds.Count; i++)
{
sortedEvidence.Add(evidenceIds[i].ToString());
}
sortedEvidence.Sort(StringComparer.Ordinal);
var leaves = new List<ReadOnlyMemory<byte>>(sortedEvidence.Count + 3)
{
Encoding.UTF8.GetBytes(sbomEntryId.ToString()),
};
foreach (var evidence in sortedEvidence)
{
leaves.Add(Encoding.UTF8.GetBytes(evidence));
}
leaves.Add(Encoding.UTF8.GetBytes(reasoningId.ToString()));
leaves.Add(Encoding.UTF8.GetBytes(vexVerdictId.ToString()));
var root = _merkleTreeBuilder.ComputeMerkleRoot(leaves);
return new ProofBundleId(Convert.ToHexStringLower(root));
}
public GraphRevisionId ComputeGraphRevisionId(
IReadOnlyList<string> nodeIds,
IReadOnlyList<string> edgeIds,
string policyDigest,
string feedsDigest,
string toolchainDigest,
string paramsDigest)
{
ArgumentNullException.ThrowIfNull(nodeIds);
ArgumentNullException.ThrowIfNull(edgeIds);
ArgumentException.ThrowIfNullOrWhiteSpace(policyDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(feedsDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(toolchainDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(paramsDigest);
var nodes = new List<string>(nodeIds);
nodes.Sort(StringComparer.Ordinal);
var edges = new List<string>(edgeIds);
edges.Sort(StringComparer.Ordinal);
var leaves = new List<ReadOnlyMemory<byte>>(nodes.Count + edges.Count + 4);
foreach (var node in nodes)
{
leaves.Add(Encoding.UTF8.GetBytes(node));
}
foreach (var edge in edges)
{
leaves.Add(Encoding.UTF8.GetBytes(edge));
}
leaves.Add(Encoding.UTF8.GetBytes(policyDigest.Trim()));
leaves.Add(Encoding.UTF8.GetBytes(feedsDigest.Trim()));
leaves.Add(Encoding.UTF8.GetBytes(toolchainDigest.Trim()));
leaves.Add(Encoding.UTF8.GetBytes(paramsDigest.Trim()));
var root = _merkleTreeBuilder.ComputeMerkleRoot(leaves);
return new GraphRevisionId(Convert.ToHexStringLower(root));
}
public string ComputeSbomDigest(ReadOnlySpan<byte> sbomJson)
{
var canonical = _canonicalizer.Canonicalize(sbomJson);
return $"sha256:{HashSha256Hex(canonical)}";
}
public SbomEntryId ComputeSbomEntryId(ReadOnlySpan<byte> sbomJson, string purl, string? version = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
var sbomDigest = ComputeSbomDigest(sbomJson);
return new SbomEntryId(sbomDigest, purl, version);
}
private byte[] Canonicalize<T>(T value)
{
var json = JsonSerializer.SerializeToUtf8Bytes(value, SerializerOptions);
return _canonicalizer.Canonicalize(json);
}
private static string HashSha256Hex(ReadOnlySpan<byte> bytes)
=> Convert.ToHexStringLower(SHA256.HashData(bytes));
}

View File

@@ -0,0 +1,31 @@
using System;
using StellaOps.Attestor.ProofChain.Internal;
namespace StellaOps.Attestor.ProofChain.Identifiers;
public readonly record struct GraphRevisionId
{
private const string Prefix = "grv_sha256:";
public GraphRevisionId(string digest)
{
Digest = Hex.NormalizeLowerHex(digest, expectedLength: 64, nameof(digest));
}
public string Digest { get; }
public override string ToString() => $"{Prefix}{Digest}";
public static GraphRevisionId Parse(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (!value.StartsWith(Prefix, StringComparison.Ordinal))
{
throw new FormatException($"Invalid GraphRevisionID: '{value}'.");
}
return new GraphRevisionId(value[Prefix.Length..]);
}
}

View File

@@ -0,0 +1,31 @@
using System.Collections.Generic;
using System;
using StellaOps.Attestor.ProofChain.Identifiers;
using StellaOps.Attestor.ProofChain.Predicates;
namespace StellaOps.Attestor.ProofChain.Identifiers;
public interface IContentAddressedIdGenerator
{
EvidenceId ComputeEvidenceId(EvidencePredicate predicate);
ReasoningId ComputeReasoningId(ReasoningPredicate predicate);
VexVerdictId ComputeVexVerdictId(VexPredicate predicate);
ProofBundleId ComputeProofBundleId(
SbomEntryId sbomEntryId,
IReadOnlyList<EvidenceId> evidenceIds,
ReasoningId reasoningId,
VexVerdictId vexVerdictId);
GraphRevisionId ComputeGraphRevisionId(
IReadOnlyList<string> nodeIds,
IReadOnlyList<string> edgeIds,
string policyDigest,
string feedsDigest,
string toolchainDigest,
string paramsDigest);
string ComputeSbomDigest(ReadOnlySpan<byte> sbomJson);
SbomEntryId ComputeSbomEntryId(ReadOnlySpan<byte> sbomJson, string purl, string? version = null);
}

View File

@@ -0,0 +1,83 @@
using System;
using StellaOps.Attestor.ProofChain.Internal;
namespace StellaOps.Attestor.ProofChain.Identifiers;
public sealed record SbomEntryId
{
public SbomEntryId(string sbomDigest, string purl, string? version = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sbomDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
SbomDigest = NormalizeSbomDigest(sbomDigest);
Purl = purl.Trim();
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
}
public string SbomDigest { get; }
public string Purl { get; }
public string? Version { get; }
public override string ToString()
{
var purl = Version is null ? Purl : $"{Purl}@{Version}";
return $"{SbomDigest}:{purl}";
}
public static SbomEntryId Parse(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
// <sbomDigest>:<purl>[@<version>]
// where <sbomDigest> is expected to be sha256:<64-hex>
var firstColon = value.IndexOf(':', StringComparison.Ordinal);
if (firstColon <= 0)
{
throw new FormatException($"Invalid SBOMEntryID: '{value}'.");
}
var secondColon = value.IndexOf(':', firstColon + 1);
if (secondColon <= 0 || secondColon >= value.Length - 1)
{
throw new FormatException($"Invalid SBOMEntryID: '{value}'.");
}
var algorithm = value[..firstColon];
if (!string.Equals(algorithm, "sha256", StringComparison.OrdinalIgnoreCase))
{
throw new FormatException($"Invalid SBOMEntryID digest algorithm: '{algorithm}'.");
}
var digest = value[(firstColon + 1)..secondColon];
digest = Hex.NormalizeLowerHex(digest, expectedLength: 64, nameof(digest));
var rest = value[(secondColon + 1)..];
if (string.IsNullOrWhiteSpace(rest))
{
throw new FormatException($"Invalid SBOMEntryID: '{value}'.");
}
// Heuristic: split version from PURL only when the '@' is not followed by "sha256:" (OCI digests).
var at = rest.LastIndexOf('@');
if (at > 0 && at < rest.Length - 1 && !rest[(at + 1)..].StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return new SbomEntryId($"sha256:{digest}", rest[..at], rest[(at + 1)..]);
}
return new SbomEntryId($"sha256:{digest}", rest);
}
private static string NormalizeSbomDigest(string value)
{
if (!ContentAddressedId.TrySplit(value, out var algorithm, out var digest) ||
!string.Equals(algorithm, "sha256", StringComparison.Ordinal))
{
throw new FormatException($"Invalid SBOM digest: '{value}'.");
}
return $"sha256:{Hex.NormalizeLowerHex(digest, expectedLength: 64, nameof(value))}";
}
}

View File

@@ -0,0 +1,15 @@
using System;
namespace StellaOps.Attestor.ProofChain.Identifiers;
public readonly record struct TrustAnchorId(Guid Value)
{
public override string ToString() => Value.ToString();
public static TrustAnchorId Parse(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
return new TrustAnchorId(Guid.Parse(value));
}
}

View File

@@ -0,0 +1,33 @@
using System;
namespace StellaOps.Attestor.ProofChain.Internal;
internal static class Hex
{
public static string NormalizeLowerHex(string value, int expectedLength, string parameterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName);
value = value.Trim();
if (value.Length != expectedLength)
{
throw new FormatException($"Expected {expectedLength} hex characters but got {value.Length}.");
}
for (var i = 0; i < value.Length; i++)
{
var c = value[i];
var isHex = (c is >= '0' and <= '9') ||
(c is >= 'a' and <= 'f') ||
(c is >= 'A' and <= 'F');
if (!isHex)
{
throw new FormatException($"Invalid hex character '{c}' at position {i}.");
}
}
return value.ToLowerInvariant();
}
}

View File

@@ -0,0 +1,9 @@
using System;
namespace StellaOps.Attestor.ProofChain.Json;
public interface IJsonCanonicalizer
{
byte[] Canonicalize(ReadOnlySpan<byte> utf8Json);
}

View File

@@ -0,0 +1,150 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Encodings.Web;
using System.Text.Json;
namespace StellaOps.Attestor.ProofChain.Json;
/// <summary>
/// Implements RFC 8785 JSON Canonicalization Scheme (JCS) for stable hashing.
/// </summary>
public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
{
private static readonly JsonWriterOptions CanonicalWriterOptions = new()
{
Indented = false,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public byte[] Canonicalize(ReadOnlySpan<byte> utf8Json)
{
var reader = new Utf8JsonReader(utf8Json, isFinalBlock: true, state: default);
using var document = JsonDocument.ParseValue(ref reader);
return Canonicalize(document.RootElement);
}
private static byte[] Canonicalize(JsonElement element)
{
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, CanonicalWriterOptions))
{
WriteCanonical(writer, element);
}
return buffer.WrittenSpan.ToArray();
}
private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
WriteObject(writer, element);
return;
case JsonValueKind.Array:
WriteArray(writer, element);
return;
case JsonValueKind.String:
writer.WriteStringValue(element.GetString());
return;
case JsonValueKind.Number:
WriteNumber(writer, element);
return;
case JsonValueKind.True:
writer.WriteBooleanValue(true);
return;
case JsonValueKind.False:
writer.WriteBooleanValue(false);
return;
case JsonValueKind.Null:
writer.WriteNullValue();
return;
default:
throw new FormatException($"Unsupported JSON token kind '{element.ValueKind}'.");
}
}
private static void WriteObject(Utf8JsonWriter writer, JsonElement element)
{
var properties = new List<(string Name, JsonElement Value)>();
foreach (var property in element.EnumerateObject())
{
properties.Add((property.Name, property.Value));
}
properties.Sort(static (x, y) => string.CompareOrdinal(x.Name, y.Name));
writer.WriteStartObject();
foreach (var (name, value) in properties)
{
writer.WritePropertyName(name);
WriteCanonical(writer, value);
}
writer.WriteEndObject();
}
private static void WriteArray(Utf8JsonWriter writer, JsonElement element)
{
writer.WriteStartArray();
foreach (var item in element.EnumerateArray())
{
WriteCanonical(writer, item);
}
writer.WriteEndArray();
}
private static void WriteNumber(Utf8JsonWriter writer, JsonElement element)
{
var raw = element.GetRawText();
if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ||
double.IsNaN(value) ||
double.IsInfinity(value))
{
throw new FormatException($"Invalid JSON number: '{raw}'.");
}
if (value == 0d)
{
writer.WriteRawValue("0", skipInputValidation: true);
return;
}
var formatted = value.ToString("R", CultureInfo.InvariantCulture);
writer.WriteRawValue(NormalizeExponent(formatted), skipInputValidation: true);
}
private static string NormalizeExponent(string formatted)
{
var e = formatted.IndexOfAny(['E', 'e']);
if (e < 0)
{
return formatted;
}
var mantissa = formatted[..e];
var exponent = formatted[(e + 1)..];
if (string.IsNullOrWhiteSpace(exponent))
{
return mantissa;
}
var sign = string.Empty;
if (exponent[0] is '+' or '-')
{
sign = exponent[0] == '-' ? "-" : string.Empty;
exponent = exponent[1..];
}
exponent = exponent.TrimStart('0');
if (exponent.Length == 0)
{
// 1e0 -> 1
return mantissa;
}
return $"{mantissa}e{sign}{exponent}";
}
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
namespace StellaOps.Attestor.ProofChain.Merkle;
public sealed class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
{
public byte[] ComputeMerkleRoot(IReadOnlyList<ReadOnlyMemory<byte>> leafValues)
{
ArgumentNullException.ThrowIfNull(leafValues);
if (leafValues.Count == 0)
{
throw new ArgumentException("At least one leaf is required.", nameof(leafValues));
}
var hashes = new List<byte[]>(PadToPowerOfTwo(leafValues.Count));
for (var i = 0; i < leafValues.Count; i++)
{
hashes.Add(SHA256.HashData(leafValues[i].Span));
}
// Pad with duplicate of last leaf hash (deterministic).
var target = hashes.Capacity;
while (hashes.Count < target)
{
hashes.Add(hashes[^1]);
}
return ComputeRootFromLeafHashes(hashes);
}
private static byte[] ComputeRootFromLeafHashes(List<byte[]> hashes)
{
while (hashes.Count > 1)
{
var next = new List<byte[]>(hashes.Count / 2);
for (var i = 0; i < hashes.Count; i += 2)
{
next.Add(HashInternal(hashes[i], hashes[i + 1]));
}
hashes = next;
}
return hashes[0];
}
private static int PadToPowerOfTwo(int count)
{
var power = 1;
while (power < count)
{
power <<= 1;
}
return power;
}
private static byte[] HashInternal(byte[] left, byte[] right)
{
var buffer = new byte[left.Length + right.Length];
Buffer.BlockCopy(left, 0, buffer, 0, left.Length);
Buffer.BlockCopy(right, 0, buffer, left.Length, right.Length);
return SHA256.HashData(buffer);
}
}

View File

@@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.ProofChain.Merkle;
public interface IMerkleTreeBuilder
{
byte[] ComputeMerkleRoot(IReadOnlyList<ReadOnlyMemory<byte>> leafValues);
}

View File

@@ -0,0 +1,29 @@
using System;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.ProofChain.Predicates;
public sealed record EvidencePredicate
{
[JsonPropertyName("source")]
public required string Source { get; init; }
[JsonPropertyName("sourceVersion")]
public required string SourceVersion { get; init; }
[JsonPropertyName("collectionTime")]
public required DateTimeOffset CollectionTime { get; init; }
[JsonPropertyName("sbomEntryId")]
public required string SbomEntryId { get; init; }
[JsonPropertyName("vulnerabilityId")]
public string? VulnerabilityId { get; init; }
[JsonPropertyName("rawFinding")]
public required object RawFinding { get; init; }
[JsonPropertyName("evidenceId")]
public string? EvidenceId { get; init; }
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.ProofChain.Predicates;
public sealed record ProofSpinePredicate
{
[JsonPropertyName("sbomEntryId")]
public required string SbomEntryId { get; init; }
[JsonPropertyName("evidenceIds")]
public required IReadOnlyList<string> EvidenceIds { get; init; }
[JsonPropertyName("reasoningId")]
public required string ReasoningId { get; init; }
[JsonPropertyName("vexVerdictId")]
public required string VexVerdictId { get; init; }
[JsonPropertyName("policyVersion")]
public required string PolicyVersion { get; init; }
[JsonPropertyName("proofBundleId")]
public required string ProofBundleId { get; init; }
}

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.ProofChain.Predicates;
public sealed record ReasoningPredicate
{
[JsonPropertyName("sbomEntryId")]
public required string SbomEntryId { get; init; }
[JsonPropertyName("evidenceIds")]
public required IReadOnlyList<string> EvidenceIds { get; init; }
[JsonPropertyName("policyVersion")]
public required string PolicyVersion { get; init; }
[JsonPropertyName("inputs")]
public required IReadOnlyDictionary<string, object> Inputs { get; init; }
[JsonPropertyName("intermediateFindings")]
public IReadOnlyDictionary<string, object>? IntermediateFindings { get; init; }
[JsonPropertyName("reasoningId")]
public string? ReasoningId { get; init; }
}

View File

@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.ProofChain.Predicates;
public sealed record VexPredicate
{
[JsonPropertyName("sbomEntryId")]
public required string SbomEntryId { get; init; }
[JsonPropertyName("vulnerabilityId")]
public required string VulnerabilityId { get; init; }
[JsonPropertyName("status")]
public required string Status { get; init; }
[JsonPropertyName("justification")]
public required string Justification { get; init; }
[JsonPropertyName("policyVersion")]
public required string PolicyVersion { get; init; }
[JsonPropertyName("reasoningId")]
public required string ReasoningId { get; init; }
[JsonPropertyName("vexVerdictId")]
public string? VexVerdictId { get; init; }
}

View File

@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using StellaOps.Attestor.ProofChain.Identifiers;
namespace StellaOps.Attestor.ProofChain.Sbom;
public sealed class CycloneDxSubjectExtractor : ISbomSubjectExtractor
{
private readonly IContentAddressedIdGenerator _idGenerator;
public CycloneDxSubjectExtractor(IContentAddressedIdGenerator idGenerator)
{
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public SbomSubjectExtractionResult ExtractCycloneDx(ReadOnlySpan<byte> sbomJson)
{
var sbomDigest = _idGenerator.ComputeSbomDigest(sbomJson);
var entryIds = ExtractEntryIds(sbomDigest, sbomJson);
return new SbomSubjectExtractionResult(sbomDigest, entryIds);
}
private static IReadOnlyList<SbomEntryId> ExtractEntryIds(string sbomDigest, ReadOnlySpan<byte> sbomJson)
{
var reader = new Utf8JsonReader(sbomJson, isFinalBlock: true, state: default);
using var document = JsonDocument.ParseValue(ref reader);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return Array.Empty<SbomEntryId>();
}
if (!document.RootElement.TryGetProperty("components", out var components) ||
components.ValueKind != JsonValueKind.Array)
{
return Array.Empty<SbomEntryId>();
}
var unique = new HashSet<string>(StringComparer.Ordinal);
var ids = new List<SbomEntryId>();
foreach (var component in components.EnumerateArray())
{
if (component.ValueKind != JsonValueKind.Object)
{
continue;
}
if (!component.TryGetProperty("purl", out var purlElement) ||
purlElement.ValueKind != JsonValueKind.String)
{
continue;
}
var purl = purlElement.GetString();
if (string.IsNullOrWhiteSpace(purl))
{
continue;
}
string? version = null;
if (!purl.Contains('@', StringComparison.Ordinal) &&
component.TryGetProperty("version", out var versionElement) &&
versionElement.ValueKind == JsonValueKind.String)
{
version = versionElement.GetString();
}
var id = new SbomEntryId(sbomDigest, purl, version);
if (unique.Add(id.ToString()))
{
ids.Add(id);
}
}
ids.Sort(static (x, y) => string.CompareOrdinal(x.ToString(), y.ToString()));
return ids;
}
}

View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using StellaOps.Attestor.ProofChain.Identifiers;
namespace StellaOps.Attestor.ProofChain.Sbom;
public interface ISbomSubjectExtractor
{
SbomSubjectExtractionResult ExtractCycloneDx(ReadOnlySpan<byte> sbomJson);
}
public sealed record SbomSubjectExtractionResult(string SbomDigest, IReadOnlyList<SbomEntryId> EntryIds);

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include=\"..\\..\\__Libraries\\StellaOps.Attestor.ProofChain\\StellaOps.Attestor.ProofChain.csproj\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Attestor.ProofChain.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}