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