diff --git a/.phive/phars.xml b/.phive/phars.xml
index c05fe29..3a2518b 100644
--- a/.phive/phars.xml
+++ b/.phive/phars.xml
@@ -1,8 +1,8 @@
-
-
-
+
+
+
diff --git a/README.md b/README.md
index 821b721..3892685 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,7 @@ echo $certificado->serialNumber()->bytes(), PHP_EOL; // número de serie del cer
## Acerca de los archivos de certificado y llave privada
Los archivos de certificado vienen en formato `X.509 DER` y los de llave privada en formato `PKCS#8 DER`.
-Ambos formatos no se pueden interpretar directamente en PHP (con `ext-openssl`), sin embargo sí lo pueden hacer
+Ambos formatos no se pueden interpretar directamente en PHP (con `ext-openssl`), sin embargo, sí lo pueden hacer
en el formato compatible [`PEM`](https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail).
Esta librería tiene la capacidad de hacer esta conversión internamente (sin `openssl`), pues solo consiste en codificar
@@ -119,6 +119,48 @@ Notas de tratamiento de archivos `DER`:
Para entender más de los formatos de llaves privadas se puede consultar la siguiente liga:
+## Leer y exportar archivos PFX
+
+Esta librería soporta obtener el objeto `Credential` desde un archivo PFX (PKCS #12) y vicerversa.
+
+Para exportar el archivo PFX:
+
+```php
+export('pfx-passphrase');
+
+// guarda el archivo pfx a la ruta local dada usando la contraseña dada
+$pfxExporter->exportToFile('credential.pfx', 'pfx-passphrase');
+```
+
+Para leer el archivo PFX y obtener un objeto `Credential`:
+
+```php
+createCredentialFromContents('contenido-del-archivo', 'pfx-passphrase');
+
+// crea un objeto Credential dada la ruta local de un archivo pfx
+$credential = $pfxReader->createCredentialsFromFile('pfxFilePath', 'pfx-passphrase');
+```
+
## Compatibilidad
Esta librería se mantendrá compatible con al menos la versión con
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 811474a..dd50aa4 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -9,11 +9,20 @@ Usamos [Versionado Semántico 2.0.0](SEMVER.md) por lo que puedes usar esta libr
Pueden aparecer cambios no liberados que se integran a la rama principal, pero no ameritan una nueva liberación de
versión, aunque sí su incorporación en la rama principal de trabajo. Generalmente, se tratan de cambios en el desarrollo.
-### Mantenimiento 2023-02-22
+## Listado de cambios
+
+### Versión 1.2.0 2023-02-24
+
+Se agrega la funcionalidad para exportar (`PfxExporter`) y leer (`PfxReader`) una credencial con formato PKCS#12 (PFX).
+Gracias `@celli33` por tu contribución.
+
+Los siguientes cambios ya estaban incluidos en la rama principal:
+
+#### Mantenimiento 2023-02-22
Los siguientes cambios son de mantenimiento:
-- Se actualiza el año en el archivo de licencia.
+- Se actualiza el año en el archivo de licencia. ¡Feliz 2023!
- Se agrega una prueba para comprobar certificados *Teletex*.
Ver https://github.com/nodecfdi/credentials/commit/cd8f1827e06a5917c41940e82b8d696379362d5d.
- Se agrega un archivo de documentación: *Ejemplo de creación de una credencial con verificaciones previas*.
@@ -28,8 +37,6 @@ Los siguientes cambios son de mantenimiento:
- Se corrige el trabajo `phpcs` eliminando las rutas fijas.
- Se actualizan las versiones de las herramientas de desarrollo.
-## Listado de cambios
-
### Versión 1.1.4 2022-01-31
- Se mejora la forma en como son procesados los tipos de datos del certificado.
diff --git a/src/Pfx/PfxExporter.php b/src/Pfx/PfxExporter.php
new file mode 100644
index 0000000..984ea2f
--- /dev/null
+++ b/src/Pfx/PfxExporter.php
@@ -0,0 +1,70 @@
+credential = $credential;
+ }
+
+ public function getCredential(): Credential
+ {
+ return $this->credential;
+ }
+
+ public function export(string $passPhrase): string
+ {
+ $pfxContents = '';
+ /** @noinspection PhpUsageOfSilenceOperatorInspection */
+ $success = @openssl_pkcs12_export(
+ $this->credential->certificate()->pem(),
+ $pfxContents,
+ [$this->credential->privateKey()->pem(), $this->credential->privateKey()->passPhrase()],
+ $passPhrase,
+ );
+ if (! $success) {
+ throw $this->exceptionFromLastError(sprintf(
+ 'Cannot export credential with certificate %s',
+ $this->credential->certificate()->serialNumber()->bytes()
+ ));
+ }
+ return $pfxContents;
+ }
+
+ public function exportToFile(string $pfxFile, string $passPhrase): void
+ {
+ /** @noinspection PhpUsageOfSilenceOperatorInspection */
+ $success = @openssl_pkcs12_export_to_file(
+ $this->credential->certificate()->pem(),
+ $pfxFile,
+ [$this->credential->privateKey()->pem(), $this->credential->privateKey()->passPhrase()],
+ $passPhrase
+ );
+ if (! $success) {
+ throw $this->exceptionFromLastError(sprintf(
+ 'Cannot export credential with certificate %s to file %s',
+ $this->credential->certificate()->serialNumber()->bytes(),
+ $pfxFile
+ ));
+ }
+ }
+
+ private function exceptionFromLastError(string $message): RuntimeException
+ {
+ $previousError = error_get_last() ?? [];
+ return new RuntimeException(sprintf('%s: %s', $message, $previousError['message'] ?? '(Unknown reason)'));
+ }
+}
diff --git a/src/Pfx/PfxReader.php b/src/Pfx/PfxReader.php
new file mode 100644
index 0000000..afd1b1e
--- /dev/null
+++ b/src/Pfx/PfxReader.php
@@ -0,0 +1,45 @@
+loadPkcs12($contents, $passPhrase);
+ $certificatePem = $pfx['cert'];
+ $privateKeyPem = $pfx['pkey'];
+ return Credential::create($certificatePem, $privateKeyPem, '');
+ }
+
+ public function createCredentialFromFile(string $fileName, string $passPhrase): Credential
+ {
+ return $this->createCredentialFromContents(self::localFileOpen($fileName), $passPhrase);
+ }
+
+ /**
+ * @return array{cert:string, pkey:string}
+ */
+ public function loadPkcs12(string $contents, string $password = ''): array
+ {
+ $pfx = [];
+ if (! openssl_pkcs12_read($contents, $pfx, $password)) {
+ throw new UnexpectedValueException('Invalid PKCS#12 contents or wrong passphrase');
+ }
+ return [
+ 'cert' => $pfx['cert'] ?? '',
+ 'pkey' => $pfx['pkey'] ?? '',
+ ];
+ }
+}
diff --git a/tests/Unit/Pfx/PfxExporterTest.php b/tests/Unit/Pfx/PfxExporterTest.php
new file mode 100644
index 0000000..92f51a9
--- /dev/null
+++ b/tests/Unit/Pfx/PfxExporterTest.php
@@ -0,0 +1,107 @@
+credentialPassphrase = trim($this->fileContents('CSD01_AAA010101AAA/password.txt'));
+ }
+
+ private function createCredential(): Credential
+ {
+ return Credential::openFiles(
+ $this->filePath('CSD01_AAA010101AAA/certificate.cer'),
+ $this->filePath('CSD01_AAA010101AAA/private_key.key'),
+ $this->credentialPassphrase
+ );
+ }
+
+ public function testExport(): void
+ {
+ $credential = $this->createCredential();
+ $pfxExporter = new PfxExporter($credential);
+
+ $pfxContents = $pfxExporter->export('');
+
+ $reader = new PfxReader();
+ $this->assertSame(
+ $reader->loadPkcs12($this->fileContents('CSD01_AAA010101AAA/credential_unprotected.pfx')),
+ $reader->loadPkcs12($pfxContents)
+ );
+ }
+
+ public function testExportToFile(): void
+ {
+ $credential = $this->createCredential();
+ $pfxExporter = new PfxExporter($credential);
+ $temporaryFile = tempnam('', '');
+ if (false === $temporaryFile) {
+ $this->fail('Expected to create a temporary file');
+ }
+
+ $pfxExporter->exportToFile($temporaryFile, '');
+
+ $reader = new PfxReader();
+ $this->assertSame(
+ $reader->loadPkcs12($this->fileContents('CSD01_AAA010101AAA/credential_unprotected.pfx')),
+ $reader->loadPkcs12((string) file_get_contents($temporaryFile))
+ );
+ }
+
+ public function testExportWithError(): void
+ {
+ // create a credential with an invalid private key to produce error
+ $certificate = Certificate::openFile($this->filePath('CSD01_AAA010101AAA/certificate.cer'));
+ $privateKey = $this->createMock(PrivateKey::class);
+ $privateKey->method('belongsTo')->willReturn(true);
+ $privateKey->method('pem')->willReturn('bar');
+ $privateKey->method('passPhrase')->willReturn('baz');
+ $malformedCredential = new Credential($certificate, $privateKey);
+
+ $pfxExporter = new PfxExporter($malformedCredential);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches(
+ '#^Cannot export credential with certificate 30001000000300023708: #'
+ );
+
+ $pfxExporter->export('');
+ }
+
+ public function testExportToFileWithError(): void
+ {
+ $credential = $this->createCredential();
+ $pfxExporter = new PfxExporter($credential);
+ $exportFile = __DIR__ . '/non-existent/path/file.pfx';
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches(
+ "#^Cannot export credential with certificate 30001000000300023708 to file $exportFile: #"
+ );
+ $pfxExporter->exportToFile($exportFile, '');
+ }
+
+ public function testGetCredential(): void
+ {
+ $credential = $this->createCredential();
+ $pfxExporter = new PfxExporter($credential);
+
+ $this->assertSame($credential, $pfxExporter->getCredential());
+ }
+}
diff --git a/tests/Unit/Pfx/PfxReaderTest.php b/tests/Unit/Pfx/PfxReaderTest.php
new file mode 100644
index 0000000..a538f46
--- /dev/null
+++ b/tests/Unit/Pfx/PfxReaderTest.php
@@ -0,0 +1,72 @@
+createCredentialFromFile(
+ $this->filePath('CSD01_AAA010101AAA/credential_unprotected.pfx'),
+ ''
+ );
+ }
+
+ /**
+ * @testWith ["CSD01_AAA010101AAA/credential_unprotected.pfx", ""]
+ * ["CSD01_AAA010101AAA/credential_protected.pfx", "CSD01_AAA010101AAA/password.txt"]
+ */
+ public function testCreateCredentialFromFile(string $dir, string $passPhrasePath): void
+ {
+ $passPhrase = $this->fileContents($passPhrasePath);
+ $reader = new PfxReader();
+ $expectedCsd = $this->obtainKnownCredential();
+
+ $csd = $reader->createCredentialFromFile($this->filePath($dir), $passPhrase);
+
+ $this->assertInstanceOf(Credential::class, $csd);
+ $this->assertSame($expectedCsd->certificate()->pem(), $csd->certificate()->pem());
+ $this->assertSame($expectedCsd->privateKey()->pem(), $csd->privateKey()->pem());
+ }
+
+ public function testCreateCredentialEmptyContents(): void
+ {
+ $reader = new PfxReader();
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('Cannot create credential from empty PFX contents');
+
+ $reader->createCredentialFromContents('', '');
+ }
+
+ public function testCreateCredentialWrongContent(): void
+ {
+ $reader = new PfxReader();
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('Invalid PKCS#12 contents or wrong passphrase');
+
+ $reader->createCredentialFromContents('invalid-contents', '');
+ }
+
+ public function testCreateCredentialWrongPassword(): void
+ {
+ $reader = new PfxReader();
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('Invalid PKCS#12 contents or wrong passphrase');
+
+ $reader->createCredentialFromFile(
+ $this->filePath('CSD01_AAA010101AAA/credential_protected.pfx'),
+ 'wrong-password'
+ );
+ }
+}
diff --git a/tests/_files/CSD01_AAA010101AAA/credential_protected.pfx b/tests/_files/CSD01_AAA010101AAA/credential_protected.pfx
new file mode 100644
index 0000000..41f2b58
Binary files /dev/null and b/tests/_files/CSD01_AAA010101AAA/credential_protected.pfx differ
diff --git a/tests/_files/CSD01_AAA010101AAA/credential_unprotected.pfx b/tests/_files/CSD01_AAA010101AAA/credential_unprotected.pfx
new file mode 100644
index 0000000..43cf2a6
Binary files /dev/null and b/tests/_files/CSD01_AAA010101AAA/credential_unprotected.pfx differ
diff --git a/tests/_files/README.md b/tests/_files/README.md
index 41322d0..5cd3cb9 100644
--- a/tests/_files/README.md
+++ b/tests/_files/README.md
@@ -33,11 +33,6 @@ openssl base64 -in data-sha256.bin -out data-sha256.txt
openssl dgst -sha256 -verify CSD01_AAA010101AAA.cer.pem -signature data-sha256.bin data-to-sign.txt
```
-
-
-
-
-
Estos archivos fueron descargados desde
@@ -55,3 +50,14 @@ Para protegerla con password nuevamente se usó:
```
openssl rsa -in aaa010101aaa_FIEL.key.pem -des3 -out aaa010101aaa_FIEL_password.key.pem
```
+
+Para generar los archivos PFX se usó:
+
+```shell
+# credential_protected.pfx
+openssl pkcs12 -export -in certificate.cer -inkey private_key.key.pem -passin pass:12345678a -out credential_protected.pfx --passout pass:12345678a
+# credential_unprotected.pfx
+openssl pkcs12 -export -in certificate.cer -inkey private_key.key.pem -passin pass:12345678a -out credential_unprotected.pfx --passout pass:
+# ver el contenido de un archivo pfx
+openssl pkcs12 -info -in credential_unprotected.pfx -passin pass: -noenc
+```