shoc-backend/Api.SeaHavenIndustries/Infrastructure/AwsWorkOrderWebhookSecretProvider.cs
2026-07-27 16:33:06 -03:00

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