mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 21:13:12 +00:00
247 lines
8.8 KiB
C#
247 lines
8.8 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Amazon.SecretsManager;
|
|
using Amazon.SecretsManager.Model;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using SeaHaven.Services.Configuration;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
namespace Api.SeaHavenIndustries.Infrastructure
|
|
{
|
|
public sealed class AwsWorkOrderWebhookSecretProvider : IWorkOrderWebhookSecretProvider
|
|
{
|
|
private const int MaxKeys = 16;
|
|
private const int MaxKidLength = 128;
|
|
private const int SecretHexLength = 64;
|
|
private const int MaxSecretDocumentLength = 32 * 1024;
|
|
|
|
private readonly IAmazonSecretsManager _client;
|
|
private readonly IOptionsMonitor<WorkOrderWebhookOptions> _options;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<AwsWorkOrderWebhookSecretProvider> _logger;
|
|
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
|
private CachedKeyset? _cache;
|
|
|
|
public AwsWorkOrderWebhookSecretProvider(
|
|
IAmazonSecretsManager client,
|
|
IOptionsMonitor<WorkOrderWebhookOptions> options,
|
|
TimeProvider timeProvider,
|
|
ILogger<AwsWorkOrderWebhookSecretProvider> logger)
|
|
{
|
|
_client = client;
|
|
_options = options;
|
|
_timeProvider = timeProvider;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<WorkOrderWebhookSecretResult> GetSecretAsync(
|
|
string keyId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(keyId) || keyId.Length > MaxKidLength)
|
|
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey);
|
|
|
|
var observed = _cache;
|
|
var now = _timeProvider.GetUtcNow();
|
|
if (observed != null && observed.ExpiresAt > now)
|
|
{
|
|
var match = observed.Lookup(keyId);
|
|
if (match != null)
|
|
return FoundCopy(match);
|
|
}
|
|
|
|
var refresh = await RefreshAsync(observed, cancellationToken);
|
|
if (refresh.Status == RefreshStatus.Unavailable)
|
|
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.Unavailable);
|
|
|
|
var keyset = refresh.Keyset;
|
|
var lookup = keyset?.Lookup(keyId);
|
|
if (lookup != null)
|
|
return FoundCopy(lookup);
|
|
|
|
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey);
|
|
}
|
|
|
|
private static WorkOrderWebhookSecretResult FoundCopy(byte[] secret) =>
|
|
new(WorkOrderWebhookSecretStatus.Found, (byte[])secret.Clone());
|
|
|
|
private async Task<RefreshResult> RefreshAsync(
|
|
CachedKeyset? observed,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var secretId = _options.CurrentValue.SecretId;
|
|
if (string.IsNullOrWhiteSpace(secretId))
|
|
return new RefreshResult(RefreshStatus.Unavailable, null);
|
|
|
|
await _refreshLock.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
if (!ReferenceEquals(_cache, observed))
|
|
return new RefreshResult(RefreshStatus.Refreshed, _cache);
|
|
|
|
CachedKeyset? replacement;
|
|
try
|
|
{
|
|
var response = await _client.GetSecretValueAsync(
|
|
new GetSecretValueRequest { SecretId = secretId },
|
|
cancellationToken);
|
|
replacement = ParseKeyset(response);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
_logger.LogError("Work-order webhook signing secret retrieval failed.");
|
|
return new RefreshResult(RefreshStatus.Unavailable, null);
|
|
}
|
|
|
|
if (replacement == null)
|
|
return new RefreshResult(RefreshStatus.Unavailable, null);
|
|
|
|
var options = _options.CurrentValue;
|
|
var ttlSeconds = Math.Clamp(options.SecretCacheSeconds, 1, 300);
|
|
replacement.SetExpiry(_timeProvider.GetUtcNow().AddSeconds(ttlSeconds));
|
|
ReplaceCache(observed, replacement);
|
|
return new RefreshResult(RefreshStatus.Refreshed, replacement);
|
|
}
|
|
finally
|
|
{
|
|
_refreshLock.Release();
|
|
}
|
|
}
|
|
|
|
private void ReplaceCache(CachedKeyset? prior, CachedKeyset replacement)
|
|
{
|
|
if (Interlocked.CompareExchange(ref _cache, replacement, prior) == prior)
|
|
{
|
|
if (prior != null)
|
|
prior.Zero();
|
|
}
|
|
else
|
|
{
|
|
replacement.Zero();
|
|
}
|
|
}
|
|
|
|
private enum RefreshStatus
|
|
{
|
|
Refreshed,
|
|
Unavailable
|
|
}
|
|
|
|
private readonly record struct RefreshResult(RefreshStatus Status, CachedKeyset? Keyset);
|
|
|
|
private static CachedKeyset? ParseKeyset(GetSecretValueResponse response)
|
|
{
|
|
if (string.IsNullOrEmpty(response.SecretString)
|
|
|| response.SecretString.Length > MaxSecretDocumentLength)
|
|
return null;
|
|
|
|
Dictionary<string, byte[]>? byKid = null;
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(response.SecretString);
|
|
if (!document.RootElement.TryGetProperty("keys", out var keysElement)
|
|
|| keysElement.ValueKind != JsonValueKind.Array)
|
|
return null;
|
|
|
|
byKid = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
|
foreach (var item in keysElement.EnumerateArray())
|
|
{
|
|
if (byKid.Count >= MaxKeys)
|
|
return InvalidKeyset(byKid);
|
|
if (item.ValueKind != JsonValueKind.Object)
|
|
return InvalidKeyset(byKid);
|
|
if (!item.TryGetProperty("kid", out var kidElement)
|
|
|| kidElement.ValueKind != JsonValueKind.String)
|
|
return InvalidKeyset(byKid);
|
|
if (!item.TryGetProperty("secret", out var secretElement)
|
|
|| secretElement.ValueKind != JsonValueKind.String)
|
|
return InvalidKeyset(byKid);
|
|
|
|
var kid = kidElement.GetString();
|
|
var secret = secretElement.GetString();
|
|
if (string.IsNullOrWhiteSpace(kid)
|
|
|| kid.Length > MaxKidLength
|
|
|| secret == null
|
|
|| secret.Length != SecretHexLength
|
|
|| !IsAsciiHex(secret))
|
|
return InvalidKeyset(byKid);
|
|
|
|
if (byKid.ContainsKey(kid))
|
|
return InvalidKeyset(byKid);
|
|
byKid.Add(kid, Encoding.UTF8.GetBytes(secret));
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
if (byKid != null)
|
|
ZeroSecrets(byKid);
|
|
return null;
|
|
}
|
|
|
|
if (byKid.Count == 0)
|
|
return null;
|
|
|
|
return new CachedKeyset(byKid);
|
|
}
|
|
|
|
private static CachedKeyset? InvalidKeyset(Dictionary<string, byte[]> byKid)
|
|
{
|
|
ZeroSecrets(byKid);
|
|
return null;
|
|
}
|
|
|
|
private static void ZeroSecrets(Dictionary<string, byte[]> byKid)
|
|
{
|
|
foreach (var secret in byKid.Values)
|
|
CryptographicOperations.ZeroMemory(secret);
|
|
}
|
|
|
|
private static bool IsAsciiHex(string value)
|
|
{
|
|
for (var i = 0; i < value.Length; i++)
|
|
{
|
|
var c = value[i];
|
|
if (!IsHexDigit(c))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static bool IsHexDigit(char c) =>
|
|
(uint)(c - '0') <= 9u
|
|
|| (uint)(c - 'a') <= 5u
|
|
|| (uint)(c - 'A') <= 5u;
|
|
|
|
private sealed class CachedKeyset
|
|
{
|
|
private readonly Dictionary<string, byte[]> _byKid;
|
|
private DateTimeOffset _expiresAt;
|
|
|
|
public DateTimeOffset ExpiresAt => _expiresAt;
|
|
|
|
public CachedKeyset(Dictionary<string, byte[]> byKid)
|
|
{
|
|
_byKid = byKid;
|
|
}
|
|
|
|
public void SetExpiry(DateTimeOffset expiresAt) => _expiresAt = expiresAt;
|
|
|
|
public byte[]? Lookup(string kid)
|
|
{
|
|
_byKid.TryGetValue(kid, out var secret);
|
|
return secret;
|
|
}
|
|
|
|
public void Zero()
|
|
{
|
|
ZeroSecrets(_byKid);
|
|
}
|
|
}
|
|
}
|
|
}
|