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; } = [];
}
}