using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace StellaOps.Notifier.Worker.Security; /// /// Service for signing and verifying tokens with support for multiple key providers. /// public interface ISigningService { /// /// Signs a payload and returns a token. /// Task SignAsync( SigningPayload payload, CancellationToken cancellationToken = default); /// /// Verifies a token and returns the payload if valid. /// Task VerifyAsync( string token, CancellationToken cancellationToken = default); /// /// Gets information about a token without full verification. /// TokenInfo? GetTokenInfo(string token); /// /// Rotates the signing key (if supported by the key provider). /// Task RotateKeyAsync(CancellationToken cancellationToken = default); } /// /// Payload to be signed. /// public sealed record SigningPayload { /// /// Unique token ID. /// public required string TokenId { get; init; } /// /// Token purpose/type. /// public required string Purpose { get; init; } /// /// Tenant ID. /// public required string TenantId { get; init; } /// /// Subject (e.g., incident ID, delivery ID). /// public required string Subject { get; init; } /// /// Target (e.g., user ID, channel ID). /// public string? Target { get; init; } /// /// When the token expires. /// public required DateTimeOffset ExpiresAt { get; init; } /// /// Additional claims. /// public IReadOnlyDictionary Claims { get; init; } = new Dictionary(); } /// /// Result of token verification. /// public sealed record SigningVerificationResult { /// /// Whether the token is valid. /// public required bool IsValid { get; init; } /// /// The verified payload (if valid). /// public SigningPayload? Payload { get; init; } /// /// Error message if invalid. /// public string? Error { get; init; } /// /// Error code if invalid. /// public SigningErrorCode? ErrorCode { get; init; } public static SigningVerificationResult Valid(SigningPayload payload) => new() { IsValid = true, Payload = payload }; public static SigningVerificationResult Invalid(string error, SigningErrorCode code) => new() { IsValid = false, Error = error, ErrorCode = code }; } /// /// Error codes for signing verification. /// public enum SigningErrorCode { InvalidFormat, InvalidSignature, Expired, InvalidPayload, KeyNotFound, Revoked } /// /// Basic information about a token (without verification). /// public sealed record TokenInfo { public required string TokenId { get; init; } public required string Purpose { get; init; } public required string TenantId { get; init; } public required DateTimeOffset ExpiresAt { get; init; } public required string KeyId { get; init; } } /// /// Interface for key providers (local, KMS, HSM). /// public interface ISigningKeyProvider { /// /// Gets the current signing key. /// Task GetCurrentKeyAsync(CancellationToken cancellationToken = default); /// /// Gets a specific key by ID. /// Task GetKeyByIdAsync(string keyId, CancellationToken cancellationToken = default); /// /// Rotates to a new key. /// Task RotateAsync(CancellationToken cancellationToken = default); /// /// Lists all active key IDs. /// Task> ListKeyIdsAsync(CancellationToken cancellationToken = default); } /// /// A signing key. /// public sealed record SigningKey { public required string KeyId { get; init; } public required byte[] KeyMaterial { get; init; } public required DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? ExpiresAt { get; init; } public bool IsCurrent { get; init; } } /// /// Options for the signing service. /// public sealed class SigningServiceOptions { public const string SectionName = "Notifier:Security:Signing"; /// /// Key provider type: "local", "kms", "hsm". /// public string KeyProvider { get; set; } = "local"; /// /// Local signing key (for local provider). /// public string LocalSigningKey { get; set; } = "change-this-default-signing-key-in-production"; /// /// Hash algorithm to use. /// public string Algorithm { get; set; } = "HMACSHA256"; /// /// Default token expiry. /// public TimeSpan DefaultExpiry { get; set; } = TimeSpan.FromHours(24); /// /// Key rotation interval. /// public TimeSpan KeyRotationInterval { get; set; } = TimeSpan.FromDays(30); /// /// How long to keep old keys for verification. /// public TimeSpan KeyRetentionPeriod { get; set; } = TimeSpan.FromDays(90); /// /// KMS key ARN (for AWS KMS provider). /// public string? KmsKeyArn { get; set; } /// /// Azure Key Vault URL (for Azure KMS provider). /// public string? AzureKeyVaultUrl { get; set; } /// /// GCP KMS key resource name (for GCP KMS provider). /// public string? GcpKmsKeyName { get; set; } } /// /// Local in-memory key provider. /// public sealed class LocalSigningKeyProvider : ISigningKeyProvider { private readonly List _keys = []; private readonly SigningServiceOptions _options; private readonly TimeProvider _timeProvider; private readonly object _lock = new(); public LocalSigningKeyProvider( IOptions options, TimeProvider timeProvider) { _options = options?.Value ?? new SigningServiceOptions(); _timeProvider = timeProvider ?? TimeProvider.System; // Initialize with the configured key var initialKey = new SigningKey { KeyId = "key-001", KeyMaterial = SHA256.HashData(Encoding.UTF8.GetBytes(_options.LocalSigningKey)), CreatedAt = _timeProvider.GetUtcNow(), IsCurrent = true }; _keys.Add(initialKey); } public Task GetCurrentKeyAsync(CancellationToken cancellationToken = default) { lock (_lock) { var current = _keys.FirstOrDefault(k => k.IsCurrent); if (current is null) { throw new InvalidOperationException("No current signing key available"); } return Task.FromResult(current); } } public Task GetKeyByIdAsync(string keyId, CancellationToken cancellationToken = default) { lock (_lock) { return Task.FromResult(_keys.FirstOrDefault(k => k.KeyId == keyId)); } } public Task RotateAsync(CancellationToken cancellationToken = default) { lock (_lock) { // Mark current key as no longer current foreach (var key in _keys) { if (key.IsCurrent) { _keys.Remove(key); _keys.Add(key with { IsCurrent = false }); break; } } // Generate new key var newKeyId = $"key-{Guid.NewGuid():N}"[..12]; var newKeyMaterial = RandomNumberGenerator.GetBytes(32); var newKey = new SigningKey { KeyId = newKeyId, KeyMaterial = newKeyMaterial, CreatedAt = _timeProvider.GetUtcNow(), IsCurrent = true }; _keys.Add(newKey); // Remove expired keys var cutoff = _timeProvider.GetUtcNow() - _options.KeyRetentionPeriod; _keys.RemoveAll(k => !k.IsCurrent && k.CreatedAt < cutoff); return Task.FromResult(newKey); } } public Task> ListKeyIdsAsync(CancellationToken cancellationToken = default) { lock (_lock) { return Task.FromResult>(_keys.Select(k => k.KeyId).ToList()); } } } /// /// Default signing service implementation. /// public sealed class SigningService : ISigningService { private readonly ISigningKeyProvider _keyProvider; private readonly SigningServiceOptions _options; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public SigningService( ISigningKeyProvider keyProvider, IOptions options, TimeProvider timeProvider, ILogger logger) { _keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider)); _options = options?.Value ?? new SigningServiceOptions(); _timeProvider = timeProvider ?? TimeProvider.System; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task SignAsync( SigningPayload payload, CancellationToken cancellationToken = default) { var key = await _keyProvider.GetCurrentKeyAsync(cancellationToken); var header = new TokenHeader { Algorithm = _options.Algorithm, KeyId = key.KeyId, Type = "NOTIFIER" }; var body = new TokenBody { TokenId = payload.TokenId, Purpose = payload.Purpose, TenantId = payload.TenantId, Subject = payload.Subject, Target = payload.Target, ExpiresAt = payload.ExpiresAt.ToUnixTimeSeconds(), IssuedAt = _timeProvider.GetUtcNow().ToUnixTimeSeconds(), Claims = payload.Claims.ToDictionary(k => k.Key, k => k.Value) }; var headerJson = JsonSerializer.Serialize(header); var bodyJson = JsonSerializer.Serialize(body); var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); var bodyBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(bodyJson)); var signatureInput = $"{headerBase64}.{bodyBase64}"; var signature = ComputeSignature(signatureInput, key.KeyMaterial); var token = $"{headerBase64}.{bodyBase64}.{signature}"; _logger.LogDebug( "Signed token {TokenId} for purpose {Purpose} tenant {TenantId}.", payload.TokenId, payload.Purpose, payload.TenantId); return token; } public async Task VerifyAsync( string token, CancellationToken cancellationToken = default) { try { var parts = token.Split('.'); if (parts.Length != 3) { return SigningVerificationResult.Invalid("Invalid token format", SigningErrorCode.InvalidFormat); } var headerBase64 = parts[0]; var bodyBase64 = parts[1]; var signature = parts[2]; // Parse header to get key ID var headerJson = Encoding.UTF8.GetString(Base64UrlDecode(headerBase64)); var header = JsonSerializer.Deserialize(headerJson); if (header is null) { return SigningVerificationResult.Invalid("Invalid header", SigningErrorCode.InvalidFormat); } // Get the key var key = await _keyProvider.GetKeyByIdAsync(header.KeyId, cancellationToken); if (key is null) { return SigningVerificationResult.Invalid("Key not found", SigningErrorCode.KeyNotFound); } // Verify signature var signatureInput = $"{headerBase64}.{bodyBase64}"; var expectedSignature = ComputeSignature(signatureInput, key.KeyMaterial); if (!CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(signature), Encoding.UTF8.GetBytes(expectedSignature))) { return SigningVerificationResult.Invalid("Invalid signature", SigningErrorCode.InvalidSignature); } // Parse body var bodyJson = Encoding.UTF8.GetString(Base64UrlDecode(bodyBase64)); var body = JsonSerializer.Deserialize(bodyJson); if (body is null) { return SigningVerificationResult.Invalid("Invalid body", SigningErrorCode.InvalidPayload); } // Check expiry var expiresAt = DateTimeOffset.FromUnixTimeSeconds(body.ExpiresAt); if (_timeProvider.GetUtcNow() > expiresAt) { return SigningVerificationResult.Invalid("Token expired", SigningErrorCode.Expired); } var payload = new SigningPayload { TokenId = body.TokenId, Purpose = body.Purpose, TenantId = body.TenantId, Subject = body.Subject, Target = body.Target, ExpiresAt = expiresAt, Claims = body.Claims }; return SigningVerificationResult.Valid(payload); } catch (Exception ex) { _logger.LogWarning(ex, "Token verification failed."); return SigningVerificationResult.Invalid("Verification failed", SigningErrorCode.InvalidFormat); } } public TokenInfo? GetTokenInfo(string token) { try { var parts = token.Split('.'); if (parts.Length != 3) { return null; } var headerJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[0])); var bodyJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[1])); var header = JsonSerializer.Deserialize(headerJson); var body = JsonSerializer.Deserialize(bodyJson); if (header is null || body is null) { return null; } return new TokenInfo { TokenId = body.TokenId, Purpose = body.Purpose, TenantId = body.TenantId, ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(body.ExpiresAt), KeyId = header.KeyId }; } catch { return null; } } public async Task RotateKeyAsync(CancellationToken cancellationToken = default) { try { await _keyProvider.RotateAsync(cancellationToken); _logger.LogInformation("Signing key rotated successfully."); return true; } catch (Exception ex) { _logger.LogError(ex, "Failed to rotate signing key."); return false; } } private string ComputeSignature(string input, byte[] key) { using var hmac = new HMACSHA256(key); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(input)); return Base64UrlEncode(hash); } private static string Base64UrlEncode(byte[] input) { return Convert.ToBase64String(input) .Replace('+', '-') .Replace('/', '_') .TrimEnd('='); } private static byte[] Base64UrlDecode(string input) { var output = input .Replace('-', '+') .Replace('_', '/'); switch (output.Length % 4) { case 2: output += "=="; break; case 3: output += "="; break; } return Convert.FromBase64String(output); } private sealed class TokenHeader { public string Algorithm { get; set; } = "HMACSHA256"; public string KeyId { get; set; } = ""; public string Type { get; set; } = "NOTIFIER"; } private sealed class TokenBody { public string TokenId { get; set; } = ""; public string Purpose { get; set; } = ""; public string TenantId { get; set; } = ""; public string Subject { get; set; } = ""; public string? Target { get; set; } public long ExpiresAt { get; set; } public long IssuedAt { get; set; } public Dictionary Claims { get; set; } = []; } }