save checkpoint. addition features and their state. check some ofthem

This commit is contained in:
master
2026-02-10 07:54:44 +02:00
parent 4bdc298ec1
commit 5593212b41
211 changed files with 10248 additions and 1208 deletions

View File

@@ -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`). |

View File

@@ -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.");
}
}

View File

@@ -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;
}
}

View File

@@ -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. |

View File

@@ -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
};
}

View File

@@ -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');
}