Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement caching #11

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ApiKeyGenerator.Tests/BasicKeyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public async Task TestAlgorithm(HashAlgorithmType? hashType)
Assert.AreEqual("Key ID is not properly formatted.", result4.Message);

// Try properly delimited garbage
var result5 = await validator.TryValidate(algorithm.Prefix + ApiKeyValidator.Encode(Guid.NewGuid().ToByteArray()) + $"{ApiKeyValidator.Separator}123" + algorithm.Suffix);
var result5 = await validator.TryValidate(algorithm.Prefix + EncryptionTools.Encode(Guid.NewGuid().ToByteArray()) + $"{ApiKeyValidator.Separator}123" + algorithm.Suffix);
Assert.IsNotNull(result5);
Assert.IsFalse(result5.Success);
Assert.AreEqual("Repository does not contain a key matching this ID.", result5.Message);
Expand Down
89 changes: 11 additions & 78 deletions src/ApiKeyValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@

namespace ApiKeyGenerator
{
public class ApiKeyValidator
/// <summary>
/// Represents a direct API key system that always validates every key and always
/// checks the data store for a stored key. For a more performant version of the
/// API key system, consider using CachedValidator.
/// </summary>
public class ApiKeyValidator : IApiKeyValidator
{
private readonly IApiKeyRepository _repository;

Expand Down Expand Up @@ -140,7 +145,7 @@ public bool TryParseKey(string key, ApiKeyAlgorithm algorithm, out ClientApiKey

// Extract the key ID and client secret
var keyId = key.Substring(algorithm.Prefix.Length, pos - algorithm.Prefix.Length);
if (!TryDecode(keyId, 16, out var keyBytes))
if (!EncryptionTools.TryDecode(keyId, 16, out var keyBytes))
{
message = "Key ID is not properly formatted.";
return false;
Expand Down Expand Up @@ -174,7 +179,7 @@ public async Task<string> GenerateApiKey(IPersistedApiKey persisted, ApiKeyAlgor
var rand = RandomNumberGenerator.Create();
var secretBytes = new byte[algorithm.ClientSecretLength];
rand.GetBytes(secretBytes);
var secret = Encode(secretBytes);
var secret = EncryptionTools.Encode(secretBytes);
string salt;
if (algorithm.Hash == HashAlgorithmType.BCrypt)
{
Expand All @@ -184,15 +189,15 @@ public async Task<string> GenerateApiKey(IPersistedApiKey persisted, ApiKeyAlgor
{
var saltBytes = new byte[algorithm.SaltLength];
rand.GetBytes(saltBytes);
salt = Encode(saltBytes);
salt = EncryptionTools.Encode(saltBytes);
}

// Construct client key
var clientKey = new ClientApiKey() { ApiKeyId = keyId, ClientSecret = secret };

// Fill information into persisted key
persisted.ApiKeyId = keyId;
persisted.Hash = Hash(algorithm, secret, salt);
persisted.Hash = EncryptionTools.Hash(algorithm, secret, salt);
persisted.Salt = salt;

// Save this API key into the repository
Expand All @@ -208,80 +213,8 @@ public async Task<string> GenerateApiKey(IPersistedApiKey persisted, ApiKeyAlgor
private bool TestKeys(ApiKeyAlgorithm algorithm, ClientApiKey clientApiKey, IPersistedApiKey persistedApiKey)
{
// Compute hash and see if it matches
var computedHash = Hash(algorithm, clientApiKey.ClientSecret, persistedApiKey.Salt);
var computedHash = EncryptionTools.Hash(algorithm, clientApiKey.ClientSecret, persistedApiKey.Salt);
return string.Equals(computedHash, persistedApiKey.Hash);
}

private static Tuple<byte[], byte[]> SecretAndSaltToBytes(ApiKeyAlgorithm algorithm, string secret, string salt)
{
var s1 = TryDecode(secret, algorithm.ClientSecretLength, out var secretBytes);
var s2 = TryDecode(salt, algorithm.SaltLength, out var saltBytes);
if (!s1 || !s2)
{
return new Tuple<byte[], byte[]>(null, null);
}

return new Tuple<byte[], byte[]>(secretBytes, saltBytes);
}

/// <summary>
/// Encode a series of bytes in the Ripple Base58 format
/// </summary>
/// <param name="bytes"></param>
/// <returns></returns>
public static string Encode(byte[] bytes)
{
return Base58Ripple.Encode(bytes);
}

internal static bool TryDecode(string text, int expectedLength, out byte[] bytes)
{
bytes = Array.Empty<byte>();

// You would think that a function called "TryDecode" would not throw an exception.
// However, you would be wrong.
try
{
bytes = new byte[expectedLength];
var success = Base58Ripple.TryDecode(text, bytes, out var numBytesWritten);
return success && numBytesWritten == expectedLength;
}
catch
{
return false;
}
}

private static string Hash(ApiKeyAlgorithm algorithm, string secret, string salt)
{
switch (algorithm.Hash)
{
case HashAlgorithmType.SHA256:
using (var sha256 = SHA256.Create())
{
var rawBytes = SecretAndSaltToBytes(algorithm, secret, salt);
var hashBytes = sha256.ComputeHash(rawBytes.Item1.Union(rawBytes.Item2).ToArray());
return Convert.ToBase64String(hashBytes);
}
case HashAlgorithmType.SHA512:
using (var sha512 = SHA512.Create())
{
var rawBytes = SecretAndSaltToBytes(algorithm, secret, salt);
var hashBytes = sha512.ComputeHash(rawBytes.Item1.Union(rawBytes.Item2).ToArray());
return Encode(hashBytes);
}
case HashAlgorithmType.BCrypt:
return BCrypt.Net.BCrypt.HashPassword(secret, salt);
case HashAlgorithmType.PBKDF2100K:
var pbkBytes = SecretAndSaltToBytes(algorithm, secret, salt);
using (var pbk = new Rfc2898DeriveBytes(pbkBytes.Item1, pbkBytes.Item2, 100_000))
{
var hash = pbk.GetBytes(64);
return Encode(hash);
}
}

throw new InvalidAlgorithmException($"Unknown hash type {algorithm.Hash}");
}
}
}
18 changes: 18 additions & 0 deletions src/BasicTimeProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;
using ApiKeyGenerator.Interfaces;

namespace ApiKeyGenerator
{
/// <summary>
/// Similar to TimeProvider in DotNet 8.0.
///
/// Provided to allow this library to be compatible with older versions of DotNet.
/// </summary>
public class BasicTimeProvider : ITimeProvider
{
public DateTimeOffset GetUtcNow()
{
return DateTimeOffset.UtcNow;
}
}
}
133 changes: 133 additions & 0 deletions src/CachedValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using ApiKeyGenerator.Interfaces;
using ApiKeyGenerator.Keys;

namespace ApiKeyGenerator
{
/// <summary>
/// For performance sensitive APIs where you are willing to make a compromise, you can use
/// this cached validator instead of the normal ApiKeyValidator. Here's why you might
/// consider using it.
///
/// * A healthy API key will be slow to validate (increases difficulty of rainbow table attacks)
/// * Algorithms like BCrypt and PBKDF2100K can take 70ms or more on 2023 hardware.
/// * Once a person has successfully validated with a specific API key from a specific IP address,
/// it is reasonable to expect that they will make another API call from that same address soon.
/// * In most systems, it is realistic to expect that revoking an API key will take a few moments
/// before all usages of that API key will stop.
///
/// Using this logic, you can define a caching system that works as follows:
/// * Validate all API keys the first time they are seen (takes 70ms; similar overhead to
/// establishing an SSL connection).
/// * Keep track of a SHA512 hash of the IP address and the key, and the time it was last validated.
/// * As long as calls continue to be made by that IP address with that same API key, allow them
/// to succeed.
/// * If the last validation time of an API key is > N1 seconds, start a background task to update
/// the verification cache, BUT continue to allow API calls to succeed.
/// * If the last validation time of an API key is > N2 seconds, force a full re-validation to
/// occur and discard the cached information.
///
/// The end result of this system is that well-behaved clients who make multiple API calls from
/// the same server in a row and do not expire their keys often will see fewer API key validation
/// delays in their API calls.
///
/// Be sure to notify customers that expiring an API key may take N * 2 seconds to propagate across
/// all systems that use this cached API validator.
///
/// Full explanation:
/// https://tedspence.com/caching-strategies-for-authentication-8346a040234d
/// </summary>
public class CachedValidator
{
private readonly IApiKeyRepository _repository;
private readonly IApiKeyValidator _validator;
private readonly int _cacheDurationMs;
private ConcurrentDictionary<string, CachedApiKeyResult> _cache;
private readonly ITimeProvider _timeProvider;
private readonly int _backgroundVerificationWindowMs;

public CachedValidator(IApiKeyRepository repository, IApiKeyValidator validator,
ITimeProvider timeProvider, int cacheDurationMs, int backgroundVerificationWindowMs)
{
_repository = repository;
_validator = validator;
_cacheDurationMs = cacheDurationMs;
_backgroundVerificationWindowMs = backgroundVerificationWindowMs;
_timeProvider = timeProvider;
_cache = new ConcurrentDictionary<string, CachedApiKeyResult>();
}

/// <summary>
/// Validate a client's API key string. If successful, returns the matching persisted API key with all
/// relevant claims information from your persistent storage. If unable to validate, returns information that
/// can assist the developer in understanding why their key could not be validated. Consult your security
/// professionals to identify which diagnostic information should be exposed to your end users.
///
/// If a caller makes multiple calls from the same source address in sequence
/// </summary>
/// <param name="clientApiKeyString">The raw client API key string as provided to your API</param>
/// <param name="remoteAddress">The remote IPv4 or IPv6 address of the client using this API key</param>
/// <returns>A result object with information about validation</returns>
public async Task<ApiKeyResult> TryValidate(string clientApiKeyString, string remoteAddress)
{
var now = _timeProvider.GetUtcNow();

// Check the cache to see if this key has been seen from this remote address recently
var hashtag = EncryptionTools.QuickStringHash(remoteAddress + ":" + clientApiKeyString);
if (_cache.TryGetValue(hashtag, out var cacheEntry))
{
var age = (now - cacheEntry.LastVerified).TotalMilliseconds;

// If this value was last seen within the cache window, it's okay to return the result
if (age < _backgroundVerificationWindowMs)
{
return cacheEntry.Result;
}

// This cache key is old enough that we must start a background task to verify it.
// We will start the task, but not await the result!
if (cacheEntry.BackgroundVerificationTask == null)
{
cacheEntry.BackgroundVerificationTask = VerifyApiKey(clientApiKeyString);
}

// If the cache entry is still within the cache window, we can return the previous
// result.
if (age < _cacheDurationMs)
{
return cacheEntry.Result;
}

// We are no longer within the caching window.
// We must await the background verification task, update the cache entry, and give
// the result back to the caller.
var backgroundResult = await cacheEntry.BackgroundVerificationTask;
cacheEntry.Result = backgroundResult;
cacheEntry.LastVerified = _timeProvider.GetUtcNow();
cacheEntry.BackgroundVerificationTask = null;
return cacheEntry.Result;
}

// First time we have seen this remote address / API key combo. Validate it, then save
// it in the cache.
var result = await VerifyApiKey(clientApiKeyString);
var entry = new CachedApiKeyResult()
{
AddressKeyHash = hashtag,
LastVerified = _timeProvider.GetUtcNow(),
Result = result,
BackgroundVerificationTask = null,
};
_cache[hashtag] = entry;
return entry.Result;
}

private async Task<ApiKeyResult> VerifyApiKey(string clientApiKeyString)
{
var result = await _validator.TryValidate(clientApiKeyString);
return result;
}
}
}
Loading