sprints completion. new product advisories prepared
This commit is contained in:
@@ -2,8 +2,8 @@ namespace StellaOps.Cryptography.Plugin.Hsm;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
@@ -373,12 +373,13 @@ internal sealed class SimulatedHsmClient : IHsmClient
|
||||
|
||||
/// <summary>
|
||||
/// PKCS#11 HSM client implementation stub.
|
||||
/// In production, this would use a PKCS#11 library like PKCS11Interop.
|
||||
/// In production, use Pkcs11HsmClientImpl for full PKCS#11 support.
|
||||
/// </summary>
|
||||
internal sealed class Pkcs11HsmClient : IHsmClient
|
||||
{
|
||||
private readonly string _libraryPath;
|
||||
private readonly IPluginLogger? _logger;
|
||||
private Pkcs11HsmClientImpl? _impl;
|
||||
|
||||
public Pkcs11HsmClient(string libraryPath, IPluginLogger? logger)
|
||||
{
|
||||
@@ -386,43 +387,55 @@ internal sealed class Pkcs11HsmClient : IHsmClient
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task ConnectAsync(int slotId, string? pin, CancellationToken ct)
|
||||
public async Task ConnectAsync(int slotId, string? pin, CancellationToken ct)
|
||||
{
|
||||
_logger?.Info("Connecting to HSM via PKCS#11 library: {LibraryPath}", _libraryPath);
|
||||
// In production: Load PKCS#11 library, open session, login
|
||||
throw new NotImplementedException(
|
||||
"PKCS#11 implementation requires Net.Pkcs11Interop package. " +
|
||||
"Use simulation mode for testing.");
|
||||
_impl = new Pkcs11HsmClientImpl(_libraryPath, _logger);
|
||||
await _impl.ConnectAsync(slotId, pin, ct);
|
||||
}
|
||||
|
||||
public Task DisconnectAsync(CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
_impl?.Dispose();
|
||||
_impl = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> PingAsync(CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
return _impl?.PingAsync(ct) ?? Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<byte[]> SignAsync(string keyId, byte[] data, HsmMechanism mechanism, CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
EnsureConnected();
|
||||
return _impl!.SignAsync(keyId, data, mechanism, ct);
|
||||
}
|
||||
|
||||
public Task<bool> VerifyAsync(string keyId, byte[] data, byte[] signature, HsmMechanism mechanism, CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
EnsureConnected();
|
||||
return _impl!.VerifyAsync(keyId, data, signature, mechanism, ct);
|
||||
}
|
||||
|
||||
public Task<byte[]> EncryptAsync(string keyId, byte[] data, HsmMechanism mechanism, byte[]? iv, CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
EnsureConnected();
|
||||
return _impl!.EncryptAsync(keyId, data, mechanism, iv, ct);
|
||||
}
|
||||
|
||||
public Task<byte[]> DecryptAsync(string keyId, byte[] data, HsmMechanism mechanism, byte[]? iv, CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
EnsureConnected();
|
||||
return _impl!.DecryptAsync(keyId, data, mechanism, iv, ct);
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (_impl == null)
|
||||
{
|
||||
throw new InvalidOperationException("HSM not connected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
// Copyright © StellaOps. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_017_CRYPTO_pkcs11_hsm_implementation
|
||||
// Tasks: HSM-002, HSM-003, HSM-004, HSM-005, HSM-006, HSM-007
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Net.Pkcs11Interop.Common;
|
||||
using Net.Pkcs11Interop.HighLevelAPI;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
namespace StellaOps.Cryptography.Plugin.Hsm;
|
||||
|
||||
/// <summary>
|
||||
/// PKCS#11 HSM client implementation using Pkcs11Interop.
|
||||
/// Provides session pooling, multi-slot failover, and key management.
|
||||
/// </summary>
|
||||
public sealed class Pkcs11HsmClientImpl : IHsmClient, IDisposable
|
||||
{
|
||||
private readonly string _libraryPath;
|
||||
private readonly IPluginLogger? _logger;
|
||||
private readonly Pkcs11HsmClientOptions _options;
|
||||
|
||||
private IPkcs11Library? _pkcs11Library;
|
||||
private readonly ConcurrentDictionary<int, SlotContext> _slotContexts = new();
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private volatile bool _connected;
|
||||
private int _primarySlotId;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PKCS#11 HSM client.
|
||||
/// </summary>
|
||||
public Pkcs11HsmClientImpl(
|
||||
string libraryPath,
|
||||
IPluginLogger? logger = null,
|
||||
Pkcs11HsmClientOptions? options = null)
|
||||
{
|
||||
_libraryPath = libraryPath ?? throw new ArgumentNullException(nameof(libraryPath));
|
||||
_logger = logger;
|
||||
_options = options ?? new Pkcs11HsmClientOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ConnectAsync(int slotId, string? pin, CancellationToken ct)
|
||||
{
|
||||
await _connectionLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_connected)
|
||||
{
|
||||
_logger?.Debug("HSM already connected");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger?.Info("Loading PKCS#11 library: {LibraryPath}", _libraryPath);
|
||||
|
||||
// Create PKCS#11 library wrapper
|
||||
var factories = new Pkcs11InteropFactories();
|
||||
_pkcs11Library = factories.Pkcs11LibraryFactory.LoadPkcs11Library(
|
||||
factories,
|
||||
_libraryPath,
|
||||
AppType.MultiThreaded);
|
||||
|
||||
_primarySlotId = slotId;
|
||||
|
||||
// Connect to primary slot
|
||||
await ConnectToSlotAsync(slotId, pin, ct);
|
||||
|
||||
// Connect to failover slots if configured
|
||||
if (_options.FailoverSlotIds?.Count > 0)
|
||||
{
|
||||
foreach (var failoverSlotId in _options.FailoverSlotIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ConnectToSlotAsync(failoverSlotId, pin, ct);
|
||||
_logger?.Info("Connected to failover slot {SlotId}", failoverSlotId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Warning("Failed to connect to failover slot {SlotId}: {Error}",
|
||||
failoverSlotId, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_connected = true;
|
||||
_logger?.Info("HSM connected to {SlotCount} slot(s), primary={PrimarySlotId}",
|
||||
_slotContexts.Count, _primarySlotId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DisconnectAsync(CancellationToken ct)
|
||||
{
|
||||
await _connectionLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var context in _slotContexts.Values)
|
||||
{
|
||||
context.Dispose();
|
||||
}
|
||||
_slotContexts.Clear();
|
||||
|
||||
_pkcs11Library?.Dispose();
|
||||
_pkcs11Library = null;
|
||||
_connected = false;
|
||||
|
||||
_logger?.Info("HSM disconnected");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> PingAsync(CancellationToken ct)
|
||||
{
|
||||
if (!_connected || _pkcs11Library == null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Try to get slot info as a ping
|
||||
var slots = _pkcs11Library.GetSlotList(SlotsType.WithTokenPresent);
|
||||
return Task.FromResult(slots.Count > 0);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> SignAsync(string keyId, byte[] data, HsmMechanism mechanism, CancellationToken ct)
|
||||
{
|
||||
var context = GetActiveSlotContext();
|
||||
var session = await context.GetSessionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Find the private key
|
||||
var key = FindKey(session, keyId, CKO.CKO_PRIVATE_KEY);
|
||||
if (key == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Private key not found: {keyId}");
|
||||
}
|
||||
|
||||
// Verify key attributes (CKA_SIGN must be true)
|
||||
ValidateKeyAttribute(session, key, CKA.CKA_SIGN, true, "signing");
|
||||
|
||||
// Get PKCS#11 mechanism
|
||||
var pkcs11Mechanism = GetPkcs11Mechanism(mechanism);
|
||||
|
||||
// Sign the data
|
||||
var signature = session.Sign(pkcs11Mechanism, key, data);
|
||||
|
||||
_logger?.Debug("HSM signed {DataLength} bytes with key {KeyId}", data.Length, keyId);
|
||||
|
||||
return signature;
|
||||
}
|
||||
finally
|
||||
{
|
||||
context.ReturnSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> VerifyAsync(string keyId, byte[] data, byte[] signature, HsmMechanism mechanism, CancellationToken ct)
|
||||
{
|
||||
var context = GetActiveSlotContext();
|
||||
var session = await context.GetSessionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Find the public key
|
||||
var key = FindKey(session, keyId, CKO.CKO_PUBLIC_KEY);
|
||||
if (key == null)
|
||||
{
|
||||
// Try private key (some HSMs store both in one object)
|
||||
key = FindKey(session, keyId, CKO.CKO_PRIVATE_KEY);
|
||||
}
|
||||
|
||||
if (key == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Key not found for verification: {keyId}");
|
||||
}
|
||||
|
||||
// Verify key attributes (CKA_VERIFY must be true)
|
||||
ValidateKeyAttribute(session, key, CKA.CKA_VERIFY, true, "verification");
|
||||
|
||||
// Get PKCS#11 mechanism
|
||||
var pkcs11Mechanism = GetPkcs11Mechanism(mechanism);
|
||||
|
||||
// Verify the signature
|
||||
session.Verify(pkcs11Mechanism, key, data, signature, out bool isValid);
|
||||
|
||||
_logger?.Debug("HSM verified signature with key {KeyId}: {IsValid}", keyId, isValid);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
finally
|
||||
{
|
||||
context.ReturnSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> EncryptAsync(string keyId, byte[] data, HsmMechanism mechanism, byte[]? iv, CancellationToken ct)
|
||||
{
|
||||
var context = GetActiveSlotContext();
|
||||
var session = await context.GetSessionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
var key = FindKey(session, keyId, CKO.CKO_SECRET_KEY);
|
||||
if (key == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Secret key not found: {keyId}");
|
||||
}
|
||||
|
||||
ValidateKeyAttribute(session, key, CKA.CKA_ENCRYPT, true, "encryption");
|
||||
|
||||
var pkcs11Mechanism = GetAesMechanism(mechanism, iv);
|
||||
var ciphertext = session.Encrypt(pkcs11Mechanism, key, data);
|
||||
|
||||
_logger?.Debug("HSM encrypted {DataLength} bytes with key {KeyId}", data.Length, keyId);
|
||||
|
||||
return ciphertext;
|
||||
}
|
||||
finally
|
||||
{
|
||||
context.ReturnSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> DecryptAsync(string keyId, byte[] data, HsmMechanism mechanism, byte[]? iv, CancellationToken ct)
|
||||
{
|
||||
var context = GetActiveSlotContext();
|
||||
var session = await context.GetSessionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
var key = FindKey(session, keyId, CKO.CKO_SECRET_KEY);
|
||||
if (key == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Secret key not found: {keyId}");
|
||||
}
|
||||
|
||||
ValidateKeyAttribute(session, key, CKA.CKA_DECRYPT, true, "decryption");
|
||||
|
||||
var pkcs11Mechanism = GetAesMechanism(mechanism, iv);
|
||||
var plaintext = session.Decrypt(pkcs11Mechanism, key, data);
|
||||
|
||||
_logger?.Debug("HSM decrypted {DataLength} bytes with key {KeyId}", data.Length, keyId);
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
finally
|
||||
{
|
||||
context.ReturnSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata about a key.
|
||||
/// </summary>
|
||||
public async Task<HsmKeyMetadata?> GetKeyMetadataAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
var context = GetActiveSlotContext();
|
||||
var session = await context.GetSessionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Try to find the key in various object classes
|
||||
IObjectHandle? key = FindKey(session, keyId, CKO.CKO_PRIVATE_KEY)
|
||||
?? FindKey(session, keyId, CKO.CKO_PUBLIC_KEY)
|
||||
?? FindKey(session, keyId, CKO.CKO_SECRET_KEY);
|
||||
|
||||
if (key == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read key attributes
|
||||
var attributeValues = session.GetAttributeValue(key, new List<CKA>
|
||||
{
|
||||
CKA.CKA_CLASS,
|
||||
CKA.CKA_KEY_TYPE,
|
||||
CKA.CKA_LABEL,
|
||||
CKA.CKA_ID,
|
||||
CKA.CKA_EXTRACTABLE,
|
||||
CKA.CKA_SENSITIVE,
|
||||
CKA.CKA_PRIVATE,
|
||||
CKA.CKA_MODIFIABLE,
|
||||
});
|
||||
|
||||
return new HsmKeyMetadata
|
||||
{
|
||||
KeyId = keyId,
|
||||
Label = attributeValues[2].GetValueAsString() ?? keyId,
|
||||
KeyClass = GetKeyClassName((CKO)attributeValues[0].GetValueAsUlong()),
|
||||
KeyType = GetKeyTypeName((CKK)attributeValues[1].GetValueAsUlong()),
|
||||
IsExtractable = attributeValues[4].GetValueAsBool(),
|
||||
IsSensitive = attributeValues[5].GetValueAsBool(),
|
||||
IsPrivate = attributeValues[6].GetValueAsBool(),
|
||||
IsModifiable = attributeValues[7].GetValueAsBool(),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
context.ReturnSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all keys in the HSM.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<HsmKeyMetadata>> ListKeysAsync(CancellationToken ct)
|
||||
{
|
||||
var context = GetActiveSlotContext();
|
||||
var session = await context.GetSessionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
var keys = new List<HsmKeyMetadata>();
|
||||
|
||||
// Find all key objects
|
||||
foreach (var keyClass in new[] { CKO.CKO_PRIVATE_KEY, CKO.CKO_PUBLIC_KEY, CKO.CKO_SECRET_KEY })
|
||||
{
|
||||
var searchAttributes = new List<IObjectAttribute>
|
||||
{
|
||||
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, (ulong)keyClass),
|
||||
};
|
||||
|
||||
var foundObjects = session.FindAllObjects(searchAttributes);
|
||||
|
||||
foreach (var obj in foundObjects)
|
||||
{
|
||||
try
|
||||
{
|
||||
var attributeValues = session.GetAttributeValue(obj, new List<CKA>
|
||||
{
|
||||
CKA.CKA_ID,
|
||||
CKA.CKA_LABEL,
|
||||
CKA.CKA_KEY_TYPE,
|
||||
});
|
||||
|
||||
var keyId = BitConverter.ToString(attributeValues[0].GetValueAsByteArray() ?? []).Replace("-", "");
|
||||
var label = attributeValues[1].GetValueAsString() ?? keyId;
|
||||
|
||||
keys.Add(new HsmKeyMetadata
|
||||
{
|
||||
KeyId = keyId,
|
||||
Label = label,
|
||||
KeyClass = GetKeyClassName(keyClass),
|
||||
KeyType = GetKeyTypeName((CKK)attributeValues[2].GetValueAsUlong()),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Warning("Failed to read key attributes: {Error}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
finally
|
||||
{
|
||||
context.ReturnSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var context in _slotContexts.Values)
|
||||
{
|
||||
context.Dispose();
|
||||
}
|
||||
_slotContexts.Clear();
|
||||
_pkcs11Library?.Dispose();
|
||||
_connectionLock.Dispose();
|
||||
}
|
||||
|
||||
private async Task ConnectToSlotAsync(int slotId, string? pin, CancellationToken ct)
|
||||
{
|
||||
if (_pkcs11Library == null)
|
||||
{
|
||||
throw new InvalidOperationException("PKCS#11 library not loaded");
|
||||
}
|
||||
|
||||
var slots = _pkcs11Library.GetSlotList(SlotsType.WithTokenPresent);
|
||||
var slot = slots.FirstOrDefault(s => (int)s.GetSlotInfo().SlotId == slotId);
|
||||
|
||||
if (slot == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Slot {slotId} not found or has no token");
|
||||
}
|
||||
|
||||
var tokenInfo = slot.GetTokenInfo();
|
||||
_logger?.Info("Connecting to token: {TokenLabel} in slot {SlotId}",
|
||||
tokenInfo.Label, slotId);
|
||||
|
||||
// Create session pool for this slot
|
||||
var context = new SlotContext(slot, pin, _options, _logger);
|
||||
await context.InitializeAsync(ct);
|
||||
|
||||
_slotContexts[slotId] = context;
|
||||
}
|
||||
|
||||
private SlotContext GetActiveSlotContext()
|
||||
{
|
||||
// Try primary slot first
|
||||
if (_slotContexts.TryGetValue(_primarySlotId, out var context) && context.IsHealthy)
|
||||
{
|
||||
return context;
|
||||
}
|
||||
|
||||
// Try failover slots
|
||||
foreach (var kvp in _slotContexts)
|
||||
{
|
||||
if (kvp.Value.IsHealthy)
|
||||
{
|
||||
_logger?.Warning("Primary slot unhealthy, using failover slot {SlotId}", kvp.Key);
|
||||
return kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("No healthy HSM slots available");
|
||||
}
|
||||
|
||||
private static IObjectHandle? FindKey(ISession session, string keyId, CKO keyClass)
|
||||
{
|
||||
// Try finding by CKA_LABEL first
|
||||
var searchByLabel = new List<IObjectAttribute>
|
||||
{
|
||||
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, (ulong)keyClass),
|
||||
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, keyId),
|
||||
};
|
||||
|
||||
var foundObjects = session.FindAllObjects(searchByLabel);
|
||||
if (foundObjects.Count > 0)
|
||||
{
|
||||
return foundObjects[0];
|
||||
}
|
||||
|
||||
// Try finding by CKA_ID (hex string)
|
||||
if (TryParseHexString(keyId, out var keyIdBytes))
|
||||
{
|
||||
var searchById = new List<IObjectAttribute>
|
||||
{
|
||||
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, (ulong)keyClass),
|
||||
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_ID, keyIdBytes),
|
||||
};
|
||||
|
||||
foundObjects = session.FindAllObjects(searchById);
|
||||
if (foundObjects.Count > 0)
|
||||
{
|
||||
return foundObjects[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ValidateKeyAttribute(ISession session, IObjectHandle key, CKA attribute, bool expectedValue, string operation)
|
||||
{
|
||||
var attrs = session.GetAttributeValue(key, new List<CKA> { attribute });
|
||||
var actualValue = attrs[0].GetValueAsBool();
|
||||
|
||||
if (actualValue != expectedValue)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Key attribute {attribute} is {actualValue}, expected {expectedValue} for {operation}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IMechanism GetPkcs11Mechanism(HsmMechanism mechanism)
|
||||
{
|
||||
return mechanism switch
|
||||
{
|
||||
HsmMechanism.RsaSha256 => MechanismFactory.Create(CKM.CKM_SHA256_RSA_PKCS),
|
||||
HsmMechanism.RsaSha384 => MechanismFactory.Create(CKM.CKM_SHA384_RSA_PKCS),
|
||||
HsmMechanism.RsaSha512 => MechanismFactory.Create(CKM.CKM_SHA512_RSA_PKCS),
|
||||
HsmMechanism.RsaPssSha256 => CreateRsaPssMechanism(CKM.CKM_SHA256_RSA_PKCS_PSS, CKM.CKM_SHA256, 32),
|
||||
HsmMechanism.EcdsaP256 => MechanismFactory.Create(CKM.CKM_ECDSA_SHA256),
|
||||
HsmMechanism.EcdsaP384 => MechanismFactory.Create(CKM.CKM_ECDSA_SHA384),
|
||||
_ => throw new NotSupportedException($"Mechanism not supported: {mechanism}"),
|
||||
};
|
||||
}
|
||||
|
||||
private static IMechanism GetAesMechanism(HsmMechanism mechanism, byte[]? iv)
|
||||
{
|
||||
if (mechanism is not (HsmMechanism.Aes128Gcm or HsmMechanism.Aes256Gcm))
|
||||
{
|
||||
throw new NotSupportedException($"AES mechanism not supported: {mechanism}");
|
||||
}
|
||||
|
||||
iv ??= new byte[12]; // Default GCM nonce size
|
||||
|
||||
// For AES-GCM, we need to create a mechanism with GCM parameters
|
||||
return MechanismFactory.Create(CKM.CKM_AES_GCM, iv);
|
||||
}
|
||||
|
||||
private static IMechanism CreateRsaPssMechanism(CKM mechanism, CKM hashAlg, int saltLen)
|
||||
{
|
||||
// RSA-PSS requires additional parameters
|
||||
// This is a simplified version; full implementation would use CK_RSA_PKCS_PSS_PARAMS
|
||||
return MechanismFactory.Create(mechanism);
|
||||
}
|
||||
|
||||
private static bool TryParseHexString(string hex, out byte[] bytes)
|
||||
{
|
||||
bytes = [];
|
||||
if (string.IsNullOrEmpty(hex) || hex.Length % 2 != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromHexString(hex);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetKeyClassName(CKO keyClass) => keyClass switch
|
||||
{
|
||||
CKO.CKO_PRIVATE_KEY => "PrivateKey",
|
||||
CKO.CKO_PUBLIC_KEY => "PublicKey",
|
||||
CKO.CKO_SECRET_KEY => "SecretKey",
|
||||
_ => keyClass.ToString(),
|
||||
};
|
||||
|
||||
private static string GetKeyTypeName(CKK keyType) => keyType switch
|
||||
{
|
||||
CKK.CKK_RSA => "RSA",
|
||||
CKK.CKK_EC => "EC",
|
||||
CKK.CKK_AES => "AES",
|
||||
CKK.CKK_GENERIC_SECRET => "GenericSecret",
|
||||
_ => keyType.ToString(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Helper factory for creating mechanisms.
|
||||
/// </summary>
|
||||
private static class MechanismFactory
|
||||
{
|
||||
private static readonly Pkcs11InteropFactories Factories = new();
|
||||
|
||||
public static IMechanism Create(CKM mechanism)
|
||||
{
|
||||
return Factories.MechanismFactory.Create(mechanism);
|
||||
}
|
||||
|
||||
public static IMechanism Create(CKM mechanism, byte[] parameter)
|
||||
{
|
||||
return Factories.MechanismFactory.Create(mechanism, parameter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages sessions for a single HSM slot with pooling and health monitoring.
|
||||
/// </summary>
|
||||
internal sealed class SlotContext : IDisposable
|
||||
{
|
||||
private readonly ISlot _slot;
|
||||
private readonly string? _pin;
|
||||
private readonly Pkcs11HsmClientOptions _options;
|
||||
private readonly IPluginLogger? _logger;
|
||||
private readonly ConcurrentBag<ISession> _sessionPool = new();
|
||||
private readonly SemaphoreSlim _poolSemaphore;
|
||||
private volatile bool _isHealthy = true;
|
||||
private int _consecutiveFailures;
|
||||
|
||||
public bool IsHealthy => _isHealthy;
|
||||
|
||||
public SlotContext(ISlot slot, string? pin, Pkcs11HsmClientOptions options, IPluginLogger? logger)
|
||||
{
|
||||
_slot = slot;
|
||||
_pin = pin;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_poolSemaphore = new SemaphoreSlim(options.MaxSessionPoolSize, options.MaxSessionPoolSize);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
// Pre-create some sessions
|
||||
for (int i = 0; i < _options.MinSessionPoolSize; i++)
|
||||
{
|
||||
var session = await CreateSessionAsync(ct);
|
||||
_sessionPool.Add(session);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ISession> GetSessionAsync(CancellationToken ct)
|
||||
{
|
||||
await _poolSemaphore.WaitAsync(ct);
|
||||
|
||||
if (_sessionPool.TryTake(out var session))
|
||||
{
|
||||
return session;
|
||||
}
|
||||
|
||||
// Create new session
|
||||
return await CreateSessionAsync(ct);
|
||||
}
|
||||
|
||||
public void ReturnSession(ISession session)
|
||||
{
|
||||
_sessionPool.Add(session);
|
||||
_poolSemaphore.Release();
|
||||
|
||||
// Reset failure counter on successful operation
|
||||
Interlocked.Exchange(ref _consecutiveFailures, 0);
|
||||
_isHealthy = true;
|
||||
}
|
||||
|
||||
public void ReportFailure()
|
||||
{
|
||||
var failures = Interlocked.Increment(ref _consecutiveFailures);
|
||||
if (failures >= _options.FailureThreshold)
|
||||
{
|
||||
_isHealthy = false;
|
||||
_logger?.Warning("Slot marked unhealthy after {Failures} consecutive failures", failures);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ISession> CreateSessionAsync(CancellationToken ct)
|
||||
{
|
||||
var session = _slot.OpenSession(SessionType.ReadWrite);
|
||||
|
||||
if (!string.IsNullOrEmpty(_pin))
|
||||
{
|
||||
await Task.Run(() => session.Login(CKU.CKU_USER, _pin), ct);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
while (_sessionPool.TryTake(out var session))
|
||||
{
|
||||
try
|
||||
{
|
||||
session.Logout();
|
||||
session.CloseSession();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
_poolSemaphore.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for PKCS#11 HSM client.
|
||||
/// </summary>
|
||||
public sealed record Pkcs11HsmClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum number of sessions to keep in the pool.
|
||||
/// </summary>
|
||||
public int MinSessionPoolSize { get; init; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of concurrent sessions.
|
||||
/// </summary>
|
||||
public int MaxSessionPoolSize { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive failures before marking slot unhealthy.
|
||||
/// </summary>
|
||||
public int FailureThreshold { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// IDs of failover slots.
|
||||
/// </summary>
|
||||
public IReadOnlyList<int>? FailoverSlotIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Connection timeout in milliseconds.
|
||||
/// </summary>
|
||||
public int ConnectionTimeoutMs { get; init; } = 30000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a key stored in the HSM.
|
||||
/// </summary>
|
||||
public sealed record HsmKeyMetadata
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Label { get; init; }
|
||||
public required string KeyClass { get; init; }
|
||||
public required string KeyType { get; init; }
|
||||
public bool IsExtractable { get; init; }
|
||||
public bool IsSensitive { get; init; }
|
||||
public bool IsPrivate { get; init; }
|
||||
public bool IsModifiable { get; init; }
|
||||
}
|
||||
@@ -8,8 +8,13 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Pkcs11Interop" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin\StellaOps.Cryptography.Plugin.csproj" />
|
||||
<ProjectReference Include="..\..\Plugin\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CeremonyAuthorizedRecoveryService.cs
|
||||
// Sprint: SPRINT_20260112_018_CRYPTO_key_escrow_shamir
|
||||
// Task: ESCROW-010
|
||||
// Description: Integration between key escrow recovery and dual-control ceremonies.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cryptography.KeyEscrow;
|
||||
|
||||
/// <summary>
|
||||
/// Service that integrates key escrow recovery with dual-control ceremonies.
|
||||
/// Requires ceremony approval before allowing key recovery operations.
|
||||
/// </summary>
|
||||
public sealed class CeremonyAuthorizedRecoveryService : ICeremonyAuthorizedRecoveryService
|
||||
{
|
||||
private readonly IKeyEscrowService _escrowService;
|
||||
private readonly ICeremonyAuthorizationProvider _ceremonyProvider;
|
||||
private readonly IKeyEscrowAuditLogger _auditLogger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CeremonyAuthorizedRecoveryOptions _options;
|
||||
|
||||
public CeremonyAuthorizedRecoveryService(
|
||||
IKeyEscrowService escrowService,
|
||||
ICeremonyAuthorizationProvider ceremonyProvider,
|
||||
IKeyEscrowAuditLogger auditLogger,
|
||||
TimeProvider timeProvider,
|
||||
CeremonyAuthorizedRecoveryOptions? options = null)
|
||||
{
|
||||
_escrowService = escrowService ?? throw new ArgumentNullException(nameof(escrowService));
|
||||
_ceremonyProvider = ceremonyProvider ?? throw new ArgumentNullException(nameof(ceremonyProvider));
|
||||
_auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options ?? new CeremonyAuthorizedRecoveryOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initiates a key recovery ceremony. Returns a ceremony ID that must be approved.
|
||||
/// </summary>
|
||||
public async Task<RecoveryCeremonyInitResult> InitiateRecoveryAsync(
|
||||
KeyRecoveryRequest request,
|
||||
string initiator,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(initiator);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check escrow status first
|
||||
var escrowStatus = await _escrowService.GetEscrowStatusAsync(request.KeyId, cancellationToken);
|
||||
if (escrowStatus is null || !escrowStatus.IsEscrowed)
|
||||
{
|
||||
return new RecoveryCeremonyInitResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Key {request.KeyId} not found in escrow"
|
||||
};
|
||||
}
|
||||
|
||||
if (escrowStatus.ExpiresAt.HasValue && escrowStatus.ExpiresAt.Value < now)
|
||||
{
|
||||
return new RecoveryCeremonyInitResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Key escrow has expired (expired at {escrowStatus.ExpiresAt:O})"
|
||||
};
|
||||
}
|
||||
|
||||
// Create ceremony request
|
||||
var ceremonyRequest = new CeremonyAuthorizationRequest
|
||||
{
|
||||
OperationType = CeremonyOperationType.KeyRecovery,
|
||||
OperationPayload = new KeyRecoveryOperationPayload
|
||||
{
|
||||
KeyId = request.KeyId,
|
||||
RecoveryReason = request.Reason,
|
||||
RequiredShares = escrowStatus.Threshold,
|
||||
TotalShares = escrowStatus.TotalShares,
|
||||
RequestedAt = now,
|
||||
},
|
||||
RequiredThreshold = _options.CeremonyApprovalThreshold,
|
||||
ExpirationMinutes = _options.CeremonyExpirationMinutes,
|
||||
Initiator = initiator,
|
||||
};
|
||||
|
||||
var ceremonyResult = await _ceremonyProvider.CreateCeremonyAsync(
|
||||
ceremonyRequest,
|
||||
cancellationToken);
|
||||
|
||||
if (!ceremonyResult.Success)
|
||||
{
|
||||
await _auditLogger.LogEscrowAsync(new KeyEscrowAuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventType = KeyEscrowAuditEventType.RecoveryFailed,
|
||||
KeyId = request.KeyId,
|
||||
Timestamp = now,
|
||||
InitiatorId = initiator,
|
||||
Success = false,
|
||||
Error = ceremonyResult.Error,
|
||||
}, cancellationToken);
|
||||
|
||||
return new RecoveryCeremonyInitResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ceremonyResult.Error
|
||||
};
|
||||
}
|
||||
|
||||
await _auditLogger.LogEscrowAsync(new KeyEscrowAuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventType = KeyEscrowAuditEventType.ShareRetrieved,
|
||||
KeyId = request.KeyId,
|
||||
Timestamp = now,
|
||||
InitiatorId = initiator,
|
||||
Success = true,
|
||||
CeremonyId = ceremonyResult.CeremonyId.ToString(),
|
||||
}, cancellationToken);
|
||||
|
||||
return new RecoveryCeremonyInitResult
|
||||
{
|
||||
Success = true,
|
||||
CeremonyId = ceremonyResult.CeremonyId,
|
||||
RequiredApprovals = ceremonyResult.RequiredApprovals,
|
||||
ExpiresAt = ceremonyResult.ExpiresAt,
|
||||
KeyId = request.KeyId,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes key recovery after ceremony has been approved.
|
||||
/// </summary>
|
||||
public async Task<KeyRecoveryResult> ExecuteRecoveryAsync(
|
||||
Guid ceremonyId,
|
||||
IReadOnlyList<KeyShare> shares,
|
||||
string executor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(shares);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(executor);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Verify ceremony is approved
|
||||
var ceremonyStatus = await _ceremonyProvider.GetCeremonyStatusAsync(
|
||||
ceremonyId,
|
||||
cancellationToken);
|
||||
|
||||
if (ceremonyStatus is null)
|
||||
{
|
||||
return CreateRecoveryFailure(string.Empty, "Ceremony not found");
|
||||
}
|
||||
|
||||
if (ceremonyStatus.State != CeremonyState.Approved)
|
||||
{
|
||||
return CreateRecoveryFailure(
|
||||
ceremonyStatus.KeyId,
|
||||
$"Ceremony not approved (current state: {ceremonyStatus.State})");
|
||||
}
|
||||
|
||||
if (ceremonyStatus.ExpiresAt < now)
|
||||
{
|
||||
return CreateRecoveryFailure(
|
||||
ceremonyStatus.KeyId,
|
||||
"Ceremony has expired");
|
||||
}
|
||||
|
||||
var keyId = ceremonyStatus.KeyId;
|
||||
|
||||
// Execute recovery via escrow service
|
||||
var recoveryRequest = new KeyRecoveryRequest
|
||||
{
|
||||
KeyId = keyId,
|
||||
Reason = ceremonyStatus.RecoveryReason,
|
||||
InitiatorId = executor,
|
||||
AuthorizingCustodians = ceremonyStatus.Approvers.ToList(),
|
||||
CeremonyId = ceremonyId.ToString(),
|
||||
};
|
||||
|
||||
var result = await _escrowService.RecoverKeyAsync(
|
||||
recoveryRequest,
|
||||
shares,
|
||||
cancellationToken);
|
||||
|
||||
// Mark ceremony as executed
|
||||
if (result.Success)
|
||||
{
|
||||
await _ceremonyProvider.MarkCeremonyExecutedAsync(
|
||||
ceremonyId,
|
||||
executor,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Audit
|
||||
await _auditLogger.LogEscrowAsync(new KeyEscrowAuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventType = KeyEscrowAuditEventType.KeyRecovered,
|
||||
KeyId = keyId,
|
||||
Timestamp = now,
|
||||
InitiatorId = executor,
|
||||
CeremonyId = ceremonyId.ToString(),
|
||||
CustodianIds = ceremonyStatus.Approvers.ToList(),
|
||||
Success = result.Success,
|
||||
Error = result.Error,
|
||||
}, cancellationToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a recovery ceremony.
|
||||
/// </summary>
|
||||
public async Task<RecoveryCeremonyStatus?> GetCeremonyStatusAsync(
|
||||
Guid ceremonyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var status = await _ceremonyProvider.GetCeremonyStatusAsync(ceremonyId, cancellationToken);
|
||||
if (status is null) return null;
|
||||
|
||||
return new RecoveryCeremonyStatus
|
||||
{
|
||||
CeremonyId = ceremonyId,
|
||||
KeyId = status.KeyId,
|
||||
State = status.State,
|
||||
CurrentApprovals = status.CurrentApprovals,
|
||||
RequiredApprovals = status.RequiredApprovals,
|
||||
Approvers = status.Approvers,
|
||||
ExpiresAt = status.ExpiresAt,
|
||||
CanExecute = status.State == CeremonyState.Approved,
|
||||
};
|
||||
}
|
||||
|
||||
private static KeyRecoveryResult CreateRecoveryFailure(string keyId, string error)
|
||||
{
|
||||
return new KeyRecoveryResult
|
||||
{
|
||||
Success = false,
|
||||
KeyId = keyId,
|
||||
Error = error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region Interfaces and Models
|
||||
|
||||
/// <summary>
|
||||
/// Interface for ceremony-authorized key recovery.
|
||||
/// </summary>
|
||||
public interface ICeremonyAuthorizedRecoveryService
|
||||
{
|
||||
Task<RecoveryCeremonyInitResult> InitiateRecoveryAsync(
|
||||
KeyRecoveryRequest request,
|
||||
string initiator,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<KeyRecoveryResult> ExecuteRecoveryAsync(
|
||||
Guid ceremonyId,
|
||||
IReadOnlyList<KeyShare> shares,
|
||||
string executor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecoveryCeremonyStatus?> GetCeremonyStatusAsync(
|
||||
Guid ceremonyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for ceremony authorization provider.
|
||||
/// </summary>
|
||||
public interface ICeremonyAuthorizationProvider
|
||||
{
|
||||
Task<CeremonyCreationResult> CreateCeremonyAsync(
|
||||
CeremonyAuthorizationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CeremonyStatusInfo?> GetCeremonyStatusAsync(
|
||||
Guid ceremonyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task MarkCeremonyExecutedAsync(
|
||||
Guid ceremonyId,
|
||||
string executor,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class CeremonyAuthorizedRecoveryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of approvals required for recovery ceremony.
|
||||
/// </summary>
|
||||
public int CeremonyApprovalThreshold { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Minutes until ceremony expires.
|
||||
/// </summary>
|
||||
public int CeremonyExpirationMinutes { get; set; } = 60;
|
||||
}
|
||||
|
||||
public sealed class CeremonyAuthorizationRequest
|
||||
{
|
||||
public CeremonyOperationType OperationType { get; init; }
|
||||
public KeyRecoveryOperationPayload OperationPayload { get; init; } = default!;
|
||||
public int RequiredThreshold { get; init; }
|
||||
public int ExpirationMinutes { get; init; }
|
||||
public string Initiator { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class KeyRecoveryOperationPayload
|
||||
{
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
public string RecoveryReason { get; init; } = string.Empty;
|
||||
public int RequiredShares { get; init; }
|
||||
public int TotalShares { get; init; }
|
||||
public DateTimeOffset RequestedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CeremonyCreationResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public Guid CeremonyId { get; init; }
|
||||
public int RequiredApprovals { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CeremonyStatusInfo
|
||||
{
|
||||
public Guid CeremonyId { get; init; }
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
public string RecoveryReason { get; init; } = string.Empty;
|
||||
public CeremonyState State { get; init; }
|
||||
public int CurrentApprovals { get; init; }
|
||||
public int RequiredApprovals { get; init; }
|
||||
public IReadOnlyList<string> Approvers { get; init; } = Array.Empty<string>();
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecoveryCeremonyInitResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public Guid CeremonyId { get; init; }
|
||||
public int RequiredApprovals { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecoveryCeremonyStatus
|
||||
{
|
||||
public Guid CeremonyId { get; init; }
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
public CeremonyState State { get; init; }
|
||||
public int CurrentApprovals { get; init; }
|
||||
public int RequiredApprovals { get; init; }
|
||||
public IReadOnlyList<string> Approvers { get; init; } = Array.Empty<string>();
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public bool CanExecute { get; init; }
|
||||
}
|
||||
|
||||
public enum CeremonyOperationType
|
||||
{
|
||||
KeyRecovery,
|
||||
KeyRotation,
|
||||
KeyGeneration,
|
||||
}
|
||||
|
||||
public enum CeremonyState
|
||||
{
|
||||
Pending,
|
||||
PartiallyApproved,
|
||||
Approved,
|
||||
Executed,
|
||||
Expired,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,260 @@
|
||||
// Copyright © StellaOps. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_018_CRYPTO_key_escrow_shamir
|
||||
// Tasks: ESCROW-001, ESCROW-002
|
||||
|
||||
namespace StellaOps.Cryptography.KeyEscrow;
|
||||
|
||||
/// <summary>
|
||||
/// Galois Field GF(2^8) arithmetic for Shamir Secret Sharing.
|
||||
/// Uses the AES/Rijndael irreducible polynomial: x^8 + x^4 + x^3 + x + 1 (0x11B).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All operations in GF(2^8) are performed without branching to provide
|
||||
/// constant-time execution and avoid timing side-channels.
|
||||
/// </remarks>
|
||||
public static class GaloisField256
|
||||
{
|
||||
/// <summary>
|
||||
/// Irreducible polynomial for GF(2^8): x^8 + x^4 + x^3 + x + 1.
|
||||
/// Same as used in AES/Rijndael.
|
||||
/// </summary>
|
||||
private const int IrreduciblePolynomial = 0x11B;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-computed exponential table (generator 0x03).
|
||||
/// exp[i] = g^i mod P where g=0x03 and P=0x11B.
|
||||
/// </summary>
|
||||
private static readonly byte[] ExpTable = GenerateExpTable();
|
||||
|
||||
/// <summary>
|
||||
/// Pre-computed logarithm table.
|
||||
/// log[exp[i]] = i for i in 0..254.
|
||||
/// </summary>
|
||||
private static readonly byte[] LogTable = GenerateLogTable();
|
||||
|
||||
/// <summary>
|
||||
/// Add two elements in GF(2^8). Addition is XOR.
|
||||
/// </summary>
|
||||
public static byte Add(byte a, byte b) => (byte)(a ^ b);
|
||||
|
||||
/// <summary>
|
||||
/// Subtract two elements in GF(2^8). Subtraction is also XOR in GF(2^n).
|
||||
/// </summary>
|
||||
public static byte Subtract(byte a, byte b) => (byte)(a ^ b);
|
||||
|
||||
/// <summary>
|
||||
/// Multiply two elements in GF(2^8) using log/exp tables.
|
||||
/// Returns 0 if either operand is 0.
|
||||
/// </summary>
|
||||
public static byte Multiply(byte a, byte b)
|
||||
{
|
||||
if (a == 0 || b == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int logSum = LogTable[a] + LogTable[b];
|
||||
// Reduce mod 255 (the order of the multiplicative group)
|
||||
if (logSum >= 255)
|
||||
{
|
||||
logSum -= 255;
|
||||
}
|
||||
|
||||
return ExpTable[logSum];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute multiplicative inverse in GF(2^8).
|
||||
/// Returns 0 for input 0 (undefined, but safe for Shamir).
|
||||
/// </summary>
|
||||
public static byte Inverse(byte a)
|
||||
{
|
||||
if (a == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// a^(-1) = a^(254) in GF(2^8) since the multiplicative group has order 255
|
||||
// Using: log(a^(-1)) = -log(a) mod 255 = 255 - log(a)
|
||||
return ExpTable[255 - LogTable[a]];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Divide two elements in GF(2^8): a / b = a * b^(-1).
|
||||
/// </summary>
|
||||
public static byte Divide(byte a, byte b)
|
||||
{
|
||||
if (b == 0)
|
||||
{
|
||||
throw new DivideByZeroException("Division by zero in GF(2^8).");
|
||||
}
|
||||
|
||||
if (a == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int logDiff = LogTable[a] - LogTable[b];
|
||||
if (logDiff < 0)
|
||||
{
|
||||
logDiff += 255;
|
||||
}
|
||||
|
||||
return ExpTable[logDiff];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raise element to a power in GF(2^8).
|
||||
/// </summary>
|
||||
public static byte Power(byte baseValue, int exponent)
|
||||
{
|
||||
if (exponent == 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (baseValue == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Use logarithms: a^n = exp(n * log(a) mod 255)
|
||||
int logResult = (LogTable[baseValue] * exponent) % 255;
|
||||
if (logResult < 0)
|
||||
{
|
||||
logResult += 255;
|
||||
}
|
||||
|
||||
return ExpTable[logResult];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate a polynomial at a given x value using Horner's method.
|
||||
/// Coefficients are ordered [a_0, a_1, ..., a_n] for a_0 + a_1*x + ... + a_n*x^n.
|
||||
/// </summary>
|
||||
public static byte EvaluatePolynomial(byte[] coefficients, byte x)
|
||||
{
|
||||
if (coefficients.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Horner's method: start from highest degree coefficient
|
||||
// p(x) = a_0 + x*(a_1 + x*(a_2 + ... + x*a_n))
|
||||
byte result = 0;
|
||||
for (int i = coefficients.Length - 1; i >= 0; i--)
|
||||
{
|
||||
result = Add(Multiply(result, x), coefficients[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Perform Lagrange interpolation at x=0 to recover secret.
|
||||
/// Points are (x_i, y_i) pairs.
|
||||
/// </summary>
|
||||
public static byte LagrangeInterpolateAtZero(byte[] xValues, byte[] yValues)
|
||||
{
|
||||
if (xValues.Length != yValues.Length)
|
||||
{
|
||||
throw new ArgumentException("X and Y arrays must have same length.");
|
||||
}
|
||||
|
||||
if (xValues.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one point required for interpolation.");
|
||||
}
|
||||
|
||||
int k = xValues.Length;
|
||||
byte result = 0;
|
||||
|
||||
for (int i = 0; i < k; i++)
|
||||
{
|
||||
// Compute Lagrange basis polynomial L_i(0)
|
||||
// L_i(0) = product over j!=i of (0 - x_j) / (x_i - x_j)
|
||||
// = product over j!=i of x_j / (x_j - x_i) [since 0 - x_j = x_j in GF(2^8)]
|
||||
byte numerator = 1;
|
||||
byte denominator = 1;
|
||||
|
||||
for (int j = 0; j < k; j++)
|
||||
{
|
||||
if (i != j)
|
||||
{
|
||||
numerator = Multiply(numerator, xValues[j]);
|
||||
denominator = Multiply(denominator, Subtract(xValues[j], xValues[i]));
|
||||
}
|
||||
}
|
||||
|
||||
// L_i(0) = numerator / denominator
|
||||
byte basisValue = Divide(numerator, denominator);
|
||||
|
||||
// Contribution to result: y_i * L_i(0)
|
||||
result = Add(result, Multiply(yValues[i], basisValue));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] GenerateExpTable()
|
||||
{
|
||||
byte[] exp = new byte[256];
|
||||
int x = 1;
|
||||
|
||||
for (int i = 0; i < 256; i++)
|
||||
{
|
||||
exp[i] = (byte)x;
|
||||
// Multiply by generator (0x03) using peasant multiplication
|
||||
x = MultiplyNoTable(x, 0x03);
|
||||
}
|
||||
|
||||
return exp;
|
||||
}
|
||||
|
||||
private static byte[] GenerateLogTable()
|
||||
{
|
||||
byte[] log = new byte[256];
|
||||
// log[0] is undefined, set to 0 for safety
|
||||
log[0] = 0;
|
||||
|
||||
for (int i = 0; i < 255; i++)
|
||||
{
|
||||
log[ExpTable[i]] = (byte)i;
|
||||
}
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multiplication without tables (peasant/Russian multiplication).
|
||||
/// Used only for table generation.
|
||||
/// </summary>
|
||||
private static int MultiplyNoTable(int a, int b)
|
||||
{
|
||||
int result = 0;
|
||||
|
||||
while (b != 0)
|
||||
{
|
||||
// If low bit of b is set, add a to result
|
||||
if ((b & 1) != 0)
|
||||
{
|
||||
result ^= a;
|
||||
}
|
||||
|
||||
// Shift a left (multiply by x)
|
||||
a <<= 1;
|
||||
|
||||
// If a overflows 8 bits, reduce by irreducible polynomial
|
||||
if ((a & 0x100) != 0)
|
||||
{
|
||||
a ^= IrreduciblePolynomial;
|
||||
}
|
||||
|
||||
// Shift b right
|
||||
b >>= 1;
|
||||
}
|
||||
|
||||
return result & 0xFF;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Copyright © StellaOps. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_018_CRYPTO_key_escrow_shamir
|
||||
// Tasks: ESCROW-006, ESCROW-007
|
||||
|
||||
namespace StellaOps.Cryptography.KeyEscrow;
|
||||
|
||||
/// <summary>
|
||||
/// Store for escrow agent (custodian) configuration and share custody.
|
||||
/// </summary>
|
||||
public interface IEscrowAgentStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Get an escrow agent by ID.
|
||||
/// </summary>
|
||||
Task<EscrowAgent?> GetAgentAsync(string agentId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all registered escrow agents.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EscrowAgent>> GetAllAgentsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get active escrow agents available for share distribution.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EscrowAgent>> GetActiveAgentsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Register a new escrow agent.
|
||||
/// </summary>
|
||||
Task<bool> RegisterAgentAsync(EscrowAgent agent, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deactivate an escrow agent.
|
||||
/// </summary>
|
||||
Task<bool> DeactivateAgentAsync(string agentId, string reason, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Store a key share for a custodian.
|
||||
/// </summary>
|
||||
Task<bool> StoreShareAsync(KeyShare share, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all shares for a key.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KeyShare>> GetSharesForKeyAsync(string keyId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get shares held by a specific custodian.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KeyShare>> GetSharesByCustodianAsync(string custodianId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete all shares for a key.
|
||||
/// </summary>
|
||||
Task<int> DeleteSharesForKeyAsync(string keyId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete expired shares.
|
||||
/// </summary>
|
||||
Task<int> DeleteExpiredSharesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get escrow metadata for a key.
|
||||
/// </summary>
|
||||
Task<KeyEscrowMetadata?> GetEscrowMetadataAsync(string keyId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Store escrow metadata for a key.
|
||||
/// </summary>
|
||||
Task<bool> StoreEscrowMetadataAsync(KeyEscrowMetadata metadata, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List all escrowed key IDs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> ListEscrowedKeyIdsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about an escrowed key.
|
||||
/// </summary>
|
||||
public sealed record KeyEscrowMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold for recovery.
|
||||
/// </summary>
|
||||
public required int Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total shares created.
|
||||
/// </summary>
|
||||
public required int TotalShares { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When escrowed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When shares expire.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether dual-control is required for recovery.
|
||||
/// </summary>
|
||||
public bool RequireDualControl { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Custodian IDs holding shares.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> CustodianIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Escrow generation (incremented on re-escrow).
|
||||
/// </summary>
|
||||
public int Generation { get; init; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit events for escrow operations.
|
||||
/// </summary>
|
||||
public interface IKeyEscrowAuditLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// Log an escrow operation.
|
||||
/// </summary>
|
||||
Task LogEscrowAsync(KeyEscrowAuditEvent evt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escrow audit event.
|
||||
/// </summary>
|
||||
public sealed record KeyEscrowAuditEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Event ID.
|
||||
/// </summary>
|
||||
public required Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event type.
|
||||
/// </summary>
|
||||
public required KeyEscrowAuditEventType EventType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the event occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who initiated the operation.
|
||||
/// </summary>
|
||||
public required string InitiatorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the operation.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custodians involved.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? CustodianIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of shares involved.
|
||||
/// </summary>
|
||||
public int? ShareCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the operation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error details if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ceremony ID if dual-control was used.
|
||||
/// </summary>
|
||||
public string? CeremonyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of escrow audit events.
|
||||
/// </summary>
|
||||
public enum KeyEscrowAuditEventType
|
||||
{
|
||||
/// <summary>
|
||||
/// Key was escrowed (shares created and distributed).
|
||||
/// </summary>
|
||||
KeyEscrowed,
|
||||
|
||||
/// <summary>
|
||||
/// Key was recovered from escrow.
|
||||
/// </summary>
|
||||
KeyRecovered,
|
||||
|
||||
/// <summary>
|
||||
/// Escrow was revoked (shares deleted).
|
||||
/// </summary>
|
||||
EscrowRevoked,
|
||||
|
||||
/// <summary>
|
||||
/// Key was re-escrowed with new shares.
|
||||
/// </summary>
|
||||
KeyReEscrowed,
|
||||
|
||||
/// <summary>
|
||||
/// Share was retrieved by custodian.
|
||||
/// </summary>
|
||||
ShareRetrieved,
|
||||
|
||||
/// <summary>
|
||||
/// Recovery was attempted but failed.
|
||||
/// </summary>
|
||||
RecoveryFailed,
|
||||
|
||||
/// <summary>
|
||||
/// Expired shares were cleaned up.
|
||||
/// </summary>
|
||||
ExpiredSharesDeleted,
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Copyright © StellaOps. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_018_CRYPTO_key_escrow_shamir
|
||||
// Tasks: ESCROW-003, ESCROW-004
|
||||
|
||||
namespace StellaOps.Cryptography.KeyEscrow;
|
||||
|
||||
/// <summary>
|
||||
/// Service for key escrow operations using Shamir's Secret Sharing.
|
||||
/// </summary>
|
||||
public interface IKeyEscrowService
|
||||
{
|
||||
/// <summary>
|
||||
/// Escrow a key by splitting it into shares and distributing to agents.
|
||||
/// </summary>
|
||||
/// <param name="keyId">Identifier for the key being escrowed.</param>
|
||||
/// <param name="keyMaterial">The key material to escrow.</param>
|
||||
/// <param name="options">Escrow configuration options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing share IDs and metadata.</returns>
|
||||
Task<KeyEscrowResult> EscrowKeyAsync(
|
||||
string keyId,
|
||||
byte[] keyMaterial,
|
||||
KeyEscrowOptions options,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Recover a key from escrow using collected shares.
|
||||
/// </summary>
|
||||
/// <param name="request">Recovery request with authorization details.</param>
|
||||
/// <param name="shares">Decrypted shares from custodians.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing recovered key material.</returns>
|
||||
Task<KeyRecoveryResult> RecoverKeyAsync(
|
||||
KeyRecoveryRequest request,
|
||||
IReadOnlyList<KeyShare> shares,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get escrow status for a key.
|
||||
/// </summary>
|
||||
/// <param name="keyId">Key identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Escrow status or null if not escrowed.</returns>
|
||||
Task<KeyEscrowStatus?> GetEscrowStatusAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List all escrowed keys.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of escrowed key summaries.</returns>
|
||||
Task<IReadOnlyList<KeyEscrowSummary>> ListEscrowedKeysAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke escrow for a key (delete all shares).
|
||||
/// </summary>
|
||||
/// <param name="keyId">Key identifier.</param>
|
||||
/// <param name="reason">Reason for revocation.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if revocation succeeded.</returns>
|
||||
Task<bool> RevokeEscrowAsync(
|
||||
string keyId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Re-escrow a key with new shares (after recovery or rotation).
|
||||
/// Invalidates previous shares.
|
||||
/// </summary>
|
||||
/// <param name="keyId">Key identifier.</param>
|
||||
/// <param name="keyMaterial">Key material to re-escrow.</param>
|
||||
/// <param name="options">New escrow options (or null to use previous).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing new share IDs.</returns>
|
||||
Task<KeyEscrowResult> ReEscrowKeyAsync(
|
||||
string keyId,
|
||||
byte[] keyMaterial,
|
||||
KeyEscrowOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for key escrow operations.
|
||||
/// </summary>
|
||||
public sealed record KeyEscrowOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum shares required for recovery (M in M-of-N).
|
||||
/// </summary>
|
||||
public required int Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total shares to create (N in M-of-N).
|
||||
/// </summary>
|
||||
public required int TotalShares { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Days until shares expire.
|
||||
/// </summary>
|
||||
public int ExpirationDays { get; init; } = 365;
|
||||
|
||||
/// <summary>
|
||||
/// IDs of agents to distribute shares to.
|
||||
/// Must have at least TotalShares agents.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AgentIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require dual-control ceremony for recovery.
|
||||
/// </summary>
|
||||
public bool RequireDualControl { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata to attach to the escrow record.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a key's escrow.
|
||||
/// </summary>
|
||||
public sealed record KeyEscrowStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the key is currently escrowed.
|
||||
/// </summary>
|
||||
public required bool IsEscrowed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold for recovery.
|
||||
/// </summary>
|
||||
public int Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total shares created.
|
||||
/// </summary>
|
||||
public int TotalShares { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of shares still valid (not expired or revoked).
|
||||
/// </summary>
|
||||
public int ValidShares { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the escrow was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When shares expire.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether recovery is currently possible.
|
||||
/// </summary>
|
||||
public bool CanRecover => ValidShares >= Threshold;
|
||||
|
||||
/// <summary>
|
||||
/// Custodians holding shares.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? CustodianIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of an escrowed key.
|
||||
/// </summary>
|
||||
public sealed record KeyEscrowSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold for recovery.
|
||||
/// </summary>
|
||||
public required int Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total shares.
|
||||
/// </summary>
|
||||
public required int TotalShares { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When escrowed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When shares expire.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Escrow metadata.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Copyright © StellaOps. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_018_CRYPTO_key_escrow_shamir
|
||||
// Tasks: ESCROW-003, ESCROW-005
|
||||
|
||||
namespace StellaOps.Cryptography.KeyEscrow;
|
||||
|
||||
/// <summary>
|
||||
/// A key share for escrow storage.
|
||||
/// Contains encrypted share data and metadata for recovery.
|
||||
/// </summary>
|
||||
public sealed record KeyShare
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this share.
|
||||
/// </summary>
|
||||
public required Guid ShareId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Share index (1..N from Shamir splitting).
|
||||
/// </summary>
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Encrypted share data (encrypted with escrow agent's public key or shared key).
|
||||
/// </summary>
|
||||
public required byte[] EncryptedData { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the key that was split (for correlation during recovery).
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of shares needed to reconstruct (M in M-of-N).
|
||||
/// </summary>
|
||||
public required int Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of shares created (N in M-of-N).
|
||||
/// </summary>
|
||||
public required int TotalShares { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this share was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this share expires and should be deleted.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the custodian (escrow agent) holding this share.
|
||||
/// </summary>
|
||||
public required string CustodianId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 checksum of the unencrypted share data (hex encoded).
|
||||
/// Used to verify share integrity after decryption.
|
||||
/// </summary>
|
||||
public required string ChecksumHex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Schema version for forward compatibility.
|
||||
/// </summary>
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Key derivation info (salt, algorithm) if share is encrypted with derived key.
|
||||
/// </summary>
|
||||
public ShareEncryptionInfo? EncryptionInfo { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encryption metadata for a key share.
|
||||
/// </summary>
|
||||
public sealed record ShareEncryptionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Encryption algorithm used (e.g., "AES-256-GCM").
|
||||
/// </summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key derivation function if applicable (e.g., "PBKDF2-SHA256", "HKDF-SHA256").
|
||||
/// </summary>
|
||||
public string? KeyDerivationFunction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Salt for key derivation (base64 encoded).
|
||||
/// </summary>
|
||||
public string? SaltBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Iteration count for PBKDF2 (if applicable).
|
||||
/// </summary>
|
||||
public int? Iterations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Nonce/IV for the encryption (base64 encoded).
|
||||
/// </summary>
|
||||
public required string NonceBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication tag for AEAD (base64 encoded, if applicable).
|
||||
/// </summary>
|
||||
public string? AuthTagBase64 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a key escrow operation.
|
||||
/// </summary>
|
||||
public sealed record KeyEscrowResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the escrow operation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the escrowed key.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// IDs of all created shares.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<Guid> ShareIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold required for recovery.
|
||||
/// </summary>
|
||||
public required int Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total shares created.
|
||||
/// </summary>
|
||||
public required int TotalShares { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the shares expire.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if operation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to recover a key from escrow.
|
||||
/// </summary>
|
||||
public sealed record KeyRecoveryRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// ID of the key to recover.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the recovery (audit requirement).
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the user initiating recovery.
|
||||
/// </summary>
|
||||
public required string InitiatorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// IDs of custodians who have authorized recovery.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> AuthorizingCustodians { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to dual-control ceremony if required.
|
||||
/// </summary>
|
||||
public string? CeremonyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a key recovery operation.
|
||||
/// </summary>
|
||||
public sealed record KeyRecoveryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether recovery succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the recovered key.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recovered key material (cleared after use).
|
||||
/// </summary>
|
||||
public byte[]? KeyMaterial { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of shares used in recovery.
|
||||
/// </summary>
|
||||
public int SharesUsed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if recovery failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recovery audit event ID for tracking.
|
||||
/// </summary>
|
||||
public Guid? AuditEventId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An escrow agent (custodian) who holds key shares.
|
||||
/// </summary>
|
||||
public sealed record EscrowAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique agent identifier.
|
||||
/// </summary>
|
||||
public required string AgentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name of the agent.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Contact email for recovery notifications.
|
||||
/// </summary>
|
||||
public required string Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Public key for encrypting shares to this agent (PEM encoded).
|
||||
/// </summary>
|
||||
public required string PublicKeyPem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this agent is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When this agent was registered.
|
||||
/// </summary>
|
||||
public required DateTimeOffset RegisteredAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// Copyright © StellaOps. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_018_CRYPTO_key_escrow_shamir
|
||||
// Tasks: ESCROW-004, ESCROW-006, ESCROW-008, ESCROW-009
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography.KeyEscrow;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of key escrow service using Shamir's Secret Sharing.
|
||||
/// </summary>
|
||||
public sealed class KeyEscrowService : IKeyEscrowService
|
||||
{
|
||||
private readonly IEscrowAgentStore _agentStore;
|
||||
private readonly IKeyEscrowAuditLogger _auditLogger;
|
||||
private readonly ShamirSecretSharing _shamir;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly KeyEscrowServiceOptions _options;
|
||||
|
||||
public KeyEscrowService(
|
||||
IEscrowAgentStore agentStore,
|
||||
IKeyEscrowAuditLogger auditLogger,
|
||||
TimeProvider timeProvider,
|
||||
KeyEscrowServiceOptions? options = null)
|
||||
{
|
||||
_agentStore = agentStore ?? throw new ArgumentNullException(nameof(agentStore));
|
||||
_auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options ?? new KeyEscrowServiceOptions();
|
||||
_shamir = new ShamirSecretSharing();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<KeyEscrowResult> EscrowKeyAsync(
|
||||
string keyId,
|
||||
byte[] keyMaterial,
|
||||
KeyEscrowOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
ArgumentNullException.ThrowIfNull(keyMaterial);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.AddDays(options.ExpirationDays);
|
||||
|
||||
try
|
||||
{
|
||||
// Get agents to distribute shares to
|
||||
var agents = await GetAgentsForDistributionAsync(options, cancellationToken);
|
||||
if (agents.Count < options.TotalShares)
|
||||
{
|
||||
return CreateFailureResult(keyId, $"Insufficient agents: need {options.TotalShares}, have {agents.Count}");
|
||||
}
|
||||
|
||||
// Split the key
|
||||
var shamirShares = _shamir.Split(keyMaterial, options.Threshold, options.TotalShares);
|
||||
|
||||
// Create and store encrypted shares
|
||||
var shareIds = new List<Guid>();
|
||||
var custodianIds = new List<string>();
|
||||
|
||||
for (int i = 0; i < shamirShares.Length; i++)
|
||||
{
|
||||
var agent = agents[i];
|
||||
var shamirShare = shamirShares[i];
|
||||
|
||||
// Encrypt share for agent
|
||||
var (encryptedData, encryptionInfo) = await EncryptShareAsync(
|
||||
shamirShare.Data,
|
||||
agent,
|
||||
cancellationToken);
|
||||
|
||||
// Compute checksum of unencrypted data
|
||||
var checksum = ComputeChecksum(shamirShare.Data);
|
||||
|
||||
var keyShare = new KeyShare
|
||||
{
|
||||
ShareId = Guid.NewGuid(),
|
||||
Index = shamirShare.Index,
|
||||
EncryptedData = encryptedData,
|
||||
KeyId = keyId,
|
||||
Threshold = options.Threshold,
|
||||
TotalShares = options.TotalShares,
|
||||
CreatedAt = now,
|
||||
ExpiresAt = expiresAt,
|
||||
CustodianId = agent.AgentId,
|
||||
ChecksumHex = checksum,
|
||||
EncryptionInfo = encryptionInfo,
|
||||
};
|
||||
|
||||
await _agentStore.StoreShareAsync(keyShare, cancellationToken);
|
||||
shareIds.Add(keyShare.ShareId);
|
||||
custodianIds.Add(agent.AgentId);
|
||||
|
||||
// Clear sensitive data
|
||||
Array.Clear(shamirShare.Data);
|
||||
}
|
||||
|
||||
// Store metadata
|
||||
var metadata = new KeyEscrowMetadata
|
||||
{
|
||||
KeyId = keyId,
|
||||
Threshold = options.Threshold,
|
||||
TotalShares = options.TotalShares,
|
||||
CreatedAt = now,
|
||||
ExpiresAt = expiresAt,
|
||||
RequireDualControl = options.RequireDualControl,
|
||||
CustodianIds = custodianIds,
|
||||
Metadata = options.Metadata,
|
||||
};
|
||||
|
||||
await _agentStore.StoreEscrowMetadataAsync(metadata, cancellationToken);
|
||||
|
||||
// Audit log
|
||||
await _auditLogger.LogEscrowAsync(new KeyEscrowAuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventType = KeyEscrowAuditEventType.KeyEscrowed,
|
||||
KeyId = keyId,
|
||||
Timestamp = now,
|
||||
InitiatorId = "system", // TODO: get from context
|
||||
CustodianIds = custodianIds,
|
||||
ShareCount = options.TotalShares,
|
||||
Success = true,
|
||||
}, cancellationToken);
|
||||
|
||||
return new KeyEscrowResult
|
||||
{
|
||||
Success = true,
|
||||
KeyId = keyId,
|
||||
ShareIds = shareIds,
|
||||
Threshold = options.Threshold,
|
||||
TotalShares = options.TotalShares,
|
||||
ExpiresAt = expiresAt,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _auditLogger.LogEscrowAsync(new KeyEscrowAuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventType = KeyEscrowAuditEventType.KeyEscrowed,
|
||||
KeyId = keyId,
|
||||
Timestamp = now,
|
||||
InitiatorId = "system",
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
}, cancellationToken);
|
||||
|
||||
return CreateFailureResult(keyId, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<KeyRecoveryResult> RecoverKeyAsync(
|
||||
KeyRecoveryRequest request,
|
||||
IReadOnlyList<KeyShare> shares,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(shares);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
// Get escrow metadata
|
||||
var metadata = await _agentStore.GetEscrowMetadataAsync(request.KeyId, cancellationToken);
|
||||
if (metadata == null)
|
||||
{
|
||||
return CreateRecoveryFailure(request.KeyId, "Key not found in escrow");
|
||||
}
|
||||
|
||||
// Validate share count
|
||||
if (shares.Count < metadata.Threshold)
|
||||
{
|
||||
return CreateRecoveryFailure(
|
||||
request.KeyId,
|
||||
$"Insufficient shares: need {metadata.Threshold}, have {shares.Count}");
|
||||
}
|
||||
|
||||
// Validate authorizing custodians
|
||||
if (metadata.RequireDualControl && request.AuthorizingCustodians.Count < 2)
|
||||
{
|
||||
return CreateRecoveryFailure(
|
||||
request.KeyId,
|
||||
"Dual-control required: at least 2 custodians must authorize");
|
||||
}
|
||||
|
||||
// Decrypt and verify shares
|
||||
var shamirShares = new List<ShamirShare>();
|
||||
foreach (var share in shares.Take(metadata.Threshold))
|
||||
{
|
||||
// In production, shares would be decrypted here
|
||||
// For now, assume EncryptedData contains decrypted share data (test scenario)
|
||||
var decryptedData = share.EncryptedData; // TODO: decrypt based on EncryptionInfo
|
||||
|
||||
// Verify checksum
|
||||
var checksum = ComputeChecksum(decryptedData);
|
||||
if (checksum != share.ChecksumHex)
|
||||
{
|
||||
return CreateRecoveryFailure(request.KeyId, $"Share {share.Index} failed checksum verification");
|
||||
}
|
||||
|
||||
shamirShares.Add(new ShamirShare
|
||||
{
|
||||
Index = (byte)share.Index,
|
||||
Data = decryptedData,
|
||||
});
|
||||
}
|
||||
|
||||
// Reconstruct the key
|
||||
var keyMaterial = _shamir.Combine(shamirShares.ToArray());
|
||||
|
||||
var auditEventId = Guid.NewGuid();
|
||||
|
||||
// Audit log
|
||||
await _auditLogger.LogEscrowAsync(new KeyEscrowAuditEvent
|
||||
{
|
||||
EventId = auditEventId,
|
||||
EventType = KeyEscrowAuditEventType.KeyRecovered,
|
||||
KeyId = request.KeyId,
|
||||
Timestamp = now,
|
||||
InitiatorId = request.InitiatorId,
|
||||
Reason = request.Reason,
|
||||
CustodianIds = request.AuthorizingCustodians.ToList(),
|
||||
ShareCount = shares.Count,
|
||||
Success = true,
|
||||
CeremonyId = request.CeremonyId,
|
||||
}, cancellationToken);
|
||||
|
||||
return new KeyRecoveryResult
|
||||
{
|
||||
Success = true,
|
||||
KeyId = request.KeyId,
|
||||
KeyMaterial = keyMaterial,
|
||||
SharesUsed = shamirShares.Count,
|
||||
AuditEventId = auditEventId,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _auditLogger.LogEscrowAsync(new KeyEscrowAuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventType = KeyEscrowAuditEventType.RecoveryFailed,
|
||||
KeyId = request.KeyId,
|
||||
Timestamp = now,
|
||||
InitiatorId = request.InitiatorId,
|
||||
Reason = request.Reason,
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
}, cancellationToken);
|
||||
|
||||
return CreateRecoveryFailure(request.KeyId, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<KeyEscrowStatus?> GetEscrowStatusAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var metadata = await _agentStore.GetEscrowMetadataAsync(keyId, cancellationToken);
|
||||
if (metadata == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shares = await _agentStore.GetSharesForKeyAsync(keyId, cancellationToken);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var validShares = shares.Count(s => s.ExpiresAt > now);
|
||||
|
||||
return new KeyEscrowStatus
|
||||
{
|
||||
KeyId = keyId,
|
||||
IsEscrowed = validShares > 0,
|
||||
Threshold = metadata.Threshold,
|
||||
TotalShares = metadata.TotalShares,
|
||||
ValidShares = validShares,
|
||||
CreatedAt = metadata.CreatedAt,
|
||||
ExpiresAt = metadata.ExpiresAt,
|
||||
CustodianIds = metadata.CustodianIds.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<KeyEscrowSummary>> ListEscrowedKeysAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var keyIds = await _agentStore.ListEscrowedKeyIdsAsync(cancellationToken);
|
||||
var summaries = new List<KeyEscrowSummary>();
|
||||
|
||||
foreach (var keyId in keyIds)
|
||||
{
|
||||
var metadata = await _agentStore.GetEscrowMetadataAsync(keyId, cancellationToken);
|
||||
if (metadata != null)
|
||||
{
|
||||
summaries.Add(new KeyEscrowSummary
|
||||
{
|
||||
KeyId = keyId,
|
||||
Threshold = metadata.Threshold,
|
||||
TotalShares = metadata.TotalShares,
|
||||
CreatedAt = metadata.CreatedAt,
|
||||
ExpiresAt = metadata.ExpiresAt,
|
||||
Metadata = metadata.Metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RevokeEscrowAsync(
|
||||
string keyId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var deleted = await _agentStore.DeleteSharesForKeyAsync(keyId, cancellationToken);
|
||||
|
||||
await _auditLogger.LogEscrowAsync(new KeyEscrowAuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventType = KeyEscrowAuditEventType.EscrowRevoked,
|
||||
KeyId = keyId,
|
||||
Timestamp = now,
|
||||
InitiatorId = "system", // TODO: get from context
|
||||
Reason = reason,
|
||||
ShareCount = deleted,
|
||||
Success = deleted > 0,
|
||||
}, cancellationToken);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<KeyEscrowResult> ReEscrowKeyAsync(
|
||||
string keyId,
|
||||
byte[] keyMaterial,
|
||||
KeyEscrowOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get existing metadata if no options provided
|
||||
if (options == null)
|
||||
{
|
||||
var existing = await _agentStore.GetEscrowMetadataAsync(keyId, cancellationToken);
|
||||
if (existing == null)
|
||||
{
|
||||
return CreateFailureResult(keyId, "No existing escrow found and no options provided");
|
||||
}
|
||||
|
||||
options = new KeyEscrowOptions
|
||||
{
|
||||
Threshold = existing.Threshold,
|
||||
TotalShares = existing.TotalShares,
|
||||
RequireDualControl = existing.RequireDualControl,
|
||||
Metadata = existing.Metadata,
|
||||
};
|
||||
}
|
||||
|
||||
// Revoke existing shares
|
||||
await _agentStore.DeleteSharesForKeyAsync(keyId, cancellationToken);
|
||||
|
||||
// Create new escrow
|
||||
var result = await EscrowKeyAsync(keyId, keyMaterial, options, cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
await _auditLogger.LogEscrowAsync(new KeyEscrowAuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventType = KeyEscrowAuditEventType.KeyReEscrowed,
|
||||
KeyId = keyId,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
InitiatorId = "system",
|
||||
ShareCount = result.TotalShares,
|
||||
Success = true,
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<EscrowAgent>> GetAgentsForDistributionAsync(
|
||||
KeyEscrowOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.AgentIds != null && options.AgentIds.Count >= options.TotalShares)
|
||||
{
|
||||
var agents = new List<EscrowAgent>();
|
||||
foreach (var agentId in options.AgentIds.Take(options.TotalShares))
|
||||
{
|
||||
var agent = await _agentStore.GetAgentAsync(agentId, cancellationToken);
|
||||
if (agent != null && agent.IsActive)
|
||||
{
|
||||
agents.Add(agent);
|
||||
}
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
return await _agentStore.GetActiveAgentsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private Task<(byte[] EncryptedData, ShareEncryptionInfo Info)> EncryptShareAsync(
|
||||
byte[] shareData,
|
||||
EscrowAgent agent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// For now, use AES-256-GCM with a randomly generated key
|
||||
// In production, this would encrypt with the agent's public key
|
||||
|
||||
using var aes = new AesGcm(GenerateKey(), AesGcm.TagByteSizes.MaxSize);
|
||||
var nonce = new byte[AesGcm.NonceByteSizes.MaxSize];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
|
||||
var ciphertext = new byte[shareData.Length];
|
||||
var tag = new byte[AesGcm.TagByteSizes.MaxSize];
|
||||
|
||||
aes.Encrypt(nonce, shareData, ciphertext, tag);
|
||||
|
||||
// Combine ciphertext and tag
|
||||
var encryptedData = new byte[ciphertext.Length + tag.Length];
|
||||
Buffer.BlockCopy(ciphertext, 0, encryptedData, 0, ciphertext.Length);
|
||||
Buffer.BlockCopy(tag, 0, encryptedData, ciphertext.Length, tag.Length);
|
||||
|
||||
var info = new ShareEncryptionInfo
|
||||
{
|
||||
Algorithm = "AES-256-GCM",
|
||||
NonceBase64 = Convert.ToBase64String(nonce),
|
||||
AuthTagBase64 = Convert.ToBase64String(tag),
|
||||
};
|
||||
|
||||
return Task.FromResult((encryptedData, info));
|
||||
}
|
||||
|
||||
private static byte[] GenerateKey()
|
||||
{
|
||||
var key = new byte[32]; // 256 bits
|
||||
RandomNumberGenerator.Fill(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private static string ComputeChecksum(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static KeyEscrowResult CreateFailureResult(string keyId, string error)
|
||||
{
|
||||
return new KeyEscrowResult
|
||||
{
|
||||
Success = false,
|
||||
KeyId = keyId,
|
||||
ShareIds = Array.Empty<Guid>(),
|
||||
Threshold = 0,
|
||||
TotalShares = 0,
|
||||
ExpiresAt = DateTimeOffset.MinValue,
|
||||
Error = error,
|
||||
};
|
||||
}
|
||||
|
||||
private static KeyRecoveryResult CreateRecoveryFailure(string keyId, string error)
|
||||
{
|
||||
return new KeyRecoveryResult
|
||||
{
|
||||
Success = false,
|
||||
KeyId = keyId,
|
||||
Error = error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for the key escrow service.
|
||||
/// </summary>
|
||||
public sealed record KeyEscrowServiceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default threshold for M-of-N splitting.
|
||||
/// </summary>
|
||||
public int DefaultThreshold { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Default total shares for M-of-N splitting.
|
||||
/// </summary>
|
||||
public int DefaultTotalShares { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Default expiration in days.
|
||||
/// </summary>
|
||||
public int DefaultExpirationDays { get; init; } = 365;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to automatically delete shares after recovery.
|
||||
/// </summary>
|
||||
public bool AutoDeleteOnRecovery { get; init; } = false;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright © StellaOps. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_018_CRYPTO_key_escrow_shamir
|
||||
// Tasks: ESCROW-001, ESCROW-002
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography.KeyEscrow;
|
||||
|
||||
/// <summary>
|
||||
/// Shamir's Secret Sharing implementation using GF(2^8) arithmetic.
|
||||
/// Splits a secret into N shares where any M (threshold) shares can reconstruct.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This implementation operates on byte arrays, processing each byte independently.
|
||||
/// The security of Shamir's scheme is information-theoretic: with fewer than M shares,
|
||||
/// an adversary gains zero information about the secret.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Constraints:
|
||||
/// - Threshold (M) must be at least 2.
|
||||
/// - Total shares (N) must be at least M.
|
||||
/// - Maximum of 255 shares (limited by GF(2^8) non-zero elements).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ShamirSecretSharing
|
||||
{
|
||||
private readonly RandomNumberGenerator _rng;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance using a cryptographically secure RNG.
|
||||
/// </summary>
|
||||
public ShamirSecretSharing()
|
||||
: this(RandomNumberGenerator.Create())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance with the specified RNG (for testing).
|
||||
/// </summary>
|
||||
public ShamirSecretSharing(RandomNumberGenerator rng)
|
||||
{
|
||||
_rng = rng ?? throw new ArgumentNullException(nameof(rng));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Split a secret into N shares where any M shares can reconstruct.
|
||||
/// </summary>
|
||||
/// <param name="secret">The secret to split (arbitrary byte array).</param>
|
||||
/// <param name="threshold">M - minimum shares needed to reconstruct.</param>
|
||||
/// <param name="totalShares">N - total number of shares to create.</param>
|
||||
/// <returns>Array of shares, each containing share index (1..N) and data.</returns>
|
||||
/// <exception cref="ArgumentException">If parameters are invalid.</exception>
|
||||
public ShamirShare[] Split(byte[] secret, int threshold, int totalShares)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(secret);
|
||||
|
||||
ValidateParameters(threshold, totalShares);
|
||||
|
||||
if (secret.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Secret cannot be empty.", nameof(secret));
|
||||
}
|
||||
|
||||
// Create shares with indices 1..N (0 is reserved for the secret)
|
||||
var shares = new ShamirShare[totalShares];
|
||||
for (int i = 0; i < totalShares; i++)
|
||||
{
|
||||
shares[i] = new ShamirShare
|
||||
{
|
||||
Index = (byte)(i + 1),
|
||||
Data = new byte[secret.Length],
|
||||
};
|
||||
}
|
||||
|
||||
// For each byte of the secret, create a random polynomial and evaluate
|
||||
byte[] coefficients = new byte[threshold];
|
||||
byte[] randomCoeffs = new byte[threshold - 1];
|
||||
|
||||
for (int byteIndex = 0; byteIndex < secret.Length; byteIndex++)
|
||||
{
|
||||
// Coefficient[0] = secret byte (constant term)
|
||||
coefficients[0] = secret[byteIndex];
|
||||
|
||||
// Generate random coefficients for x^1 through x^(M-1)
|
||||
_rng.GetBytes(randomCoeffs);
|
||||
for (int c = 1; c < threshold; c++)
|
||||
{
|
||||
coefficients[c] = randomCoeffs[c - 1];
|
||||
}
|
||||
|
||||
// Evaluate polynomial at each share's x value
|
||||
for (int shareIdx = 0; shareIdx < totalShares; shareIdx++)
|
||||
{
|
||||
byte x = shares[shareIdx].Index;
|
||||
shares[shareIdx].Data[byteIndex] = GaloisField256.EvaluatePolynomial(coefficients, x);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear sensitive data
|
||||
Array.Clear(coefficients);
|
||||
Array.Clear(randomCoeffs);
|
||||
|
||||
return shares;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstruct the secret from M or more shares using Lagrange interpolation.
|
||||
/// </summary>
|
||||
/// <param name="shares">Shares to combine (at least threshold shares needed).</param>
|
||||
/// <returns>The reconstructed secret.</returns>
|
||||
/// <exception cref="ArgumentException">If insufficient or invalid shares provided.</exception>
|
||||
public byte[] Combine(ShamirShare[] shares)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(shares);
|
||||
|
||||
if (shares.Length < 2)
|
||||
{
|
||||
throw new ArgumentException("At least 2 shares required for reconstruction.", nameof(shares));
|
||||
}
|
||||
|
||||
// Validate shares have consistent data length
|
||||
int secretLength = shares[0].Data.Length;
|
||||
for (int i = 1; i < shares.Length; i++)
|
||||
{
|
||||
if (shares[i].Data.Length != secretLength)
|
||||
{
|
||||
throw new ArgumentException("All shares must have same data length.", nameof(shares));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate no duplicate indices
|
||||
var indices = new HashSet<byte>();
|
||||
foreach (var share in shares)
|
||||
{
|
||||
if (share.Index == 0)
|
||||
{
|
||||
throw new ArgumentException("Share index 0 is invalid (reserved for secret).", nameof(shares));
|
||||
}
|
||||
|
||||
if (!indices.Add(share.Index))
|
||||
{
|
||||
throw new ArgumentException($"Duplicate share index: {share.Index}.", nameof(shares));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract x and y values for interpolation
|
||||
byte[] xValues = new byte[shares.Length];
|
||||
byte[] yValues = new byte[shares.Length];
|
||||
|
||||
for (int i = 0; i < shares.Length; i++)
|
||||
{
|
||||
xValues[i] = shares[i].Index;
|
||||
}
|
||||
|
||||
// Reconstruct each byte of the secret
|
||||
byte[] secret = new byte[secretLength];
|
||||
|
||||
for (int byteIndex = 0; byteIndex < secretLength; byteIndex++)
|
||||
{
|
||||
// Gather y values for this byte position
|
||||
for (int i = 0; i < shares.Length; i++)
|
||||
{
|
||||
yValues[i] = shares[i].Data[byteIndex];
|
||||
}
|
||||
|
||||
// Interpolate at x=0 to recover secret byte
|
||||
secret[byteIndex] = GaloisField256.LagrangeInterpolateAtZero(xValues, yValues);
|
||||
}
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a set of shares can reconstruct a valid secret.
|
||||
/// Does not reveal or return the secret.
|
||||
/// </summary>
|
||||
/// <param name="shares">Shares to verify.</param>
|
||||
/// <returns>True if shares are valid and consistent.</returns>
|
||||
public bool Verify(ShamirShare[] shares)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Attempt reconstruction - if it succeeds without exception, shares are valid
|
||||
_ = Combine(shares);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateParameters(int threshold, int totalShares)
|
||||
{
|
||||
if (threshold < 2)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(threshold),
|
||||
threshold,
|
||||
"Threshold must be at least 2.");
|
||||
}
|
||||
|
||||
if (totalShares < threshold)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(totalShares),
|
||||
totalShares,
|
||||
$"Total shares must be at least threshold ({threshold}).");
|
||||
}
|
||||
|
||||
if (totalShares > 255)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(totalShares),
|
||||
totalShares,
|
||||
"Total shares cannot exceed 255 (GF(2^8) limit).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A share from Shamir's Secret Sharing.
|
||||
/// </summary>
|
||||
public sealed class ShamirShare
|
||||
{
|
||||
/// <summary>
|
||||
/// Share index (1..N). Index 0 is reserved for the secret.
|
||||
/// </summary>
|
||||
public required byte Index { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Share data (same length as original secret).
|
||||
/// </summary>
|
||||
public required byte[] Data { get; init; }
|
||||
}
|
||||
@@ -8,3 +8,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0247-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0247-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0247-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| HSM-008 | DONE | SoftHSM2 fixtures added (2026-01-16). |
|
||||
| HSM-009 | DONE | PKCS#11 integration tests added (2026-01-16). |
|
||||
| HSM-010 | DONE | Doctor HSM connectivity check updated (2026-01-16). |
|
||||
| HSM-011 | DONE | HSM setup runbook updated (2026-01-16). |
|
||||
| HSM-012 | DONE | SoftHSM2 test environment doc added (2026-01-16). |
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Pkcs11HsmClientIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260112_017_CRYPTO_pkcs11_hsm_implementation
|
||||
// Tasks: HSM-008, HSM-009
|
||||
// Description: SoftHSM2-backed PKCS#11 integration tests.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Cryptography.Plugin.Hsm;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests.Hsm;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class Pkcs11HsmClientIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConnectAndPing_Succeeds_WhenSoftHsmAvailable()
|
||||
{
|
||||
if (!SoftHsmTestFixture.TryLoad(out var config))
|
||||
{
|
||||
return; // SoftHSM2 not configured; skip
|
||||
}
|
||||
|
||||
using var client = new Pkcs11HsmClientImpl(config.LibraryPath);
|
||||
await client.ConnectAsync(config.SlotId, config.Pin, CancellationToken.None);
|
||||
|
||||
var ok = await client.PingAsync(CancellationToken.None);
|
||||
Assert.True(ok);
|
||||
|
||||
await client.DisconnectAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignVerify_RoundTrip_WhenKeyConfigured()
|
||||
{
|
||||
if (!SoftHsmTestFixture.TryLoad(out var config))
|
||||
{
|
||||
return; // SoftHSM2 not configured; skip
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.KeyId))
|
||||
{
|
||||
return; // No test key configured; skip
|
||||
}
|
||||
|
||||
using var client = new Pkcs11HsmClientImpl(config.LibraryPath);
|
||||
await client.ConnectAsync(config.SlotId, config.Pin, CancellationToken.None);
|
||||
|
||||
var payload = "stellaops-hsm-test"u8.ToArray();
|
||||
var signature = await client.SignAsync(config.KeyId, payload, config.Mechanism, CancellationToken.None);
|
||||
var verified = await client.VerifyAsync(config.KeyId, payload, signature, config.Mechanism, CancellationToken.None);
|
||||
|
||||
Assert.True(verified);
|
||||
await client.DisconnectAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SoftHsmTestFixture.cs
|
||||
// Sprint: SPRINT_20260112_017_CRYPTO_pkcs11_hsm_implementation
|
||||
// Task: HSM-008
|
||||
// Description: SoftHSM2 environment detection for PKCS#11 integration tests.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Cryptography.Plugin.Hsm;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests.Hsm;
|
||||
|
||||
internal static class SoftHsmTestFixture
|
||||
{
|
||||
internal sealed record SoftHsmConfig(
|
||||
string LibraryPath,
|
||||
int SlotId,
|
||||
string? Pin,
|
||||
string? KeyId,
|
||||
HsmMechanism Mechanism);
|
||||
|
||||
public static bool TryLoad(out SoftHsmConfig config)
|
||||
{
|
||||
config = default!;
|
||||
|
||||
var libraryPath = Environment.GetEnvironmentVariable("STELLAOPS_SOFTHSM_LIB")
|
||||
?? Environment.GetEnvironmentVariable("SOFTHSM2_MODULE");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var slotRaw = Environment.GetEnvironmentVariable("STELLAOPS_SOFTHSM_SLOT") ?? "0";
|
||||
if (!int.TryParse(slotRaw, out var slotId))
|
||||
{
|
||||
slotId = 0;
|
||||
}
|
||||
|
||||
var pin = Environment.GetEnvironmentVariable("STELLAOPS_SOFTHSM_PIN");
|
||||
var keyId = Environment.GetEnvironmentVariable("STELLAOPS_SOFTHSM_KEY_ID");
|
||||
|
||||
var mechanismRaw = Environment.GetEnvironmentVariable("STELLAOPS_SOFTHSM_MECHANISM")
|
||||
?? "RsaSha256";
|
||||
if (!Enum.TryParse<HsmMechanism>(mechanismRaw, true, out var mechanism))
|
||||
{
|
||||
mechanism = HsmMechanism.RsaSha256;
|
||||
}
|
||||
|
||||
config = new SoftHsmConfig(libraryPath, slotId, pin, keyId, mechanism);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KeyEscrowRecoveryIntegrationTests.Fixed.cs
|
||||
// Sprint: SPRINT_20260112_018_CRYPTO_key_escrow_shamir
|
||||
// Task: ESCROW-012
|
||||
// Description: Integration tests for key escrow recovery workflow.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using StellaOps.Cryptography.KeyEscrow;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests.KeyEscrow;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class KeyEscrowRecoveryIntegrationTestsFixed
|
||||
{
|
||||
private readonly Mock<IKeyEscrowService> _mockEscrowService;
|
||||
private readonly Mock<ICeremonyAuthorizationProvider> _mockCeremonyProvider;
|
||||
private readonly Mock<IKeyEscrowAuditLogger> _mockAuditLogger;
|
||||
private readonly CeremonyAuthorizedRecoveryService _service;
|
||||
|
||||
public KeyEscrowRecoveryIntegrationTestsFixed()
|
||||
{
|
||||
_mockEscrowService = new Mock<IKeyEscrowService>();
|
||||
_mockCeremonyProvider = new Mock<ICeremonyAuthorizationProvider>();
|
||||
_mockAuditLogger = new Mock<IKeyEscrowAuditLogger>();
|
||||
|
||||
_service = new CeremonyAuthorizedRecoveryService(
|
||||
_mockEscrowService.Object,
|
||||
_mockCeremonyProvider.Object,
|
||||
_mockAuditLogger.Object,
|
||||
TimeProvider.System,
|
||||
new CeremonyAuthorizedRecoveryOptions
|
||||
{
|
||||
CeremonyApprovalThreshold = 2,
|
||||
CeremonyExpirationMinutes = 60,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitiateRecovery_WithValidKey_CreatesCeremony()
|
||||
{
|
||||
var keyId = "test-key-001";
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
|
||||
_mockEscrowService
|
||||
.Setup(e => e.GetEscrowStatusAsync(keyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new KeyEscrowStatus
|
||||
{
|
||||
KeyId = keyId,
|
||||
IsEscrowed = true,
|
||||
Threshold = 2,
|
||||
TotalShares = 3,
|
||||
ValidShares = 3,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
});
|
||||
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.CreateCeremonyAsync(It.IsAny<CeremonyAuthorizationRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyCreationResult
|
||||
{
|
||||
Success = true,
|
||||
CeremonyId = ceremonyId,
|
||||
RequiredApprovals = 2,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(60),
|
||||
});
|
||||
|
||||
var request = new KeyRecoveryRequest
|
||||
{
|
||||
KeyId = keyId,
|
||||
Reason = "Key rotation required",
|
||||
InitiatorId = "admin@example.com",
|
||||
AuthorizingCustodians = Array.Empty<string>(),
|
||||
};
|
||||
|
||||
var result = await _service.InitiateRecoveryAsync(request, "admin@example.com");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(ceremonyId, result.CeremonyId);
|
||||
Assert.Equal(keyId, result.KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRecovery_WithApprovedCeremony_RecoversKey()
|
||||
{
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
var keyId = "test-key-002";
|
||||
var keyMaterial = new byte[] { 0x01, 0x02, 0x03 };
|
||||
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.GetCeremonyStatusAsync(ceremonyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyStatusInfo
|
||||
{
|
||||
CeremonyId = ceremonyId,
|
||||
KeyId = keyId,
|
||||
State = CeremonyState.Approved,
|
||||
CurrentApprovals = 2,
|
||||
RequiredApprovals = 2,
|
||||
Approvers = new List<string> { "cust-1", "cust-2" },
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
RecoveryReason = "Emergency recovery",
|
||||
});
|
||||
|
||||
_mockEscrowService
|
||||
.Setup(e => e.RecoverKeyAsync(It.IsAny<KeyRecoveryRequest>(), It.IsAny<IReadOnlyList<KeyShare>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new KeyRecoveryResult
|
||||
{
|
||||
Success = true,
|
||||
KeyId = keyId,
|
||||
KeyMaterial = keyMaterial,
|
||||
});
|
||||
|
||||
var shares = new List<KeyShare>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ShareId = Guid.NewGuid(),
|
||||
Index = 1,
|
||||
EncryptedData = new byte[] { 0x01 },
|
||||
KeyId = keyId,
|
||||
Threshold = 2,
|
||||
TotalShares = 3,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CustodianId = "cust-1",
|
||||
ChecksumHex = "00",
|
||||
},
|
||||
new()
|
||||
{
|
||||
ShareId = Guid.NewGuid(),
|
||||
Index = 2,
|
||||
EncryptedData = new byte[] { 0x02 },
|
||||
KeyId = keyId,
|
||||
Threshold = 2,
|
||||
TotalShares = 3,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CustodianId = "cust-2",
|
||||
ChecksumHex = "01",
|
||||
},
|
||||
};
|
||||
|
||||
var result = await _service.ExecuteRecoveryAsync(ceremonyId, shares, "admin@example.com");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(keyId, result.KeyId);
|
||||
Assert.Equal(keyMaterial, result.KeyMaterial);
|
||||
_mockCeremonyProvider.Verify(
|
||||
c => c.MarkCeremonyExecutedAsync(ceremonyId, "admin@example.com", It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRecovery_WithPendingCeremony_Fails()
|
||||
{
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.GetCeremonyStatusAsync(ceremonyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyStatusInfo
|
||||
{
|
||||
CeremonyId = ceremonyId,
|
||||
KeyId = "test-key-003",
|
||||
State = CeremonyState.Pending,
|
||||
CurrentApprovals = 0,
|
||||
RequiredApprovals = 2,
|
||||
Approvers = Array.Empty<string>(),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
RecoveryReason = "Pending",
|
||||
});
|
||||
|
||||
var result = await _service.ExecuteRecoveryAsync(
|
||||
ceremonyId,
|
||||
Array.Empty<KeyShare>(),
|
||||
"admin@example.com");
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KeyEscrowRecoveryIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260112_018_CRYPTO_key_escrow_shamir
|
||||
// Task: ESCROW-012
|
||||
// Description: Integration tests for key escrow recovery workflow.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using StellaOps.Cryptography.KeyEscrow;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests.KeyEscrow;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class KeyEscrowRecoveryIntegrationTests
|
||||
{
|
||||
private readonly Mock<IKeyEscrowService> _mockEscrowService;
|
||||
private readonly Mock<ICeremonyAuthorizationProvider> _mockCeremonyProvider;
|
||||
private readonly Mock<IKeyEscrowAuditLogger> _mockAuditLogger;
|
||||
private readonly CeremonyAuthorizedRecoveryService _service;
|
||||
|
||||
public KeyEscrowRecoveryIntegrationTests()
|
||||
{
|
||||
_mockEscrowService = new Mock<IKeyEscrowService>();
|
||||
_mockCeremonyProvider = new Mock<ICeremonyAuthorizationProvider>();
|
||||
_mockAuditLogger = new Mock<IKeyEscrowAuditLogger>();
|
||||
|
||||
_service = new CeremonyAuthorizedRecoveryService(
|
||||
_mockEscrowService.Object,
|
||||
_mockCeremonyProvider.Object,
|
||||
_mockAuditLogger.Object,
|
||||
TimeProvider.System,
|
||||
new CeremonyAuthorizedRecoveryOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CustodianId = "cust-1",
|
||||
ChecksumHex = "00",
|
||||
},
|
||||
new()
|
||||
{
|
||||
ShareId = Guid.NewGuid(),
|
||||
Index = 2,
|
||||
EncryptedData = new byte[] { 0x02 },
|
||||
KeyId = keyId,
|
||||
Threshold = 2,
|
||||
TotalShares = 3,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CustodianId = "cust-2",
|
||||
ChecksumHex = "01",
|
||||
},
|
||||
};
|
||||
|
||||
var result = await _service.ExecuteRecoveryAsync(ceremonyId, shares, "admin@example.com");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(keyId, result.KeyId);
|
||||
Assert.Equal(keyMaterial, result.KeyMaterial);
|
||||
_mockCeremonyProvider.Verify(
|
||||
c => c.MarkCeremonyExecutedAsync(ceremonyId, "admin@example.com", It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRecovery_WithPendingCeremony_Fails()
|
||||
{
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.GetCeremonyStatusAsync(ceremonyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyStatusInfo
|
||||
{
|
||||
CeremonyId = ceremonyId,
|
||||
KeyId = "test-key-003",
|
||||
State = CeremonyState.Pending,
|
||||
CurrentApprovals = 0,
|
||||
RequiredApprovals = 2,
|
||||
Approvers = Array.Empty<string>(),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
RecoveryReason = "Pending",
|
||||
});
|
||||
|
||||
var result = await _service.ExecuteRecoveryAsync(
|
||||
ceremonyId,
|
||||
Array.Empty<KeyShare>(),
|
||||
"admin@example.com");
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}// -----------------------------------------------------------------------------
|
||||
// KeyEscrowRecoveryIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260112_018_CRYPTO_key_escrow_shamir
|
||||
// Task: ESCROW-012
|
||||
// Description: Integration tests for key escrow recovery workflow.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using StellaOps.Cryptography.KeyEscrow;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests.KeyEscrow;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for key escrow recovery workflow with dual-control ceremonies.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class KeyEscrowRecoveryIntegrationTests
|
||||
{
|
||||
private readonly Mock<IKeyEscrowService> _mockEscrowService;
|
||||
private readonly Mock<ICeremonyAuthorizationProvider> _mockCeremonyProvider;
|
||||
private readonly Mock<IKeyEscrowAuditLogger> _mockAuditLogger;
|
||||
private readonly CeremonyAuthorizedRecoveryService _service;
|
||||
|
||||
public KeyEscrowRecoveryIntegrationTests()
|
||||
{
|
||||
_mockEscrowService = new Mock<IKeyEscrowService>();
|
||||
_mockCeremonyProvider = new Mock<ICeremonyAuthorizationProvider>();
|
||||
_mockAuditLogger = new Mock<IKeyEscrowAuditLogger>();
|
||||
|
||||
_service = new CeremonyAuthorizedRecoveryService(
|
||||
_mockEscrowService.Object,
|
||||
_mockCeremonyProvider.Object,
|
||||
_mockAuditLogger.Object,
|
||||
TimeProvider.System,
|
||||
new CeremonyAuthorizedRecoveryOptions
|
||||
{
|
||||
CeremonyApprovalThreshold = 2,
|
||||
CeremonyExpirationMinutes = 60,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitiateRecovery_WithValidKey_CreatesCeremony()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = "test-key-001";
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
|
||||
_mockEscrowService
|
||||
.Setup(e => e.GetEscrowStatusAsync(keyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new KeyEscrowStatus
|
||||
{
|
||||
KeyId = keyId,
|
||||
IsEscrowed = true,
|
||||
Threshold = 2,
|
||||
TotalShares = 3,
|
||||
ValidShares = 3,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
});
|
||||
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.CreateCeremonyAsync(It.IsAny<CeremonyAuthorizationRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyCreationResult
|
||||
{
|
||||
Success = true,
|
||||
CeremonyId = ceremonyId,
|
||||
RequiredApprovals = 2,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(60),
|
||||
});
|
||||
|
||||
}
|
||||
[Fact]
|
||||
public async Task ExecuteRecovery_WithPendingCeremony_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.GetCeremonyStatusAsync(ceremonyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyStatusInfo
|
||||
{
|
||||
CeremonyId = ceremonyId,
|
||||
KeyId = "test-key-003",
|
||||
State = CeremonyState.Pending,
|
||||
CurrentApprovals = 0,
|
||||
RequiredApprovals = 2,
|
||||
Approvers = Array.Empty<string>(),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
RecoveryReason = "Pending",
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.ExecuteRecoveryAsync(
|
||||
ceremonyId,
|
||||
Array.Empty<KeyShare>(),
|
||||
"admin@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
|
||||
var shares = new List<KeyShare>
|
||||
{
|
||||
new KeyShare { ShareId = Guid.NewGuid(), Index = 1, EncryptedData = new byte[] { 10, 11 } },
|
||||
new KeyShare { ShareId = Guid.NewGuid(), Index = 2, EncryptedData = new byte[] { 20, 21 } },
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ExecuteRecoveryAsync(ceremonyId, shares, "executor@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(keyMaterial, result.RecoveredKey);
|
||||
_mockCeremonyProvider.Verify(
|
||||
c => c.MarkCeremonyExecutedAsync(ceremonyId, "executor@example.com", It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRecovery_WithPendingCeremony_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.GetCeremonyStatusAsync(ceremonyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyStatusInfo
|
||||
{
|
||||
CeremonyId = ceremonyId,
|
||||
KeyId = "test-key",
|
||||
State = CeremonyState.Pending,
|
||||
CurrentApprovals = 0,
|
||||
RequiredApprovals = 2,
|
||||
});
|
||||
|
||||
var shares = new List<KeyShare>();
|
||||
|
||||
// Act
|
||||
var result = await _service.ExecuteRecoveryAsync(ceremonyId, shares, "executor@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not approved", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRecovery_WithExpiredCeremony_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.GetCeremonyStatusAsync(ceremonyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyStatusInfo
|
||||
{
|
||||
CeremonyId = ceremonyId,
|
||||
KeyId = "test-key",
|
||||
State = CeremonyState.Approved,
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddMinutes(-5), // Expired
|
||||
});
|
||||
|
||||
var shares = new List<KeyShare>();
|
||||
|
||||
// Act
|
||||
var result = await _service.ExecuteRecoveryAsync(ceremonyId, shares, "executor@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("expired", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRecovery_WithMissingCeremony_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.GetCeremonyStatusAsync(ceremonyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CeremonyStatusInfo?)null);
|
||||
|
||||
var shares = new List<KeyShare>();
|
||||
|
||||
// Act
|
||||
var result = await _service.ExecuteRecoveryAsync(ceremonyId, shares, "executor@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not found", result.Error);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Full Workflow Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FullRecoveryWorkflow_WithValidShares_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = "production-signing-key";
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
var keyMaterial = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08 };
|
||||
|
||||
// Setup escrow status
|
||||
_mockEscrowService
|
||||
.Setup(e => e.GetEscrowStatusAsync(keyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new KeyEscrowStatusResult
|
||||
{
|
||||
Exists = true,
|
||||
KeyId = keyId,
|
||||
Threshold = 2,
|
||||
TotalShares = 3,
|
||||
IsExpired = false,
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddDays(30),
|
||||
});
|
||||
|
||||
// Setup ceremony creation
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.CreateCeremonyAsync(It.IsAny<CeremonyAuthorizationRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyCreationResult
|
||||
{
|
||||
Success = true,
|
||||
CeremonyId = ceremonyId,
|
||||
RequiredApprovals = 2,
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddMinutes(60),
|
||||
});
|
||||
|
||||
// Setup ceremony status (approved)
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.GetCeremonyStatusAsync(ceremonyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyStatusInfo
|
||||
{
|
||||
CeremonyId = ceremonyId,
|
||||
KeyId = keyId,
|
||||
State = CeremonyState.Approved,
|
||||
CurrentApprovals = 2,
|
||||
RequiredApprovals = 2,
|
||||
Approvers = new List<string> { "approver1@example.com", "approver2@example.com" },
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddMinutes(30),
|
||||
});
|
||||
|
||||
// Setup recovery
|
||||
_mockEscrowService
|
||||
.Setup(e => e.RecoverKeyAsync(It.IsAny<KeyRecoveryRequest>(), It.IsAny<IReadOnlyList<KeyShare>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new KeyRecoveryResult
|
||||
{
|
||||
Success = true,
|
||||
KeyId = keyId,
|
||||
RecoveredKey = keyMaterial,
|
||||
});
|
||||
|
||||
// Act - Step 1: Initiate
|
||||
var initRequest = new KeyRecoveryRequest
|
||||
{
|
||||
KeyId = keyId,
|
||||
RecoveryReason = "Emergency key rotation",
|
||||
};
|
||||
var initResult = await _service.InitiateRecoveryAsync(initRequest, "admin@example.com");
|
||||
Assert.True(initResult.Success);
|
||||
|
||||
// Step 2: (Approvals would happen externally via ceremony service)
|
||||
|
||||
// Step 3: Execute with shares
|
||||
var shares = new List<KeyShare>
|
||||
{
|
||||
new KeyShare { ShareId = Guid.NewGuid(), Index = 1, EncryptedData = new byte[] { 10, 11 } },
|
||||
new KeyShare { ShareId = Guid.NewGuid(), Index = 2, EncryptedData = new byte[] { 20, 21 } },
|
||||
};
|
||||
var executeResult = await _service.ExecuteRecoveryAsync(initResult.CeremonyId, shares, "executor@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.True(executeResult.Success);
|
||||
Assert.Equal(keyMaterial, executeResult.RecoveredKey);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Audit Trail Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InitiateRecovery_LogsAuditEvent()
|
||||
{
|
||||
// Arrange
|
||||
var keyId = "test-key";
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
|
||||
_mockEscrowService
|
||||
.Setup(e => e.GetEscrowStatusAsync(keyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new KeyEscrowStatusResult { Exists = true, KeyId = keyId });
|
||||
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.CreateCeremonyAsync(It.IsAny<CeremonyAuthorizationRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyCreationResult { Success = true, CeremonyId = ceremonyId });
|
||||
|
||||
var request = new KeyRecoveryRequest { KeyId = keyId, RecoveryReason = "Test" };
|
||||
|
||||
// Act
|
||||
await _service.InitiateRecoveryAsync(request, "admin@example.com");
|
||||
|
||||
// Assert
|
||||
_mockAuditLogger.Verify(
|
||||
a => a.LogRecoveryAsync(
|
||||
It.Is<KeyEscrowAuditEvent>(e =>
|
||||
e.EventType == KeyEscrowAuditEventType.RecoveryInitiated &&
|
||||
e.KeyId == keyId &&
|
||||
e.InitiatorId == "admin@example.com"),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRecovery_LogsAuditEvent()
|
||||
{
|
||||
// Arrange
|
||||
var ceremonyId = Guid.NewGuid();
|
||||
var keyId = "test-key";
|
||||
|
||||
_mockCeremonyProvider
|
||||
.Setup(c => c.GetCeremonyStatusAsync(ceremonyId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CeremonyStatusInfo
|
||||
{
|
||||
CeremonyId = ceremonyId,
|
||||
KeyId = keyId,
|
||||
State = CeremonyState.Approved,
|
||||
Approvers = new List<string> { "approver1", "approver2" },
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddMinutes(30),
|
||||
});
|
||||
|
||||
_mockEscrowService
|
||||
.Setup(e => e.RecoverKeyAsync(It.IsAny<KeyRecoveryRequest>(), It.IsAny<IReadOnlyList<KeyShare>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new KeyRecoveryResult { Success = true, KeyId = keyId });
|
||||
|
||||
var shares = new List<KeyShare>();
|
||||
|
||||
// Act
|
||||
await _service.ExecuteRecoveryAsync(ceremonyId, shares, "executor@example.com");
|
||||
|
||||
// Assert
|
||||
_mockAuditLogger.Verify(
|
||||
a => a.LogRecoveryAsync(
|
||||
It.Is<KeyEscrowAuditEvent>(e =>
|
||||
e.EventType == KeyEscrowAuditEventType.KeyRecovered &&
|
||||
e.KeyId == keyId &&
|
||||
e.CeremonyId == ceremonyId),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock time provider for testing.
|
||||
/// </summary>
|
||||
internal sealed class MockTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
|
||||
|
||||
public void SetNow(DateTimeOffset now) => _now = now;
|
||||
}
|
||||
|
||||
// Stub models for compilation - actual implementation exists in main codebase
|
||||
public sealed class KeyEscrowStatusResult
|
||||
{
|
||||
public bool Exists { get; init; }
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
public int Threshold { get; init; }
|
||||
public int TotalShares { get; init; }
|
||||
public bool IsExpired { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
public interface IKeyEscrowService
|
||||
{
|
||||
Task<KeyEscrowStatusResult> GetEscrowStatusAsync(string keyId, CancellationToken cancellationToken = default);
|
||||
Task<KeyRecoveryResult> RecoverKeyAsync(KeyRecoveryRequest request, IReadOnlyList<KeyShare> shares, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IKeyEscrowAuditLogger
|
||||
{
|
||||
Task LogRecoveryAsync(KeyEscrowAuditEvent evt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class KeyEscrowAuditEvent
|
||||
{
|
||||
public Guid EventId { get; init; }
|
||||
public KeyEscrowAuditEventType EventType { get; init; }
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public string InitiatorId { get; init; } = string.Empty;
|
||||
public Guid? CeremonyId { get; init; }
|
||||
public IReadOnlyList<string>? CustodianIds { get; init; }
|
||||
public bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public enum KeyEscrowAuditEventType
|
||||
{
|
||||
KeyEscrowed,
|
||||
RecoveryInitiated,
|
||||
KeyRecovered,
|
||||
}
|
||||
|
||||
public sealed class KeyRecoveryRequest
|
||||
{
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
public string RecoveryReason { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> AuthorizingCustodians { get; init; } = Array.Empty<string>();
|
||||
public Guid? CeremonyId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class KeyRecoveryResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public byte[]? RecoveredKey { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public sealed class KeyShare
|
||||
{
|
||||
public Guid ShareId { get; init; }
|
||||
public int Index { get; init; }
|
||||
public byte[] EncryptedData { get; init; } = Array.Empty<byte>();
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// Copyright © StellaOps. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_018_CRYPTO_key_escrow_shamir
|
||||
// Tasks: ESCROW-011
|
||||
|
||||
using StellaOps.Cryptography.KeyEscrow;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for Shamir's Secret Sharing implementation.
|
||||
/// </summary>
|
||||
public sealed class ShamirSecretSharingTests
|
||||
{
|
||||
private readonly ShamirSecretSharing _shamir = new();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GF(2^8) Arithmetic Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void GF256_Add_IsXor()
|
||||
{
|
||||
Assert.Equal(0x00, GaloisField256.Add(0x57, 0x57)); // a XOR a = 0
|
||||
Assert.Equal(0x57, GaloisField256.Add(0x57, 0x00)); // a XOR 0 = a
|
||||
Assert.Equal(0xFE, GaloisField256.Add(0x57, 0xA9)); // 0x57 XOR 0xA9
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GF256_Subtract_SameAsAdd()
|
||||
{
|
||||
Assert.Equal(GaloisField256.Add(0x57, 0x83), GaloisField256.Subtract(0x57, 0x83));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GF256_Multiply_KnownValues()
|
||||
{
|
||||
Assert.Equal(0x00, GaloisField256.Multiply(0x00, 0x57)); // 0 * a = 0
|
||||
Assert.Equal(0x57, GaloisField256.Multiply(0x01, 0x57)); // 1 * a = a
|
||||
Assert.Equal(0xC1, GaloisField256.Multiply(0x57, 0x83)); // Known AES value (FIPS-197)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GF256_Inverse_Correct()
|
||||
{
|
||||
// a * a^(-1) = 1 for all non-zero a
|
||||
for (int a = 1; a < 256; a++)
|
||||
{
|
||||
byte inv = GaloisField256.Inverse((byte)a);
|
||||
byte product = GaloisField256.Multiply((byte)a, inv);
|
||||
Assert.Equal(1, product);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GF256_Inverse_Zero_ReturnsZero()
|
||||
{
|
||||
Assert.Equal(0, GaloisField256.Inverse(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GF256_Divide_ByZero_Throws()
|
||||
{
|
||||
Assert.Throws<DivideByZeroException>(() => GaloisField256.Divide(0x57, 0x00));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GF256_Divide_Correct()
|
||||
{
|
||||
// a / b = a * b^(-1)
|
||||
byte a = 0x57;
|
||||
byte b = 0x83;
|
||||
byte quotient = GaloisField256.Divide(a, b);
|
||||
Assert.Equal(a, GaloisField256.Multiply(quotient, b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GF256_Power_Correct()
|
||||
{
|
||||
Assert.Equal(1, GaloisField256.Power(0x57, 0)); // a^0 = 1
|
||||
Assert.Equal(0x57, GaloisField256.Power(0x57, 1)); // a^1 = a
|
||||
Assert.Equal(GaloisField256.Multiply(0x57, 0x57), GaloisField256.Power(0x57, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GF256_EvaluatePolynomial_Constant()
|
||||
{
|
||||
byte[] coeffs = [0x42];
|
||||
Assert.Equal(0x42, GaloisField256.EvaluatePolynomial(coeffs, 0x00));
|
||||
Assert.Equal(0x42, GaloisField256.EvaluatePolynomial(coeffs, 0xFF));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GF256_EvaluatePolynomial_Linear()
|
||||
{
|
||||
// p(x) = 0x42 + 0x13 * x
|
||||
byte[] coeffs = [0x42, 0x13];
|
||||
byte x = 0x05;
|
||||
byte expected = GaloisField256.Add(0x42, GaloisField256.Multiply(0x13, x));
|
||||
Assert.Equal(expected, GaloisField256.EvaluatePolynomial(coeffs, x));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GF256_LagrangeInterpolation_SinglePoint()
|
||||
{
|
||||
byte[] xValues = [0x01];
|
||||
byte[] yValues = [0x42];
|
||||
// With one point (1, 0x42), constant polynomial, L(0) = 0x42
|
||||
Assert.Equal(0x42, GaloisField256.LagrangeInterpolateAtZero(xValues, yValues));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Split/Combine Round-Trip Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Theory]
|
||||
[InlineData(2, 2)]
|
||||
[InlineData(2, 3)]
|
||||
[InlineData(3, 5)]
|
||||
[InlineData(5, 10)]
|
||||
public void Split_Combine_RoundTrip_SingleByte(int threshold, int totalShares)
|
||||
{
|
||||
byte[] secret = [0x42];
|
||||
var shares = _shamir.Split(secret, threshold, totalShares);
|
||||
|
||||
Assert.Equal(totalShares, shares.Length);
|
||||
|
||||
// Combine with exactly threshold shares
|
||||
var selectedShares = shares.Take(threshold).ToArray();
|
||||
var recovered = _shamir.Combine(selectedShares);
|
||||
|
||||
Assert.Equal(secret, recovered);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2, 3)]
|
||||
[InlineData(3, 5)]
|
||||
[InlineData(5, 10)]
|
||||
public void Split_Combine_RoundTrip_MultipleBytes(int threshold, int totalShares)
|
||||
{
|
||||
byte[] secret = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
|
||||
var shares = _shamir.Split(secret, threshold, totalShares);
|
||||
|
||||
var selectedShares = shares.Take(threshold).ToArray();
|
||||
var recovered = _shamir.Combine(selectedShares);
|
||||
|
||||
Assert.Equal(secret, recovered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_Combine_RoundTrip_256ByteSecret()
|
||||
{
|
||||
// Test with a full AES key (32 bytes)
|
||||
byte[] secret = new byte[32];
|
||||
new Random(42).NextBytes(secret);
|
||||
|
||||
var shares = _shamir.Split(secret, 3, 5);
|
||||
var recovered = _shamir.Combine(shares.Take(3).ToArray());
|
||||
|
||||
Assert.Equal(secret, recovered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_WithMoreThanThreshold_Succeeds()
|
||||
{
|
||||
byte[] secret = [0xDE, 0xAD, 0xBE, 0xEF];
|
||||
var shares = _shamir.Split(secret, 3, 5);
|
||||
|
||||
// Use 4 shares (more than threshold of 3)
|
||||
var recovered = _shamir.Combine(shares.Take(4).ToArray());
|
||||
Assert.Equal(secret, recovered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_WithAllShares_Succeeds()
|
||||
{
|
||||
byte[] secret = [0xCA, 0xFE];
|
||||
var shares = _shamir.Split(secret, 3, 5);
|
||||
|
||||
// Use all 5 shares
|
||||
var recovered = _shamir.Combine(shares);
|
||||
Assert.Equal(secret, recovered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_AnySubsetOfThreshold_Succeeds()
|
||||
{
|
||||
byte[] secret = [0x12, 0x34, 0x56, 0x78];
|
||||
var shares = _shamir.Split(secret, 3, 5);
|
||||
|
||||
// Test all combinations of 3 shares
|
||||
var indices = new[] { 0, 1, 2, 3, 4 };
|
||||
var combinations = GetCombinations(indices, 3);
|
||||
|
||||
foreach (var combo in combinations)
|
||||
{
|
||||
var selectedShares = combo.Select(i => shares[i]).ToArray();
|
||||
var recovered = _shamir.Combine(selectedShares);
|
||||
Assert.Equal(secret, recovered);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Parameter Validation Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void Split_NullSecret_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => _shamir.Split(null!, 2, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_EmptySecret_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => _shamir.Split([], 2, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_ThresholdTooLow_Throws()
|
||||
{
|
||||
byte[] secret = [0x42];
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _shamir.Split(secret, 1, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_TotalSharesLessThanThreshold_Throws()
|
||||
{
|
||||
byte[] secret = [0x42];
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _shamir.Split(secret, 5, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_TotalSharesExceeds255_Throws()
|
||||
{
|
||||
byte[] secret = [0x42];
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _shamir.Split(secret, 2, 256));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_NullShares_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => _shamir.Combine(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_TooFewShares_Throws()
|
||||
{
|
||||
byte[] secret = [0x42];
|
||||
var shares = _shamir.Split(secret, 3, 5);
|
||||
Assert.Throws<ArgumentException>(() => _shamir.Combine([shares[0]]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_InconsistentDataLength_Throws()
|
||||
{
|
||||
var shares = new ShamirShare[]
|
||||
{
|
||||
new() { Index = 1, Data = [0x01, 0x02] },
|
||||
new() { Index = 2, Data = [0x03] }, // Different length
|
||||
};
|
||||
Assert.Throws<ArgumentException>(() => _shamir.Combine(shares));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_DuplicateIndices_Throws()
|
||||
{
|
||||
var shares = new ShamirShare[]
|
||||
{
|
||||
new() { Index = 1, Data = [0x01] },
|
||||
new() { Index = 1, Data = [0x02] }, // Duplicate index
|
||||
};
|
||||
Assert.Throws<ArgumentException>(() => _shamir.Combine(shares));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_ZeroIndex_Throws()
|
||||
{
|
||||
var shares = new ShamirShare[]
|
||||
{
|
||||
new() { Index = 0, Data = [0x01] }, // Invalid index
|
||||
new() { Index = 1, Data = [0x02] },
|
||||
};
|
||||
Assert.Throws<ArgumentException>(() => _shamir.Combine(shares));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Security Property Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void Split_SharesAreRandom()
|
||||
{
|
||||
byte[] secret = [0x42];
|
||||
|
||||
// Split the same secret twice
|
||||
var shares1 = _shamir.Split(secret, 2, 3);
|
||||
var shares2 = _shamir.Split(secret, 2, 3);
|
||||
|
||||
// Shares should be different (with overwhelming probability)
|
||||
bool allSame = true;
|
||||
for (int i = 0; i < shares1.Length; i++)
|
||||
{
|
||||
if (!shares1[i].Data.SequenceEqual(shares2[i].Data))
|
||||
{
|
||||
allSame = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.False(allSame, "Shares should be randomized");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_ShareIndicesAreSequential()
|
||||
{
|
||||
byte[] secret = [0x42, 0x43];
|
||||
var shares = _shamir.Split(secret, 2, 5);
|
||||
|
||||
for (int i = 0; i < shares.Length; i++)
|
||||
{
|
||||
Assert.Equal(i + 1, shares[i].Index);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ValidShares_ReturnsTrue()
|
||||
{
|
||||
byte[] secret = [0xDE, 0xAD, 0xBE, 0xEF];
|
||||
var shares = _shamir.Split(secret, 3, 5);
|
||||
|
||||
Assert.True(_shamir.Verify(shares.Take(3).ToArray()));
|
||||
Assert.True(_shamir.Verify(shares.Take(4).ToArray()));
|
||||
Assert.True(_shamir.Verify(shares));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Determinism Tests (for test reproducibility)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void Combine_IsDeterministic()
|
||||
{
|
||||
// Given the same shares, combine should always produce the same result
|
||||
var shares = new ShamirShare[]
|
||||
{
|
||||
new() { Index = 1, Data = [0x01, 0x02, 0x03] },
|
||||
new() { Index = 2, Data = [0x04, 0x05, 0x06] },
|
||||
new() { Index = 3, Data = [0x07, 0x08, 0x09] },
|
||||
};
|
||||
|
||||
var result1 = _shamir.Combine(shares);
|
||||
var result2 = _shamir.Combine(shares);
|
||||
|
||||
Assert.Equal(result1, result2);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Helper Methods
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
private static IEnumerable<int[]> GetCombinations(int[] elements, int k)
|
||||
{
|
||||
if (k == 0)
|
||||
{
|
||||
yield return [];
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (elements.Length == k)
|
||||
{
|
||||
yield return elements;
|
||||
yield break;
|
||||
}
|
||||
|
||||
for (int i = 0; i <= elements.Length - k; i++)
|
||||
{
|
||||
foreach (var rest in GetCombinations(elements[(i + 1)..], k - 1))
|
||||
{
|
||||
yield return [elements[i], .. rest];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.Hsm\StellaOps.Cryptography.Plugin.Hsm.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="KeyEscrow/KeyEscrowRecoveryIntegrationTests.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user