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 +```