denuncias

This commit is contained in:
2026-06-08 12:58:30 +02:00
parent 8163928623
commit ff2867d916
51 changed files with 4012 additions and 766 deletions

View File

@@ -8,12 +8,13 @@ using Microsoft.AspNetCore.DataProtection;
namespace ApiDenuncias.Services;
public sealed class EncryptedDenunciaStore : IDenunciaStore
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)
@@ -22,17 +23,23 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
.ToArray();
private readonly MySqlDenunciaStore _inner;
private readonly IEncryptionKeyProvider _encryptionKeyProvider;
private readonly IEnvelopeEncryptionKeyProvider _envelopeKeyProvider;
private readonly IEncryptionKeyProvider _legacyKeyProvider;
private readonly IDataProtector _protector;
private readonly ILogger<EncryptedDenunciaStore> _logger;
public EncryptedDenunciaStore(
MySqlDenunciaStore inner,
IEncryptionKeyProvider encryptionKeyProvider,
IDataProtectionProvider dataProtectionProvider)
IEnvelopeEncryptionKeyProvider envelopeKeyProvider,
IEncryptionKeyProvider legacyKeyProvider,
IDataProtectionProvider dataProtectionProvider,
ILogger<EncryptedDenunciaStore> logger)
{
_inner = inner;
_encryptionKeyProvider = encryptionKeyProvider;
_envelopeKeyProvider = envelopeKeyProvider;
_legacyKeyProvider = legacyKeyProvider;
_protector = dataProtectionProvider.CreateProtector("ApiDenuncias.DatabaseSensitiveData.v1");
_logger = logger;
}
public Task EnsureSchemaAsync(CancellationToken cancellationToken = default)
@@ -40,44 +47,121 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
public async Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetAllDenunciasAsync(cancellationToken))
.Select(denuncia => UnprotectComplaint(denuncia, key))
.ToList();
var denuncias = await _inner.GetAllDenunciasAsync(cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByScopeAsync(
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
var denuncias = await _inner.GetDenunciasByScopeAsync(scope, cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default)
{
var denuncias = await _inner.GetDenunciasByIdsAsync(denunciaIds, cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
var denuncias = await _inner.GetDenunciasByIdsAsync(denunciaIds, scope, cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
private async Task<List<DenunciasGestiona>> UnprotectComplaintsAsync(
List<DenunciasGestiona> denuncias,
bool skipPurgedRows,
CancellationToken cancellationToken)
{
var result = new List<DenunciasGestiona>(denuncias.Count);
var requestKeyCache = new Dictionary<DateOnly, byte[]>();
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<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetAllFicherosAsync(cancellationToken))
.Select(fichero => UnprotectAttachment(fichero, key))
.ToList();
var ficheros = await _inner.GetAllFicherosAsync(cancellationToken);
return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: true, cancellationToken);
}
public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default)
{
var ficheros = await _inner.GetFicherosByDenunciaIdsAsync(denunciaIds, cancellationToken);
return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: true, cancellationToken);
}
public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetFicherosByDenunciaAsync(denunciaId, cancellationToken))
.Select(fichero => UnprotectAttachment(fichero, key))
.ToList();
var ficheros = await _inner.GetFicherosByDenunciaAsync(denunciaId, cancellationToken);
return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: false, cancellationToken);
}
private async Task<List<FicherosDenuncias>> UnprotectAttachmentsAsync(
List<FicherosDenuncias> ficheros,
bool skipPurgedRows,
CancellationToken cancellationToken)
{
var result = new List<FicherosDenuncias>(ficheros.Count);
var requestKeyCache = new Dictionary<DateOnly, byte[]>();
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<DenunciasGestiona?> GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
var denuncia = await _inner.GetDenunciaByIdAsync(denunciaId, cancellationToken);
return denuncia is null ? null : UnprotectComplaint(denuncia, key);
return denuncia is null ? null : await UnprotectComplaintAsync(denuncia, [], cancellationToken);
}
public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken);
await _inner.UpsertDenunciaAsync(ProtectComplaint(denuncia, key), cancellationToken);
}
public async Task UpsertFicherosAsync(IEnumerable<FicherosDenuncias> ficheros, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken);
await _inner.UpsertFicherosAsync(ficheros.Select(fichero => ProtectAttachment(fichero, key)).ToArray(), cancellationToken);
}
@@ -88,13 +172,30 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
CancellationToken cancellationToken = default)
=> _inner.MarkFicherosAsUploadedAsync(denunciaId, fileNames, uploadedAtUtc, cancellationToken);
private DenunciasGestiona ProtectComplaint(DenunciasGestiona source, byte[] key)
=> TransformComplaint(ToPersistentComplaint(source), value => ProtectString(value, key));
private DenunciasGestiona UnprotectComplaint(DenunciasGestiona source, byte[] key)
private DenunciasGestiona ProtectComplaint(DenunciasGestiona source, EncryptionKeyMaterial key)
{
var decrypted = TransformComplaint(source, value => UnprotectString(value, key));
return RebuildComplaintFromPayload(decrypted);
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<DenunciasGestiona> UnprotectComplaintAsync(
DenunciasGestiona source,
Dictionary<DateOnly, byte[]> 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)
@@ -209,7 +310,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
return target;
}
private FicherosDenuncias ProtectAttachment(FicherosDenuncias source, byte[] key)
private FicherosDenuncias ProtectAttachment(FicherosDenuncias source, EncryptionKeyMaterial key)
{
var content = source.Fichero ?? [];
var hash = string.IsNullOrWhiteSpace(source.ContentSha256)
@@ -220,36 +321,67 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
{
Id_Fichero = source.Id_Fichero,
Id_Tipo = source.Id_Tipo,
Descripcion = ProtectString(source.Descripcion ?? string.Empty, key),
Descripcion = ProtectString(source.Descripcion ?? string.Empty, key.Key),
Fecha = source.Fecha,
Observaciones = ProtectString(source.Observaciones ?? string.Empty, key),
Observaciones = ProtectString(source.Observaciones ?? string.Empty, key.Key),
Id_Denuncia = source.Id_Denuncia,
NombreFichero = source.NombreFichero,
Fichero = ProtectBytes(content, key),
Fichero = ProtectBytes(content, key.Key),
Subido = source.Subido,
FechaSubida = source.FechaSubida,
ContentSha256 = hash
ContentSha256 = hash,
KeyDate = key.KeyDate,
EncryptionScheme = key.Scheme,
EncryptedAtUtc = DateTime.UtcNow
};
}
private FicherosDenuncias UnprotectAttachment(FicherosDenuncias source, byte[] key)
private async Task<FicherosDenuncias> UnprotectAttachmentAsync(
FicherosDenuncias source,
Dictionary<DateOnly, byte[]> 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),
Descripcion = UnprotectString(source.Descripcion ?? string.Empty, key, source.KeyDate),
Fecha = source.Fecha,
Observaciones = UnprotectString(source.Observaciones ?? string.Empty, key),
Observaciones = UnprotectString(source.Observaciones ?? string.Empty, key, source.KeyDate),
Id_Denuncia = source.Id_Denuncia,
NombreFichero = source.NombreFichero,
Fichero = UnprotectBytes(source.Fichero ?? [], key),
Fichero = UnprotectBytes(source.Fichero ?? [], key, source.KeyDate),
Subido = source.Subido,
FechaSubida = source.FechaSubida,
ContentSha256 = source.ContentSha256
ContentSha256 = source.ContentSha256,
KeyDate = source.KeyDate,
EncryptionScheme = source.EncryptionScheme,
EncryptedAtUtc = source.EncryptedAtUtc
};
}
private async Task<byte[]> ResolveReadKeyAsync(
DateOnly? keyDate,
Dictionary<DateOnly, byte[]> 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) ||
@@ -263,7 +395,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
return KeyVaultStringPrefix + Convert.ToBase64String(encrypted);
}
private string UnprotectString(string value, byte[] key)
private string UnprotectString(string value, byte[] key, DateOnly? keyDate = null)
{
if (string.IsNullOrWhiteSpace(value))
{
@@ -277,8 +409,13 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
var encrypted = Convert.FromBase64String(value[KeyVaultStringPrefix.Length..]);
return Encoding.UTF8.GetString(DecryptBytes(encrypted, key));
}
catch
catch (Exception ex)
{
if (keyDate.HasValue)
{
throw CreatePurgedDataException(keyDate.Value, ex);
}
return value;
}
}
@@ -302,23 +439,40 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
{
if (value.Length == 0 ||
StartsWith(value, KeyVaultBytesPrefix) ||
StartsWith(value, KeyVaultRawBytesPrefix) ||
StartsWith(value, DataProtectionBytesPrefix))
{
return value;
}
var encrypted = EncryptBytes(value, key);
var base64Bytes = Encoding.ASCII.GetBytes(Convert.ToBase64String(encrypted));
return [.. KeyVaultBytesPrefix, .. base64Bytes];
return [.. KeyVaultRawBytesPrefix, .. encrypted];
}
private byte[] UnprotectBytes(byte[] value, byte[] key)
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
@@ -326,8 +480,13 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
var base64 = Encoding.ASCII.GetString(value, KeyVaultBytesPrefix.Length, value.Length - KeyVaultBytesPrefix.Length);
return DecryptBytes(Convert.FromBase64String(base64), key);
}
catch
catch (Exception ex)
{
if (keyDate.HasValue)
{
throw CreatePurgedDataException(keyDate.Value, ex);
}
return value;
}
}
@@ -398,4 +557,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
private static string ComputeSha256Hex(byte[] content)
=> Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
private static EncryptedDataPurgedException CreatePurgedDataException(DateOnly keyDate, Exception innerException)
=> new(keyDate, innerException);
}