564 lines
21 KiB
C#
564 lines
21 KiB
C#
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<EncryptedDenunciaStore> _logger;
|
|
|
|
public EncryptedDenunciaStore(
|
|
MySqlDenunciaStore inner,
|
|
IEnvelopeEncryptionKeyProvider envelopeKeyProvider,
|
|
IEncryptionKeyProvider legacyKeyProvider,
|
|
IDataProtectionProvider dataProtectionProvider,
|
|
ILogger<EncryptedDenunciaStore> 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<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
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 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 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 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<FicherosDenuncias> 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<string> 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<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)
|
|
{
|
|
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<string, string> 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<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, 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<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) ||
|
|
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);
|
|
}
|