diff --git a/.phive/phars.xml b/.phive/phars.xml
index 35f9832..d06f6c6 100644
--- a/.phive/phars.xml
+++ b/.phive/phars.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/README.md b/README.md
index edc97eb..3046f53 100644
--- a/README.md
+++ b/README.md
@@ -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:
@@ -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
@@ -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: [
@@ -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(<<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.
diff --git a/src/OpenSSL/Exception/InvalidKeyException.php b/src/OpenSSL/Exception/InvalidKeyException.php
new file mode 100644
index 0000000..5239fca
--- /dev/null
+++ b/src/OpenSSL/Exception/InvalidKeyException.php
@@ -0,0 +1,19 @@
+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() ?? '');
+ }
+}
diff --git a/src/OpenSSL/Parser/X509PublicCertificateParser.php b/src/OpenSSL/Parser/X509PublicCertificateParser.php
new file mode 100644
index 0000000..30aefaf
--- /dev/null
+++ b/src/OpenSSL/Parser/X509PublicCertificateParser.php
@@ -0,0 +1,27 @@
+getString());
+ if (!$key) {
+ throw InvalidKeyException::unableToReadPublicKey();
+ }
+
+ $result = @openssl_x509_export($key, $parsed);
+ if (!$result) {
+ throw InvalidKeyException::unableToReadPublicKey();
+ }
+
+ return new Certificate($parsed);
+ }
+}
diff --git a/src/WSSecurity/KeyStore/Certificate.php b/src/WSSecurity/KeyStore/Certificate.php
index 3ec33db..ffb781a 100644
--- a/src/WSSecurity/KeyStore/Certificate.php
+++ b/src/WSSecurity/KeyStore/Certificate.php
@@ -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;
diff --git a/src/WSSecurity/KeyStore/ClientCertificate.php b/src/WSSecurity/KeyStore/ClientCertificate.php
new file mode 100644
index 0000000..e483669
--- /dev/null
+++ b/src/WSSecurity/KeyStore/ClientCertificate.php
@@ -0,0 +1,74 @@
+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;
+ }
+}
diff --git a/src/WSSecurity/KeyStore/Key.php b/src/WSSecurity/KeyStore/Key.php
index 239b2cc..9160fe9 100644
--- a/src/WSSecurity/KeyStore/Key.php
+++ b/src/WSSecurity/KeyStore/Key.php
@@ -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;
diff --git a/tests/Unit/OpenSSL/Parser/PrivateKeyParserTest.php b/tests/Unit/OpenSSL/Parser/PrivateKeyParserTest.php
new file mode 100644
index 0000000..509c9af
--- /dev/null
+++ b/tests/Unit/OpenSSL/Parser/PrivateKeyParserTest.php
@@ -0,0 +1,73 @@
+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;
+ }
+}
diff --git a/tests/Unit/OpenSSL/Parser/X509PublicCertificateParserTest.php b/tests/Unit/OpenSSL/Parser/X509PublicCertificateParserTest.php
new file mode 100644
index 0000000..6480616
--- /dev/null
+++ b/tests/Unit/OpenSSL/Parser/X509PublicCertificateParserTest.php
@@ -0,0 +1,46 @@
+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));
+ }
+}
diff --git a/tools/php-cs-fixer.phar b/tools/php-cs-fixer.phar
index 7912db6..9a03f3e 100755
Binary files a/tools/php-cs-fixer.phar and b/tools/php-cs-fixer.phar differ