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 _options; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly SemaphoreSlim _refreshLock = new(1, 1); private CachedKeyset? _cache; public AwsWorkOrderWebhookSecretProvider( IAmazonSecretsManager client, IOptionsMonitor options, TimeProvider timeProvider, ILogger logger) { _client = client; _options = options; _timeProvider = timeProvider; _logger = logger; } public async Task 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 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? 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(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 byKid) { ZeroSecrets(byKid); return null; } private static void ZeroSecrets(Dictionary 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 _byKid; private DateTimeOffset _expiresAt; public DateTimeOffset ExpiresAt => _expiresAt; public CachedKeyset(Dictionary 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); } } } }