Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
570 lines
17 KiB
C#
570 lines
17 KiB
C#
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; } = [];
|
|
}
|
|
}
|