Skip to content

Commit e322c08

Browse files
authored
Merge pull request #8 from LibreCodeCoop/feat/xml-signer
feat(xml): implement XML-DSig signing in DpsSigner
2 parents 1701595 + 476abef commit e322c08

File tree

2 files changed

+233
-15
lines changed

2 files changed

+233
-15
lines changed

src/Xml/DpsSigner.php

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,9 @@ public function sign(string $xml, string $cnpj): string
4545
throw new PfxImportException('Cannot read PFX file for CNPJ ' . $cnpj);
4646
}
4747

48-
$signingMaterial = $this->importPfx($pfxContent, $password, $cnpj);
49-
unset($signingMaterial);
48+
[$privateKeyPem, $certificatePem] = $this->importPfx($pfxContent, $password, $cnpj);
5049

51-
return $this->signXml($xml);
50+
return $this->signXml($xml, $privateKeyPem, $certificatePem);
5251
}
5352

5453
// -------------------------------------------------------------------------
@@ -131,31 +130,112 @@ private function repackLegacyPfx(string $pfxContent, string $password): string
131130
}
132131
}
133132

134-
private function signXml(string $xml): string
133+
/**
134+
* Signs an XML document per ABRASF 2.04 / XML-DSig (RSA-SHA1, enveloped signature).
135+
*
136+
* Steps:
137+
* 1. Locate the element with @Id (infDPS).
138+
* 2. Compute SHA-1 digest of its canonical (C14N) form.
139+
* 3. Build the Signature/SignedInfo structure in the ds: namespace.
140+
* 4. Compute C14N of SignedInfo and RSA-SHA1 sign it.
141+
* 5. Append SignatureValue and KeyInfo (X509Certificate) to complete Signature.
142+
*/
143+
private function signXml(string $xml, string $privateKeyPem, string $certificatePem): string
135144
{
145+
$dsNs = 'http://www.w3.org/2000/09/xmldsig#';
146+
$c14nAlgo = 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315';
147+
$sigAlgo = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1';
148+
$sha1Algo = 'http://www.w3.org/2000/09/xmldsig#sha1';
149+
$envAlgo = 'http://www.w3.org/2000/09/xmldsig#enveloped-signature';
150+
136151
$doc = new \DOMDocument('1.0', 'UTF-8');
137152
$doc->preserveWhiteSpace = false;
138-
$doc->formatOutput = false;
139153

140154
if (!$doc->loadXML($xml)) {
141155
throw new PfxImportException('Cannot parse XML for signing');
142156
}
143157

144-
$xpath = new \DOMXPath($doc);
145-
// Find the element to sign — the root DPS element
146-
$infDps = $xpath->query('//*[@Id]')->item(0);
158+
$xpath = new \DOMXPath($doc);
159+
$idNode = $xpath->query('//*[@Id]')->item(0);
147160

148-
if ($infDps === null) {
161+
if (!$idNode instanceof \DOMElement) {
149162
throw new PfxImportException('No element with @Id attribute found in DPS XML');
150163
}
151164

152-
$signedXml = new \DOMDocument('1.0', 'UTF-8');
153-
$signedXml->preserveWhiteSpace = false;
165+
$refId = $idNode->getAttribute('Id');
166+
167+
// 1. Digest the reference element (Signature not yet in the document — enveloped transform is a no-op here)
168+
$refCanonical = $idNode->C14N();
169+
$digestValue = base64_encode(hash('sha1', $refCanonical, binary: true));
170+
171+
// 2. Build Signature element
172+
$sig = $doc->createElementNS($dsNs, 'Signature');
173+
$doc->documentElement->appendChild($sig);
174+
175+
// 2a. SignedInfo
176+
$signedInfo = $doc->createElementNS($dsNs, 'SignedInfo');
177+
$sig->appendChild($signedInfo);
178+
179+
$c14nMethod = $doc->createElementNS($dsNs, 'CanonicalizationMethod');
180+
$c14nMethod->setAttribute('Algorithm', $c14nAlgo);
181+
$signedInfo->appendChild($c14nMethod);
182+
183+
$sigMethod = $doc->createElementNS($dsNs, 'SignatureMethod');
184+
$sigMethod->setAttribute('Algorithm', $sigAlgo);
185+
$signedInfo->appendChild($sigMethod);
186+
187+
$reference = $doc->createElementNS($dsNs, 'Reference');
188+
$reference->setAttribute('URI', '#' . $refId);
189+
$signedInfo->appendChild($reference);
190+
191+
$transforms = $doc->createElementNS($dsNs, 'Transforms');
192+
$reference->appendChild($transforms);
193+
194+
$t1 = $doc->createElementNS($dsNs, 'Transform');
195+
$t1->setAttribute('Algorithm', $envAlgo);
196+
$transforms->appendChild($t1);
197+
198+
$t2 = $doc->createElementNS($dsNs, 'Transform');
199+
$t2->setAttribute('Algorithm', $c14nAlgo);
200+
$transforms->appendChild($t2);
201+
202+
$digestMethod = $doc->createElementNS($dsNs, 'DigestMethod');
203+
$digestMethod->setAttribute('Algorithm', $sha1Algo);
204+
$reference->appendChild($digestMethod);
205+
206+
$digestValueEl = $doc->createElementNS($dsNs, 'DigestValue');
207+
$digestValueEl->textContent = $digestValue;
208+
$reference->appendChild($digestValueEl);
209+
210+
// 3. Canonicalise SignedInfo and sign it
211+
$signedInfoC14n = $signedInfo->C14N();
212+
213+
$privKey = openssl_pkey_get_private($privateKeyPem);
214+
if ($privKey === false) {
215+
throw new PfxImportException('Cannot load private key for XML signing');
216+
}
217+
218+
$rawSignature = '';
219+
if (!openssl_sign($signedInfoC14n, $rawSignature, $privKey, OPENSSL_ALGO_SHA1)) {
220+
throw new PfxImportException('openssl_sign failed: ' . (openssl_error_string() ?: 'unknown error'));
221+
}
222+
223+
// 4. SignatureValue
224+
$sigValueEl = $doc->createElementNS($dsNs, 'SignatureValue');
225+
$sigValueEl->textContent = base64_encode($rawSignature);
226+
$sig->appendChild($sigValueEl);
227+
228+
// 5. KeyInfo / X509Certificate
229+
$certB64 = preg_replace('/-----[A-Z ]+-----|[\r\n]/', '', $certificatePem) ?? '';
230+
231+
$keyInfo = $doc->createElementNS($dsNs, 'KeyInfo');
232+
$x509Data = $doc->createElementNS($dsNs, 'X509Data');
233+
$x509Cert = $doc->createElementNS($dsNs, 'X509Certificate');
234+
$x509Cert->textContent = $certB64;
235+
$x509Data->appendChild($x509Cert);
236+
$keyInfo->appendChild($x509Data);
237+
$sig->appendChild($keyInfo);
154238

155-
// Use PHP's built-in xmldsig extension when available; otherwise fall back
156-
// to manual C14N + RSA-SHA1 computation.
157-
// TODO: Implement full XML-DSig per ABRASF 2.04 spec in Phase 2.
158-
// For now return the unsigned XML so the test scaffold builds green.
159239
return $doc->saveXML() ?: $xml;
160240
}
161241
}

tests/Unit/Xml/DpsSignerTest.php

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreCodeCoop\NfsePHP\Tests\Unit\Xml;
9+
10+
use LibreCodeCoop\NfsePHP\Exception\PfxImportException;
11+
use LibreCodeCoop\NfsePHP\SecretStore\NoOpSecretStore;
12+
use LibreCodeCoop\NfsePHP\Tests\TestCase;
13+
use LibreCodeCoop\NfsePHP\Xml\DpsSigner;
14+
15+
/**
16+
* @covers \LibreCodeCoop\NfsePHP\Xml\DpsSigner
17+
*/
18+
class DpsSignerTest extends TestCase
19+
{
20+
private DpsSigner $signer;
21+
22+
private NoOpSecretStore $store;
23+
24+
private string $testCnpj = '11222333000181';
25+
26+
private string $testXml = '<DPS><infDPS Id="DPS11222333000181"><cMun>3303302</cMun></infDPS></DPS>';
27+
28+
private string $pfxPath = '';
29+
30+
protected function setUp(): void
31+
{
32+
$this->store = new NoOpSecretStore();
33+
$this->signer = new DpsSigner($this->store);
34+
$this->setupTestCert();
35+
}
36+
37+
protected function tearDown(): void
38+
{
39+
if ($this->pfxPath !== '' && is_file($this->pfxPath)) {
40+
unlink($this->pfxPath);
41+
}
42+
}
43+
44+
private function setupTestCert(): void
45+
{
46+
$privKey = openssl_pkey_new([
47+
'private_key_bits' => 2048,
48+
'private_key_type' => OPENSSL_KEYTYPE_RSA,
49+
]);
50+
self::assertNotFalse($privKey, 'openssl_pkey_new must succeed in this environment');
51+
52+
$csr = openssl_csr_new(
53+
['commonName' => $this->testCnpj],
54+
$privKey,
55+
['digest_alg' => 'sha256'],
56+
);
57+
self::assertNotFalse($csr, 'openssl_csr_new must succeed');
58+
59+
$cert = openssl_csr_sign($csr, null, $privKey, 1, ['digest_alg' => 'sha256']);
60+
self::assertNotFalse($cert, 'openssl_csr_sign must succeed');
61+
62+
$pfxData = '';
63+
$ok = openssl_pkcs12_export($cert, $pfxData, $privKey, 'testpass');
64+
self::assertTrue($ok, 'openssl_pkcs12_export must succeed');
65+
66+
$this->pfxPath = sys_get_temp_dir() . '/nfse_test_' . $this->testCnpj . '.pfx';
67+
file_put_contents($this->pfxPath, $pfxData);
68+
69+
$this->store->put('pfx/' . $this->testCnpj, [
70+
'pfx_path' => $this->pfxPath,
71+
'password' => 'testpass',
72+
]);
73+
}
74+
75+
public function testSignReturnsXmlContainingSignatureElement(): void
76+
{
77+
$signed = $this->signer->sign($this->testXml, $this->testCnpj);
78+
79+
self::assertStringContainsString('<Signature', $signed);
80+
}
81+
82+
public function testSignReturnsXmlContainingDigestValue(): void
83+
{
84+
$signed = $this->signer->sign($this->testXml, $this->testCnpj);
85+
86+
self::assertStringContainsString('DigestValue', $signed);
87+
}
88+
89+
public function testSignReturnsXmlContainingSignatureValue(): void
90+
{
91+
$signed = $this->signer->sign($this->testXml, $this->testCnpj);
92+
93+
self::assertStringContainsString('SignatureValue', $signed);
94+
}
95+
96+
public function testSignReturnsXmlContainingX509Certificate(): void
97+
{
98+
$signed = $this->signer->sign($this->testXml, $this->testCnpj);
99+
100+
self::assertStringContainsString('X509Certificate', $signed);
101+
}
102+
103+
public function testSignThrowsPfxImportExceptionWhenFileNotFound(): void
104+
{
105+
$store = new NoOpSecretStore();
106+
$store->put('pfx/99999999999999', [
107+
'pfx_path' => '/nonexistent/path/cert.pfx',
108+
'password' => 'x',
109+
]);
110+
111+
$signer = new DpsSigner($store);
112+
113+
$this->expectException(PfxImportException::class);
114+
$signer->sign($this->testXml, '99999999999999');
115+
}
116+
117+
public function testSignedXmlIsStillValidXml(): void
118+
{
119+
$signed = $this->signer->sign($this->testXml, $this->testCnpj);
120+
121+
$doc = new \DOMDocument();
122+
self::assertTrue($doc->loadXML($signed), 'Signed output must be valid XML');
123+
}
124+
125+
public function testSignatureElementIsAppendedToDpsRoot(): void
126+
{
127+
$signed = $this->signer->sign($this->testXml, $this->testCnpj);
128+
129+
$doc = new \DOMDocument();
130+
$doc->loadXML($signed);
131+
132+
$xpath = new \DOMXPath($doc);
133+
$xpath->registerNamespace('ds', 'http://www.w3.org/2000/09/xmldsig#');
134+
$nodes = $xpath->query('/DPS/ds:Signature');
135+
136+
self::assertSame(1, $nodes->length, 'Signature must be a direct child of DPS root');
137+
}
138+
}

0 commit comments

Comments
 (0)