diff --git a/src/Digdir.Library.Dialogporten.WebApiClient/Common/ClaimsPrincipalExtensions.cs b/src/Digdir.Library.Dialogporten.WebApiClient/Common/ClaimsPrincipalExtensions.cs new file mode 100644 index 000000000..5a6818763 --- /dev/null +++ b/src/Digdir.Library.Dialogporten.WebApiClient/Common/ClaimsPrincipalExtensions.cs @@ -0,0 +1,66 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; + +namespace Altinn.ApiClients.Dialogporten.Common; + +internal static class ClaimsPrincipalExtensions +{ + public static bool VerifyDialogId(this ClaimsPrincipal claimsPrincipal, Guid dialogId) + { + const string dialogIdClaimName = "i"; + return claimsPrincipal.TryGetClaimValue(dialogIdClaimName, out var dialogIdString) + && Guid.TryParse(dialogIdString, out var dialogIdClaim) + && dialogId == dialogIdClaim; + } + + public static bool VerifyActions(this ClaimsPrincipal claimsPrincipal, params string[] requiredActions) + { + const string actionsClaimName = "a"; + const string actionSeparator = ";"; + if (requiredActions.Length == 0) + { + return true; + } + + if (!claimsPrincipal.TryGetClaimValue(actionsClaimName, out var actions)) + { + return false; + } + + var requiredActionsLength = requiredActions + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + var intersectionLength = actions + .Split(actionSeparator) + .Intersect(requiredActions, StringComparer.OrdinalIgnoreCase) + .Count(); + return intersectionLength == requiredActionsLength; + } + + public static bool VerifyExpirationTime(this ClaimsPrincipal claimsPrincipal, IClock clock, TimeSpan clockSkew) + { + const string expirationTimeClaimName = "exp"; + var exp = claimsPrincipal.TryGetClaimValue(expirationTimeClaimName, out var exps) + && long.TryParse(exps, out var expl) + ? DateTimeOffset.FromUnixTimeSeconds(expl).Add(clockSkew) + : DateTimeOffset.MinValue; + return clock.UtcNow <= exp; + } + + public static bool VerifyNotValidBefore(this ClaimsPrincipal claimsPrincipal, IClock clock, TimeSpan clockSkew) + { + const string notValidBeforeClaimName = "nbf"; + var nbf = claimsPrincipal.TryGetClaimValue(notValidBeforeClaimName, out var nbfs) + && long.TryParse(nbfs, out var nbfl) + ? DateTimeOffset.FromUnixTimeSeconds(nbfl).Add(-clockSkew) + : DateTimeOffset.MaxValue; + return nbf <= clock.UtcNow; + } + + public static bool TryGetClaimValue(this ClaimsPrincipal claimsPrincipal, string claimType, + [NotNullWhen(true)] out string? value) + { + value = claimsPrincipal.FindFirst(claimType)?.Value; + return value is not null; + } +} diff --git a/src/Digdir.Library.Dialogporten.WebApiClient/Digdir.Library.Dialogporten.WebApiClient.csproj b/src/Digdir.Library.Dialogporten.WebApiClient/Digdir.Library.Dialogporten.WebApiClient.csproj index 79016e963..77306e2d1 100644 --- a/src/Digdir.Library.Dialogporten.WebApiClient/Digdir.Library.Dialogporten.WebApiClient.csproj +++ b/src/Digdir.Library.Dialogporten.WebApiClient/Digdir.Library.Dialogporten.WebApiClient.csproj @@ -34,6 +34,7 @@ + diff --git a/src/Digdir.Library.Dialogporten.WebApiClient/IDialogTokenValidator.cs b/src/Digdir.Library.Dialogporten.WebApiClient/IDialogTokenValidator.cs index 1b175f10e..ebcfadbb2 100644 --- a/src/Digdir.Library.Dialogporten.WebApiClient/IDialogTokenValidator.cs +++ b/src/Digdir.Library.Dialogporten.WebApiClient/IDialogTokenValidator.cs @@ -5,7 +5,17 @@ namespace Altinn.ApiClients.Dialogporten; public interface IDialogTokenValidator { - IValidationResult Validate(ReadOnlySpan token); + IValidationResult Validate(ReadOnlySpan token, + Guid? dialogId = null, + string[]? requiredActions = null, + DialogTokenValidationParameters? options = null); +} + +public class DialogTokenValidationParameters +{ + public static DialogTokenValidationParameters Default { get; } = new(); + public bool ValidateLifetime { get; set; } = true; + public TimeSpan ClockSkew { get; set; } = TimeSpan.FromSeconds(10); } public interface IValidationResult diff --git a/src/Digdir.Library.Dialogporten.WebApiClient/Services/DialogTokenValidator.cs b/src/Digdir.Library.Dialogporten.WebApiClient/Services/DialogTokenValidator.cs index 9d89ccd91..ac1968498 100644 --- a/src/Digdir.Library.Dialogporten.WebApiClient/Services/DialogTokenValidator.cs +++ b/src/Digdir.Library.Dialogporten.WebApiClient/Services/DialogTokenValidator.cs @@ -21,9 +21,13 @@ public DialogTokenValidator(IEdDsaSecurityKeysCache publicKeysCache, IClock cloc _clock = clock; } - public IValidationResult Validate(ReadOnlySpan token) + public IValidationResult Validate(ReadOnlySpan token, + Guid? dialogId = null, + string[]? requiredActions = null, + DialogTokenValidationParameters? options = null) { const string tokenPropertyName = "token"; + options ??= DialogTokenValidationParameters.Default; var validationResult = new DefaultValidationResult(); Span tokenDecodeBuffer = stackalloc byte[Base64Url.GetMaxDecodedLength(token.Length)]; @@ -39,9 +43,24 @@ public IValidationResult Validate(ReadOnlySpan token) validationResult.AddError(tokenPropertyName, "Invalid signature"); } - if (!VerifyExpiration(decodedTokenParts)) + if (options.ValidateLifetime && !claimsPrincipal.VerifyNotValidBefore(_clock, options.ClockSkew)) { - validationResult.AddError(tokenPropertyName, "Token has expired"); + validationResult.AddError(tokenPropertyName, "Invalid nbf"); + } + + if (options.ValidateLifetime && !claimsPrincipal.VerifyExpirationTime(_clock, options.ClockSkew)) + { + validationResult.AddError(tokenPropertyName, "Invalid exp"); + } + + if (dialogId.HasValue && !validationResult.ClaimsPrincipal.VerifyDialogId(dialogId.Value)) + { + validationResult.AddError(tokenPropertyName, "Invalid dialog ID"); + } + + if (requiredActions is not null && !validationResult.ClaimsPrincipal.VerifyActions(requiredActions)) + { + validationResult.AddError(tokenPropertyName, "Invalid actions"); } return validationResult; @@ -145,28 +164,6 @@ private bool VerifySignature( && SignatureAlgorithm.Ed25519.Verify(publicKey, signedPart, decodedTokenParts.Signature); } - private bool VerifyExpiration(JwksTokenParts decodedTokenParts) - { - const string expiresPropertyName = "exp"; - if (!TryGetPropertyValue(decodedTokenParts.Body, expiresPropertyName, out var expiresSpan)) - { - return false; - } - - if (!Utf8Parser.TryParse(expiresSpan, out long expiresUnixTimeSeconds, out var bytesConsumed)) - { - return false; - } - - if (bytesConsumed != expiresSpan.Length) - { - return false; - } - - var expires = DateTimeOffset.FromUnixTimeSeconds(expiresUnixTimeSeconds); - return expires >= _clock.UtcNow; - } - private static bool TryDecodePart(ReadOnlySpan tokenPart, Span buffer, out ReadOnlySpan span, out int length) { span = default; diff --git a/src/Digdir.Library.Dialogporten.WebApiClient/Services/EdDsaSecurityKeysCacheService.cs b/src/Digdir.Library.Dialogporten.WebApiClient/Services/EdDsaSecurityKeysCacheService.cs index be771d660..817c387bd 100644 --- a/src/Digdir.Library.Dialogporten.WebApiClient/Services/EdDsaSecurityKeysCacheService.cs +++ b/src/Digdir.Library.Dialogporten.WebApiClient/Services/EdDsaSecurityKeysCacheService.cs @@ -20,6 +20,12 @@ internal sealed class DefaultEdDsaSecurityKeysCache : IEdDsaSecurityKeysCache private List _publicKeys = []; public ReadOnlyCollection PublicKeys => _publicKeys.AsReadOnly(); internal void SetPublicKeys(List publicKeys) => _publicKeys = publicKeys; + + public DefaultEdDsaSecurityKeysCache() { } + internal DefaultEdDsaSecurityKeysCache(IEnumerable publicKeys) + { + _publicKeys = publicKeys.ToList(); + } } internal sealed class EdDsaSecurityKeysCacheService : BackgroundService diff --git a/src/Digdir.Tool.Dialogporten.Benchmarks/Digdir.Tool.Dialogporten.Benchmarks.csproj b/src/Digdir.Tool.Dialogporten.Benchmarks/Digdir.Tool.Dialogporten.Benchmarks.csproj index 7dbde8269..5a9df0be2 100644 --- a/src/Digdir.Tool.Dialogporten.Benchmarks/Digdir.Tool.Dialogporten.Benchmarks.csproj +++ b/src/Digdir.Tool.Dialogporten.Benchmarks/Digdir.Tool.Dialogporten.Benchmarks.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Digdir.Tool.Dialogporten.Benchmarks/Program.cs b/src/Digdir.Tool.Dialogporten.Benchmarks/Program.cs index 587aee869..e098ce13d 100644 --- a/src/Digdir.Tool.Dialogporten.Benchmarks/Program.cs +++ b/src/Digdir.Tool.Dialogporten.Benchmarks/Program.cs @@ -1,3 +1,3 @@ using BenchmarkDotNet.Running; using Digdir.Tool.Dialogporten.Benchmarks; -BenchmarkRunner.Run(); +BenchmarkRunner.Run(); diff --git a/src/Digdir.Tool.Dialogporten.Benchmarks/TokenValidatorBenchmarks.cs b/src/Digdir.Tool.Dialogporten.Benchmarks/TokenValidatorBenchmarks.cs new file mode 100644 index 000000000..84fb50930 --- /dev/null +++ b/src/Digdir.Tool.Dialogporten.Benchmarks/TokenValidatorBenchmarks.cs @@ -0,0 +1,41 @@ +using System.Buffers.Text; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Altinn.ApiClients.Dialogporten; +using Altinn.ApiClients.Dialogporten.Common; +using Altinn.ApiClients.Dialogporten.Services; +using BenchmarkDotNet.Attributes; +using NSec.Cryptography; + +namespace Digdir.Tool.Dialogporten.Benchmarks; + +[MemoryDiagnoser] +[SuppressMessage("Performance", "CA1822:Mark members as static")] +public class TokenValidatorBenchmarks +{ + private const string ValidTimeStampString = "2025-02-14T09:00:00Z"; + private const string DialogToken = + "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImRwLXN0YWdpbmctMjQwMzIyLW81eW1uIn0.eyJqdGkiOiIzOGNmZGNiOS0zODhiLTQ3YjgtYTFiZi05ZjE1YjI4MTk4OTQiLCJjIjoidXJuOmFsdGlubjpwZXJzb246aWRlbnRpZmllci1ubzoxNDg4NjQ5ODIyNiIsImwiOjMsInAiOiJ1cm46YWx0aW5uOnBlcnNvbjppZGVudGlmaWVyLW5vOjE0ODg2NDk4MjI2IiwicyI6InVybjphbHRpbm46cmVzb3VyY2U6ZGFnbC1jb3JyZXNwb25kZW5jZSIsImkiOiIwMTk0ZmU4Mi05MjgwLTc3YTUtYTdjZC01ZmYwZTZhNmZhMDciLCJhIjoicmVhZCIsImlzcyI6Imh0dHBzOi8vcGxhdGZvcm0udHQwMi5hbHRpbm4ubm8vZGlhbG9ncG9ydGVuL2FwaS92MSIsImlhdCI6MTczOTUyMzM2NywibmJmIjoxNzM5NTIzMzY3LCJleHAiOjE3Mzk1MjM5Njd9.O_f-RJhRPT7B76S7aOGw6jfxKDki3uJQLLC8nVlcNVJWFIOQUsy6gU4bG1ZdqoMBZPvb2K2X4I5fGpHW9dQMAA"; + private static readonly PublicKeyPair[] ValidPublicKeyPairs = + [ + new("dp-staging-240322-o5ymn", ToPublicKey("zs9hR9oqgf53th2lTdrBq3C1TZ9UlR-HVJOiUpWV63o")), + new("dp-staging-240322-rju3g", ToPublicKey("23Sijekv5ATW4sSEiRPzL_rXH-zRV8MK8jcs5ExCmSU")) + ]; + private static readonly DialogTokenValidator _sut = new( + new DefaultEdDsaSecurityKeysCache(ValidPublicKeyPairs), + new BenchmarkClock(ValidTimeStampString)); + + + [Benchmark] + public IValidationResult ValidateDialogToken() => _sut.Validate(DialogToken); + + private static PublicKey ToPublicKey(string key) + => PublicKey.Import(SignatureAlgorithm.Ed25519, Base64Url.DecodeFromChars(key), KeyBlobFormat.RawPublicKey); + + private sealed class BenchmarkClock(DateTimeOffset utcNow) : IClock + { + public DateTimeOffset UtcNow { get; } = utcNow; + public BenchmarkClock(string input) : this(DateTimeOffset.Parse(input, CultureInfo.InvariantCulture)) { } + } +} + diff --git a/tests/Digdir.Library.Dialogporten.WebApiClient.Unit.Tests/DialogTokenValidatorTests.cs b/tests/Digdir.Library.Dialogporten.WebApiClient.Unit.Tests/DialogTokenValidatorTests.cs index 8ab884276..023d83ee4 100644 --- a/tests/Digdir.Library.Dialogporten.WebApiClient.Unit.Tests/DialogTokenValidatorTests.cs +++ b/tests/Digdir.Library.Dialogporten.WebApiClient.Unit.Tests/DialogTokenValidatorTests.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Text; using System.Text.Json; +using Altinn.ApiClients.Dialogporten; using Altinn.ApiClients.Dialogporten.Common; using Altinn.ApiClients.Dialogporten.Services; using NSec.Cryptography; @@ -12,7 +13,7 @@ namespace Digdir.Library.Dialogporten.WebApiClient.Unit.Tests; public class DialogTokenValidatorTests { - private const string ValidTimeStampString = "2025-02-14T09:00:00Z"; + private static readonly DateTimeOffset ValidTimeStamp = DateTimeOffset.Parse("2025-02-14T09:00:00Z", CultureInfo.InvariantCulture); private const string DialogToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImRwLXN0YWdpbmctMjQwMzIyLW81eW1uIn0.eyJqdGkiOiIzOGNmZGNiOS0zODhiLTQ3YjgtYTFiZi05ZjE1YjI4MTk4OTQiLCJjIjoidXJuOmFsdGlubjpwZXJzb246aWRlbnRpZmllci1ubzoxNDg4NjQ5ODIyNiIsImwiOjMsInAiOiJ1cm46YWx0aW5uOnBlcnNvbjppZGVudGlmaWVyLW5vOjE0ODg2NDk4MjI2IiwicyI6InVybjphbHRpbm46cmVzb3VyY2U6ZGFnbC1jb3JyZXNwb25kZW5jZSIsImkiOiIwMTk0ZmU4Mi05MjgwLTc3YTUtYTdjZC01ZmYwZTZhNmZhMDciLCJhIjoicmVhZCIsImlzcyI6Imh0dHBzOi8vcGxhdGZvcm0udHQwMi5hbHRpbm4ubm8vZGlhbG9ncG9ydGVuL2FwaS92MSIsImlhdCI6MTczOTUyMzM2NywibmJmIjoxNzM5NTIzMzY3LCJleHAiOjE3Mzk1MjM5Njd9.O_f-RJhRPT7B76S7aOGw6jfxKDki3uJQLLC8nVlcNVJWFIOQUsy6gU4bG1ZdqoMBZPvb2K2X4I5fGpHW9dQMAA"; private static readonly PublicKeyPair[] ValidPublicKeyPairs = @@ -25,9 +26,7 @@ public class DialogTokenValidatorTests public void ShouldReturnIsValid_GivenValidToken() { // Arrange - var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture), - ValidPublicKeyPairs); + var sut = GetSut(ValidTimeStamp, ValidPublicKeyPairs); // Act var result = sut.Validate(DialogToken); @@ -39,10 +38,8 @@ public void ShouldReturnIsValid_GivenValidToken() [Fact] public void ShouldThrowException_GivenNoPublicKeys() { - // Arrange - var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture)); + var sut = GetSut(ValidTimeStamp); // Assert Assert.Throws(() => sut.Validate(DialogToken)); @@ -52,9 +49,7 @@ public void ShouldThrowException_GivenNoPublicKeys() public void ShouldReturnError_GivenMalformedToken() { // Arrange - var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture), - ValidPublicKeyPairs); + var sut = GetSut(ValidTimeStamp, ValidPublicKeyPairs); // Act var result = sut.Validate("This.TokenIsMalformed...."); @@ -69,9 +64,7 @@ public void ShouldReturnError_GivenMalformedToken() public void ShouldReturnError_GivenInvalidToken() { // Arrange - var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture), - ValidPublicKeyPairs); + var sut = GetSut(ValidTimeStamp, ValidPublicKeyPairs); // Act var result = sut.Validate("This.TokenIs.Invalid"); @@ -81,13 +74,12 @@ public void ShouldReturnError_GivenInvalidToken() Assert.True(result.Errors.ContainsKey("token")); Assert.Contains("Invalid token format", result.Errors["token"]); } + [Fact] public void ShouldReturnError_GivenNoPublicKeyWithCorrectKeyId() { // Arrange - var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture), - ValidPublicKeyPairs); + var sut = GetSut(ValidTimeStamp, ValidPublicKeyPairs); var token = UpdateTokenHeader(DialogToken, "kid", "dp-testing-fake-kid"); // Act @@ -113,17 +105,32 @@ public void ShouldReturnError_GivenExpiredToken() // Assert Assert.False(result.IsValid); Assert.True(result.Errors.ContainsKey("token")); - Assert.Contains("Token has expired", result.Errors["token"]); + Assert.Contains("Invalid exp", result.Errors["token"]); } [Fact] - public void ShouldReturnError_GivenEmptyToken() + public void ShouldReturnError_WhenUsedBeforeNbf() { // Arrange var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture), + DateTimeOffset.Parse("2025-02-14T08:50:00Z", CultureInfo.InvariantCulture), ValidPublicKeyPairs); + // Act + var result = sut.Validate(DialogToken); + + // Assert + Assert.False(result.IsValid); + Assert.True(result.Errors.ContainsKey("token")); + Assert.Contains("Invalid nbf", result.Errors["token"]); + } + + [Fact] + public void ShouldReturnError_GivenEmptyToken() + { + // Arrange + var sut = GetSut(ValidTimeStamp, ValidPublicKeyPairs); + // Act var result = sut.Validate(""); @@ -137,9 +144,7 @@ public void ShouldReturnError_GivenEmptyToken() public void ShouldReturnError_GivenTokenWithWrongSignature() { // Arrange - var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture), - ValidPublicKeyPairs); + var sut = GetSut(ValidTimeStamp, ValidPublicKeyPairs); var token = UpdateTokenPayload(DialogToken, "l", "4"); @@ -156,9 +161,7 @@ public void ShouldReturnError_GivenTokenWithWrongSignature() public void ShouldReturnError_GivenTokenWithWrongAlg() { // Arrange - var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture), - ValidPublicKeyPairs); + var sut = GetSut(ValidTimeStamp, ValidPublicKeyPairs); var token = UpdateTokenHeader(DialogToken, "alg", "RS512"); @@ -181,9 +184,7 @@ public void ShouldReturnError_GivenMalformedJsonHeader() "kid": "dp-staging-240322-o5ymn" """u8; // Arrange - var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture), - ValidPublicKeyPairs); + var sut = GetSut(ValidTimeStamp, ValidPublicKeyPairs); var tokenParts = DialogToken.Split('.'); tokenParts[0] = Base64Url.EncodeToString(invalidHeader); @@ -207,9 +208,7 @@ public void ShouldReturnError_GivenMalformedJsonBody() "c": "urn:altinn:person:identifier-no:14886498226", """u8; // Arrange - var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture), - ValidPublicKeyPairs); + var sut = GetSut(ValidTimeStamp, ValidPublicKeyPairs); var tokenParts = DialogToken.Split('.'); tokenParts[1] = Base64Url.EncodeToString(invalidBody); @@ -225,12 +224,10 @@ public void ShouldReturnError_GivenMalformedJsonBody() } [Fact] - public void ShouldContainClaims_GivenValidTokenWithClaims() + public void ShouldReturnClaims_GivenValidTokenWithClaims() { // Arrange - var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture), - ValidPublicKeyPairs); + var sut = GetSut(ValidTimeStamp, ValidPublicKeyPairs); // Act var result = sut.Validate(DialogToken); @@ -260,9 +257,7 @@ public void ClaimsShouldBeNull_GivenInvalidTokenFormat() "c": "urn:altinn:person:identifier-no:14886498226", """u8; // Arrange - var sut = GetSut( - DateTimeOffset.Parse(ValidTimeStampString, CultureInfo.InvariantCulture), - ValidPublicKeyPairs); + var sut = GetSut(ValidTimeStamp, ValidPublicKeyPairs); var tokenParts = DialogToken.Split('.'); tokenParts[1] = Base64Url.EncodeToString(invalidBody); var token = string.Join(".", tokenParts); @@ -275,8 +270,42 @@ public void ClaimsShouldBeNull_GivenInvalidTokenFormat() Assert.Null(result.ClaimsPrincipal); } - private static DialogTokenValidator GetSut(DateTimeOffset simulatedNow, params PublicKeyPair[] publicKeyPairs) + [Fact] + public void ShouldReturnError_GivenTokenWithInvalidDialogId() + { + // Arrange + var wrongDialogId = new Guid("329491ca-a4e9-4460-8988-f2dc80ea39fe"); + var sut = GetSut(ValidTimeStamp, publicKeyPairs: ValidPublicKeyPairs); + + // Act + var result = sut.Validate(DialogToken, dialogId: wrongDialogId); + + // Assert + Assert.False(result.IsValid); + Assert.True(result.Errors.ContainsKey("token")); + Assert.Contains("Invalid dialog ID", result.Errors["token"]); + } + + [Fact] + public void ShouldReturnError_GivenTokenWithInvalidActions() + { + // Arrange + var sut = GetSut(ValidTimeStamp, publicKeyPairs: ValidPublicKeyPairs); + + // Act + var result = sut.Validate(DialogToken, requiredActions: ["write"]); + + // Assert + Assert.False(result.IsValid); + Assert.True(result.Errors.ContainsKey("token")); + Assert.Contains("Invalid actions", result.Errors["token"]); + } + + private static DialogTokenValidator GetSut( + DateTimeOffset simulatedNow, + params PublicKeyPair[] publicKeyPairs) { + DialogTokenValidationParameters.Default.ClockSkew = TimeSpan.Zero; var keyCache = Substitute.For(); var clock = Substitute.For(); keyCache.PublicKeys.Returns(new ReadOnlyCollection(publicKeyPairs));