sprints completion. new product advisories prepared

This commit is contained in:
master
2026-01-16 16:30:03 +02:00
parent a927d924e3
commit 4ca3ce8fb4
255 changed files with 42434 additions and 1020 deletions

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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>();
}

View File

@@ -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];
}
}
}
}

View File

@@ -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>