Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,42 @@ public async Task<Result<CertificadoCarregadoDto>> CarregarAsync(

if (_carregados.TryGetValue(localizacao.CertificadoId, out var jaCarregado))
{
return Result<CertificadoCarregadoDto>.Ok(jaCarregado);
return Result<CertificadoCarregadoDto>.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<CertificadoCarregadoDto>.Ok(Copiar(carregado.Valor));
}

/// <summary>
/// Entrega uma cópia do material, nunca a instância guardada.
/// </summary>
/// <remarks>
/// Quem consome o certificado zera os bytes depois de usá-los — é higiene correta, e o serviço
/// fiscal faz isso num <c>finally</c> 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 <c>LoadPkcs12</c> falhava com "ASN1 corrupted data" — classe
/// universal 0 onde se espera 16, que é o decodificador dizendo que o primeiro byte é <c>0x00</c> em
/// vez do <c>0x30</c> 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.
/// </remarks>
private static CertificadoCarregadoDto Copiar(CertificadoCarregadoDto certificado) =>
certificado with { Pfx = [.. certificado.Pfx] };

public Task<Result> RemoverAsync(string bucket, string objectName, CancellationToken ct)
{
// Remover invalida tudo: o arquivo deixou de existir, e servir o que estava em memória entregaria
Expand Down
19 changes: 12 additions & 7 deletions src/eContabil.Infrastructure/Sefaz/ZeusFiscalService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,15 @@ public async Task<Result<RetornoDistribuicaoDto>> ConsultarDistribuicaoAsync(
return Result<RetornoDistribuicaoDto>.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(
Expand Down Expand Up @@ -114,11 +117,13 @@ public async Task<Result<RetornoManifestacaoDto>> ManifestarCienciaAsync(
return Result<RetornoManifestacaoDto>.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);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Xunit;

namespace eContabil.Infrastructure.Tests.Certificados;

/// <summary>
/// Várias empresas sincronizam ao mesmo tempo, e cada uma abre o próprio certificado.
/// </summary>
/// <remarks>
/// 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 <c>LoadPkcs12</c> acontecem no mesmo instante. Em produção isso apareceu como
/// <c>ASN1 corrupted data</c> 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.
/// </remarks>
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<string>();

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<string>();

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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Xunit;

namespace eContabil.Infrastructure.Tests.Certificados;

/// <summary>
/// O mesmo array de PFX é aberto uma vez por consulta à SEFAZ, e uma execução encadeia até vinte.
/// </summary>
/// <remarks>
/// 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 <see cref="X509KeyStorageFlags"/> são exercitadas porque o comportamento pode
/// diferir entre elas: produção roda em Linux com <c>EphemeralKeySet</c>, e a máquina de quem desenvolve
/// costuma ser Windows com <c>UserKeySet</c>. Testar só a do desenvolvedor deixaria passar exatamente o
/// defeito que só aparece no contêiner.
/// </remarks>
public class CarregamentoRepetidoDoPfxTests
{
private const string Senha = "senha-do-teste";

public static TheoryData<X509KeyStorageFlags> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CertificadoLocalizacao>(), Arg.Any<CancellationToken>());
}

[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) =>
Expand Down
Loading
Loading