From 0863a323db69339cdc762b22bd3889556bc0dcc4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:27:48 -0300 Subject: [PATCH 1/2] fix: extract PEM directly from legacy PFX fallback Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/DpsSigner.php | 38 ++++++++++++++++++++++++++------ tests/Unit/Xml/DpsSignerTest.php | 35 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/Xml/DpsSigner.php b/src/Xml/DpsSigner.php index 075a830..16b306c 100644 --- a/src/Xml/DpsSigner.php +++ b/src/Xml/DpsSigner.php @@ -64,9 +64,7 @@ private function importPfx(string $pfxContent, string $password, string $cnpj): $lastError = openssl_error_string() ?: ''; if (str_contains($lastError, self::LEGACY_OPENSSL_ERROR)) { - // Legacy PFX — re-pack via CLI and retry - $pfxContent = $this->repackLegacyPfx($pfxContent, $password); - $ok = openssl_pkcs12_read($pfxContent, $certs, $password); + return $this->extractLegacyPemMaterial($pfxContent, $password, $cnpj); } if (!$ok) { @@ -82,10 +80,12 @@ private function importPfx(string $pfxContent, string $password, string $cnpj): } /** - * Re-pack a legacy PFX into a modern one using the OpenSSL CLI. + * Extract private key and leaf certificate from a legacy PFX via OpenSSL CLI. * The password is passed via environment variable to avoid shell injection. + * + * @return array{string, string} [privateKeyPem, certificatePem] */ - private function repackLegacyPfx(string $pfxContent, string $password): string + private function extractLegacyPemMaterial(string $pfxContent, string $password, string $cnpj): array { $tmpIn = tempnam(sys_get_temp_dir(), 'nfse_in_'); $tmpOut = tempnam(sys_get_temp_dir(), 'nfse_out_'); @@ -100,7 +100,7 @@ private function repackLegacyPfx(string $pfxContent, string $password): string // Use env var to avoid password in process list (avoids shell injection) putenv('NFSE_PFX_PASS=' . $password); $cmd = sprintf( - 'openssl pkcs12 -legacy -in %s -passin env:NFSE_PFX_PASS -out %s -passout env:NFSE_PFX_PASS 2>/dev/null', + 'openssl pkcs12 -legacy -in %s -passin env:NFSE_PFX_PASS -nodes -out %s 2>/dev/null', escapeshellarg($tmpIn), escapeshellarg($tmpOut), ); @@ -117,7 +117,7 @@ private function repackLegacyPfx(string $pfxContent, string $password): string throw new PfxImportException('openssl CLI repack produced empty output'); } - return $result; + return $this->extractPemParts($result, $cnpj); } finally { putenv('NFSE_PFX_PASS'); @@ -130,6 +130,30 @@ private function repackLegacyPfx(string $pfxContent, string $password): string } } + /** + * @return array{string, string} [privateKeyPem, certificatePem] + */ + private function extractPemParts(string $pemBundle, string $cnpj): array + { + $privateKeyMatched = preg_match( + '/-----BEGIN(?: ENCRYPTED)? PRIVATE KEY-----.*?-----END(?: ENCRYPTED)? PRIVATE KEY-----/s', + $pemBundle, + $privateKeyMatches, + ) === 1; + + $certificateMatched = preg_match( + '/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/s', + $pemBundle, + $certificateMatches, + ) === 1; + + if (!$privateKeyMatched || !$certificateMatched) { + throw new PfxImportException('Failed to extract PEM material from legacy PFX for CNPJ ' . $cnpj); + } + + return [$privateKeyMatches[0], $certificateMatches[0]]; + } + /** * Signs an XML document per ABRASF 2.04 / XML-DSig (RSA-SHA1, enveloped signature). * diff --git a/tests/Unit/Xml/DpsSignerTest.php b/tests/Unit/Xml/DpsSignerTest.php index 056ba21..765acd4 100644 --- a/tests/Unit/Xml/DpsSignerTest.php +++ b/tests/Unit/Xml/DpsSignerTest.php @@ -135,4 +135,39 @@ public function testSignatureElementIsAppendedToDpsRoot(): void self::assertSame(1, $nodes->length, 'Signature must be a direct child of DPS root'); } + + public function testExtractPemPartsReturnsPrivateKeyAndCertificateFromCliBundle(): void + { + $privateKey = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + self::assertNotFalse($privateKey); + + $certificateRequest = openssl_csr_new( + ['commonName' => $this->testCnpj], + $privateKey, + ['digest_alg' => 'sha256'], + ); + self::assertNotFalse($certificateRequest); + + $certificate = openssl_csr_sign($certificateRequest, null, $privateKey, 1, ['digest_alg' => 'sha256']); + self::assertNotFalse($certificate); + + $privateKeyPem = ''; + self::assertTrue(openssl_pkey_export($privateKey, $privateKeyPem)); + + $certificatePem = ''; + self::assertTrue(openssl_x509_export($certificate, $certificatePem)); + + $pemBundle = "Bag Attributes\nlocalKeyID: 01 02 03\n" . $certificatePem . "\n" . $privateKeyPem; + + $method = new \ReflectionMethod(DpsSigner::class, 'extractPemParts'); + $method->setAccessible(true); + + $parts = $method->invoke($this->signer, $pemBundle, $this->testCnpj); + + self::assertSame(rtrim($privateKeyPem), $parts[0]); + self::assertSame(rtrim($certificatePem), $parts[1]); + } } From 6be2ab9c6569bef95781ef9f7f119d4f396a932e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:30:57 -0300 Subject: [PATCH 2/2] fix: remove redundant condition in legacy fallback Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/DpsSigner.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Xml/DpsSigner.php b/src/Xml/DpsSigner.php index 16b306c..dd75106 100644 --- a/src/Xml/DpsSigner.php +++ b/src/Xml/DpsSigner.php @@ -67,13 +67,11 @@ private function importPfx(string $pfxContent, string $password, string $cnpj): return $this->extractLegacyPemMaterial($pfxContent, $password, $cnpj); } - if (!$ok) { - $opensslError = openssl_error_string(); + $opensslError = openssl_error_string(); - throw new PfxImportException( - 'Failed to import PFX for CNPJ ' . $cnpj . ': ' . ($opensslError ?: 'unknown OpenSSL error') - ); - } + throw new PfxImportException( + 'Failed to import PFX for CNPJ ' . $cnpj . ': ' . ($opensslError ?: 'unknown OpenSSL error') + ); } return [$certs['pkey'], $certs['cert']];