save work
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#if STELLAOPS_EXPERIMENTAL_DISTRIBUTED_VERIFY
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
@@ -439,3 +441,5 @@ public class DistributionStats
|
||||
public int VirtualNodesPerNode { get; init; }
|
||||
public Dictionary<string, string> CircuitBreakerStates { get; init; } = [];
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -27,6 +27,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{36FBCE51-0429-4F2B-87FD-95B37941001D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core.Tests", "StellaOps.Attestor\StellaOps.Attestor.Core.Tests\StellaOps.Attestor.Core.Tests.csproj", "{B45076F7-DDD2-41A9-A853-30905ED62BFC}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -169,6 +171,18 @@ Global
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -178,5 +192,6 @@ Global
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77} = {78C966F5-2242-D8EC-ADCA-A1A9C7F723A6}
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C} = {78C966F5-2242-D8EC-ADCA-A1A9C7F723A6}
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6} = {78C966F5-2242-D8EC-ADCA-A1A9C7F723A6}
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC} = {78C966F5-2242-D8EC-ADCA-A1A9C7F723A6}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Org.BouncyCastle.Asn1;
|
||||
using Org.BouncyCastle.Asn1.Sec;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.Math;
|
||||
using Org.BouncyCastle.X509;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Tests.Fixtures.Rekor;
|
||||
|
||||
internal static class RekorOfflineReceiptFixtures
|
||||
{
|
||||
private const string CheckpointOrigin = "rekor.sigstore.dev - test-fixture";
|
||||
private const string SignatureIdentity = "rekor.sigstore.dev";
|
||||
|
||||
private static readonly JsonSerializerOptions ReceiptJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
internal static readonly byte[] PayloadDigest =
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes("stellaops-rekor-offline-receipt-fixture"));
|
||||
|
||||
internal static readonly byte[] RekorPublicKeySpki;
|
||||
internal static readonly string SignedCheckpointNote;
|
||||
internal static readonly string ReceiptJson;
|
||||
|
||||
static RekorOfflineReceiptFixtures()
|
||||
{
|
||||
var curve = SecNamedCurves.GetByName("secp256r1");
|
||||
var domain = new ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());
|
||||
|
||||
// Deterministic test private key scalar (1 <= d < n).
|
||||
var d = new BigInteger("4a3b2c1d0e0f11223344556677889900aabbccddeeff00112233445566778899", 16);
|
||||
var privateKey = new ECPrivateKeyParameters(d, domain);
|
||||
var publicKeyPoint = domain.G.Multiply(d).Normalize();
|
||||
var publicKey = new ECPublicKeyParameters(publicKeyPoint, domain);
|
||||
|
||||
RekorPublicKeySpki = SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(publicKey).GetDerEncoded();
|
||||
|
||||
var expectedRoot = MerkleProofVerifier.HashLeaf(PayloadDigest);
|
||||
var rootBase64 = Convert.ToBase64String(expectedRoot);
|
||||
var rootHex = Convert.ToHexString(expectedRoot).ToLowerInvariant();
|
||||
|
||||
var checkpointBody = $"{CheckpointOrigin}\n1\n{rootBase64}\n";
|
||||
var signatureDer = SignCheckpointBodyDeterministic(checkpointBody, privateKey);
|
||||
var signatureBase64 = Convert.ToBase64String(signatureDer);
|
||||
|
||||
SignedCheckpointNote = checkpointBody + "\n" + "\u2014 " + SignatureIdentity + " " + signatureBase64 + "\n";
|
||||
|
||||
var receipt = new RekorReceiptDocument(
|
||||
Uuid: "fixture-uuid",
|
||||
LogIndex: 0,
|
||||
RootHash: rootHex,
|
||||
Hashes: Array.Empty<string>(),
|
||||
Checkpoint: SignedCheckpointNote);
|
||||
|
||||
ReceiptJson = JsonSerializer.Serialize(receipt, ReceiptJsonOptions);
|
||||
}
|
||||
|
||||
private static byte[] SignCheckpointBodyDeterministic(string checkpointBody, ECPrivateKeyParameters privateKey)
|
||||
{
|
||||
var bodyBytes = Encoding.UTF8.GetBytes(checkpointBody);
|
||||
var hash = SHA256.HashData(bodyBytes);
|
||||
|
||||
var signer = new ECDsaSigner(new HMacDsaKCalculator(new Sha256Digest()));
|
||||
signer.Init(true, privateKey);
|
||||
var sig = signer.GenerateSignature(hash);
|
||||
|
||||
var r = new DerInteger(sig[0]);
|
||||
var s = new DerInteger(sig[1]);
|
||||
return new DerSequence(r, s).GetDerEncoded();
|
||||
}
|
||||
|
||||
private sealed record RekorReceiptDocument(
|
||||
string Uuid,
|
||||
long LogIndex,
|
||||
string RootHash,
|
||||
IReadOnlyList<string> Hashes,
|
||||
string Checkpoint);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Core.Tests.Fixtures.Rekor;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Tests;
|
||||
|
||||
public sealed class RekorOfflineReceiptVerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidReceipt_Succeeds()
|
||||
{
|
||||
var (directory, receiptPath) = CreateTempReceipt(RekorOfflineReceiptFixtures.ReceiptJson);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await RekorOfflineReceiptVerifier.VerifyAsync(
|
||||
receiptPath,
|
||||
RekorOfflineReceiptFixtures.PayloadDigest,
|
||||
RekorOfflineReceiptFixtures.RekorPublicKeySpki,
|
||||
allowOfflineWithoutSignature: false);
|
||||
|
||||
result.Verified.Should().BeTrue();
|
||||
result.CheckpointSignatureValid.Should().BeTrue();
|
||||
result.LogIndex.Should().Be(0);
|
||||
result.ComputedRootHash.Should().Be(result.ExpectedRootHash);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_CheckpointPathReference_Succeeds()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "stellaops-attestor-rekor-offline-" + Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(directory, "checkpoint.sig"), RekorOfflineReceiptFixtures.SignedCheckpointNote, Encoding.UTF8);
|
||||
|
||||
var receiptJson = MutateReceiptJson(root => root["checkpoint"] = "checkpoint.sig");
|
||||
var receiptPath = Path.Combine(directory, "rekor-receipt.json");
|
||||
File.WriteAllText(receiptPath, receiptJson, Encoding.UTF8);
|
||||
|
||||
var result = await RekorOfflineReceiptVerifier.VerifyAsync(
|
||||
receiptPath,
|
||||
RekorOfflineReceiptFixtures.PayloadDigest,
|
||||
RekorOfflineReceiptFixtures.RekorPublicKeySpki,
|
||||
allowOfflineWithoutSignature: false);
|
||||
|
||||
result.Verified.Should().BeTrue();
|
||||
result.CheckpointSignatureValid.Should().BeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_TamperedCheckpointSignature_Fails()
|
||||
{
|
||||
var tampered = MutateReceiptJson(root =>
|
||||
{
|
||||
var checkpoint = root["checkpoint"]!.GetValue<string>();
|
||||
root["checkpoint"] = TamperCheckpointSignature(checkpoint);
|
||||
});
|
||||
|
||||
var (directory, receiptPath) = CreateTempReceipt(tampered);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await RekorOfflineReceiptVerifier.VerifyAsync(
|
||||
receiptPath,
|
||||
RekorOfflineReceiptFixtures.PayloadDigest,
|
||||
RekorOfflineReceiptFixtures.RekorPublicKeySpki,
|
||||
allowOfflineWithoutSignature: false);
|
||||
|
||||
result.Verified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("signature", because: "signature verification must fail");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RootHashMismatch_Fails()
|
||||
{
|
||||
var badJson = MutateReceiptJson(root => root["rootHash"] = new string('0', 64));
|
||||
|
||||
var (directory, receiptPath) = CreateTempReceipt(badJson);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await RekorOfflineReceiptVerifier.VerifyAsync(
|
||||
receiptPath,
|
||||
RekorOfflineReceiptFixtures.PayloadDigest,
|
||||
RekorOfflineReceiptFixtures.RekorPublicKeySpki,
|
||||
allowOfflineWithoutSignature: false);
|
||||
|
||||
result.Verified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("rootHash", because: "receipt root must match checkpoint root");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_AllowOfflineWithoutSignature_AllowsUnsignedCheckpoint()
|
||||
{
|
||||
var checkpointBodyOnly = RekorOfflineReceiptFixtures.SignedCheckpointNote.Split("\n\n", StringSplitOptions.None)[0] + "\n";
|
||||
var minimalJson = MutateReceiptJson(root => root["checkpoint"] = checkpointBodyOnly);
|
||||
|
||||
var (directory, receiptPath) = CreateTempReceipt(minimalJson);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await RekorOfflineReceiptVerifier.VerifyAsync(
|
||||
receiptPath,
|
||||
RekorOfflineReceiptFixtures.PayloadDigest,
|
||||
RekorOfflineReceiptFixtures.RekorPublicKeySpki,
|
||||
allowOfflineWithoutSignature: true);
|
||||
|
||||
result.Verified.Should().BeTrue();
|
||||
result.CheckpointSignatureValid.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string DirectoryPath, string ReceiptPath) CreateTempReceipt(string receiptJson)
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "stellaops-attestor-rekor-offline-" + Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var receiptPath = Path.Combine(directory, "rekor-receipt.json");
|
||||
File.WriteAllText(receiptPath, receiptJson, Encoding.UTF8);
|
||||
|
||||
return (directory, receiptPath);
|
||||
}
|
||||
|
||||
private static string MutateReceiptJson(Action<JsonObject> mutate)
|
||||
{
|
||||
var root = JsonNode.Parse(RekorOfflineReceiptFixtures.ReceiptJson)?.AsObject()
|
||||
?? throw new InvalidOperationException("Fixture receipt JSON is invalid.");
|
||||
mutate(root);
|
||||
return root.ToJsonString();
|
||||
}
|
||||
|
||||
private static string TamperCheckpointSignature(string signedCheckpoint)
|
||||
{
|
||||
var lines = signedCheckpoint
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace("\r", "\n", StringComparison.Ordinal)
|
||||
.Split('\n', StringSplitOptions.None);
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var trimmed = lines[i].TrimStart();
|
||||
if (!trimmed.StartsWith("\u2014", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tokens = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToArray();
|
||||
if (tokens.Length < 3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sig = tokens[^1];
|
||||
var chars = sig.ToCharArray();
|
||||
var flipIndex = Array.FindIndex(chars, c => c != '=');
|
||||
if (flipIndex < 0)
|
||||
{
|
||||
flipIndex = 0;
|
||||
}
|
||||
|
||||
chars[flipIndex] = chars[flipIndex] == 'A' ? 'B' : 'A';
|
||||
var tampered = new string(chars);
|
||||
tokens[^1] = tampered;
|
||||
lines[i] = string.Join(' ', tokens);
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not locate signature line in signed checkpoint fixture.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<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="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<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="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
@@ -10,13 +10,6 @@ namespace StellaOps.Attestor.Core.Verification;
|
||||
/// </summary>
|
||||
public static partial class CheckpointSignatureVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Rekor checkpoint format regular expression.
|
||||
/// Format: "rekor.sigstore.dev - {log_id}\n{tree_size}\n{root_hash}\n{timestamp}\n"
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^(?<origin>[^\n]+)\n(?<size>\d+)\n(?<root>[A-Za-z0-9+/=]+)\n(?<timestamp>\d+)?\n?")]
|
||||
private static partial Regex CheckpointBodyRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a Rekor checkpoint signature.
|
||||
/// </summary>
|
||||
@@ -33,48 +26,23 @@ public static partial class CheckpointSignatureVerifier
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
ArgumentNullException.ThrowIfNull(publicKey);
|
||||
|
||||
// Parse checkpoint body
|
||||
var match = CheckpointBodyRegex().Match(checkpoint);
|
||||
if (!match.Success)
|
||||
var normalized = NormalizeToLf(checkpoint);
|
||||
if (!TryParseCheckpoint(normalized, out var origin, out var treeSize, out var rootHash, out var failureReason))
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid checkpoint format",
|
||||
};
|
||||
}
|
||||
|
||||
var origin = match.Groups["origin"].Value;
|
||||
var sizeStr = match.Groups["size"].Value;
|
||||
var rootBase64 = match.Groups["root"].Value;
|
||||
|
||||
if (!long.TryParse(sizeStr, out var treeSize))
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid tree size in checkpoint",
|
||||
};
|
||||
}
|
||||
|
||||
byte[] rootHash;
|
||||
try
|
||||
{
|
||||
rootHash = Convert.FromBase64String(rootBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid root hash encoding in checkpoint",
|
||||
Origin = origin,
|
||||
TreeSize = treeSize,
|
||||
RootHash = rootHash,
|
||||
FailureReason = failureReason ?? "Invalid checkpoint format"
|
||||
};
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
try
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes(checkpoint);
|
||||
var data = Encoding.UTF8.GetBytes(normalized);
|
||||
var verified = VerifySignature(data, signature, publicKey);
|
||||
|
||||
return new CheckpointVerificationResult
|
||||
@@ -96,6 +64,64 @@ public static partial class CheckpointSignatureVerifier
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a signed checkpoint note (e.g. <c>checkpoint.sig</c>), extracting the canonical body and signature(s).
|
||||
/// </summary>
|
||||
/// <param name="signedCheckpoint">Signed checkpoint note text.</param>
|
||||
/// <param name="publicKey">The Rekor log public key (PEM/SPKI or raw).</param>
|
||||
public static CheckpointVerificationResult VerifySignedCheckpointNote(
|
||||
string signedCheckpoint,
|
||||
byte[] publicKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signedCheckpoint);
|
||||
ArgumentNullException.ThrowIfNull(publicKey);
|
||||
|
||||
var normalized = NormalizeToLf(signedCheckpoint);
|
||||
|
||||
if (!TrySplitSignedNote(normalized, out var body, out var signatures, out var failureReason))
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = failureReason ?? "Invalid signed checkpoint format"
|
||||
};
|
||||
}
|
||||
|
||||
CheckpointVerificationResult? parsed = null;
|
||||
|
||||
foreach (var signature in signatures)
|
||||
{
|
||||
var result = VerifyCheckpoint(body, signature, publicKey);
|
||||
parsed ??= result;
|
||||
if (result.Verified)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed is not null)
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
Origin = parsed.Origin,
|
||||
TreeSize = parsed.TreeSize,
|
||||
RootHash = parsed.RootHash,
|
||||
FailureReason = "Signature verification failed"
|
||||
};
|
||||
}
|
||||
|
||||
var parsedOnly = ParseCheckpoint(body);
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
Origin = parsedOnly.Origin,
|
||||
TreeSize = parsedOnly.TreeSize,
|
||||
RootHash = parsedOnly.RootHash,
|
||||
FailureReason = "Checkpoint signature missing"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a checkpoint without verifying the signature.
|
||||
/// </summary>
|
||||
@@ -103,40 +129,16 @@ public static partial class CheckpointSignatureVerifier
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkpoint);
|
||||
|
||||
var match = CheckpointBodyRegex().Match(checkpoint);
|
||||
if (!match.Success)
|
||||
var normalized = NormalizeToLf(checkpoint);
|
||||
if (!TryParseCheckpoint(normalized, out var origin, out var treeSize, out var rootHash, out var failureReason))
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid checkpoint format",
|
||||
};
|
||||
}
|
||||
|
||||
var origin = match.Groups["origin"].Value;
|
||||
var sizeStr = match.Groups["size"].Value;
|
||||
var rootBase64 = match.Groups["root"].Value;
|
||||
|
||||
if (!long.TryParse(sizeStr, out var treeSize))
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid tree size in checkpoint",
|
||||
};
|
||||
}
|
||||
|
||||
byte[] rootHash;
|
||||
try
|
||||
{
|
||||
rootHash = Convert.FromBase64String(rootBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid root hash encoding in checkpoint",
|
||||
Origin = origin,
|
||||
TreeSize = treeSize,
|
||||
RootHash = rootHash,
|
||||
FailureReason = failureReason ?? "Invalid checkpoint format"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,6 +151,176 @@ public static partial class CheckpointSignatureVerifier
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeToLf(string value) =>
|
||||
value.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace("\r", "\n", StringComparison.Ordinal);
|
||||
|
||||
private static bool TryParseCheckpoint(
|
||||
string checkpoint,
|
||||
out string? origin,
|
||||
out long treeSize,
|
||||
out byte[]? rootHash,
|
||||
out string? failureReason)
|
||||
{
|
||||
origin = null;
|
||||
treeSize = 0;
|
||||
rootHash = null;
|
||||
failureReason = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(checkpoint))
|
||||
{
|
||||
failureReason = "Checkpoint is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
var lines = checkpoint.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (lines.Length < 3)
|
||||
{
|
||||
failureReason = "Invalid checkpoint format";
|
||||
return false;
|
||||
}
|
||||
|
||||
origin = lines[0];
|
||||
|
||||
if (!long.TryParse(lines[1], NumberStyles.None, CultureInfo.InvariantCulture, out var parsedTreeSize))
|
||||
{
|
||||
failureReason = "Invalid tree size in checkpoint";
|
||||
return false;
|
||||
}
|
||||
|
||||
treeSize = parsedTreeSize;
|
||||
|
||||
try
|
||||
{
|
||||
rootHash = Convert.FromBase64String(lines[2]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
failureReason = "Invalid root hash encoding in checkpoint";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TrySplitSignedNote(
|
||||
string signedCheckpoint,
|
||||
out string body,
|
||||
out IReadOnlyList<byte[]> signatures,
|
||||
out string? failureReason)
|
||||
{
|
||||
body = string.Empty;
|
||||
failureReason = null;
|
||||
var sigs = new List<byte[]>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signedCheckpoint))
|
||||
{
|
||||
signatures = Array.Empty<byte[]>();
|
||||
failureReason = "Signed checkpoint is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Note format: "<body>\n\n— origin <base64sig>\n"
|
||||
var separator = signedCheckpoint.IndexOf("\n\n", StringComparison.Ordinal);
|
||||
string signatureSection;
|
||||
|
||||
if (separator >= 0)
|
||||
{
|
||||
body = signedCheckpoint.Substring(0, separator + 1);
|
||||
signatureSection = signedCheckpoint[(separator + 2)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
var lines = signedCheckpoint.Split('\n');
|
||||
var bodyLines = new List<string>();
|
||||
var signatureLines = new List<string>();
|
||||
var inSignature = false;
|
||||
|
||||
foreach (var raw in lines)
|
||||
{
|
||||
var line = raw.TrimEnd();
|
||||
var trimmed = line.Trim();
|
||||
if (!inSignature && LooksLikeSignatureLine(trimmed))
|
||||
{
|
||||
inSignature = true;
|
||||
}
|
||||
|
||||
if (inSignature)
|
||||
{
|
||||
signatureLines.Add(line);
|
||||
}
|
||||
else
|
||||
{
|
||||
bodyLines.Add(line);
|
||||
}
|
||||
}
|
||||
|
||||
body = string.Join('\n', bodyLines).TrimEnd('\n') + "\n";
|
||||
signatureSection = string.Join('\n', signatureLines);
|
||||
}
|
||||
|
||||
foreach (var raw in signatureSection.Split('\n'))
|
||||
{
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? token = null;
|
||||
|
||||
if (trimmed.StartsWith("sig ", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("signature ", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
token = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
|
||||
}
|
||||
else if (trimmed.Length > 0 && CharUnicodeInfo.GetUnicodeCategory(trimmed[0]) == UnicodeCategory.DashPunctuation)
|
||||
{
|
||||
token = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
sigs.Add(Convert.FromBase64String(token));
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// ignore non-base64 tokens
|
||||
}
|
||||
}
|
||||
|
||||
signatures = sigs;
|
||||
if (signatures.Count == 0)
|
||||
{
|
||||
failureReason = "Checkpoint signature missing";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool LooksLikeSignatureLine(string trimmed)
|
||||
{
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("sig ", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("signature ", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return CharUnicodeInfo.GetUnicodeCategory(trimmed[0]) == UnicodeCategory.DashPunctuation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an ECDSA or Ed25519 signature.
|
||||
/// </summary>
|
||||
@@ -227,22 +399,26 @@ public static partial class CheckpointSignatureVerifier
|
||||
// Compute SHA-256 hash of data
|
||||
var hash = SHA256.HashData(data);
|
||||
|
||||
// Verify signature (try both DER and raw formats)
|
||||
// Verify signature (try DER and raw formats deterministically)
|
||||
try
|
||||
{
|
||||
if (ecdsa.VerifyHash(hash, signature, DSASignatureFormat.Rfc3279DerSequence))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (signature.Length == 64 &&
|
||||
ecdsa.VerifyHash(hash, signature, DSASignatureFormat.IeeeP1363FixedFieldConcatenation))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback to platform default format (if different from the above).
|
||||
return ecdsa.VerifyHash(hash, signature);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Try DER format
|
||||
try
|
||||
{
|
||||
return ecdsa.VerifyHash(hash, signature, DSASignatureFormat.Rfc3279DerSequence);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a Rekor receipt (rekor-receipt.json) for offline/air-gapped operation.
|
||||
/// </summary>
|
||||
public static class RekorOfflineReceiptVerifier
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static async Task<RekorInclusionVerificationResult> VerifyAsync(
|
||||
string receiptPath,
|
||||
byte[] payloadDigest,
|
||||
byte[] rekorPublicKey,
|
||||
bool allowOfflineWithoutSignature = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(receiptPath);
|
||||
ArgumentNullException.ThrowIfNull(payloadDigest);
|
||||
ArgumentNullException.ThrowIfNull(rekorPublicKey);
|
||||
|
||||
if (!File.Exists(receiptPath))
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure("Rekor receipt file not found.");
|
||||
}
|
||||
|
||||
RekorReceiptDocument? receipt;
|
||||
try
|
||||
{
|
||||
var receiptJson = await File.ReadAllTextAsync(receiptPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
|
||||
receipt = JsonSerializer.Deserialize<RekorReceiptDocument>(receiptJson, SerializerOptions);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure($"Failed to read/parse Rekor receipt: {ex.Message}");
|
||||
}
|
||||
|
||||
if (receipt is null ||
|
||||
string.IsNullOrWhiteSpace(receipt.Uuid) ||
|
||||
receipt.LogIndex < 0 ||
|
||||
string.IsNullOrWhiteSpace(receipt.RootHash) ||
|
||||
receipt.Hashes is null ||
|
||||
string.IsNullOrWhiteSpace(receipt.Checkpoint))
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure("Rekor receipt is missing required fields.");
|
||||
}
|
||||
|
||||
var receiptDirectory = Path.GetDirectoryName(Path.GetFullPath(receiptPath)) ?? Environment.CurrentDirectory;
|
||||
var checkpointText = await ResolveCheckpointAsync(receipt.Checkpoint, receiptDirectory, cancellationToken).ConfigureAwait(false);
|
||||
if (checkpointText is null)
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure("Rekor checkpoint content not found.");
|
||||
}
|
||||
|
||||
var checkpointResult = allowOfflineWithoutSignature
|
||||
? CheckpointSignatureVerifier.ParseCheckpoint(checkpointText)
|
||||
: CheckpointSignatureVerifier.VerifySignedCheckpointNote(checkpointText, rekorPublicKey);
|
||||
|
||||
if (!allowOfflineWithoutSignature && !checkpointResult.Verified)
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure(
|
||||
$"Rekor checkpoint signature verification failed: {checkpointResult.FailureReason ?? "unknown"}");
|
||||
}
|
||||
|
||||
if (checkpointResult.RootHash is not { Length: 32 } expectedRoot)
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure("Rekor checkpoint root hash must be 32 bytes (sha256).");
|
||||
}
|
||||
|
||||
if (checkpointResult.TreeSize <= 0)
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure("Rekor checkpoint tree size must be positive.");
|
||||
}
|
||||
|
||||
var receiptRootBytes = TryParseHashBytes(receipt.RootHash);
|
||||
if (receiptRootBytes is not { Length: 32 })
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure("Rekor receipt rootHash has invalid encoding.");
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(receiptRootBytes, expectedRoot))
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure(
|
||||
"Rekor receipt rootHash does not match checkpoint root hash.",
|
||||
expectedRootHash: Convert.ToHexString(expectedRoot).ToLowerInvariant());
|
||||
}
|
||||
|
||||
var proofHashes = new List<byte[]>(receipt.Hashes.Count);
|
||||
foreach (var h in receipt.Hashes)
|
||||
{
|
||||
var bytes = TryParseHashBytes(h);
|
||||
if (bytes is not { Length: 32 })
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure("Rekor receipt hashes contains invalid hash value.");
|
||||
}
|
||||
|
||||
proofHashes.Add(bytes);
|
||||
}
|
||||
|
||||
var leafHash = MerkleProofVerifier.HashLeaf(payloadDigest);
|
||||
var computedRoot = MerkleProofVerifier.ComputeRootFromPath(
|
||||
leafHash,
|
||||
receipt.LogIndex,
|
||||
checkpointResult.TreeSize,
|
||||
proofHashes);
|
||||
|
||||
if (computedRoot is null)
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure("Failed to compute Rekor Merkle root from inclusion proof.");
|
||||
}
|
||||
|
||||
var computedRootHex = Convert.ToHexString(computedRoot).ToLowerInvariant();
|
||||
var expectedRootHex = Convert.ToHexString(expectedRoot).ToLowerInvariant();
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(computedRoot, expectedRoot))
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure(
|
||||
"Rekor inclusion proof verification failed (computed root mismatch).",
|
||||
computedRootHex,
|
||||
expectedRootHex);
|
||||
}
|
||||
|
||||
return RekorInclusionVerificationResult.Success(
|
||||
receipt.LogIndex,
|
||||
computedRootHex,
|
||||
expectedRootHex,
|
||||
checkpointSignatureValid: checkpointResult.Verified);
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveCheckpointAsync(string checkpointField, string receiptDirectory, CancellationToken ct)
|
||||
{
|
||||
var value = checkpointField.Trim();
|
||||
|
||||
// Inline checkpoint content (contains at least one newline).
|
||||
if (value.Contains('\n') || value.Contains('\r'))
|
||||
{
|
||||
return checkpointField;
|
||||
}
|
||||
|
||||
var candidates = new List<string>();
|
||||
|
||||
// If the value looks like a path, resolve it.
|
||||
if (value.IndexOfAny(['/', '\\']) >= 0 || value.EndsWith(".sig", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
candidates.Add(Path.IsPathRooted(value) ? value : Path.Combine(receiptDirectory, value));
|
||||
}
|
||||
|
||||
// Standard offline bundle layout fallbacks.
|
||||
candidates.Add(Path.Combine(receiptDirectory, "checkpoint.sig"));
|
||||
candidates.Add(Path.Combine(receiptDirectory, "tlog", "checkpoint.sig"));
|
||||
candidates.Add(Path.Combine(receiptDirectory, "evidence", "tlog", "checkpoint.sig"));
|
||||
|
||||
foreach (var candidate in candidates.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? TryParseHashBytes(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trimmed = trimmed["sha256:".Length..];
|
||||
}
|
||||
|
||||
if (trimmed.Length % 2 == 0 && trimmed.All(static c => (c >= '0' && c <= '9') ||
|
||||
(c >= 'a' && c <= 'f') ||
|
||||
(c >= 'A' && c <= 'F')))
|
||||
{
|
||||
try
|
||||
{
|
||||
return Convert.FromHexString(trimmed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(trimmed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RekorReceiptDocument(
|
||||
[property: JsonPropertyName("uuid")] string Uuid,
|
||||
[property: JsonPropertyName("logIndex")] long LogIndex,
|
||||
[property: JsonPropertyName("rootHash")] string RootHash,
|
||||
[property: JsonPropertyName("hashes")] IReadOnlyList<string> Hashes,
|
||||
[property: JsonPropertyName("checkpoint")] string Checkpoint);
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
// Description: PostgreSQL implementation of the Rekor submission queue
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#if STELLAOPS_EXPERIMENTAL_REKOR_QUEUE
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -522,3 +524,5 @@ public sealed class PostgresRekorSubmissionQueue : IRekorSubmissionQueue
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -14,12 +14,17 @@ using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Transparency;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.Core.Offline;
|
||||
using StellaOps.Attestor.Infrastructure.Rekor;
|
||||
using StellaOps.Attestor.Infrastructure.Offline;
|
||||
using StellaOps.Attestor.Infrastructure.Signing;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Transparency;
|
||||
using StellaOps.Attestor.Infrastructure.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Bulk;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Verify;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure;
|
||||
|
||||
@@ -37,8 +42,28 @@ public static class ServiceCollectionExtensions
|
||||
return new AttestorSubmissionValidator(canonicalizer, options.Security.SignerIdentity.Mode);
|
||||
});
|
||||
services.AddSingleton<AttestorMetrics>();
|
||||
services.AddSingleton<AttestorActivitySource>();
|
||||
services.AddSingleton<ITimeSkewValidator>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
return new TimeSkewValidator(options.TimeSkew);
|
||||
});
|
||||
services.AddSingleton<IAttestorVerificationCache>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
if (!options.Cache.Verification.Enabled)
|
||||
{
|
||||
return new NoOpAttestorVerificationCache();
|
||||
}
|
||||
|
||||
return ActivatorUtilities.CreateInstance<InMemoryAttestorVerificationCache>(sp);
|
||||
});
|
||||
services.AddSingleton<IAttestorVerificationEngine, AttestorVerificationEngine>();
|
||||
services.AddSingleton<IAttestorSubmissionService, AttestorSubmissionService>();
|
||||
services.AddSingleton<IAttestorVerificationService, AttestorVerificationService>();
|
||||
services.AddSingleton<IAttestorBundleService, AttestorBundleService>();
|
||||
services.AddSingleton<AttestorSigningKeyRegistry>();
|
||||
services.AddSingleton<IAttestationSigningService, AttestorSigningService>();
|
||||
services.AddHttpClient<HttpRekorClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -235,7 +235,8 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
{
|
||||
Backend = canonicalOutcome.Backend,
|
||||
Url = submission.LogUrl ?? canonicalOutcome.Url,
|
||||
LogId = null
|
||||
LogId = null,
|
||||
IntegratedTime = submission.IntegratedTime
|
||||
},
|
||||
CreatedAt = now,
|
||||
Status = submission.Status ?? "included",
|
||||
|
||||
@@ -133,7 +133,7 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
Status = entry.Status,
|
||||
Issues = allIssues,
|
||||
CheckedAt = evaluationTime,
|
||||
Report = report with { Succeeded = succeeded, Issues = allIssues }
|
||||
Report = report
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// Description: Background service for processing the Rekor retry queue
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#if STELLAOPS_EXPERIMENTAL_REKOR_QUEUE
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -224,3 +226,5 @@ public sealed class AttestorSubmissionRequest
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
public byte[] DssePayload { get; init; } = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -36,9 +36,9 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
|
||||
var entry = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1:pkg:npm/lodash@4.17.21";
|
||||
var request = new CreateSpineRequest
|
||||
{
|
||||
EvidenceIds = new[] { "sha256:ev123abc456def789012345678901234567890123456789012345678901234" },
|
||||
ReasoningId = "sha256:reason123abc456def789012345678901234567890123456789012345678901",
|
||||
VexVerdictId = "sha256:vex123abc456def789012345678901234567890123456789012345678901234",
|
||||
EvidenceIds = new[] { $"sha256:{new string('a', 64)}" },
|
||||
ReasoningId = $"sha256:{new string('b', 64)}",
|
||||
VexVerdictId = $"sha256:{new string('c', 64)}",
|
||||
PolicyVersion = "v1.0.0"
|
||||
};
|
||||
|
||||
@@ -100,8 +100,8 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
|
||||
var request = new CreateSpineRequest
|
||||
{
|
||||
EvidenceIds = new[] { "invalid-not-sha256" }, // Invalid format
|
||||
ReasoningId = "sha256:reason123abc456def789012345678901234567890123456789012345678901",
|
||||
VexVerdictId = "sha256:vex123abc456def789012345678901234567890123456789012345678901234",
|
||||
ReasoningId = $"sha256:{new string('b', 64)}",
|
||||
VexVerdictId = $"sha256:{new string('c', 64)}",
|
||||
PolicyVersion = "v1.0.0"
|
||||
};
|
||||
|
||||
@@ -127,9 +127,9 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
|
||||
// Create spine first
|
||||
var createRequest = new CreateSpineRequest
|
||||
{
|
||||
EvidenceIds = new[] { "sha256:ev123abc456def789012345678901234567890123456789012345678901234" },
|
||||
ReasoningId = "sha256:reason123abc456def789012345678901234567890123456789012345678901",
|
||||
VexVerdictId = "sha256:vex123abc456def789012345678901234567890123456789012345678901234",
|
||||
EvidenceIds = new[] { $"sha256:{new string('a', 64)}" },
|
||||
ReasoningId = $"sha256:{new string('b', 64)}",
|
||||
VexVerdictId = $"sha256:{new string('c', 64)}",
|
||||
PolicyVersion = "v1.0.0"
|
||||
};
|
||||
await _client.PostAsJsonAsync($"/proofs/{Uri.EscapeDataString(entry)}/spine", createRequest);
|
||||
@@ -227,9 +227,9 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
|
||||
var entry = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1:pkg:npm/test@1.0.0";
|
||||
var request = new CreateSpineRequest
|
||||
{
|
||||
EvidenceIds = new[] { "sha256:ev123abc456def789012345678901234567890123456789012345678901234" },
|
||||
ReasoningId = "sha256:reason123abc456def789012345678901234567890123456789012345678901",
|
||||
VexVerdictId = "sha256:vex123abc456def789012345678901234567890123456789012345678901234",
|
||||
EvidenceIds = new[] { $"sha256:{new string('a', 64)}" },
|
||||
ReasoningId = $"sha256:{new string('b', 64)}",
|
||||
VexVerdictId = $"sha256:{new string('c', 64)}",
|
||||
PolicyVersion = "v1.0.0"
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
[Collection("SmSoftGate")]
|
||||
public sealed class AttestorSigningServiceTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _temporaryPaths = new();
|
||||
|
||||
@@ -62,6 +62,7 @@ public sealed class AttestorSubmissionServiceTests
|
||||
archiveStore,
|
||||
auditSink,
|
||||
verificationCache,
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
@@ -141,6 +142,7 @@ public sealed class AttestorSubmissionServiceTests
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new StubVerificationCache(),
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
@@ -207,6 +209,7 @@ public sealed class AttestorSubmissionServiceTests
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new StubVerificationCache(),
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
@@ -276,6 +279,7 @@ public sealed class AttestorSubmissionServiceTests
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new StubVerificationCache(),
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
|
||||
@@ -76,6 +76,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new NullVerificationCache(),
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
@@ -98,6 +99,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
@@ -169,6 +171,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new NullVerificationCache(),
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
@@ -191,6 +194,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
@@ -253,6 +257,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new NullVerificationCache(),
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
@@ -275,6 +280,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
@@ -467,6 +473,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
@@ -552,6 +559,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
@@ -636,6 +644,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new NullVerificationCache(),
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
@@ -658,6 +667,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
engine,
|
||||
new TimeSkewValidator(options.Value.TimeSkew),
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
@@ -717,6 +727,15 @@ public sealed class AttestorVerificationServiceTests
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Task<RekorInclusionVerificationResult> VerifyInclusionAsync(
|
||||
string rekorUuid,
|
||||
byte[] payloadDigest,
|
||||
RekorBackend backend,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(RekorInclusionVerificationResult.Failure("not_supported"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ public sealed class CheckpointSignatureVerifierTests
|
||||
private const string ValidCheckpointBody = """
|
||||
rekor.sigstore.dev - 2605736670972794746
|
||||
123456789
|
||||
abc123def456ghi789jkl012mno345pqr678stu901vwx234=
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
1702345678
|
||||
""";
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// Description: PostgreSQL integration tests for Rekor submission queue
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#if STELLAOPS_EXPERIMENTAL_REKOR_QUEUE
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -379,6 +381,8 @@ public class PostgresRekorSubmissionQueueIntegrationTests : IAsyncLifetime
|
||||
#endregion
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for testing.
|
||||
/// </summary>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// Task: T11
|
||||
// =============================================================================
|
||||
|
||||
#if STELLAOPS_EXPERIMENTAL_REKOR_QUEUE
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -226,3 +228,5 @@ public sealed class RekorSubmissionResponse
|
||||
public string? Uuid { get; init; }
|
||||
public long? Index { get; init; }
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Queue;
|
||||
using StellaOps.Attestor.Infrastructure.Queue;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
@@ -14,7 +14,8 @@ using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests.Signing;
|
||||
|
||||
public class Sm2AttestorTests
|
||||
[Collection("SmSoftGate")]
|
||||
public sealed class Sm2AttestorTests : IDisposable
|
||||
{
|
||||
private readonly string? _gate;
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests.Signing;
|
||||
|
||||
[CollectionDefinition("SmSoftGate", DisableParallelization = true)]
|
||||
public sealed class SmSoftGateCollection
|
||||
{
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
// TimeSkewValidationIntegrationTests.cs
|
||||
// Sprint: SPRINT_3000_0001_0003_rekor_time_skew_validation
|
||||
// Task: T10
|
||||
// Description: Integration tests for time skew validation in submission and verification services
|
||||
// Description: Integration coverage for time skew validation in submission + verification.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
@@ -15,575 +13,394 @@ using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Transparency;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Transparency;
|
||||
using StellaOps.Attestor.Infrastructure.Verification;
|
||||
using StellaOps.Attestor.Tests.Support;
|
||||
using StellaOps.Attestor.Verify;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for time skew validation in submission and verification services.
|
||||
/// Per SPRINT_3000_0001_0003 - T10: Add integration coverage.
|
||||
/// </summary>
|
||||
public sealed class TimeSkewValidationIntegrationTests : IDisposable
|
||||
public sealed class TimeSkewValidationIntegrationTests
|
||||
{
|
||||
private static readonly byte[] HmacSecret = Encoding.UTF8.GetBytes("attestor-hmac-secret");
|
||||
private static readonly string HmacSecretBase64 = Convert.ToBase64String(HmacSecret);
|
||||
|
||||
private readonly AttestorMetrics _metrics;
|
||||
private readonly AttestorActivitySource _activitySource;
|
||||
private readonly DefaultDsseCanonicalizer _canonicalizer;
|
||||
private readonly InMemoryAttestorEntryRepository _repository;
|
||||
private readonly InMemoryAttestorDedupeStore _dedupeStore;
|
||||
private readonly InMemoryAttestorAuditSink _auditSink;
|
||||
private readonly NullAttestorArchiveStore _archiveStore;
|
||||
private readonly NullTransparencyWitnessClient _witnessClient;
|
||||
private readonly NullVerificationCache _verificationCache;
|
||||
private bool _disposed;
|
||||
|
||||
public TimeSkewValidationIntegrationTests()
|
||||
{
|
||||
_metrics = new AttestorMetrics();
|
||||
_activitySource = new AttestorActivitySource();
|
||||
_canonicalizer = new DefaultDsseCanonicalizer();
|
||||
_repository = new InMemoryAttestorEntryRepository();
|
||||
_dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
_auditSink = new InMemoryAttestorAuditSink();
|
||||
_archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
_witnessClient = new NullTransparencyWitnessClient();
|
||||
_verificationCache = new NullVerificationCache();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_metrics.Dispose();
|
||||
_activitySource.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
#region Submission Integration Tests
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 18, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task Submission_WithTimeSkewBeyondRejectThreshold_ThrowsTimeSkewValidationException_WhenFailOnRejectEnabled()
|
||||
public async Task SubmitAsync_WhenSkewRejected_Throws_WhenFailOnRejectEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var timeSkewOptions = new TimeSkewOptions
|
||||
var options = CreateOptions(new TimeSkewOptions
|
||||
{
|
||||
Enabled = true,
|
||||
WarnThresholdSeconds = 60,
|
||||
RejectThresholdSeconds = 300,
|
||||
FailOnReject = true
|
||||
};
|
||||
|
||||
var options = CreateAttestorOptions(timeSkewOptions);
|
||||
|
||||
// Create a Rekor client that returns an integrated time way in the past
|
||||
var pastTime = DateTimeOffset.UtcNow.AddSeconds(-600); // 10 minutes ago
|
||||
var rekorClient = new ConfigurableTimeRekorClient(pastTime);
|
||||
|
||||
var timeSkewValidator = new InstrumentedTimeSkewValidator(
|
||||
timeSkewOptions,
|
||||
_metrics,
|
||||
new NullLogger<InstrumentedTimeSkewValidator>());
|
||||
|
||||
var submissionService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
|
||||
var (request, context) = CreateSubmissionRequest();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<TimeSkewValidationException>(async () =>
|
||||
{
|
||||
await submissionService.SubmitAsync(request, context);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submission_WithTimeSkewBeyondRejectThreshold_Succeeds_WhenFailOnRejectDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var timeSkewOptions = new TimeSkewOptions
|
||||
{
|
||||
Enabled = true,
|
||||
WarnThresholdSeconds = 60,
|
||||
RejectThresholdSeconds = 300,
|
||||
FailOnReject = false // Disabled - should log but not fail
|
||||
};
|
||||
|
||||
var options = CreateAttestorOptions(timeSkewOptions);
|
||||
|
||||
// Create a Rekor client that returns an integrated time way in the past
|
||||
var pastTime = DateTimeOffset.UtcNow.AddSeconds(-600); // 10 minutes ago
|
||||
var rekorClient = new ConfigurableTimeRekorClient(pastTime);
|
||||
|
||||
var timeSkewValidator = new InstrumentedTimeSkewValidator(
|
||||
timeSkewOptions,
|
||||
_metrics,
|
||||
new NullLogger<InstrumentedTimeSkewValidator>());
|
||||
|
||||
var submissionService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
|
||||
var (request, context) = CreateSubmissionRequest();
|
||||
|
||||
// Act
|
||||
var result = await submissionService.SubmitAsync(request, context);
|
||||
|
||||
// Assert - should succeed but emit metrics
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Uuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submission_WithTimeSkewBelowWarnThreshold_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var timeSkewOptions = new TimeSkewOptions
|
||||
{
|
||||
Enabled = true,
|
||||
WarnThresholdSeconds = 60,
|
||||
RejectThresholdSeconds = 300,
|
||||
FailOnReject = true
|
||||
};
|
||||
|
||||
var options = CreateAttestorOptions(timeSkewOptions);
|
||||
|
||||
// Create a Rekor client that returns an integrated time just a few seconds ago
|
||||
var recentTime = DateTimeOffset.UtcNow.AddSeconds(-10); // 10 seconds ago
|
||||
var rekorClient = new ConfigurableTimeRekorClient(recentTime);
|
||||
|
||||
var timeSkewValidator = new InstrumentedTimeSkewValidator(
|
||||
timeSkewOptions,
|
||||
_metrics,
|
||||
new NullLogger<InstrumentedTimeSkewValidator>());
|
||||
|
||||
var submissionService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
|
||||
var (request, context) = CreateSubmissionRequest();
|
||||
|
||||
// Act
|
||||
var result = await submissionService.SubmitAsync(request, context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Uuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submission_WithFutureTimestamp_ThrowsTimeSkewValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var timeSkewOptions = new TimeSkewOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxFutureSkewSeconds = 60,
|
||||
FailOnReject = true
|
||||
};
|
||||
|
||||
var options = CreateAttestorOptions(timeSkewOptions);
|
||||
|
||||
// Create a Rekor client that returns a future integrated time
|
||||
var futureTime = DateTimeOffset.UtcNow.AddSeconds(120); // 2 minutes in the future
|
||||
var rekorClient = new ConfigurableTimeRekorClient(futureTime);
|
||||
|
||||
var timeSkewValidator = new InstrumentedTimeSkewValidator(
|
||||
timeSkewOptions,
|
||||
_metrics,
|
||||
new NullLogger<InstrumentedTimeSkewValidator>());
|
||||
|
||||
var submissionService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
|
||||
var (request, context) = CreateSubmissionRequest();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<TimeSkewValidationException>(async () =>
|
||||
{
|
||||
await submissionService.SubmitAsync(request, context);
|
||||
});
|
||||
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer, options.Value.Security.SignerIdentity.Mode);
|
||||
|
||||
var rekorClient = new FixedRekorClient(integratedTime: FixedNow.AddSeconds(-600));
|
||||
var submissionService = CreateSubmissionService(options, validator, canonicalizer, rekorClient, new TimeSkewValidator(options.Value.TimeSkew), new FixedTimeProvider(FixedNow));
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
var context = CreateSubmissionContext();
|
||||
|
||||
await Assert.ThrowsAsync<TimeSkewValidationException>(() => submissionService.SubmitAsync(request, context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submission_WhenValidationDisabled_SkipsTimeSkewCheck()
|
||||
public async Task SubmitAsync_WhenSkewRejected_Succeeds_WhenFailOnRejectDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var timeSkewOptions = new TimeSkewOptions
|
||||
{
|
||||
Enabled = false // Disabled
|
||||
};
|
||||
|
||||
var options = CreateAttestorOptions(timeSkewOptions);
|
||||
|
||||
// Create a Rekor client with a very old integrated time
|
||||
var veryOldTime = DateTimeOffset.UtcNow.AddHours(-24);
|
||||
var rekorClient = new ConfigurableTimeRekorClient(veryOldTime);
|
||||
|
||||
var timeSkewValidator = new InstrumentedTimeSkewValidator(
|
||||
timeSkewOptions,
|
||||
_metrics,
|
||||
new NullLogger<InstrumentedTimeSkewValidator>());
|
||||
|
||||
var submissionService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
|
||||
var (request, context) = CreateSubmissionRequest();
|
||||
|
||||
// Act - should succeed even with very old timestamp because validation is disabled
|
||||
var result = await submissionService.SubmitAsync(request, context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Uuid);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Verification Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Verification_WithTimeSkewBeyondRejectThreshold_IncludesIssueInReport_WhenFailOnRejectEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var timeSkewOptions = new TimeSkewOptions
|
||||
var options = CreateOptions(new TimeSkewOptions
|
||||
{
|
||||
Enabled = true,
|
||||
WarnThresholdSeconds = 60,
|
||||
RejectThresholdSeconds = 300,
|
||||
FailOnReject = true
|
||||
};
|
||||
MaxFutureSkewSeconds = 60,
|
||||
FailOnReject = false
|
||||
});
|
||||
|
||||
var options = CreateAttestorOptions(timeSkewOptions);
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer, options.Value.Security.SignerIdentity.Mode);
|
||||
|
||||
// First, submit with normal time
|
||||
var submitRekorClient = new ConfigurableTimeRekorClient(DateTimeOffset.UtcNow);
|
||||
var submitTimeSkewValidator = new TimeSkewValidator(new TimeSkewOptions { Enabled = false }); // Disable for submission
|
||||
var rekorClient = new FixedRekorClient(integratedTime: FixedNow.AddSeconds(-600));
|
||||
var submissionService = CreateSubmissionService(options, validator, canonicalizer, rekorClient, new TimeSkewValidator(options.Value.TimeSkew), new FixedTimeProvider(FixedNow));
|
||||
|
||||
var submitService = CreateSubmissionService(options, submitRekorClient, submitTimeSkewValidator);
|
||||
var (request, context) = CreateSubmissionRequest();
|
||||
var submissionResult = await submitService.SubmitAsync(request, context);
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
var context = CreateSubmissionContext();
|
||||
|
||||
// Now manually update the entry with an old integrated time for verification testing
|
||||
var entry = await _repository.GetByUuidAsync(submissionResult.Uuid);
|
||||
Assert.NotNull(entry);
|
||||
var result = await submissionService.SubmitAsync(request, context);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Uuid));
|
||||
}
|
||||
|
||||
// Create a new entry with old integrated time
|
||||
var oldIntegratedTime = DateTimeOffset.UtcNow.AddSeconds(-600); // 10 minutes ago
|
||||
var updatedEntry = entry with
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WhenSkewRejected_ReturnsFailed_WhenFailOnRejectEnabled()
|
||||
{
|
||||
var options = CreateOptions(new TimeSkewOptions
|
||||
{
|
||||
Log = entry.Log with
|
||||
Enabled = true,
|
||||
WarnThresholdSeconds = 60,
|
||||
RejectThresholdSeconds = 300,
|
||||
MaxFutureSkewSeconds = 60,
|
||||
FailOnReject = true
|
||||
});
|
||||
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
|
||||
var entry = new AttestorEntry
|
||||
{
|
||||
RekorUuid = "uuid-1",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
IntegratedTimeUtc = oldIntegratedTime
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "sbom"
|
||||
},
|
||||
BundleSha256 = new string('b', 64),
|
||||
Index = 1,
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/",
|
||||
IntegratedTime = FixedNow.AddSeconds(-600).ToUnixTimeSeconds()
|
||||
},
|
||||
CreatedAt = FixedNow.AddMinutes(-10),
|
||||
Status = "included",
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Mode = "keyless",
|
||||
Issuer = "issuer",
|
||||
SubjectAlternativeName = "subject",
|
||||
KeyId = "key-1"
|
||||
}
|
||||
};
|
||||
await _repository.SaveAsync(updatedEntry);
|
||||
|
||||
// Create verification service with time skew validation enabled
|
||||
var verifyTimeSkewValidator = new InstrumentedTimeSkewValidator(
|
||||
timeSkewOptions,
|
||||
_metrics,
|
||||
new NullLogger<InstrumentedTimeSkewValidator>());
|
||||
await repository.SaveAsync(entry);
|
||||
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var verificationService = CreateVerificationService(options, rekorClient, verifyTimeSkewValidator);
|
||||
var verificationService = CreateVerificationService(
|
||||
options,
|
||||
canonicalizer: new DefaultDsseCanonicalizer(),
|
||||
repository: repository,
|
||||
timeSkewValidator: new TimeSkewValidator(options.Value.TimeSkew),
|
||||
timeProvider: timeProvider);
|
||||
|
||||
// Act
|
||||
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = submissionResult.Uuid,
|
||||
Bundle = request.Bundle
|
||||
Uuid = entry.RekorUuid,
|
||||
Offline = true,
|
||||
RefreshProof = false
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.Ok);
|
||||
Assert.Contains(verifyResult.Issues, i => i.Contains("time_skew"));
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains(result.Issues, issue => issue.StartsWith("time_skew_rejected:", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verification_WithTimeSkewBelowThreshold_PassesValidation()
|
||||
public async Task VerifyAsync_WhenSkewRejected_DoesNotFail_WhenFailOnRejectDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var timeSkewOptions = new TimeSkewOptions
|
||||
var options = CreateOptions(new TimeSkewOptions
|
||||
{
|
||||
Enabled = true,
|
||||
WarnThresholdSeconds = 60,
|
||||
RejectThresholdSeconds = 300,
|
||||
FailOnReject = true
|
||||
};
|
||||
|
||||
var options = CreateAttestorOptions(timeSkewOptions);
|
||||
|
||||
// Submit with recent integrated time
|
||||
var recentTime = DateTimeOffset.UtcNow.AddSeconds(-5);
|
||||
var rekorClient = new ConfigurableTimeRekorClient(recentTime);
|
||||
|
||||
var timeSkewValidator = new InstrumentedTimeSkewValidator(
|
||||
timeSkewOptions,
|
||||
_metrics,
|
||||
new NullLogger<InstrumentedTimeSkewValidator>());
|
||||
|
||||
var submitService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
|
||||
var (request, context) = CreateSubmissionRequest();
|
||||
var submissionResult = await submitService.SubmitAsync(request, context);
|
||||
|
||||
// Verify
|
||||
var verifyRekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var verificationService = CreateVerificationService(options, verifyRekorClient, timeSkewValidator);
|
||||
|
||||
// Act
|
||||
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = submissionResult.Uuid,
|
||||
Bundle = request.Bundle
|
||||
MaxFutureSkewSeconds = 60,
|
||||
FailOnReject = false
|
||||
});
|
||||
|
||||
// Assert - should pass (no time skew issue)
|
||||
// Note: Other issues may exist (e.g., witness_missing) but not time_skew
|
||||
Assert.DoesNotContain(verifyResult.Issues, i => i.Contains("time_skew_rejected"));
|
||||
}
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
|
||||
[Fact]
|
||||
public async Task Verification_OfflineMode_SkipsTimeSkewValidation()
|
||||
{
|
||||
// Arrange
|
||||
var timeSkewOptions = new TimeSkewOptions
|
||||
var entry = new AttestorEntry
|
||||
{
|
||||
Enabled = true, // Enabled, but should be skipped in offline mode due to missing integrated time
|
||||
WarnThresholdSeconds = 60,
|
||||
RejectThresholdSeconds = 300,
|
||||
FailOnReject = true
|
||||
RekorUuid = "uuid-2",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = new string('c', 64),
|
||||
Kind = "sbom"
|
||||
},
|
||||
BundleSha256 = new string('d', 64),
|
||||
Index = 1,
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/",
|
||||
IntegratedTime = FixedNow.AddSeconds(-600).ToUnixTimeSeconds()
|
||||
},
|
||||
CreatedAt = FixedNow.AddMinutes(-10),
|
||||
Status = "included",
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Mode = "keyless",
|
||||
Issuer = "issuer",
|
||||
SubjectAlternativeName = "subject",
|
||||
KeyId = "key-1"
|
||||
}
|
||||
};
|
||||
|
||||
var options = CreateAttestorOptions(timeSkewOptions);
|
||||
await repository.SaveAsync(entry);
|
||||
|
||||
// Submit without integrated time (simulates offline stored entry)
|
||||
var rekorClient = new ConfigurableTimeRekorClient(integratedTime: null);
|
||||
var timeSkewValidator = new InstrumentedTimeSkewValidator(
|
||||
timeSkewOptions,
|
||||
_metrics,
|
||||
new NullLogger<InstrumentedTimeSkewValidator>());
|
||||
var verificationService = CreateVerificationService(
|
||||
options,
|
||||
canonicalizer: new DefaultDsseCanonicalizer(),
|
||||
repository: repository,
|
||||
timeSkewValidator: new TimeSkewValidator(options.Value.TimeSkew),
|
||||
timeProvider: timeProvider);
|
||||
|
||||
var submitService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
|
||||
var (request, context) = CreateSubmissionRequest();
|
||||
var submissionResult = await submitService.SubmitAsync(request, context);
|
||||
|
||||
// Verify
|
||||
var verifyRekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var verificationService = CreateVerificationService(options, verifyRekorClient, timeSkewValidator);
|
||||
|
||||
// Act
|
||||
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = submissionResult.Uuid,
|
||||
Bundle = request.Bundle
|
||||
Uuid = entry.RekorUuid,
|
||||
Offline = true,
|
||||
RefreshProof = false
|
||||
});
|
||||
|
||||
// Assert - should not have time skew issues (skipped due to missing integrated time)
|
||||
Assert.DoesNotContain(verifyResult.Issues, i => i.Contains("time_skew_rejected"));
|
||||
Assert.True(result.Ok);
|
||||
Assert.DoesNotContain(result.Issues, issue => issue.StartsWith("time_skew_rejected:", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Metrics Integration Tests
|
||||
|
||||
[Fact]
|
||||
public void TimeSkewMetrics_AreRegistered()
|
||||
{
|
||||
// Assert - metrics should be created
|
||||
Assert.NotNull(_metrics.TimeSkewDetectedTotal);
|
||||
Assert.NotNull(_metrics.TimeSkewSeconds);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private IOptions<AttestorOptions> CreateAttestorOptions(TimeSkewOptions timeSkewOptions)
|
||||
private static IOptions<AttestorOptions> CreateOptions(TimeSkewOptions timeSkew)
|
||||
{
|
||||
return Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.stellaops.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
Url = "https://rekor.example/"
|
||||
},
|
||||
Mirror = new AttestorOptions.RekorMirrorOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
Enabled = false
|
||||
}
|
||||
},
|
||||
TimeSkew = timeSkewOptions
|
||||
Verification = new AttestorOptions.VerificationOptions
|
||||
{
|
||||
RequireTransparencyInclusion = false,
|
||||
RequireCheckpoint = false,
|
||||
RequireWitnessEndorsement = false
|
||||
},
|
||||
TimeSkew = timeSkew
|
||||
});
|
||||
}
|
||||
|
||||
private AttestorSubmissionService CreateSubmissionService(
|
||||
IOptions<AttestorOptions> options,
|
||||
IRekorClient rekorClient,
|
||||
ITimeSkewValidator timeSkewValidator)
|
||||
private static SubmissionContext CreateSubmissionContext() => new()
|
||||
{
|
||||
return new AttestorSubmissionService(
|
||||
new AttestorSubmissionValidator(_canonicalizer),
|
||||
_repository,
|
||||
_dedupeStore,
|
||||
rekorClient,
|
||||
_witnessClient,
|
||||
_archiveStore,
|
||||
_auditSink,
|
||||
_verificationCache,
|
||||
timeSkewValidator,
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
_metrics);
|
||||
}
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default",
|
||||
ClientCertificate = null,
|
||||
MtlsThumbprint = "00"
|
||||
};
|
||||
|
||||
private AttestorVerificationService CreateVerificationService(
|
||||
IOptions<AttestorOptions> options,
|
||||
IRekorClient rekorClient,
|
||||
ITimeSkewValidator timeSkewValidator)
|
||||
private static AttestorSubmissionRequest CreateValidRequest(DefaultDsseCanonicalizer canonicalizer)
|
||||
{
|
||||
var engine = new AttestorVerificationEngine(
|
||||
_canonicalizer,
|
||||
new TestCryptoHash(),
|
||||
options,
|
||||
new NullLogger<AttestorVerificationEngine>());
|
||||
|
||||
return new AttestorVerificationService(
|
||||
_repository,
|
||||
_canonicalizer,
|
||||
rekorClient,
|
||||
_witnessClient,
|
||||
engine,
|
||||
timeSkewValidator,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
_metrics,
|
||||
_activitySource,
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
private (AttestorSubmissionRequest Request, SubmissionContext Context) CreateSubmissionRequest()
|
||||
{
|
||||
var artifactSha256 = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
var payloadJson = $$$"""{"_type":"https://in-toto.io/Statement/v0.1","subject":[{"name":"test","digest":{"sha256":"{{{artifactSha256}}}"}}],"predicateType":"https://slsa.dev/provenance/v1","predicate":{}}""";
|
||||
var payload = Encoding.UTF8.GetBytes(payloadJson);
|
||||
|
||||
var payloadBase64 = Convert.ToBase64String(payload);
|
||||
|
||||
// Create HMAC signature
|
||||
using var hmac = new HMACSHA256(HmacSecret);
|
||||
var signature = hmac.ComputeHash(payload);
|
||||
var signatureBase64 = Convert.ToBase64String(signature);
|
||||
|
||||
var bundle = new DsseBundle
|
||||
{
|
||||
Mode = "kms",
|
||||
PayloadType = payloadType,
|
||||
Payload = payloadBase64,
|
||||
Signatures =
|
||||
[
|
||||
new DsseSignature
|
||||
{
|
||||
KeyId = "kms-key-1",
|
||||
Sig = signatureBase64
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var bundleBytes = _canonicalizer.Canonicalize(bundle);
|
||||
var bundleSha256 = Convert.ToHexStringLower(SHA256.HashData(bundleBytes));
|
||||
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = bundle,
|
||||
Meta = new AttestorSubmissionRequest.MetaData
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = "keyless",
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
|
||||
Signatures =
|
||||
{
|
||||
new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = "test",
|
||||
Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
BundleSha256 = bundleSha256,
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = artifactSha256,
|
||||
Kind = "container",
|
||||
ImageDigest = $"sha256:{artifactSha256}"
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "sbom"
|
||||
},
|
||||
LogPreference = "primary"
|
||||
LogPreference = "primary",
|
||||
Archive = false
|
||||
}
|
||||
};
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
return (request, context);
|
||||
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
|
||||
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
|
||||
return request;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Doubles
|
||||
|
||||
/// <summary>
|
||||
/// A Rekor client that returns configurable integrated times.
|
||||
/// </summary>
|
||||
private sealed class ConfigurableTimeRekorClient : IRekorClient
|
||||
private static AttestorSubmissionService CreateSubmissionService(
|
||||
IOptions<AttestorOptions> options,
|
||||
AttestorSubmissionValidator validator,
|
||||
IDsseCanonicalizer canonicalizer,
|
||||
IRekorClient rekorClient,
|
||||
ITimeSkewValidator timeSkewValidator,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
private readonly DateTimeOffset? _integratedTime;
|
||||
private int _callCount;
|
||||
return new AttestorSubmissionService(
|
||||
validator,
|
||||
new InMemoryAttestorEntryRepository(),
|
||||
new InMemoryAttestorDedupeStore(),
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
new NullAttestorArchiveStore(NullLogger<NullAttestorArchiveStore>.Instance),
|
||||
new InMemoryAttestorAuditSink(),
|
||||
new NullVerificationCache(),
|
||||
timeSkewValidator,
|
||||
options,
|
||||
NullLogger<AttestorSubmissionService>.Instance,
|
||||
timeProvider,
|
||||
new AttestorMetrics());
|
||||
}
|
||||
|
||||
public ConfigurableTimeRekorClient(DateTimeOffset? integratedTime)
|
||||
private static AttestorVerificationService CreateVerificationService(
|
||||
IOptions<AttestorOptions> options,
|
||||
IDsseCanonicalizer canonicalizer,
|
||||
IAttestorEntryRepository repository,
|
||||
ITimeSkewValidator timeSkewValidator,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var engine = new AttestorVerificationEngine(
|
||||
canonicalizer,
|
||||
new TestCryptoHash(),
|
||||
options,
|
||||
NullLogger<AttestorVerificationEngine>.Instance);
|
||||
|
||||
return new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
new NullRekorClient(),
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
timeSkewValidator,
|
||||
options,
|
||||
NullLogger<AttestorVerificationService>.Instance,
|
||||
new AttestorMetrics(),
|
||||
new AttestorActivitySource(),
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
|
||||
private sealed class NullVerificationCache : IAttestorVerificationCache
|
||||
{
|
||||
public Task<AttestorVerificationResult?> GetAsync(string subject, string envelopeId, string policyVersion, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<AttestorVerificationResult?>(null);
|
||||
|
||||
public Task SetAsync(string subject, string envelopeId, string policyVersion, AttestorVerificationResult result, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task InvalidateSubjectAsync(string subject, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NullRekorClient : IRekorClient
|
||||
{
|
||||
public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("NullRekorClient does not support submissions.");
|
||||
|
||||
public Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<RekorProofResponse?>(null);
|
||||
|
||||
public Task<RekorInclusionVerificationResult> VerifyInclusionAsync(string rekorUuid, byte[] payloadDigest, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(RekorInclusionVerificationResult.Failure("not_supported"));
|
||||
}
|
||||
|
||||
private sealed class FixedRekorClient : IRekorClient
|
||||
{
|
||||
private readonly long? _integratedTimeSeconds;
|
||||
private readonly RekorProofResponse _proof;
|
||||
|
||||
public FixedRekorClient(DateTimeOffset? integratedTime)
|
||||
{
|
||||
_integratedTime = integratedTime;
|
||||
_integratedTimeSeconds = integratedTime?.ToUnixTimeSeconds();
|
||||
_proof = new RekorProofResponse
|
||||
{
|
||||
Checkpoint = new RekorProofResponse.RekorCheckpoint
|
||||
{
|
||||
Origin = "rekor.test",
|
||||
Size = 1,
|
||||
RootHash = new string('a', 64),
|
||||
Timestamp = FixedNow
|
||||
},
|
||||
Inclusion = new RekorProofResponse.RekorInclusionProof
|
||||
{
|
||||
LeafHash = new string('b', 64),
|
||||
Path = Array.Empty<string>()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public Task<RekorSubmissionResponse> SubmitAsync(
|
||||
RekorSubmissionRequest request,
|
||||
string url,
|
||||
CancellationToken cancellationToken = default)
|
||||
public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var uuid = Guid.NewGuid().ToString("N");
|
||||
var index = Interlocked.Increment(ref _callCount);
|
||||
|
||||
return Task.FromResult(new RekorSubmissionResponse
|
||||
{
|
||||
Uuid = uuid,
|
||||
Index = index,
|
||||
LogUrl = url,
|
||||
Index = 1,
|
||||
LogUrl = new Uri(backend.Url, $"/api/v2/log/entries/{uuid}").ToString(),
|
||||
Status = "included",
|
||||
IntegratedTimeUtc = _integratedTime
|
||||
Proof = _proof,
|
||||
IntegratedTime = _integratedTimeSeconds
|
||||
});
|
||||
}
|
||||
|
||||
public Task<RekorProofResponse?> GetProofAsync(
|
||||
string uuid,
|
||||
string url,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<RekorProofResponse?>(new RekorProofResponse
|
||||
{
|
||||
TreeId = "test-tree-id",
|
||||
LogIndex = 1,
|
||||
TreeSize = 100,
|
||||
RootHash = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
|
||||
Hashes = [Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))]
|
||||
});
|
||||
}
|
||||
public Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<RekorProofResponse?>(_proof);
|
||||
|
||||
public Task<RekorEntryResponse?> GetEntryAsync(
|
||||
string uuid,
|
||||
string url,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<RekorEntryResponse?>(null);
|
||||
}
|
||||
public Task<RekorInclusionVerificationResult> VerifyInclusionAsync(string rekorUuid, byte[] payloadDigest, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(RekorInclusionVerificationResult.Failure("not_supported"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -41,21 +41,32 @@ public class AnchorsController : ControllerBase
|
||||
/// <param name="anchorId">The anchor ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The trust anchor.</returns>
|
||||
[HttpGet("{anchorId:guid}")]
|
||||
[HttpGet("{anchorId}")]
|
||||
[ProducesResponseType(typeof(TrustAnchorDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TrustAnchorDto>> GetAnchorAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
[FromRoute] string anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Getting trust anchor {AnchorId}", anchorId);
|
||||
if (!Guid.TryParse(anchorId, out var parsedAnchorId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid anchor ID",
|
||||
Detail = "Anchor ID must be a valid GUID.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation("Getting trust anchor {AnchorId}", parsedAnchorId);
|
||||
|
||||
// TODO: Implement using IProofChainRepository.GetTrustAnchorAsync
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Trust Anchor Not Found",
|
||||
Detail = $"No trust anchor found with ID {anchorId}",
|
||||
Detail = $"No trust anchor found with ID {parsedAnchorId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Attestor.WebService.Contracts.Proofs;
|
||||
|
||||
@@ -57,13 +59,29 @@ public class ProofsController : ControllerBase
|
||||
// 5. Sign and store spine
|
||||
// 6. Return proof bundle ID
|
||||
|
||||
foreach (var evidenceId in request.EvidenceIds)
|
||||
{
|
||||
if (!IsValidSha256Id(evidenceId))
|
||||
{
|
||||
return UnprocessableEntity(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid evidence ID",
|
||||
Detail = "Evidence IDs must be in format sha256:<64-hex>",
|
||||
Status = StatusCodes.Status422UnprocessableEntity
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var proofBundleId = ComputeProofBundleId(entry, request);
|
||||
|
||||
var receiptUrl = $"/proofs/{Uri.EscapeDataString(entry)}/receipt";
|
||||
var response = new CreateSpineResponse
|
||||
{
|
||||
ProofBundleId = $"sha256:{Guid.NewGuid():N}",
|
||||
ReceiptUrl = $"/proofs/{entry}/receipt"
|
||||
ProofBundleId = proofBundleId,
|
||||
ReceiptUrl = receiptUrl
|
||||
};
|
||||
|
||||
return CreatedAtAction(nameof(GetReceiptAsync), new { entry }, response);
|
||||
return Created(receiptUrl, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -159,4 +177,62 @@ public class ProofsController : ControllerBase
|
||||
&& parts[1].All(c => "0123456789abcdef".Contains(c))
|
||||
&& parts[2] == "pkg";
|
||||
}
|
||||
|
||||
private static string ComputeProofBundleId(string entry, CreateSpineRequest request)
|
||||
{
|
||||
var evidenceIds = request.EvidenceIds
|
||||
.Select(static value => (value ?? string.Empty).Trim())
|
||||
.Where(static value => value.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal);
|
||||
|
||||
var material = string.Join(
|
||||
"\n",
|
||||
new[]
|
||||
{
|
||||
entry.Trim(),
|
||||
request.PolicyVersion.Trim(),
|
||||
request.ReasoningId.Trim(),
|
||||
request.VexVerdictId.Trim()
|
||||
}.Concat(evidenceIds));
|
||||
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(material));
|
||||
return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static bool IsValidSha256Id(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!value.StartsWith("sha256:", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hex = value.AsSpan()["sha256:".Length..];
|
||||
if (hex.Length != 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var c in hex)
|
||||
{
|
||||
if (c is >= '0' and <= '9')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c is >= 'a' and <= 'f')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,18 +22,35 @@ public class VerifyController : ControllerBase
|
||||
/// <summary>
|
||||
/// Verify a proof chain.
|
||||
/// </summary>
|
||||
/// <param name="proofBundleId">The proof bundle ID.</param>
|
||||
/// <param name="request">The verification request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification receipt.</returns>
|
||||
[HttpPost]
|
||||
[HttpPost("{proofBundleId}")]
|
||||
[ProducesResponseType(typeof(VerificationReceiptDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<VerificationReceiptDto>> VerifyAsync(
|
||||
[FromBody] VerifyProofRequest request,
|
||||
[FromRoute] string proofBundleId,
|
||||
[FromBody] VerifyProofRequest? request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Verifying proof bundle {BundleId}", request.ProofBundleId);
|
||||
if (!IsValidSha256Id(proofBundleId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid proof bundle ID",
|
||||
Detail = "Proof bundle ID must be in format sha256:<64-hex>",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
request ??= new VerifyProofRequest
|
||||
{
|
||||
ProofBundleId = proofBundleId
|
||||
};
|
||||
|
||||
_logger.LogInformation("Verifying proof bundle {BundleId}", proofBundleId);
|
||||
|
||||
// TODO: Implement using IVerificationPipeline per advisory §9.1
|
||||
// Pipeline steps:
|
||||
@@ -82,7 +99,7 @@ public class VerifyController : ControllerBase
|
||||
|
||||
var receipt = new VerificationReceiptDto
|
||||
{
|
||||
ProofBundleId = request.ProofBundleId,
|
||||
ProofBundleId = proofBundleId,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = request.AnchorId,
|
||||
@@ -142,4 +159,40 @@ public class VerifyController : ControllerBase
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsValidSha256Id(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!value.StartsWith("sha256:", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hex = value.AsSpan()["sha256:".Length..];
|
||||
if (hex.Length != 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var c in hex)
|
||||
{
|
||||
if (c is >= '0' and <= '9')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c is >= 'a' and <= 'f')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Attestor.Core.Offline;
|
||||
@@ -118,6 +121,7 @@ builder.Services.AddOptions<AttestorOptions>()
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddAttestorInfrastructure();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -145,6 +149,7 @@ if (attestorOptions.Telemetry.EnableTracing)
|
||||
|
||||
if (attestorOptions.Security.Authority is { Issuer: not null } authority)
|
||||
{
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
@@ -177,6 +182,17 @@ if (attestorOptions.Security.Authority is { Issuer: not null } authority)
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = NoAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = NoAuthHandler.SchemeName;
|
||||
}).AddScheme<AuthenticationSchemeOptions, NoAuthHandler>(
|
||||
authenticationScheme: NoAuthHandler.SchemeName,
|
||||
displayName: null,
|
||||
configureOptions: options => { options.TimeProvider ??= TimeProvider.System; });
|
||||
}
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
@@ -302,6 +318,8 @@ app.UseAuthorization();
|
||||
app.MapHealthChecks("/health/ready");
|
||||
app.MapHealthChecks("/health/live");
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.MapGet("/api/v1/attestations", async (HttpRequest request, IAttestorEntryRepository repository, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!AttestationListContracts.TryBuildQuery(request, out var query, out var error))
|
||||
@@ -809,3 +827,28 @@ static IResult UnsupportedMediaTypeResult()
|
||||
["code"] = "unsupported_media_type"
|
||||
});
|
||||
}
|
||||
|
||||
internal sealed class NoAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "NoAuth";
|
||||
|
||||
#pragma warning disable CS0618
|
||||
public NoAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock)
|
||||
: base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync() =>
|
||||
Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
|
||||
| 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 | | |
|
||||
| SPRINT_3000_0001_0001-T1 | DONE | `IRekorClient.VerifyInclusionAsync` contract present. | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T2 | DONE | `MerkleProofVerifier` implemented. | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T3 | DONE | `CheckpointSignatureVerifier` implemented + used by offline receipt verifier. | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T4 | DONE | `RekorVerificationOptions` drafted under Core/Configuration. | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T5 | DONE | `HttpRekorClient.VerifyInclusionAsync` implemented (Merkle root verification). | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T6 | DONE | `StubRekorClient.VerifyInclusionAsync` implemented. | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T6a | DONE | Offline checkpoint/receipt contract + schema: `docs/modules/attestor/transparency.md`, `docs/schemas/rekor-receipt.schema.json`. | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T6b | DONE | Offline fixtures + harness: `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Core.Tests/Fixtures/Rekor/RekorOfflineReceiptFixtures.cs`, `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Core.Tests/RekorOfflineReceiptVerifierTests.cs`. | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T7 | DONE | Verification pipeline evaluates inclusion proof + witness status. | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T8 | DONE | Offline mode supported (no external log refresh when `Offline=true`). | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T9 | DONE | Unit coverage present (Merkle + checkpoint) via `dotnet test src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Core.Tests/StellaOps.Attestor.Core.Tests.csproj -c Release`. | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T10 | DONE | Integration coverage present (`RekorInclusionVerificationIntegrationTests`). | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T11 | DONE | Rekor verification metrics exposed. | 2025-12-18 |
|
||||
| SPRINT_3000_0001_0001-T12 | DONE | Docs synced (module architecture + transparency contract). | 2025-12-18 |
|
||||
|
||||
# Attestor · Sprint 3000-0001-0002 (Rekor Durable Retry Queue & Metrics)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user