// Copyright © StellaOps. All rights reserved. // SPDX-License-Identifier: BUSL-1.1 // 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; /// /// Implementation of key escrow service using Shamir's Secret Sharing. /// 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(); } /// public async Task 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(); var custodianIds = new List(); 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); } } /// public async Task RecoverKeyAsync( KeyRecoveryRequest request, IReadOnlyList 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(); 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); } } /// public async Task 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(), }; } /// public async Task> ListEscrowedKeysAsync( CancellationToken cancellationToken = default) { var keyIds = await _agentStore.ListEscrowedKeyIdsAsync(cancellationToken); var summaries = new List(); 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; } /// public async Task 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; } /// public async Task 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> GetAgentsForDistributionAsync( KeyEscrowOptions options, CancellationToken cancellationToken) { if (options.AgentIds != null && options.AgentIds.Count >= options.TotalShares) { var agents = new List(); 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(), 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, }; } } /// /// Options for the key escrow service. /// public sealed record KeyEscrowServiceOptions { /// /// Default threshold for M-of-N splitting. /// public int DefaultThreshold { get; init; } = 3; /// /// Default total shares for M-of-N splitting. /// public int DefaultTotalShares { get; init; } = 5; /// /// Default expiration in days. /// public int DefaultExpirationDays { get; init; } = 365; /// /// Whether to automatically delete shares after recovery. /// public bool AutoDeleteOnRecovery { get; init; } = false; }