Skip to content

Commit

Permalink
Add shortcut key-store for combined PEM client certificates
Browse files Browse the repository at this point in the history
  • Loading branch information
veewee committed Nov 23, 2023
1 parent 1d3c693 commit d0279b4
Show file tree
Hide file tree
Showing 9 changed files with 309 additions and 7 deletions.
39 changes: 32 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,30 @@ $wsseMiddleware = new WsseMiddleware(
);
```

#### Signing a SOAP request with PKCS12 or X509 certificate.
### Key stores

This is one of the most common implementation of WSS out there.
You are granted a certificate by the soap service with which you need to fetch data.
This package provides a couple of `Key` wrappers that can be used to pass private / public keys:

* `KeyStore\Certificate`: Contains a public X.509 certificate in PEM format.
* `KeyStore\Key`: Contains a PKCS_8 private key in PEM format.
* `KeyStore\ClientCertificate`: Contains both a public X.509 certificate and PKCS_8 private key in PEM format.

Example:

```php
use Soap\Psr18WsseMiddleware\WSSecurity\KeyStore\Certificate;
use Soap\Psr18WsseMiddleware\WSSecurity\KeyStore\ClientCertificate;
use Soap\Psr18WsseMiddleware\WSSecurity\KeyStore\Key;

$privKey = Key::fromFile('security_token.priv')->withPassphrase('xxx'); // Regular private key (not wrapped in X509)
$pubKey = Certificate::fromFile('security_token.pub'); // Public X509 cert

// or:

$bundle = ClientCertificate::fromFile('client-certificate.pem')->withPassphrase('xxx');
$privKey = $bunlde->privateKey();
$pubKey = $bunlde->publicCertificate();
```

In case of a p12 certificate: convert it to a private key and public X509 certificate first:

Expand All @@ -109,6 +129,11 @@ openssl pkcs12 -in your.p12 -out security_token.pub -clcerts -nokeys
openssl pkcs12 -in your.p12 -out security_token.priv -nocerts -nodes
```

#### Signing a SOAP request with PKCS12 or X509 certificate.

This is one of the most common implementation of WSS out there.
You are granted a certificate by the soap service with which you need to fetch data.

Next, you can configure the middleware like this:

```php
Expand All @@ -120,8 +145,8 @@ use Soap\Psr18WsseMiddleware\WSSecurity\KeyIdentifier;
use Soap\Psr18WsseMiddleware\WsseMiddleware;
use Soap\Psr18WsseMiddleware\WSSecurity\Entry;

$privKey = Key::fromFile('security_token.priv')->withPassphrase('xxx'); // Regular private key (not wrapped in X509)
$pubKey = Certificate::fromFile('security_token.pub'); // Public X509 cert
$privKey = Key::fromFile('security_token.priv')->withPassphrase('xxx');
$pubKey = Certificate::fromFile('security_token.pub');

$wsseMiddleware = new WsseMiddleware(
outgoing: [
Expand Down Expand Up @@ -162,7 +187,7 @@ use Soap\Psr18WsseMiddleware\WSSecurity\Entry;
use VeeWee\Xml\Dom\Document;
use function VeeWee\Xml\Dom\Locator\document_element;

$privKey = Key::fromFile('security_token.priv')->withPassphrase('xxx'); // Regular private key (not wrapped in X509)
$privKey = Key::fromFile('security_token.priv')->withPassphrase('xxx');

// These are provided through the STS service.
$samlAssertion = Document::fromXmlString(<<<EOXML
Expand Down Expand Up @@ -227,7 +252,7 @@ use Soap\Psr18WsseMiddleware\WSSecurity\KeyIdentifier;
use Soap\Psr18WsseMiddleware\WsseMiddleware;
use Soap\Psr18WsseMiddleware\WSSecurity\Entry;

$privKey = Key::fromFile('security_token.priv')->withPassphrase('xxx'); // Regular private key (not wrapped in X509)
$privKey = Key::fromFile('security_token.priv')->withPassphrase('xxx'); // Private key
$pubKey = Certificate::fromFile('security_token.pub'); // Public X509 cert
$signKey = Certificate::fromFile('sign-key.pem'); // X509 cert for signing. Could be the same as $pubKey.

Expand Down
19 changes: 19 additions & 0 deletions src/OpenSSL/Exception/InvalidKeyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);

namespace Soap\Psr18WsseMiddleware\OpenSSL\Exception;

use RuntimeException;

final class InvalidKeyException extends RuntimeException
{
public static function unableToReadPrivateKey(): self
{
return new self('Unable to read the format of the provided private key.');
}

public static function unableToReadPublicKey(): self
{
return new self('Unable to read the format of the provided public key.');
}
}
27 changes: 27 additions & 0 deletions src/OpenSSL/Parser/PrivateKeyParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);

namespace Soap\Psr18WsseMiddleware\OpenSSL\Parser;

use ParagonIE\HiddenString\HiddenString;
use Soap\Psr18WsseMiddleware\OpenSSL\Exception\InvalidKeyException;
use Soap\Psr18WsseMiddleware\WSSecurity\KeyStore\Key;

final class PrivateKeyParser
{
public function __invoke(HiddenString $privateKey, ?HiddenString $password = null): Key
{
$parsed = '';
$key = @openssl_pkey_get_private($privateKey->getString(), $password?->getString() ?: null);
if (!$key) {
throw InvalidKeyException::unableToReadPrivateKey();
}

$result = @openssl_pkey_export($key, $parsed, $password?->getString() ?: null);
if (!$result) {
throw InvalidKeyException::unableToReadPrivateKey();
}

return (new Key($parsed))->withPassphrase($password?->getString() ?? '');
}
}
27 changes: 27 additions & 0 deletions src/OpenSSL/Parser/X509PublicCertificateParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);

namespace Soap\Psr18WsseMiddleware\OpenSSL\Parser;

use ParagonIE\HiddenString\HiddenString;
use Soap\Psr18WsseMiddleware\OpenSSL\Exception\InvalidKeyException;
use Soap\Psr18WsseMiddleware\WSSecurity\KeyStore\Certificate;

final class X509PublicCertificateParser
{
public function __invoke(HiddenString $publicKey): Certificate
{
$parsed = '';
$key = @openssl_x509_read($publicKey->getString());
if (!$key) {
throw InvalidKeyException::unableToReadPublicKey();
}

$result = @openssl_x509_export($key, $parsed);
if (!$result) {
throw InvalidKeyException::unableToReadPublicKey();
}

return new Certificate($parsed);
}
}
3 changes: 3 additions & 0 deletions src/WSSecurity/KeyStore/Certificate.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
use ParagonIE\HiddenString\HiddenString;
use function Psl\File\read;

/**
* Contains a PEM representation of a public X.509 Certificate.
*/
final class Certificate implements KeyInterface
{
private HiddenString $key;
Expand Down
74 changes: 74 additions & 0 deletions src/WSSecurity/KeyStore/ClientCertificate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);

namespace Soap\Psr18WsseMiddleware\WSSecurity\KeyStore;

use ParagonIE\HiddenString\HiddenString;
use Soap\Psr18WsseMiddleware\OpenSSL\Parser\PrivateKeyParser;
use Soap\Psr18WsseMiddleware\OpenSSL\Parser\X509PublicCertificateParser;
use function Psl\File\read;

/**
* Contains a PEM bundle of both public X.509 Certificate and an (un)encrypted private key PKCS_8.
*/
final class ClientCertificate implements KeyInterface
{
private HiddenString $key;
private HiddenString $passphrase;

public function __construct(string $key)
{
$this->key = new HiddenString($key);
$this->passphrase = new HiddenString('');
}

/**
* @param non-empty-string $file
*/
public static function fromFile(string $file): self
{
return new self(read($file));
}

/**
* Parse out the private part of the bundled X509 certificate.
*/
public function privateKey(): Key
{
return (new PrivateKeyParser())($this->key, $this->passphrase);
}

/**
* Parse out the public part of the bundled X509 certificate.
*/
public function publicCertificate(): Certificate
{
return (new X509PublicCertificateParser())($this->key);
}

/**
* Provides the full content of the bundled pem certificate.
*/
public function contents(): string
{
return $this->key->getString();
}

public function passphrase(): string
{
return $this->passphrase->getString();
}

public function isCertificate(): bool
{
return true;
}

public function withPassphrase(string $passphrase): self
{
$new = clone $this;
$new->passphrase = new HiddenString($passphrase);

return $new;
}
}
3 changes: 3 additions & 0 deletions src/WSSecurity/KeyStore/Key.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
use ParagonIE\HiddenString\HiddenString;
use function Psl\File\read;

/**
* Contains a PEM representation of an (un)encrypted private key (PKCS_8).
*/
final class Key implements KeyInterface
{
private HiddenString $key;
Expand Down
76 changes: 76 additions & 0 deletions tests/Unit/OpenSSL/Parser/PrivateKeyParserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);

namespace SoapTest\Psr18WsseMiddleware\Unit\OpenSSL\Parser;

use ParagonIE\HiddenString\HiddenString;
use PHPUnit\Framework\TestCase;
use Soap\Psr18WsseMiddleware\OpenSSL\Exception\InvalidKeyException;
use Soap\Psr18WsseMiddleware\OpenSSL\Parser\PrivateKeyParser;
use Soap\Psr18WsseMiddleware\WSSecurity\KeyStore\Key;
use function Psl\File\read;

final class PrivateKeyParserTest extends TestCase
{

public function test_it_can_read_private_key(): void
{
$key = $this->createPrivateKey();
$parser = new PrivateKeyParser();

$actual = $parser(new HiddenString($key));

static::assertInstanceOf(Key::class, $actual);
static::assertSame($key, $actual->contents());
}


public function test_it_can_read_encrypted_private_key(): void
{
$key = $this->createPrivateKey($passPhrase = 'password');
$parser = new PrivateKeyParser();

static::assertStringContainsString('ENCRYPTED PRIVATE KEY', $key);

$actual = $parser(new HiddenString($key), new HiddenString($passPhrase));

static::assertInstanceOf(Key::class, $actual);
static::assertSame($passPhrase, $actual->passphrase());
static::assertStringContainsString('ENCRYPTED PRIVATE KEY', $actual->contents());
}


public function test_it_can_read_from_bundle(): void
{
$bundle = FIXTURE_DIR . '/certificates/wsse-client-x509.pem';
$parser = new PrivateKeyParser();

$actual = $parser(new HiddenString(read($bundle)));

static::assertInstanceOf(Key::class, $actual);
static::assertSame('', $actual->passphrase());
static::assertStringContainsString('PRIVATE KEY', $actual->contents());
}


public function test_it_can_not_read_invalid_private_key(): void
{
$key = 'notavalidkey';
$parser = new PrivateKeyParser();

$this->expectException(InvalidKeyException::class);
$parser(new HiddenString($key));
}

private function createPrivateKey(?string $passPhrase = null): string
{
$key = openssl_pkey_new();
static::assertNotFalse($key);

$parsed = '';
$result = openssl_pkey_export($key, $parsed, $passPhrase);
static::assertNotFalse($result);

return $parsed;
}
}
48 changes: 48 additions & 0 deletions tests/Unit/OpenSSL/Parser/X509PublicCertificateParserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);

namespace SoapTest\Psr18WsseMiddleware\Unit\OpenSSL\Parser;

use ParagonIE\HiddenString\HiddenString;
use PHPUnit\Framework\TestCase;
use Soap\Psr18WsseMiddleware\OpenSSL\Exception\InvalidKeyException;
use Soap\Psr18WsseMiddleware\OpenSSL\Parser\X509PublicCertificateParser;
use Soap\Psr18WsseMiddleware\WSSecurity\KeyStore\Certificate;
use function Psl\File\read;

final class X509PublicCertificateParserTest extends TestCase
{

public function test_it_can_read_public_x509_key(): void
{
$parser = new X509PublicCertificateParser();
$file = FIXTURE_DIR . '/certificates/wsse-server-x509.crt';

$actual = $parser(new HiddenString(read($file)));

static::assertInstanceOf(Certificate::class, $actual);
static::assertStringEqualsFile($file, $actual->contents());
}


public function test_it_can_read_from_bundle(): void
{
$bundle = FIXTURE_DIR . '/certificates/wsse-client-x509.pem';
$parser = new X509PublicCertificateParser();

$actual = $parser(new HiddenString(read($bundle)));

static::assertInstanceOf(Certificate::class, $actual);
static::assertStringContainsString('CERTIFICATE', $actual->contents());
}


public function test_it_can_not_read_invalid_certificate(): void
{
$key = 'notavalidkey';
$parser = new X509PublicCertificateParser();

$this->expectException(InvalidKeyException::class);
$parser(new HiddenString($key));
}
}

0 comments on commit d0279b4

Please sign in to comment.