using System.Reflection; using System.Security.Cryptography; using System.Text; using ApiDenuncias.Helpers; using GestionaDenuncias.Shared.Models; using ApiDenuncias.Services; using Microsoft.AspNetCore.DataProtection; namespace ApiDenuncias.Services; public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaStore { private const string DataProtectionStringPrefix = "enc:v1:"; private const string KeyVaultStringPrefix = "enc:v2:"; private static readonly byte[] DataProtectionBytesPrefix = Encoding.ASCII.GetBytes("enc:v1:"); private static readonly byte[] KeyVaultBytesPrefix = Encoding.ASCII.GetBytes("enc:v2:"); private static readonly byte[] KeyVaultRawBytesPrefix = Encoding.ASCII.GetBytes("enc:v3:"); private const int AesGcmNonceSize = 12; private const int AesGcmTagSize = 16; private static readonly PropertyInfo[] ComplaintProperties = typeof(DenunciasGestiona) .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(property => property.CanRead && property.CanWrite) .ToArray(); private readonly MySqlDenunciaStore _inner; private readonly IEnvelopeEncryptionKeyProvider _envelopeKeyProvider; private readonly IEncryptionKeyProvider _legacyKeyProvider; private readonly IDataProtector _protector; private readonly ILogger _logger; public EncryptedDenunciaStore( MySqlDenunciaStore inner, IEnvelopeEncryptionKeyProvider envelopeKeyProvider, IEncryptionKeyProvider legacyKeyProvider, IDataProtectionProvider dataProtectionProvider, ILogger logger) { _inner = inner; _envelopeKeyProvider = envelopeKeyProvider; _legacyKeyProvider = legacyKeyProvider; _protector = dataProtectionProvider.CreateProtector("ApiDenuncias.DatabaseSensitiveData.v1"); _logger = logger; } public Task EnsureSchemaAsync(CancellationToken cancellationToken = default) => _inner.EnsureSchemaAsync(cancellationToken); public async Task> GetAllDenunciasAsync(CancellationToken cancellationToken = default) { var denuncias = await _inner.GetAllDenunciasAsync(cancellationToken); return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken); } public async Task> GetDenunciasByScopeAsync( DenunciaListScope scope, CancellationToken cancellationToken = default) { var denuncias = await _inner.GetDenunciasByScopeAsync(scope, cancellationToken); return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken); } public async Task> GetDenunciasByIdsAsync( IReadOnlyCollection denunciaIds, CancellationToken cancellationToken = default) { var denuncias = await _inner.GetDenunciasByIdsAsync(denunciaIds, cancellationToken); return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken); } public async Task> GetDenunciasByIdsAsync( IReadOnlyCollection denunciaIds, DenunciaListScope scope, CancellationToken cancellationToken = default) { var denuncias = await _inner.GetDenunciasByIdsAsync(denunciaIds, scope, cancellationToken); return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken); } private async Task> UnprotectComplaintsAsync( List denuncias, bool skipPurgedRows, CancellationToken cancellationToken) { var result = new List(denuncias.Count); var requestKeyCache = new Dictionary(); foreach (var denuncia in denuncias) { try { result.Add(await UnprotectComplaintAsync(denuncia, requestKeyCache, cancellationToken)); } catch (EncryptedDataPurgedException ex) when (skipPurgedRows) { _logger.LogWarning( "Se omite la denuncia {DenunciaId} en el listado porque sus datos estan purgados para la clave diaria {KeyDate}.", denuncia.Id_Denuncia, ex.KeyDate); } } return result; } public async Task> GetAllFicherosAsync(CancellationToken cancellationToken = default) { var ficheros = await _inner.GetAllFicherosAsync(cancellationToken); return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: true, cancellationToken); } public async Task> GetFicherosByDenunciaIdsAsync( IReadOnlyCollection denunciaIds, CancellationToken cancellationToken = default) { var ficheros = await _inner.GetFicherosByDenunciaIdsAsync(denunciaIds, cancellationToken); return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: true, cancellationToken); } public async Task> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default) { var ficheros = await _inner.GetFicherosByDenunciaAsync(denunciaId, cancellationToken); return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: false, cancellationToken); } private async Task> UnprotectAttachmentsAsync( List ficheros, bool skipPurgedRows, CancellationToken cancellationToken) { var result = new List(ficheros.Count); var requestKeyCache = new Dictionary(); foreach (var fichero in ficheros) { try { result.Add(await UnprotectAttachmentAsync(fichero, requestKeyCache, cancellationToken)); } catch (EncryptedDataPurgedException ex) when (skipPurgedRows) { _logger.LogWarning( "Se omite el adjunto {AttachmentId} de la denuncia {DenunciaId} en el listado porque sus datos estan purgados para la clave diaria {KeyDate}.", fichero.Id_Fichero, fichero.Id_Denuncia, ex.KeyDate); } } return result; } public async Task GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default) { var denuncia = await _inner.GetDenunciaByIdAsync(denunciaId, cancellationToken); return denuncia is null ? null : await UnprotectComplaintAsync(denuncia, [], cancellationToken); } public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default) { var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken); await _inner.UpsertDenunciaAsync(ProtectComplaint(denuncia, key), cancellationToken); } public async Task UpsertFicherosAsync(IEnumerable ficheros, CancellationToken cancellationToken = default) { var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken); await _inner.UpsertFicherosAsync(ficheros.Select(fichero => ProtectAttachment(fichero, key)).ToArray(), cancellationToken); } public Task MarkFicherosAsUploadedAsync( int denunciaId, IEnumerable fileNames, DateTime uploadedAtUtc, CancellationToken cancellationToken = default) => _inner.MarkFicherosAsUploadedAsync(denunciaId, fileNames, uploadedAtUtc, cancellationToken); private DenunciasGestiona ProtectComplaint(DenunciasGestiona source, EncryptionKeyMaterial key) { var protectedComplaint = TransformComplaint( ToPersistentComplaint(source), value => ProtectString(value, key.Key)); protectedComplaint.KeyDate = key.KeyDate; protectedComplaint.EncryptionScheme = key.Scheme; protectedComplaint.EncryptedAtUtc = DateTime.UtcNow; return protectedComplaint; } private async Task UnprotectComplaintAsync( DenunciasGestiona source, Dictionary requestKeyCache, CancellationToken cancellationToken) { var key = await ResolveReadKeyAsync(source.KeyDate, requestKeyCache, cancellationToken); var decrypted = TransformComplaint(source, value => UnprotectString(value, key, source.KeyDate)); var rebuilt = RebuildComplaintFromPayload(decrypted); rebuilt.KeyDate = source.KeyDate; rebuilt.EncryptionScheme = source.EncryptionScheme; rebuilt.EncryptedAtUtc = source.EncryptedAtUtc; return rebuilt; } private static DenunciasGestiona ToPersistentComplaint(DenunciasGestiona source) { return new DenunciasGestiona { // Permanentes tecnicos y de trazabilidad. Id_RegistroDenuncia = source.Id_RegistroDenuncia, Id_Denuncia = source.Id_Denuncia, Fecha = source.Fecha, Expediente_Gestiona = source.Expediente_Gestiona, CodigoExpedienteGestiona = source.CodigoExpedienteGestiona, Id_Persona_Gestiona = source.Id_Persona_Gestiona, Etiqueta = source.Etiqueta, Estado = source.Estado, Confidencial = source.Confidencial, EsActualizacion = source.EsActualizacion, ProcedureId = source.ProcedureId, GroupId = source.GroupId, NombreDenuncia = source.NombreDenuncia, EstadoDenuncia = source.EstadoDenuncia, ArchivoElegido = source.ArchivoElegido, FechaSubidaAGestiona = source.FechaSubidaAGestiona, EnGestiona = source.EnGestiona, EnRechazada = source.EnRechazada, // Payload temporal cifrado. Los campos funcionales se derivan de aqui al leer. CamposFormularioJson = source.CamposFormularioJson, TextoOriginalReport = source.TextoOriginalReport }; } private static DenunciasGestiona RebuildComplaintFromPayload(DenunciasGestiona stored) { var rebuilt = TryParseStoredReport(stored) ?? new DenunciasGestiona(); if (rebuilt.Id_Denuncia == 0) { rebuilt.Id_Denuncia = stored.Id_Denuncia; } if (rebuilt.Fecha == DateTime.MinValue) { rebuilt.Fecha = stored.Fecha; } if (string.IsNullOrWhiteSpace(rebuilt.CamposFormularioJson)) { rebuilt.CamposFormularioJson = stored.CamposFormularioJson; } rebuilt.TextoOriginalReport = stored.TextoOriginalReport; ApplyPersistentTechnicalFields(rebuilt, stored); return rebuilt; } private static DenunciasGestiona? TryParseStoredReport(DenunciasGestiona stored) { if (string.IsNullOrWhiteSpace(stored.TextoOriginalReport)) { return null; } try { return ReportParser.ParseReport(stored.TextoOriginalReport); } catch { return null; } } private static void ApplyPersistentTechnicalFields(DenunciasGestiona target, DenunciasGestiona stored) { target.Id_RegistroDenuncia = stored.Id_RegistroDenuncia; target.Id_Denuncia = stored.Id_Denuncia == 0 ? target.Id_Denuncia : stored.Id_Denuncia; target.Expediente_Gestiona = stored.Expediente_Gestiona; target.CodigoExpedienteGestiona = stored.CodigoExpedienteGestiona; target.Id_Persona_Gestiona = stored.Id_Persona_Gestiona; target.Etiqueta = stored.Etiqueta; target.Estado = stored.Estado; target.Confidencial = stored.Confidencial || target.Confidencial; target.EsActualizacion = stored.EsActualizacion; target.ProcedureId = stored.ProcedureId; target.GroupId = stored.GroupId; target.NombreDenuncia = stored.NombreDenuncia; target.EstadoDenuncia = stored.EstadoDenuncia; target.ArchivoElegido = stored.ArchivoElegido; target.FechaSubidaAGestiona = stored.FechaSubidaAGestiona; target.EnGestiona = stored.EnGestiona; target.EnRechazada = stored.EnRechazada; } private static DenunciasGestiona TransformComplaint(DenunciasGestiona source, Func transformString) { var target = new DenunciasGestiona(); foreach (var property in ComplaintProperties) { var value = property.GetValue(source); if (property.PropertyType == typeof(string)) { property.SetValue(target, transformString((string?)value ?? string.Empty)); } else { property.SetValue(target, value); } } return target; } private FicherosDenuncias ProtectAttachment(FicherosDenuncias source, EncryptionKeyMaterial key) { var content = source.Fichero ?? []; var hash = string.IsNullOrWhiteSpace(source.ContentSha256) ? ComputeSha256Hex(content) : source.ContentSha256.Trim().ToLowerInvariant(); return new FicherosDenuncias { Id_Fichero = source.Id_Fichero, Id_Tipo = source.Id_Tipo, Descripcion = ProtectString(source.Descripcion ?? string.Empty, key.Key), Fecha = source.Fecha, Observaciones = ProtectString(source.Observaciones ?? string.Empty, key.Key), Id_Denuncia = source.Id_Denuncia, NombreFichero = source.NombreFichero, Fichero = ProtectBytes(content, key.Key), Subido = source.Subido, FechaSubida = source.FechaSubida, ContentSha256 = hash, KeyDate = key.KeyDate, EncryptionScheme = key.Scheme, EncryptedAtUtc = DateTime.UtcNow }; } private async Task UnprotectAttachmentAsync( FicherosDenuncias source, Dictionary requestKeyCache, CancellationToken cancellationToken) { var key = await ResolveReadKeyAsync(source.KeyDate, requestKeyCache, cancellationToken); return new FicherosDenuncias { Id_Fichero = source.Id_Fichero, Id_Tipo = source.Id_Tipo, Descripcion = UnprotectString(source.Descripcion ?? string.Empty, key, source.KeyDate), Fecha = source.Fecha, Observaciones = UnprotectString(source.Observaciones ?? string.Empty, key, source.KeyDate), Id_Denuncia = source.Id_Denuncia, NombreFichero = source.NombreFichero, Fichero = UnprotectBytes(source.Fichero ?? [], key, source.KeyDate), Subido = source.Subido, FechaSubida = source.FechaSubida, ContentSha256 = source.ContentSha256, KeyDate = source.KeyDate, EncryptionScheme = source.EncryptionScheme, EncryptedAtUtc = source.EncryptedAtUtc }; } private async Task ResolveReadKeyAsync( DateOnly? keyDate, Dictionary requestKeyCache, CancellationToken cancellationToken) { if (keyDate.HasValue) { if (requestKeyCache.TryGetValue(keyDate.Value, out var cached)) { return cached; } var key = await _envelopeKeyProvider.GetKeyForDateAsync(keyDate.Value, cancellationToken); var copy = key.CopyKey(); requestKeyCache[keyDate.Value] = copy; return copy; } return await _legacyKeyProvider.GetKeyAsync(cancellationToken); } private string ProtectString(string value, byte[] key) { if (string.IsNullOrWhiteSpace(value) || value.StartsWith(KeyVaultStringPrefix, StringComparison.Ordinal) || value.StartsWith(DataProtectionStringPrefix, StringComparison.Ordinal)) { return value; } var encrypted = EncryptBytes(Encoding.UTF8.GetBytes(value), key); return KeyVaultStringPrefix + Convert.ToBase64String(encrypted); } private string UnprotectString(string value, byte[] key, DateOnly? keyDate = null) { if (string.IsNullOrWhiteSpace(value)) { return value; } if (value.StartsWith(KeyVaultStringPrefix, StringComparison.Ordinal)) { try { var encrypted = Convert.FromBase64String(value[KeyVaultStringPrefix.Length..]); return Encoding.UTF8.GetString(DecryptBytes(encrypted, key)); } catch (Exception ex) { if (keyDate.HasValue) { throw CreatePurgedDataException(keyDate.Value, ex); } return value; } } if (!value.StartsWith(DataProtectionStringPrefix, StringComparison.Ordinal)) { return value; } try { return _protector.Unprotect(value[DataProtectionStringPrefix.Length..]); } catch { return value; } } private byte[] ProtectBytes(byte[] value, byte[] key) { if (value.Length == 0 || StartsWith(value, KeyVaultBytesPrefix) || StartsWith(value, KeyVaultRawBytesPrefix) || StartsWith(value, DataProtectionBytesPrefix)) { return value; } var encrypted = EncryptBytes(value, key); return [.. KeyVaultRawBytesPrefix, .. encrypted]; } private byte[] UnprotectBytes(byte[] value, byte[] key, DateOnly? keyDate = null) { if (value.Length == 0) { return value; } if (StartsWith(value, KeyVaultRawBytesPrefix)) { try { return DecryptBytes(value[KeyVaultRawBytesPrefix.Length..], key); } catch (Exception ex) { if (keyDate.HasValue) { throw CreatePurgedDataException(keyDate.Value, ex); } return value; } } if (StartsWith(value, KeyVaultBytesPrefix)) { try { var base64 = Encoding.ASCII.GetString(value, KeyVaultBytesPrefix.Length, value.Length - KeyVaultBytesPrefix.Length); return DecryptBytes(Convert.FromBase64String(base64), key); } catch (Exception ex) { if (keyDate.HasValue) { throw CreatePurgedDataException(keyDate.Value, ex); } return value; } } if (!StartsWith(value, DataProtectionBytesPrefix)) { return value; } try { var base64 = Encoding.ASCII.GetString(value, DataProtectionBytesPrefix.Length, value.Length - DataProtectionBytesPrefix.Length); return _protector.Unprotect(Convert.FromBase64String(base64)); } catch { return value; } } private static byte[] EncryptBytes(byte[] plainBytes, byte[] key) { var nonce = RandomNumberGenerator.GetBytes(AesGcmNonceSize); var cipherBytes = new byte[plainBytes.Length]; var tag = new byte[AesGcmTagSize]; using var aes = new AesGcm(key, AesGcmTagSize); aes.Encrypt(nonce, plainBytes, cipherBytes, tag); return [.. nonce, .. tag, .. cipherBytes]; } private static byte[] DecryptBytes(byte[] encryptedBytes, byte[] key) { if (encryptedBytes.Length < AesGcmNonceSize + AesGcmTagSize) { throw new CryptographicException("Payload cifrado invalido."); } var nonce = encryptedBytes.AsSpan(0, AesGcmNonceSize); var tag = encryptedBytes.AsSpan(AesGcmNonceSize, AesGcmTagSize); var cipherBytes = encryptedBytes.AsSpan(AesGcmNonceSize + AesGcmTagSize); var plainBytes = new byte[cipherBytes.Length]; using var aes = new AesGcm(key, AesGcmTagSize); aes.Decrypt(nonce, cipherBytes, tag, plainBytes); return plainBytes; } private static bool StartsWith(byte[] value, byte[] prefix) { if (value.Length < prefix.Length) { return false; } for (var i = 0; i < prefix.Length; i++) { if (value[i] != prefix[i]) { return false; } } return true; } private static string ComputeSha256Hex(byte[] content) => Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant(); private static EncryptedDataPurgedException CreatePurgedDataException(DateOnly keyDate, Exception innerException) => new(keyDate, innerException); }