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
48 changes: 35 additions & 13 deletions src/Xml/DpsSigner.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,28 +64,26 @@ 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) {
$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']];
}

/**
* 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_');
Expand All @@ -100,7 +98,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),
);
Expand All @@ -117,7 +115,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');

Expand All @@ -130,6 +128,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).
*
Expand Down
35 changes: 35 additions & 0 deletions tests/Unit/Xml/DpsSignerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
Loading