Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Service for signing and verifying tokens with support for multiple key providers.
|
||||
/// </summary>
|
||||
public interface ISigningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs a payload and returns a token.
|
||||
/// </summary>
|
||||
Task<string> SignAsync(
|
||||
SigningPayload payload,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a token and returns the payload if valid.
|
||||
/// </summary>
|
||||
Task<SigningVerificationResult> VerifyAsync(
|
||||
string token,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about a token without full verification.
|
||||
/// </summary>
|
||||
TokenInfo? GetTokenInfo(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Rotates the signing key (if supported by the key provider).
|
||||
/// </summary>
|
||||
Task<bool> RotateKeyAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload to be signed.
|
||||
/// </summary>
|
||||
public sealed record SigningPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique token ID.
|
||||
/// </summary>
|
||||
public required string TokenId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Token purpose/type.
|
||||
/// </summary>
|
||||
public required string Purpose { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject (e.g., incident ID, delivery ID).
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target (e.g., user ID, channel ID).
|
||||
/// </summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the token expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional claims.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Claims { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of token verification.
|
||||
/// </summary>
|
||||
public sealed record SigningVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the token is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The verified payload (if valid).
|
||||
/// </summary>
|
||||
public SigningPayload? Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if invalid.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code if invalid.
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for signing verification.
|
||||
/// </summary>
|
||||
public enum SigningErrorCode
|
||||
{
|
||||
InvalidFormat,
|
||||
InvalidSignature,
|
||||
Expired,
|
||||
InvalidPayload,
|
||||
KeyNotFound,
|
||||
Revoked
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Basic information about a token (without verification).
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for key providers (local, KMS, HSM).
|
||||
/// </summary>
|
||||
public interface ISigningKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current signing key.
|
||||
/// </summary>
|
||||
Task<SigningKey> GetCurrentKeyAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific key by ID.
|
||||
/// </summary>
|
||||
Task<SigningKey?> GetKeyByIdAsync(string keyId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Rotates to a new key.
|
||||
/// </summary>
|
||||
Task<SigningKey> RotateAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all active key IDs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> ListKeyIdsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signing key.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for the signing service.
|
||||
/// </summary>
|
||||
public sealed class SigningServiceOptions
|
||||
{
|
||||
public const string SectionName = "Notifier:Security:Signing";
|
||||
|
||||
/// <summary>
|
||||
/// Key provider type: "local", "kms", "hsm".
|
||||
/// </summary>
|
||||
public string KeyProvider { get; set; } = "local";
|
||||
|
||||
/// <summary>
|
||||
/// Local signing key (for local provider).
|
||||
/// </summary>
|
||||
public string LocalSigningKey { get; set; } = "change-this-default-signing-key-in-production";
|
||||
|
||||
/// <summary>
|
||||
/// Hash algorithm to use.
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = "HMACSHA256";
|
||||
|
||||
/// <summary>
|
||||
/// Default token expiry.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultExpiry { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Key rotation interval.
|
||||
/// </summary>
|
||||
public TimeSpan KeyRotationInterval { get; set; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// How long to keep old keys for verification.
|
||||
/// </summary>
|
||||
public TimeSpan KeyRetentionPeriod { get; set; } = TimeSpan.FromDays(90);
|
||||
|
||||
/// <summary>
|
||||
/// KMS key ARN (for AWS KMS provider).
|
||||
/// </summary>
|
||||
public string? KmsKeyArn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Azure Key Vault URL (for Azure KMS provider).
|
||||
/// </summary>
|
||||
public string? AzureKeyVaultUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// GCP KMS key resource name (for GCP KMS provider).
|
||||
/// </summary>
|
||||
public string? GcpKmsKeyName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local in-memory key provider.
|
||||
/// </summary>
|
||||
public sealed class LocalSigningKeyProvider : ISigningKeyProvider
|
||||
{
|
||||
private readonly List<SigningKey> _keys = [];
|
||||
private readonly SigningServiceOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public LocalSigningKeyProvider(
|
||||
IOptions<SigningServiceOptions> 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<SigningKey> 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<SigningKey?> GetKeyByIdAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_keys.FirstOrDefault(k => k.KeyId == keyId));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<SigningKey> 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<IReadOnlyList<string>> ListKeyIdsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<string>>(_keys.Select(k => k.KeyId).ToList());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default signing service implementation.
|
||||
/// </summary>
|
||||
public sealed class SigningService : ISigningService
|
||||
{
|
||||
private readonly ISigningKeyProvider _keyProvider;
|
||||
private readonly SigningServiceOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SigningService> _logger;
|
||||
|
||||
public SigningService(
|
||||
ISigningKeyProvider keyProvider,
|
||||
IOptions<SigningServiceOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SigningService> 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<string> 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<SigningVerificationResult> 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<TokenHeader>(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<TokenBody>(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<TokenHeader>(headerJson);
|
||||
var body = JsonSerializer.Deserialize<TokenBody>(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<bool> 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<string, string> Claims { get; set; } = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user