Skip to content

Commit

Permalink
Merge pull request #17 from celli33/main
Browse files Browse the repository at this point in the history
Feat: added pkcs12 support
  • Loading branch information
eclipxe13 authored Feb 24, 2023
2 parents e5387ed + db783d7 commit 263de44
Show file tree
Hide file tree
Showing 10 changed files with 362 additions and 13 deletions.
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

0 comments on commit 263de44

Please sign in to comment.