save work

This commit is contained in:
StellaOps Bot
2025-12-19 07:28:23 +02:00
parent 6410a6d082
commit 2eafe98d44
97 changed files with 5040 additions and 1443 deletions

View File

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

View File

@@ -23,6 +23,7 @@ using Xunit;
namespace StellaOps.Attestor.Tests;
[Collection("SmSoftGate")]
public sealed class AttestorSigningServiceTests : IDisposable
{
private readonly List<string> _temporaryPaths = new();

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ public sealed class CheckpointSignatureVerifierTests
private const string ValidCheckpointBody = """
rekor.sigstore.dev - 2605736670972794746
123456789
abc123def456ghi789jkl012mno345pqr678stu901vwx234=
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
1702345678
""";

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
using Xunit;
namespace StellaOps.Attestor.Tests.Signing;
[CollectionDefinition("SmSoftGate", DisableParallelization = true)]
public sealed class SmSoftGateCollection
{
}

View File

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