From bb29bee9b183d334fd7fe91135c653dd22fe64d7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 22 Mar 2026 00:26:36 -0300 Subject: [PATCH 1/2] test(xml): add DpsSigner unit tests for XML-DSig output Signed-off-by: Vitor Mattos --- tests/Unit/Xml/DpsSignerTest.php | 138 +++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/Unit/Xml/DpsSignerTest.php diff --git a/tests/Unit/Xml/DpsSignerTest.php b/tests/Unit/Xml/DpsSignerTest.php new file mode 100644 index 0000000..056ba21 --- /dev/null +++ b/tests/Unit/Xml/DpsSignerTest.php @@ -0,0 +1,138 @@ +3303302'; + + private string $pfxPath = ''; + + protected function setUp(): void + { + $this->store = new NoOpSecretStore(); + $this->signer = new DpsSigner($this->store); + $this->setupTestCert(); + } + + protected function tearDown(): void + { + if ($this->pfxPath !== '' && is_file($this->pfxPath)) { + unlink($this->pfxPath); + } + } + + private function setupTestCert(): void + { + $privKey = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + self::assertNotFalse($privKey, 'openssl_pkey_new must succeed in this environment'); + + $csr = openssl_csr_new( + ['commonName' => $this->testCnpj], + $privKey, + ['digest_alg' => 'sha256'], + ); + self::assertNotFalse($csr, 'openssl_csr_new must succeed'); + + $cert = openssl_csr_sign($csr, null, $privKey, 1, ['digest_alg' => 'sha256']); + self::assertNotFalse($cert, 'openssl_csr_sign must succeed'); + + $pfxData = ''; + $ok = openssl_pkcs12_export($cert, $pfxData, $privKey, 'testpass'); + self::assertTrue($ok, 'openssl_pkcs12_export must succeed'); + + $this->pfxPath = sys_get_temp_dir() . '/nfse_test_' . $this->testCnpj . '.pfx'; + file_put_contents($this->pfxPath, $pfxData); + + $this->store->put('pfx/' . $this->testCnpj, [ + 'pfx_path' => $this->pfxPath, + 'password' => 'testpass', + ]); + } + + public function testSignReturnsXmlContainingSignatureElement(): void + { + $signed = $this->signer->sign($this->testXml, $this->testCnpj); + + self::assertStringContainsString('signer->sign($this->testXml, $this->testCnpj); + + self::assertStringContainsString('DigestValue', $signed); + } + + public function testSignReturnsXmlContainingSignatureValue(): void + { + $signed = $this->signer->sign($this->testXml, $this->testCnpj); + + self::assertStringContainsString('SignatureValue', $signed); + } + + public function testSignReturnsXmlContainingX509Certificate(): void + { + $signed = $this->signer->sign($this->testXml, $this->testCnpj); + + self::assertStringContainsString('X509Certificate', $signed); + } + + public function testSignThrowsPfxImportExceptionWhenFileNotFound(): void + { + $store = new NoOpSecretStore(); + $store->put('pfx/99999999999999', [ + 'pfx_path' => '/nonexistent/path/cert.pfx', + 'password' => 'x', + ]); + + $signer = new DpsSigner($store); + + $this->expectException(PfxImportException::class); + $signer->sign($this->testXml, '99999999999999'); + } + + public function testSignedXmlIsStillValidXml(): void + { + $signed = $this->signer->sign($this->testXml, $this->testCnpj); + + $doc = new \DOMDocument(); + self::assertTrue($doc->loadXML($signed), 'Signed output must be valid XML'); + } + + public function testSignatureElementIsAppendedToDpsRoot(): void + { + $signed = $this->signer->sign($this->testXml, $this->testCnpj); + + $doc = new \DOMDocument(); + $doc->loadXML($signed); + + $xpath = new \DOMXPath($doc); + $xpath->registerNamespace('ds', 'http://www.w3.org/2000/09/xmldsig#'); + $nodes = $xpath->query('/DPS/ds:Signature'); + + self::assertSame(1, $nodes->length, 'Signature must be a direct child of DPS root'); + } +} From 476abefee3e45772e8cd72dacbcb6547d3d68360 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 22 Mar 2026 00:27:47 -0300 Subject: [PATCH 2/2] feat(xml): implement XML-DSig signing in DpsSigner Signed-off-by: Vitor Mattos --- src/Xml/DpsSigner.php | 110 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 15 deletions(-) diff --git a/src/Xml/DpsSigner.php b/src/Xml/DpsSigner.php index a98238f..075a830 100644 --- a/src/Xml/DpsSigner.php +++ b/src/Xml/DpsSigner.php @@ -45,10 +45,9 @@ public function sign(string $xml, string $cnpj): string throw new PfxImportException('Cannot read PFX file for CNPJ ' . $cnpj); } - $signingMaterial = $this->importPfx($pfxContent, $password, $cnpj); - unset($signingMaterial); + [$privateKeyPem, $certificatePem] = $this->importPfx($pfxContent, $password, $cnpj); - return $this->signXml($xml); + return $this->signXml($xml, $privateKeyPem, $certificatePem); } // ------------------------------------------------------------------------- @@ -131,31 +130,112 @@ private function repackLegacyPfx(string $pfxContent, string $password): string } } - private function signXml(string $xml): string + /** + * Signs an XML document per ABRASF 2.04 / XML-DSig (RSA-SHA1, enveloped signature). + * + * Steps: + * 1. Locate the element with @Id (infDPS). + * 2. Compute SHA-1 digest of its canonical (C14N) form. + * 3. Build the Signature/SignedInfo structure in the ds: namespace. + * 4. Compute C14N of SignedInfo and RSA-SHA1 sign it. + * 5. Append SignatureValue and KeyInfo (X509Certificate) to complete Signature. + */ + private function signXml(string $xml, string $privateKeyPem, string $certificatePem): string { + $dsNs = 'http://www.w3.org/2000/09/xmldsig#'; + $c14nAlgo = 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315'; + $sigAlgo = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'; + $sha1Algo = 'http://www.w3.org/2000/09/xmldsig#sha1'; + $envAlgo = 'http://www.w3.org/2000/09/xmldsig#enveloped-signature'; + $doc = new \DOMDocument('1.0', 'UTF-8'); $doc->preserveWhiteSpace = false; - $doc->formatOutput = false; if (!$doc->loadXML($xml)) { throw new PfxImportException('Cannot parse XML for signing'); } - $xpath = new \DOMXPath($doc); - // Find the element to sign — the root DPS element - $infDps = $xpath->query('//*[@Id]')->item(0); + $xpath = new \DOMXPath($doc); + $idNode = $xpath->query('//*[@Id]')->item(0); - if ($infDps === null) { + if (!$idNode instanceof \DOMElement) { throw new PfxImportException('No element with @Id attribute found in DPS XML'); } - $signedXml = new \DOMDocument('1.0', 'UTF-8'); - $signedXml->preserveWhiteSpace = false; + $refId = $idNode->getAttribute('Id'); + + // 1. Digest the reference element (Signature not yet in the document — enveloped transform is a no-op here) + $refCanonical = $idNode->C14N(); + $digestValue = base64_encode(hash('sha1', $refCanonical, binary: true)); + + // 2. Build Signature element + $sig = $doc->createElementNS($dsNs, 'Signature'); + $doc->documentElement->appendChild($sig); + + // 2a. SignedInfo + $signedInfo = $doc->createElementNS($dsNs, 'SignedInfo'); + $sig->appendChild($signedInfo); + + $c14nMethod = $doc->createElementNS($dsNs, 'CanonicalizationMethod'); + $c14nMethod->setAttribute('Algorithm', $c14nAlgo); + $signedInfo->appendChild($c14nMethod); + + $sigMethod = $doc->createElementNS($dsNs, 'SignatureMethod'); + $sigMethod->setAttribute('Algorithm', $sigAlgo); + $signedInfo->appendChild($sigMethod); + + $reference = $doc->createElementNS($dsNs, 'Reference'); + $reference->setAttribute('URI', '#' . $refId); + $signedInfo->appendChild($reference); + + $transforms = $doc->createElementNS($dsNs, 'Transforms'); + $reference->appendChild($transforms); + + $t1 = $doc->createElementNS($dsNs, 'Transform'); + $t1->setAttribute('Algorithm', $envAlgo); + $transforms->appendChild($t1); + + $t2 = $doc->createElementNS($dsNs, 'Transform'); + $t2->setAttribute('Algorithm', $c14nAlgo); + $transforms->appendChild($t2); + + $digestMethod = $doc->createElementNS($dsNs, 'DigestMethod'); + $digestMethod->setAttribute('Algorithm', $sha1Algo); + $reference->appendChild($digestMethod); + + $digestValueEl = $doc->createElementNS($dsNs, 'DigestValue'); + $digestValueEl->textContent = $digestValue; + $reference->appendChild($digestValueEl); + + // 3. Canonicalise SignedInfo and sign it + $signedInfoC14n = $signedInfo->C14N(); + + $privKey = openssl_pkey_get_private($privateKeyPem); + if ($privKey === false) { + throw new PfxImportException('Cannot load private key for XML signing'); + } + + $rawSignature = ''; + if (!openssl_sign($signedInfoC14n, $rawSignature, $privKey, OPENSSL_ALGO_SHA1)) { + throw new PfxImportException('openssl_sign failed: ' . (openssl_error_string() ?: 'unknown error')); + } + + // 4. SignatureValue + $sigValueEl = $doc->createElementNS($dsNs, 'SignatureValue'); + $sigValueEl->textContent = base64_encode($rawSignature); + $sig->appendChild($sigValueEl); + + // 5. KeyInfo / X509Certificate + $certB64 = preg_replace('/-----[A-Z ]+-----|[\r\n]/', '', $certificatePem) ?? ''; + + $keyInfo = $doc->createElementNS($dsNs, 'KeyInfo'); + $x509Data = $doc->createElementNS($dsNs, 'X509Data'); + $x509Cert = $doc->createElementNS($dsNs, 'X509Certificate'); + $x509Cert->textContent = $certB64; + $x509Data->appendChild($x509Cert); + $keyInfo->appendChild($x509Data); + $sig->appendChild($keyInfo); - // Use PHP's built-in xmldsig extension when available; otherwise fall back - // to manual C14N + RSA-SHA1 computation. - // TODO: Implement full XML-DSig per ABRASF 2.04 spec in Phase 2. - // For now return the unsigned XML so the test scaffold builds green. return $doc->saveXML() ?: $xml; } }