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
6 changes: 3 additions & 3 deletions .phive/phars.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<phive xmlns="https://phar.io/phive">
<phar name="php-cs-fixer" version="^3.14.4" installed="3.14.4" location="./tools/php-cs-fixer" copy="false"/>
<phar name="phpcs" version="^3.7.1" installed="3.7.1" location="./tools/phpcs" copy="false"/>
<phar name="phpcbf" version="^3.7.1" installed="3.7.1" location="./tools/phpcbf" copy="false"/>
<phar name="phpstan" version="^1.10.1" installed="1.10.1" location="./tools/phpstan" copy="false"/>
<phar name="phpcs" version="^3.7.2" installed="3.7.2" location="./tools/phpcs" copy="false"/>
<phar name="phpcbf" version="^3.7.2" installed="3.7.2" location="./tools/phpcbf" copy="false"/>
<phar name="phpstan" version="^1.10.2" installed="1.10.2" location="./tools/phpstan" copy="false"/>
<phar name="composer-normalize" version="^2.29.0" installed="2.29.0" location="./tools/composer-normalize" copy="false"/>
</phive>
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
<https://github.com/kjur/jsrsasign/wiki/Tutorial-for-PKCS5-and-PKCS8-PEM-private-key-formats-differences>

## 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
<?php declare(strict_types=1);

use PhpCfdi\Credentials\Pfx\PfxExporter;

$credential = PhpCfdi\Credentials\Credential::openFiles(
'certificate/certificado.cer',
'certificate/private-key.key',
'password'
);

$pfxExporter = new PfxExporter($credential);

// crea el binary string usando la contraseña dada
$pfxContents = $pfxExporter->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
<?php declare(strict_types=1);

use PhpCfdi\Credentials\Pfx\PfxReader;

$pfxReader = new PfxReader();

// crea un objeto Credential dado el contenido de un archivo pfx
$credential = $pfxReader->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
Expand Down
15 changes: 11 additions & 4 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*.
Expand All @@ -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.
Expand Down
70 changes: 70 additions & 0 deletions src/Pfx/PfxExporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace PhpCfdi\Credentials\Pfx;

use PhpCfdi\Credentials\Credential;
use PhpCfdi\Credentials\Internal\LocalFileOpenTrait;
use RuntimeException;

class PfxExporter
{
use LocalFileOpenTrait;

/** @var Credential $credential */
private $credential;

public function __construct(Credential $credential)
{
$this->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)'));
}
}
45 changes: 45 additions & 0 deletions src/Pfx/PfxReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace PhpCfdi\Credentials\Pfx;

use PhpCfdi\Credentials\Credential;
use PhpCfdi\Credentials\Internal\LocalFileOpenTrait;
use UnexpectedValueException;

class PfxReader
{
use LocalFileOpenTrait;

public function createCredentialFromContents(string $contents, string $passPhrase): Credential
{
if ('' === $contents) {
throw new UnexpectedValueException('Cannot create credential from empty PFX contents');
}
$pfx = $this->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'] ?? '',
];
}
}
107 changes: 107 additions & 0 deletions tests/Unit/Pfx/PfxExporterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

declare(strict_types=1);

namespace PhpCfdi\Credentials\Tests\Unit\Pfx;

use PhpCfdi\Credentials\Certificate;
use PhpCfdi\Credentials\Credential;
use PhpCfdi\Credentials\Pfx\PfxExporter;
use PhpCfdi\Credentials\Pfx\PfxReader;
use PhpCfdi\Credentials\PrivateKey;
use PhpCfdi\Credentials\Tests\TestCase;
use RuntimeException;

class PfxExporterTest extends TestCase
{
/** @var string */
private $credentialPassphrase;

protected function setUp(): void
{
parent::setUp();
$this->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());
}
}
72 changes: 72 additions & 0 deletions tests/Unit/Pfx/PfxReaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace PhpCfdi\Credentials\Tests\Unit\Pfx;

use PhpCfdi\Credentials\Credential;
use PhpCfdi\Credentials\Pfx\PfxReader;
use PhpCfdi\Credentials\Tests\TestCase;
use UnexpectedValueException;

class PfxReaderTest extends TestCase
{
private function obtainKnownCredential(): Credential
{
$reader = new PfxReader();
return $reader->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'
);
}
}
Binary file not shown.
Binary file not shown.
Loading