diff --git a/src/eContabil.Infrastructure/Certificados/CofreComCacheDeExecucao.cs b/src/eContabil.Infrastructure/Certificados/CofreComCacheDeExecucao.cs index c51754c..501eaca 100644 --- a/src/eContabil.Infrastructure/Certificados/CofreComCacheDeExecucao.cs +++ b/src/eContabil.Infrastructure/Certificados/CofreComCacheDeExecucao.cs @@ -41,21 +41,42 @@ public async Task> CarregarAsync( if (_carregados.TryGetValue(localizacao.CertificadoId, out var jaCarregado)) { - return Result.Ok(jaCarregado); + return Result.Ok(Copiar(jaCarregado)); } var carregado = await cofre.CarregarAsync(localizacao, ct); - if (carregado.Sucesso) + if (carregado.Falha) { - // A chave é o identificador do certificado, não o da empresa: a substituição de um - // certificado gera um identificador novo, e o material antigo nunca é servido no lugar dele. - _carregados[localizacao.CertificadoId] = carregado.Valor; + return carregado; } - return carregado; + // A chave é o identificador do certificado, não o da empresa: a substituição de um certificado + // gera um identificador novo, e o material antigo nunca é servido no lugar dele. + _carregados[localizacao.CertificadoId] = carregado.Valor; + + return Result.Ok(Copiar(carregado.Valor)); } + /// + /// Entrega uma cópia do material, nunca a instância guardada. + /// + /// + /// Quem consome o certificado zera os bytes depois de usá-los — é higiene correta, e o serviço + /// fiscal faz isso num finally a cada chamada à SEFAZ. Enquanto cada chamada baixava o + /// arquivo outra vez, ela zerava um array só seu e ninguém se importava. + /// + /// Servindo a instância do cache, a primeira chamada destruía o material das dezenove seguintes: o + /// segundo lote encontrava zeros e o LoadPkcs12 falhava com "ASN1 corrupted data" — classe + /// universal 0 onde se espera 16, que é o decodificador dizendo que o primeiro byte é 0x00 em + /// vez do 0x30 que abre todo DER. A captura parou por doze horas. + /// + /// A cópia custa alguns kilobytes por chamada e torna o defeito impossível, sem depender de nenhum + /// consumidor saber que o array é compartilhado. + /// + private static CertificadoCarregadoDto Copiar(CertificadoCarregadoDto certificado) => + certificado with { Pfx = [.. certificado.Pfx] }; + public Task RemoverAsync(string bucket, string objectName, CancellationToken ct) { // Remover invalida tudo: o arquivo deixou de existir, e servir o que estava em memória entregaria diff --git a/src/eContabil.Infrastructure/Sefaz/ZeusFiscalService.cs b/src/eContabil.Infrastructure/Sefaz/ZeusFiscalService.cs index 814de47..98d9dc9 100644 --- a/src/eContabil.Infrastructure/Sefaz/ZeusFiscalService.cs +++ b/src/eContabil.Infrastructure/Sefaz/ZeusFiscalService.cs @@ -51,12 +51,15 @@ public async Task> ConsultarDistribuicaoAsync( return Result.Falhou(carregado.Erro!); } - // A chave privada vive só nesta chamada: nunca é cacheada entre execuções nem guardada em campo. - using var certificado = X509CertificateLoader.LoadPkcs12( - carregado.Valor.Pfx, carregado.Valor.Senha, LeitorDeCertificado.FlagsDeArmazenamento()); - try { + // Dentro do `try` de propósito. Certificado ilegível é falha esperada — arquivo corrompido, + // senha divergente —, e fora daqui virava exceção: o job era apagado pelo Hangfire sem + // aparecer em "Failed", e nada era gravado no histórico de execuções. A captura já ficou + // doze horas parada sem deixar uma linha que explicasse o motivo. + using var certificado = X509CertificateLoader.LoadPkcs12( + carregado.Valor.Pfx, carregado.Valor.Senha, LeitorDeCertificado.FlagsDeArmazenamento()); + var servico = new ServicosNFe(MontarConfiguracao(uf.Valor, consulta.Ambiente), certificado); var resposta = servico.NfeDistDFeInteresse( @@ -114,11 +117,13 @@ public async Task> ManifestarCienciaAsync( return Result.Falhou(carregado.Erro!); } - using var certificado = X509CertificateLoader.LoadPkcs12( - carregado.Valor.Pfx, carregado.Valor.Senha, LeitorDeCertificado.FlagsDeArmazenamento()); - try { + // Dentro do `try` pelo mesmo motivo da consulta: certificado ilegível é falha esperada e + // precisa virar resultado, não exceção que some do painel. + using var certificado = X509CertificateLoader.LoadPkcs12( + carregado.Valor.Pfx, carregado.Valor.Senha, LeitorDeCertificado.FlagsDeArmazenamento()); + var servico = new ServicosNFe( MontarConfiguracao(uf.Valor, manifestacao.Ambiente), certificado); diff --git a/tests/eContabil.Infrastructure.Tests/Certificados/CarregamentoConcorrenteDoPfxTests.cs b/tests/eContabil.Infrastructure.Tests/Certificados/CarregamentoConcorrenteDoPfxTests.cs new file mode 100644 index 0000000..9d05b19 --- /dev/null +++ b/tests/eContabil.Infrastructure.Tests/Certificados/CarregamentoConcorrenteDoPfxTests.cs @@ -0,0 +1,96 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Xunit; + +namespace eContabil.Infrastructure.Tests.Certificados; + +/// +/// Várias empresas sincronizam ao mesmo tempo, e cada uma abre o próprio certificado. +/// +/// +/// O ciclo enfileira todas as empresas elegíveis de uma vez e os oito workers as pegam em paralelo, de +/// modo que dois ou mais LoadPkcs12 acontecem no mesmo instante. Em produção isso apareceu como +/// ASN1 corrupted data intermitente: a mesma empresa, com o mesmo certificado, falhava numa +/// execução e passava na seguinte cem segundos depois. +/// +/// O teste roda em Linux no CI e no contêiner — que é onde o carregador usa OpenSSL, caminho diferente +/// do usado na máquina Windows de quem desenvolve. +/// +public class CarregamentoConcorrenteDoPfxTests +{ + private const string Senha = "senha-do-teste"; + + [Theory] + [InlineData(8)] + [InlineData(32)] + public void LoadPkcs12_EmParalelo_AbreTodosSemFalhar(int simultaneos) + { + var pfx = GerarPfx(); + var falhas = new ConcurrentBag(); + + Parallel.For(0, simultaneos, _ => + { + try + { + using var certificado = X509CertificateLoader.LoadPkcs12( + pfx, Senha, X509KeyStorageFlags.EphemeralKeySet); + + if (!certificado.HasPrivateKey) + { + falhas.Add("certificado aberto sem chave privada"); + } + } + catch (CryptographicException excecao) + { + falhas.Add($"{excecao.GetType().Name}: {excecao.Message}"); + } + }); + + Assert.True(falhas.IsEmpty, $"{falhas.Count}/{simultaneos} falharam: {string.Join(" | ", falhas.Distinct())}"); + } + + [Theory] + [InlineData(8)] + [InlineData(32)] + public void LoadPkcs12_EmParaleloComArraysDistintos_AbreTodosSemFalhar(int simultaneos) + { + // Distingue as duas causas possíveis: compartilhar o array entre as chamadas, ou o próprio + // carregador não suportar concorrência. Aqui cada thread tem a sua cópia. + var origem = GerarPfx(); + var falhas = new ConcurrentBag(); + + Parallel.For(0, simultaneos, _ => + { + try + { + using var certificado = X509CertificateLoader.LoadPkcs12( + origem.ToArray(), Senha, X509KeyStorageFlags.EphemeralKeySet); + + if (!certificado.HasPrivateKey) + { + falhas.Add("certificado aberto sem chave privada"); + } + } + catch (CryptographicException excecao) + { + falhas.Add($"{excecao.GetType().Name}: {excecao.Message}"); + } + }); + + Assert.True(falhas.IsEmpty, $"{falhas.Count}/{simultaneos} falharam: {string.Join(" | ", falhas.Distinct())}"); + } + + private static byte[] GerarPfx() + { + using var chave = RSA.Create(2048); + + var pedido = new CertificateRequest( + "CN=eContabil Teste", chave, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + using var certificado = pedido.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + + return certificado.Export(X509ContentType.Pkcs12, Senha); + } +} diff --git a/tests/eContabil.Infrastructure.Tests/Certificados/CarregamentoRepetidoDoPfxTests.cs b/tests/eContabil.Infrastructure.Tests/Certificados/CarregamentoRepetidoDoPfxTests.cs new file mode 100644 index 0000000..69872e7 --- /dev/null +++ b/tests/eContabil.Infrastructure.Tests/Certificados/CarregamentoRepetidoDoPfxTests.cs @@ -0,0 +1,70 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Xunit; + +namespace eContabil.Infrastructure.Tests.Certificados; + +/// +/// O mesmo array de PFX é aberto uma vez por consulta à SEFAZ, e uma execução encadeia até vinte. +/// +/// +/// Enquanto cada consulta baixava o certificado do cofre outra vez, cada uma recebia um array novo e a +/// pergunta nunca apareceu. Com o cache por execução, o array passou a ser o mesmo nas vinte — e se o +/// carregador tocar no buffer que recebe, a segunda consulta abre lixo. +/// +/// As duas variantes de são exercitadas porque o comportamento pode +/// diferir entre elas: produção roda em Linux com EphemeralKeySet, e a máquina de quem desenvolve +/// costuma ser Windows com UserKeySet. Testar só a do desenvolvedor deixaria passar exatamente o +/// defeito que só aparece no contêiner. +/// +public class CarregamentoRepetidoDoPfxTests +{ + private const string Senha = "senha-do-teste"; + + public static TheoryData Flags => + [X509KeyStorageFlags.EphemeralKeySet, X509KeyStorageFlags.UserKeySet]; + + [Theory] + [MemberData(nameof(Flags))] + public void LoadPkcs12_ComOMesmoArray_AbreNasDuasVezes(X509KeyStorageFlags flags) + { + var pfx = GerarPfx(); + + using (var primeira = X509CertificateLoader.LoadPkcs12(pfx, Senha, flags)) + { + Assert.True(primeira.HasPrivateKey); + } + + // Falhar aqui com "ASN1 corrupted data" significa que o carregador destruiu o buffer de entrada: + // reaproveitar o array entre consultas quebra a captura a partir do segundo lote. + using var segunda = X509CertificateLoader.LoadPkcs12(pfx, Senha, flags); + + Assert.True(segunda.HasPrivateKey); + } + + [Theory] + [MemberData(nameof(Flags))] + public void LoadPkcs12_NaoAlteraOArrayQueRecebe(X509KeyStorageFlags flags) + { + // A mesma pergunta, verificada direto nos bytes. + var pfx = GerarPfx(); + var copia = pfx.ToArray(); + + using var certificado = X509CertificateLoader.LoadPkcs12(pfx, Senha, flags); + + Assert.Equal(copia, pfx); + } + + private static byte[] GerarPfx() + { + using var chave = RSA.Create(2048); + + var pedido = new CertificateRequest( + "CN=eContabil Teste", chave, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + using var certificado = pedido.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + + return certificado.Export(X509ContentType.Pkcs12, Senha); + } +} diff --git a/tests/eContabil.Infrastructure.Tests/Certificados/CofreComCacheDeExecucaoTests.cs b/tests/eContabil.Infrastructure.Tests/Certificados/CofreComCacheDeExecucaoTests.cs index 0b403e5..0d48660 100644 --- a/tests/eContabil.Infrastructure.Tests/Certificados/CofreComCacheDeExecucaoTests.cs +++ b/tests/eContabil.Infrastructure.Tests/Certificados/CofreComCacheDeExecucaoTests.cs @@ -101,22 +101,38 @@ await _interno.Received(2).CarregarAsync( } [Fact] - public async Task Dispose_ZeraOMaterialDecifrado() + public async Task Dispose_EsvaziaOCache() + { + // O descarte zera o material guardado e esvazia o cache. A verificação é pelo comportamento, e + // não pelos bytes que o chamador recebeu: ele leva uma cópia, e destruí-la sob os pés dele foi + // justamente o defeito que parou a captura por doze horas. + ComCertificado(Certificado, "pfx-original"); + var cofre = new CofreComCacheDeExecucao(_interno); + + await cofre.CarregarAsync(Localizacao(Certificado), TestContext.Current.CancellationToken); + + cofre.Dispose(); + + await cofre.CarregarAsync(Localizacao(Certificado), TestContext.Current.CancellationToken); + + await _interno.Received(2).CarregarAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Dispose_NaoDestroiOMaterialQueOChamadorRecebeu() { - // Não é garantia contra despejo de memória, mas encurta a janela em que a chave fica legível no - // heap depois de deixar de ser necessária. ComCertificado(Certificado, "pfx-original"); var cofre = new CofreComCacheDeExecucao(_interno); var carregado = await cofre.CarregarAsync( Localizacao(Certificado), TestContext.Current.CancellationToken); - var bytes = carregado.Valor.Pfx; - Assert.Contains(bytes, valor => valor != 0); + var doChamador = carregado.Valor.Pfx; cofre.Dispose(); - Assert.All(bytes, valor => Assert.Equal(0, valor)); + Assert.Contains(doChamador, valor => valor != 0); } private void ComCertificado(Guid certificadoId, string conteudo) => diff --git a/tests/eContabil.Infrastructure.Tests/Certificados/ZeragemDoCacheTests.cs b/tests/eContabil.Infrastructure.Tests/Certificados/ZeragemDoCacheTests.cs new file mode 100644 index 0000000..f6d494d --- /dev/null +++ b/tests/eContabil.Infrastructure.Tests/Certificados/ZeragemDoCacheTests.cs @@ -0,0 +1,108 @@ +using System.Security.Cryptography; +using System.Text; +using eContabil.Application.Certificados; +using eContabil.Infrastructure.Certificados; +using eContabil.Shared; +using NSubstitute; +using Xunit; + +namespace eContabil.Infrastructure.Tests.Certificados; + +/// +/// Quem consome o certificado zera os bytes depois de usar, e o cache não pode ser destruído por isso. +/// +/// +/// O serviço fiscal apaga o material num finally a cada chamada à SEFAZ — higiene correta, e +/// anterior ao cache. Enquanto cada chamada baixava o arquivo do cofre outra vez, ela zerava um array +/// só seu. +/// +/// Servindo a instância guardada, a primeira das vinte chamadas encadeadas destruía o material das +/// dezenove seguintes. Em produção apareceu como ASN1 corrupted data a partir do segundo lote, e +/// a captura ficou doze horas parada: empresa com "nada novo" fazia uma chamada só e passava, empresa +/// com documentos encadeava e morria. +/// +public class ZeragemDoCacheTests +{ + private static readonly Guid Certificado = Guid.CreateVersion7(); + + private readonly ICertificadoCofre _interno = Substitute.For(); + + [Fact] + public async Task CarregarAsync_DepoisDoConsumidorZerarOMaterial_AindaDevolveOCertificado() + { + // É o defeito exato: a primeira chamada usa e limpa, a segunda precisa receber material íntegro. + var cofre = new CofreComCacheDeExecucao(_interno); + ComCertificado(); + + var primeira = await cofre.CarregarAsync(Localizacao(), TestContext.Current.CancellationToken); + CryptographicOperations.ZeroMemory(primeira.Valor.Pfx); + + var segunda = await cofre.CarregarAsync(Localizacao(), TestContext.Current.CancellationToken); + + // 0x30 abre todo DER; zerado, o carregador reclama de classe universal 0 onde espera 16. + Assert.Equal(0x30, segunda.Valor.Pfx[0]); + Assert.Contains(segunda.Valor.Pfx, valor => valor != 0); + } + + [Fact] + public async Task CarregarAsync_EmVinteChamadas_DevolveMaterialIntegroEmTodas() + { + // Vinte é o teto de lotes de uma execução incremental; a carga inicial chega a duzentas. + var cofre = new CofreComCacheDeExecucao(_interno); + ComCertificado(); + + for (var lote = 0; lote < 20; lote++) + { + var carregado = await cofre.CarregarAsync( + Localizacao(), TestContext.Current.CancellationToken); + + Assert.Equal(0x30, carregado.Valor.Pfx[0]); + + // Como o serviço fiscal faz no `finally` de cada chamada. + CryptographicOperations.ZeroMemory(carregado.Valor.Pfx); + } + } + + [Fact] + public async Task CarregarAsync_NaoDevolveDuasVezesOMesmoArray() + { + // A garantia estrutural por trás dos dois testes acima: cada chamada leva a própria cópia, e + // nenhum consumidor consegue alcançar o material dos outros. + var cofre = new CofreComCacheDeExecucao(_interno); + ComCertificado(); + + var primeira = await cofre.CarregarAsync(Localizacao(), TestContext.Current.CancellationToken); + var segunda = await cofre.CarregarAsync(Localizacao(), TestContext.Current.CancellationToken); + + Assert.NotSame(primeira.Valor.Pfx, segunda.Valor.Pfx); + Assert.Equal(primeira.Valor.Pfx, segunda.Valor.Pfx); + } + + [Fact] + public async Task CarregarAsync_ContinuaIndoAoCofreUmaVezSo() + { + // A cópia não pode custar o motivo de o cache existir: duzentas leituras do object storage e + // duzentas derivações de PBKDF2 numa carga inicial. + var cofre = new CofreComCacheDeExecucao(_interno); + ComCertificado(); + + for (var lote = 0; lote < 20; lote++) + { + await cofre.CarregarAsync(Localizacao(), TestContext.Current.CancellationToken); + } + + await _interno.Received(1).CarregarAsync( + Arg.Any(), Arg.Any()); + } + + private void ComCertificado() => + _interno.CarregarAsync(Arg.Any(), Arg.Any()) + .Returns(_ => Result.Ok(new CertificadoCarregadoDto( + PfxFalso(), "senha", "SP", "11222333000181"))); + + /// Bytes com a cara de um DER: começa em 0x30, como todo PKCS#12. + private static byte[] PfxFalso() => [0x30, 0x82, 0x0A, 0xFF, .. Encoding.UTF8.GetBytes("conteudo")]; + + private static CertificadoLocalizacao Localizacao() => + new(Certificado, "certificados", $"{Certificado}.pfx.enc", "senha-cifrada", "SP", "11222333000181"); +}