save checkpoint. addition features and their state. check some ofthem
This commit is contained in:
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0050-M | DONE | Revalidated maintainability for StellaOps.Attestor.Core.Tests. |
|
||||
| AUDIT-0050-T | DONE | Revalidated test coverage for StellaOps.Attestor.Core.Tests. |
|
||||
| AUDIT-0050-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| RB-004-REKOR-OFFLINE-20260209 | DONE | Added deterministic unit coverage for valid/tampered offline Rekor proofs and break-glass behavior (`RekorVerificationServiceOfflineTests`). |
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Tests.Verification;
|
||||
|
||||
public sealed class RekorVerificationServiceOfflineTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyEntryAsync_OfflineValidProof_PassesWithoutBreakGlass()
|
||||
{
|
||||
var service = CreateService(new RekorVerificationOptions
|
||||
{
|
||||
EnableOfflineVerification = true,
|
||||
RequireOfflineProofVerification = true,
|
||||
AllowOfflineBreakGlassVerification = false
|
||||
});
|
||||
|
||||
var entry = CreateEntry();
|
||||
|
||||
var result = await service.VerifyEntryAsync(entry, TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.InclusionProofValid.Should().BeTrue();
|
||||
result.UsedBreakGlassMode.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyEntryAsync_OfflineTamperedProof_FailsWhenBreakGlassDisabled()
|
||||
{
|
||||
var service = CreateService(new RekorVerificationOptions
|
||||
{
|
||||
EnableOfflineVerification = true,
|
||||
RequireOfflineProofVerification = true,
|
||||
AllowOfflineBreakGlassVerification = false
|
||||
});
|
||||
|
||||
var entry = CreateEntry(tamperRootHash: true);
|
||||
|
||||
var result = await service.VerifyEntryAsync(entry, TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.FailureCode.Should().Be(RekorVerificationFailureCode.InvalidInclusionProof);
|
||||
result.UsedBreakGlassMode.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyEntryAsync_OfflineTamperedProof_UsesBreakGlassWhenEnabled()
|
||||
{
|
||||
var service = CreateService(new RekorVerificationOptions
|
||||
{
|
||||
EnableOfflineVerification = true,
|
||||
RequireOfflineProofVerification = true,
|
||||
AllowOfflineBreakGlassVerification = true
|
||||
});
|
||||
|
||||
var entry = CreateEntry(tamperRootHash: true);
|
||||
|
||||
var result = await service.VerifyEntryAsync(entry, TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.UsedBreakGlassMode.Should().BeTrue();
|
||||
result.InclusionProofValid.Should().BeFalse();
|
||||
result.BreakGlassReason.Should().Contain("Merkle inclusion proof");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyEntryAsync_OfflineMismatchedLogIndex_Fails()
|
||||
{
|
||||
var service = CreateService(new RekorVerificationOptions
|
||||
{
|
||||
EnableOfflineVerification = true,
|
||||
RequireOfflineProofVerification = true,
|
||||
AllowOfflineBreakGlassVerification = false
|
||||
});
|
||||
|
||||
var entry = CreateEntry(logIndexOverride: 11);
|
||||
|
||||
var result = await service.VerifyEntryAsync(entry, TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.FailureCode.Should().Be(RekorVerificationFailureCode.LogIndexMismatch);
|
||||
}
|
||||
|
||||
private static RekorVerificationService CreateService(RekorVerificationOptions options)
|
||||
{
|
||||
return new RekorVerificationService(
|
||||
new ThrowingRekorClient(),
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
NullLogger<RekorVerificationService>.Instance);
|
||||
}
|
||||
|
||||
private static RekorEntryReference CreateEntry(bool tamperRootHash = false, long? logIndexOverride = null)
|
||||
{
|
||||
var entryBodyDigest = SHA256.HashData(Encoding.UTF8.GetBytes("rekor-entry-body"));
|
||||
var leafHash = MerkleProofVerifier.HashLeaf(entryBodyDigest);
|
||||
|
||||
var path = new[]
|
||||
{
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes("rekor-path-0")),
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes("rekor-path-1")),
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes("rekor-path-2"))
|
||||
};
|
||||
|
||||
const long proofLeafIndex = 5;
|
||||
const long treeSize = 8;
|
||||
|
||||
var rootHash = MerkleProofVerifier.ComputeRootFromPath(leafHash, proofLeafIndex, treeSize, path)
|
||||
?? throw new InvalidOperationException("Failed to construct deterministic Rekor proof fixture.");
|
||||
|
||||
if (tamperRootHash)
|
||||
{
|
||||
rootHash = rootHash.ToArray();
|
||||
rootHash[0] ^= 0x01;
|
||||
}
|
||||
|
||||
return new RekorEntryReference
|
||||
{
|
||||
Uuid = "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
LogIndex = logIndexOverride ?? proofLeafIndex,
|
||||
IntegratedTime = new DateTimeOffset(2026, 2, 9, 12, 0, 0, TimeSpan.Zero),
|
||||
EntryBodyHash = Convert.ToHexString(entryBodyDigest).ToLowerInvariant(),
|
||||
InclusionProof = new StoredInclusionProof
|
||||
{
|
||||
LeafIndex = proofLeafIndex,
|
||||
TreeSize = treeSize,
|
||||
RootHash = Convert.ToHexString(rootHash).ToLowerInvariant(),
|
||||
Hashes = path.Select(static x => Convert.ToHexString(x).ToLowerInvariant()).ToArray()
|
||||
},
|
||||
RekorUrl = "https://rekor.sigstore.dev",
|
||||
ExpectedBuildTime = new DateTimeOffset(2026, 2, 9, 11, 59, 30, TimeSpan.Zero)
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ThrowingRekorClient : IRekorClient
|
||||
{
|
||||
public Task<RekorSubmissionResponse> SubmitAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
RekorBackend backend,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("SubmitAsync should not be called in offline verification tests.");
|
||||
|
||||
public Task<RekorProofResponse?> GetProofAsync(
|
||||
string rekorUuid,
|
||||
RekorBackend backend,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("GetProofAsync should not be called in offline verification tests.");
|
||||
|
||||
public Task<RekorInclusionVerificationResult> VerifyInclusionAsync(
|
||||
string rekorUuid,
|
||||
byte[] payloadDigest,
|
||||
RekorBackend backend,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("VerifyInclusionAsync should not be called in offline verification tests.");
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,24 @@ public sealed class RekorVerificationOptions
|
||||
/// </remarks>
|
||||
public bool EnableOfflineVerification { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Require cryptographic verification of offline inclusion proofs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When true, offline verification recomputes Merkle roots and rejects structurally
|
||||
/// valid but cryptographically invalid proofs.
|
||||
/// </remarks>
|
||||
public bool RequireOfflineProofVerification { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Allow explicit break-glass bypass for offline proof verification failures.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This should only be enabled in emergency disconnected scenarios and must be
|
||||
/// audited by downstream promotion policies.
|
||||
/// </remarks>
|
||||
public bool AllowOfflineBreakGlassVerification { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configuration options.
|
||||
/// </summary>
|
||||
@@ -194,6 +212,11 @@ public sealed class RekorVerificationOptions
|
||||
errors.Add("CronSchedule must be specified");
|
||||
}
|
||||
|
||||
if (!EnableOfflineVerification && AllowOfflineBreakGlassVerification)
|
||||
{
|
||||
errors.Add("AllowOfflineBreakGlassVerification requires EnableOfflineVerification=true");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0049-T | DONE | Revalidated test coverage for StellaOps.Attestor.Core. |
|
||||
| AUDIT-0049-A | TODO | Reopened on revalidation; address canonicalization, time/ID determinism, and Ed25519 gaps. |
|
||||
| TASK-029-003 | DONE | SPRINT_20260120_029 - Add DSSE verification report signer + tests. |
|
||||
| RB-004-REKOR-OFFLINE-20260209 | DONE | Hardened periodic offline Rekor verification path with cryptographic inclusion checks and explicit break-glass result markers. |
|
||||
|
||||
@@ -182,6 +182,16 @@ public sealed record RekorVerificationResult
|
||||
/// </summary>
|
||||
public TimeSpan? Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether verification passed by using explicit break-glass policy bypass.
|
||||
/// </summary>
|
||||
public bool UsedBreakGlassMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason recorded when break-glass mode was used.
|
||||
/// </summary>
|
||||
public string? BreakGlassReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful verification result.
|
||||
/// </summary>
|
||||
@@ -189,7 +199,9 @@ public sealed record RekorVerificationResult
|
||||
string entryUuid,
|
||||
TimeSpan? timeSkew,
|
||||
DateTimeOffset verifiedAt,
|
||||
TimeSpan? duration = null) => new()
|
||||
TimeSpan? duration = null,
|
||||
bool usedBreakGlassMode = false,
|
||||
string? breakGlassReason = null) => new()
|
||||
{
|
||||
EntryUuid = entryUuid,
|
||||
IsValid = true,
|
||||
@@ -198,7 +210,9 @@ public sealed record RekorVerificationResult
|
||||
TimeSkewValid = true,
|
||||
TimeSkewAmount = timeSkew,
|
||||
VerifiedAt = verifiedAt,
|
||||
Duration = duration
|
||||
Duration = duration,
|
||||
UsedBreakGlassMode = usedBreakGlassMode,
|
||||
BreakGlassReason = breakGlassReason
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -213,7 +227,9 @@ public sealed record RekorVerificationResult
|
||||
bool inclusionProofValid = false,
|
||||
bool timeSkewValid = false,
|
||||
TimeSpan? timeSkewAmount = null,
|
||||
TimeSpan? duration = null) => new()
|
||||
TimeSpan? duration = null,
|
||||
bool usedBreakGlassMode = false,
|
||||
string? breakGlassReason = null) => new()
|
||||
{
|
||||
EntryUuid = entryUuid,
|
||||
IsValid = false,
|
||||
@@ -224,7 +240,9 @@ public sealed record RekorVerificationResult
|
||||
FailureReason = reason,
|
||||
FailureCode = code,
|
||||
VerifiedAt = verifiedAt,
|
||||
Duration = duration
|
||||
Duration = duration,
|
||||
UsedBreakGlassMode = usedBreakGlassMode,
|
||||
BreakGlassReason = breakGlassReason
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
@@ -218,40 +219,92 @@ public sealed class RekorVerificationService : IRekorVerificationService
|
||||
System.Diagnostics.Stopwatch stopwatch,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Offline verification using stored inclusion proof
|
||||
var opts = _options.Value;
|
||||
var proof = entry.InclusionProof!;
|
||||
|
||||
// Verify inclusion proof structure
|
||||
if (entry.LogIndex != proof.LeafIndex)
|
||||
{
|
||||
return Task.FromResult(OfflineProofFailureWithOptionalBreakGlass(
|
||||
entry,
|
||||
startTime,
|
||||
stopwatch,
|
||||
opts,
|
||||
RekorVerificationFailureCode.LogIndexMismatch,
|
||||
$"Stored proof leaf index {proof.LeafIndex} does not match entry log index {entry.LogIndex}"));
|
||||
}
|
||||
|
||||
if (!IsValidInclusionProof(proof))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(RekorVerificationResult.Failure(
|
||||
entry.Uuid,
|
||||
"Invalid stored inclusion proof structure",
|
||||
RekorVerificationFailureCode.InvalidInclusionProof,
|
||||
return Task.FromResult(OfflineProofFailureWithOptionalBreakGlass(
|
||||
entry,
|
||||
startTime,
|
||||
signatureValid: true,
|
||||
inclusionProofValid: false,
|
||||
duration: stopwatch.Elapsed));
|
||||
stopwatch,
|
||||
opts,
|
||||
RekorVerificationFailureCode.InvalidInclusionProof,
|
||||
"Invalid stored inclusion proof structure"));
|
||||
}
|
||||
|
||||
// Verify Merkle inclusion (simplified - actual impl would do full proof verification)
|
||||
if (!VerifyMerkleInclusion(entry.EntryBodyHash, proof))
|
||||
if (!TryParseSha256Hash(entry.EntryBodyHash, out var entryBodyDigest))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_metrics.RecordInclusionProofFailure();
|
||||
return Task.FromResult(RekorVerificationResult.Failure(
|
||||
entry.Uuid,
|
||||
"Merkle inclusion proof verification failed",
|
||||
RekorVerificationFailureCode.InvalidInclusionProof,
|
||||
return Task.FromResult(OfflineProofFailureWithOptionalBreakGlass(
|
||||
entry,
|
||||
startTime,
|
||||
signatureValid: true,
|
||||
inclusionProofValid: false,
|
||||
duration: stopwatch.Elapsed));
|
||||
stopwatch,
|
||||
opts,
|
||||
RekorVerificationFailureCode.BodyHashMismatch,
|
||||
"Entry body hash is missing or invalid"));
|
||||
}
|
||||
|
||||
if (!TryParseSha256Hash(proof.RootHash, out var expectedRootHash))
|
||||
{
|
||||
return Task.FromResult(OfflineProofFailureWithOptionalBreakGlass(
|
||||
entry,
|
||||
startTime,
|
||||
stopwatch,
|
||||
opts,
|
||||
RekorVerificationFailureCode.InvalidInclusionProof,
|
||||
"Stored inclusion proof root hash is invalid"));
|
||||
}
|
||||
|
||||
var proofHashes = new List<byte[]>(proof.Hashes.Count);
|
||||
foreach (var hash in proof.Hashes)
|
||||
{
|
||||
if (!TryParseSha256Hash(hash, out var hashBytes))
|
||||
{
|
||||
return Task.FromResult(OfflineProofFailureWithOptionalBreakGlass(
|
||||
entry,
|
||||
startTime,
|
||||
stopwatch,
|
||||
opts,
|
||||
RekorVerificationFailureCode.InvalidInclusionProof,
|
||||
"Stored inclusion proof contains invalid path hash"));
|
||||
}
|
||||
|
||||
proofHashes.Add(hashBytes);
|
||||
}
|
||||
|
||||
if (opts.RequireOfflineProofVerification)
|
||||
{
|
||||
var leafHash = MerkleProofVerifier.HashLeaf(entryBodyDigest);
|
||||
var verified = MerkleProofVerifier.VerifyInclusion(
|
||||
leafHash,
|
||||
proof.LeafIndex,
|
||||
proof.TreeSize,
|
||||
proofHashes,
|
||||
expectedRootHash);
|
||||
|
||||
if (!verified)
|
||||
{
|
||||
return Task.FromResult(OfflineProofFailureWithOptionalBreakGlass(
|
||||
entry,
|
||||
startTime,
|
||||
stopwatch,
|
||||
opts,
|
||||
RekorVerificationFailureCode.InvalidInclusionProof,
|
||||
"Merkle inclusion proof verification failed"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check time skew
|
||||
var opts = _options.Value;
|
||||
var timeSkewResult = CheckTimeSkew(entry, opts.MaxTimeSkewSeconds);
|
||||
if (!timeSkewResult.IsValid)
|
||||
{
|
||||
@@ -464,24 +517,90 @@ public sealed class RekorVerificationService : IRekorVerificationService
|
||||
{
|
||||
return proof.LeafIndex >= 0 &&
|
||||
proof.TreeSize > proof.LeafIndex &&
|
||||
proof.Hashes.Count > 0 &&
|
||||
!string.IsNullOrEmpty(proof.RootHash);
|
||||
}
|
||||
|
||||
private static bool VerifyMerkleInclusion(string? entryBodyHash, StoredInclusionProof proof)
|
||||
private RekorVerificationResult OfflineProofFailureWithOptionalBreakGlass(
|
||||
RekorEntryReference entry,
|
||||
DateTimeOffset startTime,
|
||||
System.Diagnostics.Stopwatch stopwatch,
|
||||
RekorVerificationOptions options,
|
||||
RekorVerificationFailureCode failureCode,
|
||||
string reason)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entryBodyHash))
|
||||
stopwatch.Stop();
|
||||
_metrics.RecordInclusionProofFailure();
|
||||
|
||||
if (options.AllowOfflineBreakGlassVerification)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Offline Rekor verification accepted via break-glass for entry {Uuid}: {Reason}",
|
||||
entry.Uuid,
|
||||
reason);
|
||||
return new RekorVerificationResult
|
||||
{
|
||||
EntryUuid = entry.Uuid,
|
||||
IsValid = true,
|
||||
SignatureValid = true,
|
||||
InclusionProofValid = false,
|
||||
TimeSkewValid = true,
|
||||
VerifiedAt = startTime,
|
||||
Duration = stopwatch.Elapsed,
|
||||
UsedBreakGlassMode = true,
|
||||
BreakGlassReason = reason
|
||||
};
|
||||
}
|
||||
|
||||
return RekorVerificationResult.Failure(
|
||||
entry.Uuid,
|
||||
reason,
|
||||
failureCode,
|
||||
startTime,
|
||||
signatureValid: true,
|
||||
inclusionProofValid: false,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
private static bool TryParseSha256Hash(string? value, out byte[] hash)
|
||||
{
|
||||
hash = Array.Empty<byte>();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Simplified Merkle inclusion verification
|
||||
// Real implementation would:
|
||||
// 1. Compute leaf hash from entry body
|
||||
// 2. Walk up the tree using sibling hashes
|
||||
// 3. Compare computed root with stored root
|
||||
var normalized = value.Trim();
|
||||
if (normalized.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = normalized["sha256:".Length..];
|
||||
}
|
||||
|
||||
// For now, just validate structure
|
||||
return proof.Hashes.All(h => !string.IsNullOrEmpty(h));
|
||||
if (normalized.Length % 2 == 0 && normalized.All(IsHexChar))
|
||||
{
|
||||
try
|
||||
{
|
||||
hash = Convert.FromHexString(normalized);
|
||||
return hash.Length == 32;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
hash = Convert.FromBase64String(normalized);
|
||||
return hash.Length == 32;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHexChar(char c) =>
|
||||
(c >= '0' && c <= '9') ||
|
||||
(c >= 'a' && c <= 'f') ||
|
||||
(c >= 'A' && c <= 'F');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user