From bda96456017015e711a122395a519c157de2104e Mon Sep 17 00:00:00 2001 From: Johan Kromhout Date: Tue, 21 Jul 2026 18:19:20 +0200 Subject: [PATCH] Add Private Key Agent support for signing and decryption Prior to this change, RSA signing and RSA key-transport decryption could only be done with a private key loaded into the PHP process. For better security we need those two operations to be delegated to a remote Private Key Agent, so the private key never enters the application's memory, while public operations (verify, encrypt) keep running locally. This change adds the extension points needed to route the private-key operations to the agent without breaking existing consumers. Insecure algorithms (SHA-1, RSA v1.5) stay blacklisted by default. As a hardening side effect, the key-transport read path now enforces the OAEP DigestMethod and MGF that were previously ignored. --- CHANGELOG.md | 93 +++ README.md | 150 +++++ composer.json | 6 +- .../KeyTransport/AbstractKeyTransporter.php | 53 +- .../KeyTransportAlgorithmFactory.php | 77 ++- src/Alg/KeyTransport/PrivateKeyAgentRSA.php | 74 +++ src/Alg/Signature/PrivateKeyAgentRSA.php | 102 ++++ .../Signature/SignatureAlgorithmFactory.php | 76 ++- src/Backend/OAEPParametersAware.php | 30 + src/Backend/OpenSSL.php | 42 +- src/Backend/PrivateKeyAgent/AlgorithmMap.php | 151 +++++ .../FingerprintKeyNameResolver.php | 84 +++ .../PrivateKeyAgent/KeyNameResolver.php | 30 + .../PrivateKeyAgentEncryptionBackend.php | 215 +++++++ .../PrivateKeyAgentHttpClient.php | 316 ++++++++++ .../PrivateKeyAgentSignatureBackend.php | 188 ++++++ .../PrivateKeyAgent/StaticKeyNameResolver.php | 51 ++ src/Backend/PrivateKeyAgent/TokenProvider.php | 24 + src/Backend/SignatureBackend.php | 1 + src/Exception/AgentProtocolException.php | 15 + .../AgentSignatureMismatchException.php | 18 + src/Exception/AgentUnavailableException.php | 15 + src/Exception/AuthenticationException.php | 14 + src/Exception/AuthorizationException.php | 14 + src/Exception/InvalidRequestException.php | 17 + src/Exception/MissingTokenException.php | 17 + .../PrivateKeyAgentExceptionInterface.php | 18 + src/Exception/UnknownKeyException.php | 15 + src/XML/xenc/EncryptedKey.php | 41 ++ .../KeyTransportAlgorithmFactoryTest.php | 123 ++++ .../KeyTransport/PrivateKeyAgentRSATest.php | 83 +++ .../Alg/Signature/PrivateKeyAgentRSATest.php | 176 ++++++ .../SignatureAlgorithmFactoryTest.php | 126 ++++ tests/Backend/OpenSSLTest.php | 86 +++ .../PrivateKeyAgent/AlgorithmMapTest.php | 145 +++++ .../FingerprintKeyNameResolverTest.php | 152 +++++ .../PrivateKeyAgentEncryptionBackendTest.php | 418 +++++++++++++ .../PrivateKeyAgentHttpClientTest.php | 562 ++++++++++++++++++ .../PrivateKeyAgentNoFallbackTest.php | 428 +++++++++++++ .../PrivateKeyAgentSignatureBackendTest.php | 420 +++++++++++++ .../StaticKeyNameResolverTest.php | 90 +++ tests/Key/X509CertificateTest.php | 36 ++ tests/XML/xenc/EncryptedKeyTest.php | 377 ++++++++++++ tests/XML/xenc/NonAwareDecryptorInterface.php | 17 + .../XML/xenc/OaepAwareDecryptorInterface.php | 18 + 45 files changed, 5199 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/Alg/KeyTransport/PrivateKeyAgentRSA.php create mode 100644 src/Alg/Signature/PrivateKeyAgentRSA.php create mode 100644 src/Backend/OAEPParametersAware.php create mode 100644 src/Backend/PrivateKeyAgent/AlgorithmMap.php create mode 100644 src/Backend/PrivateKeyAgent/FingerprintKeyNameResolver.php create mode 100644 src/Backend/PrivateKeyAgent/KeyNameResolver.php create mode 100644 src/Backend/PrivateKeyAgent/PrivateKeyAgentEncryptionBackend.php create mode 100644 src/Backend/PrivateKeyAgent/PrivateKeyAgentHttpClient.php create mode 100644 src/Backend/PrivateKeyAgent/PrivateKeyAgentSignatureBackend.php create mode 100644 src/Backend/PrivateKeyAgent/StaticKeyNameResolver.php create mode 100644 src/Backend/PrivateKeyAgent/TokenProvider.php create mode 100644 src/Exception/AgentProtocolException.php create mode 100644 src/Exception/AgentSignatureMismatchException.php create mode 100644 src/Exception/AgentUnavailableException.php create mode 100644 src/Exception/AuthenticationException.php create mode 100644 src/Exception/AuthorizationException.php create mode 100644 src/Exception/InvalidRequestException.php create mode 100644 src/Exception/MissingTokenException.php create mode 100644 src/Exception/PrivateKeyAgentExceptionInterface.php create mode 100644 src/Exception/UnknownKeyException.php create mode 100644 tests/Alg/KeyTransport/PrivateKeyAgentRSATest.php create mode 100644 tests/Alg/Signature/PrivateKeyAgentRSATest.php create mode 100644 tests/Backend/PrivateKeyAgent/AlgorithmMapTest.php create mode 100644 tests/Backend/PrivateKeyAgent/FingerprintKeyNameResolverTest.php create mode 100644 tests/Backend/PrivateKeyAgent/PrivateKeyAgentEncryptionBackendTest.php create mode 100644 tests/Backend/PrivateKeyAgent/PrivateKeyAgentHttpClientTest.php create mode 100644 tests/Backend/PrivateKeyAgent/PrivateKeyAgentNoFallbackTest.php create mode 100644 tests/Backend/PrivateKeyAgent/PrivateKeyAgentSignatureBackendTest.php create mode 100644 tests/Backend/PrivateKeyAgent/StaticKeyNameResolverTest.php create mode 100644 tests/XML/xenc/NonAwareDecryptorInterface.php create mode 100644 tests/XML/xenc/OaepAwareDecryptorInterface.php diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..b8eafa7b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,93 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## v3.1.0 + +This release introduces the extension points needed to perform RSA signing and +RSA key-transport decryption through a remote +[Private Key Agent (PKA)](https://github.com/OpenConext/OpenConext-private-key-agent), +so that private keys are never loaded into the PHP process on those paths. No +existing public method signatures changed, but see the behavior change below. + +### ⚠️ Behavior change on the key-transport decryption path + +`EncryptedKey::decrypt()` now **reads and enforces** the `` and +`` children of the `` element. Previously these +children were ignored: + +- A non-SHA-1 digest/MGF is now either delegated to the PKA backend or + explicitly rejected with `UnsupportedAlgorithmException` (local `OpenSSL` + backend), instead of silently weakening to SHA-1 or failing with an opaque + OpenSSL error. +- Peers that today send inconsistent-but-ignored digest/MGF children on + otherwise-working SHA-1 ciphertext will now get an explicit error. + +### Added + +- **Private Key Agent backends** (`SimpleSAML\XMLSecurity\Backend\PrivateKeyAgent\`): + - `PrivateKeyAgentSignatureBackend` (implements `SignatureBackend`), hashes + locally and delegates the raw RSA signature to the agent; `verify()` runs + locally. + - `PrivateKeyAgentEncryptionBackend` (implements `EncryptionBackend` and the new + `OAEPParametersAware`), delegates RSA key-transport decryption to the agent; + `encrypt()` runs locally. The agent's algorithms couple the OAEP digest and + MGF1 hash one-to-one, so only matched pairs are supported and the default for + a parameterless `xmlenc11#rsa-oaep` deviates from the W3C default of SHA-1, + see "Operational requirements and limitations" in the README before deploying. + - Supporting contracts and helpers: `TokenProvider`, `KeyNameResolver`, + `StaticKeyNameResolver`, `FingerprintKeyNameResolver`, and the internal + `AlgorithmMap` / `PrivateKeyAgentHttpClient`. +- **Capability interface** `SimpleSAML\XMLSecurity\Backend\OAEPParametersAware` + (`setOAEPParams(?string $digestAlg, ?string $mgf)`) to carry OAEP digest/MGF to + backends without changing the `EncryptionBackend` interface. Implemented by + `OpenSSL`, `PrivateKeyAgentEncryptionBackend`, and `AbstractKeyTransporter`. +- **Algorithm-factory closure registration** + `SignatureAlgorithmFactory::registerAlgorithmFactory()` and + `KeyTransportAlgorithmFactory::registerAlgorithmFactory()`, register a + `\Closure(KeyInterface, string): AlgorithmInterface`. The existing blacklist + applies unchanged; `registerAlgorithm()` is untouched. +- **Registrable algorithm wrappers** `Alg\Signature\PrivateKeyAgentRSA` and + `Alg\KeyTransport\PrivateKeyAgentRSA` with deterministic key-type routing: + an `X509Certificate` routes to the agent backend, an `AsymmetricKey` + (`PrivateKey`/`PublicKey`) keeps the local `OpenSSL` backend, any other + `KeyInterface` fails closed. Routing is never a fallback-on-error. The injected + PKA backend is treated as a prototype: each wrapper clones it, so one backend + registered at boot safely serves any number of concurrent algorithm instances. +- A dedicated marker interface `Exception\PrivateKeyAgentExceptionInterface` and + the agent-interaction exceptions `AuthenticationException`, + `AuthorizationException`, `UnknownKeyException`, `AgentUnavailableException`, + `AgentProtocolException`, `InvalidRequestException`, `MissingTokenException`. +- New hard dependencies for the HTTP transport: `psr/http-client`, + `psr/http-message`, `psr/http-factory` (PSR-18/PSR-17, injected via the + constructor; no auto-discovery). +- New hard dependency on the `ext-filter` PHP extension. It is required by + `PrivateKeyAgentHttpClient`, which uses `filter_var()` with + `FILTER_VALIDATE_IP`/`FILTER_FLAG_IPV4` to determine whether the configured + agent URL points at a loopback address, so that plain HTTP is only accepted + for local agents. `ext-filter` is enabled by default in PHP, but is now + declared explicitly because the library depends on it. + +### Changed + +- `OpenSSL` now implements `OAEPParametersAware` and fails closed with + `UnsupportedAlgorithmException` on any non-SHA-1 OAEP digest/MGF (on all PHP + versions), instead of silently using SHA-1. + +### Security notes + +- **Insecure algorithms stay blocked by default.** SHA-1 signing + (`SIG_RSA_SHA1`) and RSA v1.5 key transport (`KEY_TRANSPORT_RSA_1_5`) remain in + each factory's `DEFAULT_BLACKLIST`. Because the PKA wrappers are only built via + `getAlgorithm()`, a blacklisted algorithm throws `BlacklistedAlgorithmException` + before any backend is reached. Unblocking them requires an explicit, + operator-configured blacklist on the factory. +- **Transport security.** The agent base URL must be `https://`. Plain `http://` + is only allowed via the explicit `allowInsecureHttp: true` constructor flag and + is then further restricted to loopback hosts (`localhost`, `127.0.0.0/8`, `::1`), + so the bearer token can never leave the host in cleartext. Both checks are + validated fail-closed at construction. +- Bearer tokens never appear in exception messages or logs; token parameters + carry `#[\SensitiveParameter]`. +- **No fallback.** When the agent is unreachable or fails, the operation raises a + controlled exception and never falls back to a local private-key operation. diff --git a/README.md b/README.md index 9bba10b2..8166c3f8 100644 --- a/README.md +++ b/README.md @@ -653,6 +653,156 @@ supported here, you will have to implement the `encrypt()` method yourself. Not available yet. +## Private Key Agent backends + +This library can perform RSA signing and RSA key-transport decryption through a +remote [Private Key Agent (PKA)](https://github.com/OpenConext/OpenConext-private-key-agent), +so that private keys are never loaded into the PHP process on those paths. +Signature verification and public-key encryption keep running locally. + +The building blocks live under `SimpleSAML\XMLSecurity\Backend\PrivateKeyAgent\`. +You provide two small contracts and wire the backends through the algorithm +factories at boot: + +- A `TokenProvider` (`getToken(string $keyName): string`) that returns the bearer + token for a key. This is secret-/config-dependent, so the library ships only the + contract, you implement it. +- A `KeyNameResolver` that maps an `X509Certificate` to the agent key name. Two + strategies are provided: `FingerprintKeyNameResolver` (an explicit + `[fingerprint => key_name]` map) and `StaticKeyNameResolver` (one fixed name). + **Use `FingerprintKeyNameResolver`, also when you have only one key** — a + one-entry map costs one line of configuration and is what binds the certificate + you were given to the key the agent uses. See + [Caller obligations](#caller-obligations). + +The backends require a PSR-18 HTTP client and PSR-17 request/stream factories +(injected explicitly, there is no auto-discovery). Retries, timeouts and circuit +breaking are the responsibility of the client you inject. + +```php +use SimpleSAML\XMLSecurity\Alg\KeyTransport\KeyTransportAlgorithmFactory; +use SimpleSAML\XMLSecurity\Alg\KeyTransport\PrivateKeyAgentRSA; +use SimpleSAML\XMLSecurity\Backend\PrivateKeyAgent\FingerprintKeyNameResolver; +use SimpleSAML\XMLSecurity\Backend\PrivateKeyAgent\PrivateKeyAgentEncryptionBackend; +use SimpleSAML\XMLSecurity\Constants as C; + +// $httpClient, $requestFactory, $streamFactory are your PSR-18/PSR-17 instances; +// $tokenProvider implements TokenProvider. +$backend = new PrivateKeyAgentEncryptionBackend( + $httpClient, + $requestFactory, + $streamFactory, + 'https://agent.internal/', // must be https:// (or pass allowInsecureHttp: true) + $tokenProvider, + new FingerprintKeyNameResolver([ + // hex SHA-256 fingerprint of the certificate => agent key name + '3b7f...c1a9' => 'my-key-name', + ]), +); + +// Register a factory closure once, at boot. The blacklist still applies. +KeyTransportAlgorithmFactory::registerAlgorithmFactory( + C::KEY_TRANSPORT_OAEP, + fn ($key, $algId) => new PrivateKeyAgentRSA($key, $algId, $backend), +); +``` + +Once registered, calling `KeyTransportAlgorithmFactory::getAlgorithm($algId, $cert)` +with an `X509Certificate` routes the operation to the agent, while an +`AsymmetricKey` keeps using the local `OpenSSL` backend, enabling phased +migration. The signing side works the same way with +`SignatureAlgorithmFactory::registerAlgorithmFactory()` and +`Alg\Signature\PrivateKeyAgentRSA`. + +The backend passed to the closure acts as a **prototype**: every `PrivateKeyAgentRSA` +instance clones it and configures its own copy. One backend registered at boot can +therefore serve any number of concurrent algorithm instances, each with its own digest +or cipher, without them interfering with each other. The HTTP client, token provider +and key name resolver stay shared, so connection reuse is unaffected. + +SHA-1 signing (`SIG_RSA_SHA1`) and RSA v1.5 key transport +(`KEY_TRANSPORT_RSA_1_5`) are blocked by each factory's `DEFAULT_BLACKLIST` and +can only be enabled through an explicit, operator-configured blacklist. + +### Caller obligations + +Delegating the private key to an agent moves part of the security boundary out of +this library. Three properties cannot be enforced here and are requirements on the +code that wires the backends up. + +**1. The certificate you pass in decides which private key is used. Never derive it +from an incoming message.** `getAlgorithm($algId, $cert)` routes to the agent based +on the certificate you hand it, and the `KeyNameResolver` turns that certificate +into the agent key name. The certificate must come from local configuration or from +metadata you have already validated. A certificate taken from the message being +processed hands the choice of key to the sender. + +How much the resolver protects you differs: + +- `FingerprintKeyNameResolver` fails closed: a certificate that is not in the map + raises `UnknownKeyException`, so an unexpected certificate cannot reach the agent. +- `StaticKeyNameResolver` performs **no binding at all**, it ignores the + certificate and returns its fixed name for anything. Every certificate that + reaches it is signed or decrypted with the configured key. Use it only where the + certificate is a fixed local constant (test harnesses, single-key sidecars). + +**2. Signing is verified locally; decryption cannot be.** After the agent returns a +signature, `PrivateKeyAgentSignatureBackend` verifies it against the public key of the +certificate it was given and throws `AgentSignatureMismatchException` if it does not +match, so a wrong key name, a substituted key on the agent, or an intercepted response +fails closed. **There is no equivalent check on the decrypt path**: the agent's +plaintext cannot be validated against the certificate, so obligation 1 is the only +control there. Treat the decrypt path as the stricter of the two. + +**3. Scope bearer tokens to the key.** `TokenProvider::getToken()` receives the +resolved key name so that it can return a token scoped to that key. Returning one +token for every key satisfies the interface but leaves agent-side authorisation as +the only thing standing between a caller and every key the agent holds. + +### Operational requirements and limitations + +**TLS verification is your client's responsibility.** This library only checks the +scheme of the agent base URL; certificate and hostname verification happen entirely +inside the PSR-18 client you inject, which **must** be configured to verify peers. +An `https://` URL on a client with verification disabled is fully interceptable, and +an interceptor sees both the bearer token and every ciphertext. Plain `http://` +requires the explicit `allowInsecureHttp: true` flag and is then accepted only for +loopback hosts (`localhost`, `127.0.0.0/8`, `::1`), so it cannot leave the machine. + +**The blacklist is enforced by the factories, not by the backends.** Constructing +`PrivateKeyAgentEncryptionBackend` or `PrivateKeyAgentSignatureBackend` and calling +them directly bypasses `DEFAULT_BLACKLIST` entirely, the backends will happily map +`KEY_TRANSPORT_RSA_1_5` or SHA-1 signing to an agent algorithm. Always go through +`getAlgorithm()`. + +**A backend instance serves one operation at a time.** `setDigestAlg()`, `setCipher()` +and `setOAEPParams()` mutate the backend, so a single instance cannot be used for two +operations with different parameters at once. Going through `getAlgorithm()` handles +this for you (each algorithm instance clones the backend), but code that drives a +backend directly must give each concurrent operation its own instance or `clone`. + +**OAEP parameters must form a matched pair.** The agent's algorithms couple the +digest and the MGF1 hash one-to-one, so only matching combinations are supported; +anything else fails closed with `UnsupportedAlgorithmException`. Two consequences +are worth knowing before you deploy: + +- `xmlenc11#rsa-oaep` with **both** `DigestMethod` and `MGF` absent is mapped to + SHA-256/MGF1-SHA-256. This is a deliberate policy choice and it **deviates from + the W3C XML Encryption 1.1 default of SHA-1/MGF1-SHA-1**. A peer that omits both + elements and relies on the W3C default produces ciphertext this path cannot + decrypt. +- `xmlenc#rsa-oaep-mgf1p` accepts only an absent or SHA-1 `DigestMethod`. The + combination of a SHA-256 digest with the fixed MGF1-SHA-1 is legal per the W3C + specification and is emitted by some SAML stacks, but no agent algorithm + implements it, so it is rejected rather than silently mapped to something else. + +**Do not let peers observe decryption failures.** A failed decryption at the agent +surfaces as `InvalidRequestException`, which is distinguishable from authentication +and availability errors. Callers must ensure this distinction never reaches a remote +party, whether through error responses or timing, otherwise it becomes a padding +oracle. The standard mitigation (continuing with a random session key on failure) +belongs in the layer above this library. + ## Keys for testing purposes All encrypted keys use '1234' as passphrase. diff --git a/composer.json b/composer.json index a5ceba15..e1213e2a 100644 --- a/composer.json +++ b/composer.json @@ -37,12 +37,16 @@ "require": { "php": "^8.5", "ext-dom": "*", + "ext-filter": "*", "ext-hash": "*", "ext-mbstring": "*", "ext-openssl": "*", "ext-pcre": "*", "ext-spl": "*", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0|^2.0", "simplesamlphp/assert": "~3.0", "simplesamlphp/xml-common": "3.0" }, @@ -51,7 +55,7 @@ }, "extra": { "branch-alias": { - "dev-master": "v2.4.x-dev" + "dev-master": "v3.1.x-dev" } }, "config": { diff --git a/src/Alg/KeyTransport/AbstractKeyTransporter.php b/src/Alg/KeyTransport/AbstractKeyTransporter.php index d9312f74..b3f7298a 100644 --- a/src/Alg/KeyTransport/AbstractKeyTransporter.php +++ b/src/Alg/KeyTransport/AbstractKeyTransporter.php @@ -7,6 +7,7 @@ use SimpleSAML\Assert\Assert; use SimpleSAML\XMLSecurity\Backend; use SimpleSAML\XMLSecurity\Backend\EncryptionBackend; +use SimpleSAML\XMLSecurity\Backend\OAEPParametersAware; use SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException; use SimpleSAML\XMLSecurity\Key\KeyInterface; @@ -15,7 +16,7 @@ * * @package simplesamlphp/xml-security */ -abstract class AbstractKeyTransporter implements KeyTransportAlgorithmInterface +abstract class AbstractKeyTransporter implements KeyTransportAlgorithmInterface, OAEPParametersAware { protected const string DEFAULT_BACKEND = Backend\OpenSSL::class; @@ -23,6 +24,12 @@ abstract class AbstractKeyTransporter implements KeyTransportAlgorithmInterface /** @var \SimpleSAML\XMLSecurity\Backend\EncryptionBackend */ protected EncryptionBackend $backend; + /** Stored OAEP digest algorithm URI, or null for algorithm default. */ + private ?string $oaepDigestAlg = null; + + /** Stored MGF URI, or null for algorithm default. */ + private ?string $oaepMgf = null; + /** * Build a key transport algorithm. @@ -78,8 +85,52 @@ public function setBackend(?EncryptionBackend $backend): void return; } + // Fail-closed: if we already have non-null OAEP params and the new backend doesn't support them, refuse. + if ( + ($this->oaepDigestAlg !== null || $this->oaepMgf !== null) + && !($backend instanceof OAEPParametersAware) + ) { + throw new UnsupportedAlgorithmException( + 'OAEP parameters are set but the backend does not implement OAEPParametersAware.', + ); + } + $this->backend = $backend; $this->backend->setCipher($this->algId); + + // Forward stored OAEP params to the new backend if it supports them. + if ($backend instanceof OAEPParametersAware) { + $backend->setOAEPParams($this->oaepDigestAlg, $this->oaepMgf); + } + } + + + /** + * Store OAEP digest/MGF parameters and forward them to the backend when one is set. + * + * Fail-closed: if non-null params are given and the current backend does not implement + * OAEPParametersAware, throws UnsupportedAlgorithmException. + * + * @param string|null $digestAlg OAEP digest algorithm URI, or null for algorithm default. + * @param string|null $mgf MGF URI, or null for algorithm default. + * + * @throws \SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException If the backend does not support + * OAEP parameters. + */ + public function setOAEPParams(?string $digestAlg = null, ?string $mgf = null): void + { + if (($digestAlg !== null || $mgf !== null) && !($this->backend instanceof OAEPParametersAware)) { + throw new UnsupportedAlgorithmException( + 'OAEP parameters are set but the backend does not implement OAEPParametersAware.', + ); + } + + $this->oaepDigestAlg = $digestAlg; + $this->oaepMgf = $mgf; + + if ($this->backend instanceof OAEPParametersAware) { + $this->backend->setOAEPParams($digestAlg, $mgf); + } } diff --git a/src/Alg/KeyTransport/KeyTransportAlgorithmFactory.php b/src/Alg/KeyTransport/KeyTransportAlgorithmFactory.php index c0b6b154..8c63bd0a 100644 --- a/src/Alg/KeyTransport/KeyTransportAlgorithmFactory.php +++ b/src/Alg/KeyTransport/KeyTransportAlgorithmFactory.php @@ -7,10 +7,12 @@ use SimpleSAML\Assert\Assert; use SimpleSAML\XMLSecurity\Constants as C; use SimpleSAML\XMLSecurity\Exception\BlacklistedAlgorithmException; +use SimpleSAML\XMLSecurity\Exception\RuntimeException; use SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException; use SimpleSAML\XMLSecurity\Key\KeyInterface; use function array_key_exists; +use function get_debug_type; use function sprintf; /** @@ -42,10 +44,17 @@ class KeyTransportAlgorithmFactory /** * A cache of algorithm implementations indexed by algorithm ID. * - * @var array + * @var array> */ protected static array $cache = []; + /** + * Closure factories registered via registerAlgorithmFactory(), keyed by algorithm ID. + * + * @var array + */ + protected static array $closureMap = []; + /** * Whether the factory has been initialized or not. */ @@ -102,6 +111,22 @@ public function getAlgorithm( sprintf('Blacklisted algorithm: \'%s\'.', $algId), BlacklistedAlgorithmException::class, ); + + if (array_key_exists($algId, self::$closureMap)) { + $instance = (self::$closureMap[$algId])($key, $algId); + if (!($instance instanceof KeyTransportAlgorithmInterface)) { + throw new RuntimeException( + sprintf( + 'Registered factory for \'%s\' returned %s instead of %s.', + $algId, + get_debug_type($instance), + KeyTransportAlgorithmInterface::class, + ), + ); + } + return $instance; + } + Assert::keyExists( self::$cache, $algId, @@ -134,4 +159,54 @@ public static function registerAlgorithm(string $className): void self::$cache[$algId] = $className; } } + + + /** + * Register a closure factory for a specific algorithm ID. + * + * The closure is invoked by getAlgorithm() when the given algorithm is requested, + * taking priority over any class-string registered via registerAlgorithm(). The + * blacklist check still runs before the closure is invoked. The returned value must + * implement KeyTransportAlgorithmInterface; a wrong return type throws RuntimeException. + * + * Lifetime: registration is process-global and is not scoped to a factory instance or to a + * request. It lives until unregisterAlgorithmFactory()/clearAlgorithmFactories() is called or + * the process ends, which matters under worker SAPIs (FrankenPHP, RoadRunner, Swoole) where the + * process is reused across requests. Everything the closure captures -- backend, token provider, + * key-name resolver -- is shared by every subsequent caller of that algorithm URI. + * + * @param string $algId The algorithm URI to register the factory for. + * @param \Closure $factory Callable with signature + * `(KeyInterface $key, string $algId): KeyTransportAlgorithmInterface`. + */ + public static function registerAlgorithmFactory(string $algId, \Closure $factory): void + { + self::$closureMap[$algId] = $factory; + } + + + /** + * Remove the closure factory registered for a specific algorithm ID. + * + * Removing a factory that was never registered is a no-op. Any class-string registered via + * registerAlgorithm() for the same algorithm remains in place and takes over again. + * + * @param string $algId The algorithm URI to unregister the factory for. + */ + public static function unregisterAlgorithmFactory(string $algId): void + { + unset(self::$closureMap[$algId]); + } + + + /** + * Remove all closure factories registered via registerAlgorithmFactory(). + * + * Only the closure registrations are cleared; the built-in algorithm registry populated from + * registerAlgorithm() is left untouched. + */ + public static function clearAlgorithmFactories(): void + { + self::$closureMap = []; + } } diff --git a/src/Alg/KeyTransport/PrivateKeyAgentRSA.php b/src/Alg/KeyTransport/PrivateKeyAgentRSA.php new file mode 100644 index 00000000..149a51a6 --- /dev/null +++ b/src/Alg/KeyTransport/PrivateKeyAgentRSA.php @@ -0,0 +1,74 @@ +setBackend(clone $pkaBackend); + } elseif (!($key instanceof AsymmetricKey)) { + throw new InvalidArgumentException( + sprintf( + '%s requires an X509Certificate or AsymmetricKey, got %s.', + self::class, + $key::class, + ), + ); + } + // AsymmetricKey: keep the local OpenSSL default backend the parent constructed. + } + + + /** + * @return string[] + */ + public static function getSupportedAlgorithms(): array + { + return C::$KEY_TRANSPORT_ALGORITHMS; + } +} diff --git a/src/Alg/Signature/PrivateKeyAgentRSA.php b/src/Alg/Signature/PrivateKeyAgentRSA.php new file mode 100644 index 00000000..64991e11 --- /dev/null +++ b/src/Alg/Signature/PrivateKeyAgentRSA.php @@ -0,0 +1,102 @@ +setBackend(clone $pkaBackend); + } elseif (!($key instanceof AsymmetricKey)) { + throw new InvalidArgumentException( + sprintf( + '%s requires an X509Certificate or AsymmetricKey, got %s.', + self::class, + $key::class, + ), + ); + } + // AsymmetricKey: keep the local OpenSSL default backend the parent constructed. + } + + + /** + * @return string[] + */ + public static function getSupportedAlgorithms(): array + { + return array_keys(C::$RSA_DIGESTS); + } +} diff --git a/src/Alg/Signature/SignatureAlgorithmFactory.php b/src/Alg/Signature/SignatureAlgorithmFactory.php index f5f13466..194f1ae4 100644 --- a/src/Alg/Signature/SignatureAlgorithmFactory.php +++ b/src/Alg/Signature/SignatureAlgorithmFactory.php @@ -7,10 +7,12 @@ use SimpleSAML\Assert\Assert; use SimpleSAML\XMLSecurity\Constants as C; use SimpleSAML\XMLSecurity\Exception\BlacklistedAlgorithmException; +use SimpleSAML\XMLSecurity\Exception\RuntimeException; use SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException; use SimpleSAML\XMLSecurity\Key\KeyInterface; use function array_key_exists; +use function get_debug_type; use function sprintf; /** @@ -47,10 +49,17 @@ final class SignatureAlgorithmFactory /** * A cache of algorithm implementations indexed by algorithm ID. * - * @var array + * @var array> */ protected static array $cache = []; + /** + * Closure factories registered via registerAlgorithmFactory(), keyed by algorithm ID. + * + * @var array + */ + protected static array $closureMap = []; + /** * Whether the factory has been initialized or not. */ @@ -107,6 +116,22 @@ public function getAlgorithm( sprintf('Blacklisted algorithm: \'%s\'.', $algId), BlacklistedAlgorithmException::class, ); + + if (array_key_exists($algId, self::$closureMap)) { + $instance = (self::$closureMap[$algId])($key, $algId); + if (!($instance instanceof SignatureAlgorithmInterface)) { + throw new RuntimeException( + sprintf( + 'Registered factory for \'%s\' returned %s instead of %s.', + $algId, + get_debug_type($instance), + SignatureAlgorithmInterface::class, + ), + ); + } + return $instance; + } + Assert::keyExists( self::$cache, $algId, @@ -139,4 +164,53 @@ public static function registerAlgorithm(string $className): void self::$cache[$algId] = $className; } } + + + /** + * Register a closure factory for a specific algorithm ID. + * + * The closure is invoked by getAlgorithm() when the given algorithm is requested, + * taking priority over any class-string registered via registerAlgorithm(). The + * blacklist check still runs before the closure is invoked. The returned value must + * implement SignatureAlgorithmInterface; a wrong return type throws RuntimeException. + * + * Lifetime: registration is process-global and is not scoped to a factory instance or to a + * request. It lives until unregisterAlgorithmFactory()/clearAlgorithmFactories() is called or + * the process ends, which matters under worker SAPIs (FrankenPHP, RoadRunner, Swoole) where the + * process is reused across requests. Everything the closure captures -- backend, token provider, + * key-name resolver -- is shared by every subsequent caller of that algorithm URI. + * + * @param string $algId The algorithm URI to register the factory for. + * @param \Closure $factory Callable with signature (KeyInterface $key, string $algId): SignatureAlgorithmInterface. + */ + public static function registerAlgorithmFactory(string $algId, \Closure $factory): void + { + self::$closureMap[$algId] = $factory; + } + + + /** + * Remove the closure factory registered for a specific algorithm ID. + * + * Removing a factory that was never registered is a no-op. Any class-string registered via + * registerAlgorithm() for the same algorithm remains in place and takes over again. + * + * @param string $algId The algorithm URI to unregister the factory for. + */ + public static function unregisterAlgorithmFactory(string $algId): void + { + unset(self::$closureMap[$algId]); + } + + + /** + * Remove all closure factories registered via registerAlgorithmFactory(). + * + * Only the closure registrations are cleared; the built-in algorithm registry populated from + * registerAlgorithm() is left untouched. + */ + public static function clearAlgorithmFactories(): void + { + self::$closureMap = []; + } } diff --git a/src/Backend/OAEPParametersAware.php b/src/Backend/OAEPParametersAware.php new file mode 100644 index 00000000..781b6b14 --- /dev/null +++ b/src/Backend/OAEPParametersAware.php @@ -0,0 +1,30 @@ + element. + * + * @param string|null $digestAlg Digest algorithm URI for OAEP (e.g. xmldsig#sha1). Null = algorithm default. + * @param string|null $mgf Mask generation function URI for OAEP (xenc11). Null = algorithm default. + * + * @throws \SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException If the digest/mgf value or + * combination is unsupported. + * @throws \SimpleSAML\XMLSecurity\Exception\RuntimeException If no cipher has been configured yet. + */ + public function setOAEPParams(?string $digestAlg = null, ?string $mgf = null): void; +} diff --git a/src/Backend/OpenSSL.php b/src/Backend/OpenSSL.php index b66b570a..00da5770 100644 --- a/src/Backend/OpenSSL.php +++ b/src/Backend/OpenSSL.php @@ -8,6 +8,7 @@ use SimpleSAML\XMLSecurity\Exception\InvalidArgumentException; use SimpleSAML\XMLSecurity\Exception\OpenSSLException; use SimpleSAML\XMLSecurity\Exception\RuntimeException; +use SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException; use SimpleSAML\XMLSecurity\Key\AsymmetricKey; use SimpleSAML\XMLSecurity\Key\KeyInterface; use SimpleSAML\XMLSecurity\Key\PrivateKey; @@ -31,7 +32,7 @@ * * @package SimpleSAML\XMLSecurity\Backend */ -final class OpenSSL implements EncryptionBackend, SignatureBackend, SignaturePadding +final class OpenSSL implements EncryptionBackend, OAEPParametersAware, SignatureBackend, SignaturePadding { public const int AUTH_TAG_LEN = 16; @@ -54,6 +55,9 @@ final class OpenSSL implements EncryptionBackend, SignatureBackend, SignaturePad protected bool $useAuthTag = false; + /** Tracks whether a cipher has been set (required before setOAEPParams()). */ + private bool $cipherConfigured = false; + /** * Build a new OpenSSL backend. @@ -239,6 +243,7 @@ public function setCipher(string $cipher): void // configure the backend depending on the actual algorithm to use $this->useAuthTag = false; $this->cipher = $cipher; + $this->cipherConfigured = true; switch ($cipher) { case C::KEY_TRANSPORT_RSA_1_5: $this->enc_padding = OPENSSL_PKCS1_PADDING; @@ -260,6 +265,41 @@ public function setCipher(string $cipher): void } + /** + * Configure OAEP digest and MGF parameters. + * + * Only SHA-1 digest and MGF1-SHA-1 are supported locally. Any other value throws + * UnsupportedAlgorithmException on all PHP versions, use the PrivateKeyAgent backend + * for other OAEP digest/MGF combinations. + * + * @param string|null $digestAlg Digest algorithm URI. Null or xmldsig#sha1 accepted. + * @param string|null $mgf MGF URI. Null or xmlenc11#mgf1sha1 accepted. + * + * @throws \SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException For any non-SHA-1 value. + * @throws \SimpleSAML\XMLSecurity\Exception\RuntimeException If called before setCipher(). + */ + public function setOAEPParams(?string $digestAlg = null, ?string $mgf = null): void + { + if (!$this->cipherConfigured) { + throw new RuntimeException('setOAEPParams() must be called after setCipher().'); + } + + $mgf1Sha1 = 'http://www.w3.org/2009/xmlenc11#mgf1sha1'; + + if ($digestAlg !== null && $digestAlg !== C::DIGEST_SHA1) { + throw new UnsupportedAlgorithmException( + 'OpenSSL backend only supports SHA-1 OAEP; use the PrivateKeyAgent backend for other digests.', + ); + } + + if ($mgf !== null && $mgf !== $mgf1Sha1) { + throw new UnsupportedAlgorithmException( + 'OpenSSL backend only supports MGF1-SHA-1; use the PrivateKeyAgent backend for other MGF values.', + ); + } + } + + /** * Set the padding method to be used by this backend. * diff --git a/src/Backend/PrivateKeyAgent/AlgorithmMap.php b/src/Backend/PrivateKeyAgent/AlgorithmMap.php new file mode 100644 index 00000000..854c098d --- /dev/null +++ b/src/Backend/PrivateKeyAgent/AlgorithmMap.php @@ -0,0 +1,151 @@ + 'rsa-pkcs1-v1_5-sha1', + C::DIGEST_SHA256 => 'rsa-pkcs1-v1_5-sha256', + C::DIGEST_SHA384 => 'rsa-pkcs1-v1_5-sha384', + C::DIGEST_SHA512 => 'rsa-pkcs1-v1_5-sha512', + // SHA-224 signing is not supported by the agent + default => throw new UnsupportedAlgorithmException( + sprintf('Digest algorithm \'%s\' is not supported for PKA signing.', $digestAlg), + ), + }; + } + + + /** + * Map a key-transport cipher URI plus optional OAEP digest/MGF to a PKA decryption algorithm identifier. + * + * @param string $cipherUri The XMLSec key-transport algorithm URI. + * @param string|null $digestAlg The OAEP digest algorithm URI, or null for algorithm default. + * @param string|null $mgf The MGF URI, or null for algorithm default. + * + * @return string The agent algorithm identifier (e.g. 'rsa-pkcs1-oaep-mgf1-sha256'). + * + * @throws \SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException For unsupported combinations. + */ + public static function getKeyTransportAlgorithm( + string $cipherUri, + ?string $digestAlg = null, + ?string $mgf = null, + ): string { + if ($cipherUri === C::KEY_TRANSPORT_RSA_1_5) { + return 'rsa-pkcs1-v1_5'; + } + + if ($cipherUri === C::KEY_TRANSPORT_OAEP_MGF1P) { + return self::mapOaepMgf1p($digestAlg, $mgf); + } + + if ($cipherUri === C::KEY_TRANSPORT_OAEP) { + return self::mapOaep($digestAlg, $mgf); + } + + throw new UnsupportedAlgorithmException( + sprintf('Key-transport cipher \'%s\' is not supported by the PKA backend.', $cipherUri), + ); + } + + + /** + * Map xmlenc#rsa-oaep-mgf1p (fixed SHA-1 MGF1) + optional digest to an agent algorithm. + */ + private static function mapOaepMgf1p(?string $digestAlg, ?string $mgf): string + { + // mgf1p fixes MGF1-SHA-1; any explicit non-SHA-1 mgf is unsupported + if ($mgf !== null && $mgf !== self::MGF1_SHA1) { + throw new UnsupportedAlgorithmException( + sprintf( + 'rsa-oaep-mgf1p only supports MGF1-SHA-1; \'%s\' is not supported.', + $mgf, + ), + ); + } + + // Only SHA-1 digest (or absent/null = default SHA-1) is accepted for mgf1p + if ($digestAlg !== null && $digestAlg !== C::DIGEST_SHA1) { + throw new UnsupportedAlgorithmException( + sprintf( + 'rsa-oaep-mgf1p with digest \'%s\' is not supported; only SHA-1 is allowed.', + $digestAlg, + ), + ); + } + + return 'rsa-pkcs1-oaep-mgf1-sha1'; + } + + + /** + * Map xmlenc11#rsa-oaep + digest/MGF pair to an agent algorithm. + */ + private static function mapOaep(?string $digestAlg, ?string $mgf): string + { + // Both absent → default variant SHA-256/MGF1-SHA-256 + if ($digestAlg === null && $mgf === null) { + return 'rsa-pkcs1-oaep-mgf1-sha256'; + } + + // Exactly one present → unsupported (spec: both or neither) + if (($digestAlg === null) !== ($mgf === null)) { + throw new UnsupportedAlgorithmException( + 'rsa-oaep requires both digest and MGF to be set, or neither.', + ); + } + + // Both present: validate the digest↔mgf pair + return match (true) { + $digestAlg === C::DIGEST_SHA1 && $mgf === self::MGF1_SHA1 => 'rsa-pkcs1-oaep-mgf1-sha1', + $digestAlg === C::DIGEST_SHA224 && $mgf === self::MGF1_SHA224 => 'rsa-pkcs1-oaep-mgf1-sha224', + $digestAlg === C::DIGEST_SHA256 && $mgf === self::MGF1_SHA256 => 'rsa-pkcs1-oaep-mgf1-sha256', + $digestAlg === C::DIGEST_SHA384 && $mgf === self::MGF1_SHA384 => 'rsa-pkcs1-oaep-mgf1-sha384', + $digestAlg === C::DIGEST_SHA512 && $mgf === self::MGF1_SHA512 => 'rsa-pkcs1-oaep-mgf1-sha512', + default => throw new UnsupportedAlgorithmException( + sprintf( + 'rsa-oaep digest/MGF combination \'%s\'/\'%s\' is not supported.', + $digestAlg, + $mgf, + ), + ), + }; + } +} diff --git a/src/Backend/PrivateKeyAgent/FingerprintKeyNameResolver.php b/src/Backend/PrivateKeyAgent/FingerprintKeyNameResolver.php new file mode 100644 index 00000000..644a0ccb --- /dev/null +++ b/src/Backend/PrivateKeyAgent/FingerprintKeyNameResolver.php @@ -0,0 +1,84 @@ + Fingerprint (hex SHA-256) → key name */ + private readonly array $map; + + + /** + * @param array $map Map of hex SHA-256 fingerprint → key name. + * + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidArgumentException On malformed or duplicate (case-insensitive) + * fingerprints, or invalid key names. + */ + public function __construct(array $map) + { + // Validate key names + foreach ($map as $fingerprint => $keyName) { + if (preg_match(self::KEY_NAME_PATTERN, $keyName) !== 1) { + throw new InvalidArgumentException( + sprintf( + 'Invalid key name \'%s\' for fingerprint \'%s\'; must match [a-zA-Z0-9_-]{1,64}.', + $keyName, + $fingerprint, + ), + ); + } + } + + // Validate fingerprint format and normalise to lowercase; reject fingerprints that collide only by case. + $normalisedMap = []; + foreach ($map as $fingerprint => $keyName) { + if (preg_match('/^[0-9a-fA-F]{64}$/', (string) $fingerprint) !== 1) { + throw new InvalidArgumentException( + sprintf('Invalid fingerprint \'%s\'; must be 64 hex characters.', $fingerprint), + ); + } + + $normalisedFingerprint = strtolower((string) $fingerprint); + if (isset($normalisedMap[$normalisedFingerprint])) { + throw new InvalidArgumentException( + sprintf('Duplicate fingerprint \'%s\' in map (case-insensitive).', $normalisedFingerprint), + ); + } + + $normalisedMap[$normalisedFingerprint] = $keyName; + } + + $this->map = $normalisedMap; + } + + + public function resolve(X509Certificate $certificate): string + { + $fingerprint = strtolower($certificate->getRawThumbprint(C::DIGEST_SHA256)); + + if (!isset($this->map[$fingerprint])) { + throw new UnknownKeyException( + sprintf('No key name found for certificate with SHA-256 fingerprint \'%s\'.', $fingerprint), + ); + } + + return $this->map[$fingerprint]; + } +} diff --git a/src/Backend/PrivateKeyAgent/KeyNameResolver.php b/src/Backend/PrivateKeyAgent/KeyNameResolver.php new file mode 100644 index 00000000..e84d6bf9 --- /dev/null +++ b/src/Backend/PrivateKeyAgent/KeyNameResolver.php @@ -0,0 +1,30 @@ +httpClient = new PrivateKeyAgentHttpClient( + $httpClient, + $requestFactory, + $streamFactory, + $agentBaseUrl, + $allowInsecureHttp, + ); + + $this->localBackend = new OpenSSL(); + } + + + /** + * Give the copy its own local OpenSSL backend, so reconfiguring the clone's cipher does not + * reach back into the original. The HTTP client, token provider and key-name resolver are + * stateless and stay shared, which keeps connection reuse intact. + */ + public function __clone(): void + { + $this->localBackend = clone $this->localBackend; + } + + + /** + * Set the key-transport cipher. + * + * Only RSA key-transport algorithm URIs are accepted; block ciphers are not supported + * by this backend. Resets the stored OAEP parameters to null. + * + * @param string $cipher A key-transport algorithm URI from C::$KEY_TRANSPORT_ALGORITHMS. + * + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidArgumentException If the cipher is not a supported + * RSA key-transport algorithm. + */ + public function setCipher(string $cipher): void + { + if (!in_array($cipher, C::$KEY_TRANSPORT_ALGORITHMS, strict: true)) { + throw new InvalidArgumentException( + sprintf( + 'PKA encryption backend only supports RSA key-transport ciphers; \'%s\' is not supported.', + $cipher, + ), + ); + } + + $this->cipherUri = $cipher; + $this->oaepDigestAlg = null; + $this->oaepMgf = null; + + $this->localBackend->setCipher($cipher); + } + + + /** + * Configure OAEP digest/MGF parameters. + * + * Must be called after setCipher(); calling before setCipher() throws RuntimeException. + * Validates the full cipher+digest+mgf combination via AlgorithmMap (fail-closed). + * + * @param string|null $digestAlg OAEP digest algorithm URI, or null for algorithm default. + * @param string|null $mgf MGF URI, or null for algorithm default. + * + * @throws \SimpleSAML\XMLSecurity\Exception\RuntimeException If called before setCipher(). + * @throws \SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException If the combination is invalid. + */ + public function setOAEPParams(?string $digestAlg = null, ?string $mgf = null): void + { + if ($this->cipherUri === null) { + throw new RuntimeException('setOAEPParams() must be called after setCipher().'); + } + + // Validate the combination; throws UnsupportedAlgorithmException on invalid input. + AlgorithmMap::getKeyTransportAlgorithm($this->cipherUri, $digestAlg, $mgf); + + $this->oaepDigestAlg = $digestAlg; + $this->oaepMgf = $mgf; + } + + + /** + * Encrypt a plaintext using the local OpenSSL backend with the public key extracted from + * the certificate. The agent is never called for encryption. + * + * @param \SimpleSAML\XMLSecurity\Key\KeyInterface $key The encryption key. + * @param string $plaintext The plaintext to encrypt. + * + * @return string Ciphertext. + * + * @throws \SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException + */ + public function encrypt( + #[SensitiveParameter] + KeyInterface $key, + string $plaintext, + ): string { + $this->localBackend->setOAEPParams($this->oaepDigestAlg, $this->oaepMgf); + + $encryptKey = ($key instanceof X509Certificate) ? $key->getPublicKey() : $key; + return $this->localBackend->encrypt($encryptKey, $plaintext); + } + + + /** + * Decrypt RSA-encrypted data by delegating the raw RSA operation to the agent. + * + * @param \SimpleSAML\XMLSecurity\Key\KeyInterface $key Must be an X509Certificate. + * @param string $ciphertext The RSA-encrypted bytes. + * + * @return string Plaintext. + * + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidArgumentException If $key is not an X509Certificate. + * @throws \SimpleSAML\XMLSecurity\Exception\MissingTokenException If no bearer token is available. + * @throws \SimpleSAML\XMLSecurity\Exception\UnknownKeyException If the key name cannot be resolved. + * @throws \SimpleSAML\XMLSecurity\Exception\AgentUnavailableException If the agent cannot be reached. + */ + public function decrypt( + #[SensitiveParameter] + KeyInterface $key, + string $ciphertext, + ): string { + if (!($key instanceof X509Certificate)) { + throw new InvalidArgumentException( + sprintf( + 'PrivateKeyAgentEncryptionBackend requires an X509Certificate, got %s.', + $key::class, + ), + ); + } + + $agentAlgorithm = AlgorithmMap::getKeyTransportAlgorithm( + $this->cipherUri ?? '', + $this->oaepDigestAlg, + $this->oaepMgf, + ); + + $keyName = $this->keyNameResolver->resolve($key); + $token = $this->tokenProvider->getToken($keyName); + + return $this->httpClient->decrypt($keyName, $token, $agentAlgorithm, $ciphertext); + } +} diff --git a/src/Backend/PrivateKeyAgent/PrivateKeyAgentHttpClient.php b/src/Backend/PrivateKeyAgent/PrivateKeyAgentHttpClient.php new file mode 100644 index 00000000..e89d7320 --- /dev/null +++ b/src/Backend/PrivateKeyAgent/PrivateKeyAgentHttpClient.php @@ -0,0 +1,316 @@ +baseUrl = rtrim($agentBaseUrl, '/'); + } + + + /** + * Determine whether the host component of a URL is a loopback address. + * + * Uses the parsed host rather than a string prefix, so credentials cannot be used to disguise a + * remote host (e.g. http://localhost@evil.example resolves to host "evil.example"). + */ + private static function isLoopbackUrl(string $url): bool + { + $host = parse_url($url, PHP_URL_HOST); + if (!is_string($host) || $host === '') { + return false; + } + + // Strip the brackets that surround an IPv6 literal in a URL. + $host = strtolower(trim($host, '[]')); + + if ($host === 'localhost' || $host === '::1') { + return true; + } + + return filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false + && str_starts_with($host, '127.'); + } + + + /** + * Sign a pre-computed hash using the agent. + * + * @param string $keyName The agent key name (validated against [a-zA-Z0-9_-]{1,64}). + * @param string $token Bearer token for authentication. + * @param string $algorithm Agent algorithm identifier (e.g. 'rsa-pkcs1-v1_5-sha256'). + * @param string $hashBytes Raw binary hash bytes. + * + * @return string Raw binary signature. + * + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidArgumentException If the key name is invalid. + * @throws \SimpleSAML\XMLSecurity\Exception\AgentUnavailableException On network errors. + * @throws \SimpleSAML\XMLSecurity\Exception\AuthenticationException On HTTP 401. + * @throws \SimpleSAML\XMLSecurity\Exception\AuthorizationException On HTTP 403. + * @throws \SimpleSAML\XMLSecurity\Exception\UnknownKeyException On HTTP 404. + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidRequestException On HTTP 400. + * @throws \SimpleSAML\XMLSecurity\Exception\AgentProtocolException On unexpected response. + */ + public function sign( + string $keyName, + #[\SensitiveParameter] string $token, + string $algorithm, + string $hashBytes, + ): string { + $this->validateKeyName($keyName); + $url = $this->baseUrl . '/v1/sign/' . rawurlencode($keyName); + $body = json_encode([ + 'algorithm' => $algorithm, + 'hash' => base64_encode($hashBytes), + ]); + + $response = $this->sendRequest($url, $token, $body); + $data = $this->decodeJsonResponse($response); + + if (!isset($data['signature']) || !is_string($data['signature'])) { + throw new AgentProtocolException('Agent sign response is missing the "signature" field.'); + } + + $decoded = base64_decode($data['signature'], strict: true); + if ($decoded === false) { + throw new AgentProtocolException('Agent sign response contains invalid base64 in "signature".'); + } + + return $decoded; + } + + + /** + * Decrypt RSA-encrypted data using the agent. + * + * @param string $keyName The agent key name (validated against [a-zA-Z0-9_-]{1,64}). + * @param string $token Bearer token for authentication. + * @param string $algorithm Agent algorithm identifier (e.g. 'rsa-pkcs1-oaep-mgf1-sha256'). + * @param string $ciphertext Raw binary ciphertext. + * + * @return string Raw binary plaintext. + * + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidArgumentException If the key name is invalid. + * @throws \SimpleSAML\XMLSecurity\Exception\AgentUnavailableException On network errors. + * @throws \SimpleSAML\XMLSecurity\Exception\AuthenticationException On HTTP 401. + * @throws \SimpleSAML\XMLSecurity\Exception\AuthorizationException On HTTP 403. + * @throws \SimpleSAML\XMLSecurity\Exception\UnknownKeyException On HTTP 404. + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidRequestException On HTTP 400. + * @throws \SimpleSAML\XMLSecurity\Exception\AgentProtocolException On unexpected response. + */ + public function decrypt( + string $keyName, + #[\SensitiveParameter] string $token, + string $algorithm, + string $ciphertext, + ): string { + $this->validateKeyName($keyName); + $url = $this->baseUrl . '/v1/decrypt/' . rawurlencode($keyName); + $body = json_encode([ + 'algorithm' => $algorithm, + 'encrypted_data' => base64_encode($ciphertext), + ]); + + $response = $this->sendRequest($url, $token, $body); + $data = $this->decodeJsonResponse($response); + + if (!isset($data['decrypted_data']) || !is_string($data['decrypted_data'])) { + throw new AgentProtocolException('Agent decrypt response is missing the "decrypted_data" field.'); + } + + $decoded = base64_decode($data['decrypted_data'], strict: true); + if ($decoded === false) { + throw new AgentProtocolException('Agent decrypt response contains invalid base64 in "decrypted_data".'); + } + + return $decoded; + } + + + /** + * Validate a key name against the agent format. + * + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidArgumentException If invalid. + */ + private function validateKeyName(string $keyName): void + { + if (preg_match(self::KEY_NAME_PATTERN, $keyName) !== 1) { + throw new InvalidArgumentException( + 'Invalid key name: must match [a-zA-Z0-9_-]{1,64}.', + ); + } + } + + + /** + * Validate a bearer token against the RFC 6750 b64token grammar. + * + * Rejects tokens before they ever reach ->withHeader(), so a malformed token + * (e.g. a trailing newline from a secret file) never triggers a PSR-7 + * \InvalidArgumentException whose message would interpolate the token value. + * + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidArgumentException If invalid. + */ + private function validateToken(#[\SensitiveParameter] string $token): void + { + if (preg_match(self::TOKEN_PATTERN, $token) !== 1) { + throw new InvalidArgumentException('Invalid bearer token supplied by TokenProvider.'); + } + } + + + /** + * Build and send a POST request to the agent, returning the raw response body. + * + * @throws \SimpleSAML\XMLSecurity\Exception\AgentUnavailableException On network errors or 429/5xx. + * @throws \SimpleSAML\XMLSecurity\Exception\AuthenticationException On HTTP 401. + * @throws \SimpleSAML\XMLSecurity\Exception\AuthorizationException On HTTP 403. + * @throws \SimpleSAML\XMLSecurity\Exception\UnknownKeyException On HTTP 404. + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidRequestException On HTTP 400. + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidArgumentException If the token is malformed. + * @throws \SimpleSAML\XMLSecurity\Exception\AgentProtocolException On unexpected status or response. + */ + private function sendRequest(string $url, #[\SensitiveParameter] string $token, string $body): string + { + $this->validateToken($token); + + $stream = $this->streamFactory->createStream($body); + + try { + $request = $this->requestFactory + ->createRequest('POST', $url) + ->withHeader('Authorization', 'Bearer ' . $token) + ->withHeader('Content-Type', 'application/json') + ->withBody($stream); + } catch (\InvalidArgumentException) { + throw new InvalidArgumentException('Invalid bearer token supplied by TokenProvider.'); + } + + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface) { + // Do not chain or quote the client exception: its message may embed the Authorization header. + throw new AgentUnavailableException('Agent is unreachable.'); + } + + $statusCode = $response->getStatusCode(); + $responseBody = (string) $response->getBody(); + + return match (true) { + $statusCode === 200 => $responseBody, + $statusCode === 400 => throw new InvalidRequestException( + 'Agent rejected the request (HTTP 400).', + ), + $statusCode === 401 => throw new AuthenticationException( + 'Agent authentication failed (HTTP 401).', + ), + $statusCode === 403 => throw new AuthorizationException( + 'Agent authorisation denied (HTTP 403).', + ), + $statusCode === 404 => throw new UnknownKeyException( + 'Agent key not found (HTTP 404).', + ), + $statusCode === 429 || $statusCode >= 500 => throw new AgentUnavailableException( + sprintf('Agent temporarily unavailable (HTTP %d).', $statusCode), + ), + default => throw new AgentProtocolException( + sprintf('Unexpected HTTP status code from agent: %d.', $statusCode), + ), + }; + } + + + /** + * Decode and validate a JSON response body. + * + * @return array + * + * @throws \SimpleSAML\XMLSecurity\Exception\AgentProtocolException On invalid JSON. + */ + private function decodeJsonResponse(string $body): array + { + $data = json_decode($body, associative: true); + if (!is_array($data)) { + throw new AgentProtocolException('Agent returned invalid or non-object JSON response.'); + } + + return $data; + } +} diff --git a/src/Backend/PrivateKeyAgent/PrivateKeyAgentSignatureBackend.php b/src/Backend/PrivateKeyAgent/PrivateKeyAgentSignatureBackend.php new file mode 100644 index 00000000..c07916fb --- /dev/null +++ b/src/Backend/PrivateKeyAgent/PrivateKeyAgentSignatureBackend.php @@ -0,0 +1,188 @@ +httpClient = new PrivateKeyAgentHttpClient( + $httpClient, + $requestFactory, + $streamFactory, + $agentBaseUrl, + $allowInsecureHttp, + ); + + $this->localBackend = new OpenSSL(); + // Default to SHA-256 so the backend is safe to use even if setDigestAlg() is never called again. + $this->setDigestAlg(C::DIGEST_SHA256); + } + + + /** + * Give the copy its own local OpenSSL backend, so reconfiguring the clone's digest does not + * reach back into the original. The HTTP client, token provider and key-name resolver are + * stateless and stay shared, which keeps connection reuse intact. + */ + public function __clone(): void + { + $this->localBackend = clone $this->localBackend; + } + + + /** + * Set the digest algorithm to use for signing. + * + * Only SHA-1/256/384/512 are supported; SHA-224 and unknown digests throw + * UnsupportedAlgorithmException. + * + * @param string $digest The XMLSec digest algorithm URI. + * + * @throws \SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException If unsupported. + */ + public function setDigestAlg(string $digest): void + { + // Throws UnsupportedAlgorithmException if not in the agent map. + $agentAlgorithm = AlgorithmMap::getSigningAlgorithm($digest); + + $this->phpDigest = C::$DIGEST_ALGORITHMS[$digest]; + $this->agentAlgorithm = $agentAlgorithm; + + $this->localBackend->setDigestAlg($digest); + } + + + /** + * Sign the given plaintext by hashing locally and delegating the RSA operation to the agent. + * + * The signature returned by the agent is verified locally against the public key of $key before + * it is returned, at the cost of one public-key operation. A signature made with any other key, + * or over any other plaintext, is rejected rather than returned. + * + * @param \SimpleSAML\XMLSecurity\Key\KeyInterface $key Must be an X509Certificate. + * @param string $plaintext The canonicalized SignedInfo to sign. + * + * @return string Binary RSA signature. + * + * @throws \SimpleSAML\XMLSecurity\Exception\InvalidArgumentException If $key is not an X509Certificate. + * @throws \SimpleSAML\XMLSecurity\Exception\MissingTokenException If no bearer token is available. + * @throws \SimpleSAML\XMLSecurity\Exception\UnknownKeyException If the key name cannot be resolved. + * @throws \SimpleSAML\XMLSecurity\Exception\AgentUnavailableException If the agent cannot be reached. + * @throws \SimpleSAML\XMLSecurity\Exception\AgentSignatureMismatchException If the returned signature does + * not verify against the certificate's public key. + */ + public function sign( + #[\SensitiveParameter] + KeyInterface $key, + string $plaintext, + ): string { + if (!($key instanceof X509Certificate)) { + throw new InvalidArgumentException( + sprintf( + 'PrivateKeyAgentSignatureBackend requires an X509Certificate, got %s.', + $key::class, + ), + ); + } + + $keyName = $this->keyNameResolver->resolve($key); + $token = $this->tokenProvider->getToken($keyName); + $hashBytes = hash($this->phpDigest, $plaintext, binary: true); + + $signature = $this->httpClient->sign($keyName, $token, $this->agentAlgorithm, $hashBytes); + + // Fail closed: never hand back a signature we cannot attribute to the certificate we were given. + if (!$this->verify($key, $plaintext, $signature)) { + throw new AgentSignatureMismatchException( + 'Agent returned a signature that does not verify against the certificate\'s public key.', + ); + } + + return $signature; + } + + + /** + * Verify a signature locally using the public key extracted from the certificate. + * + * The agent is never called for verification. If $key is an X509Certificate its public + * key is unwrapped first; other KeyInterface types are passed through directly. + * + * @param \SimpleSAML\XMLSecurity\Key\KeyInterface $key + * @param string $plaintext + * @param string $signature + * + * @return bool + */ + public function verify( + #[\SensitiveParameter] + KeyInterface $key, + string $plaintext, + string $signature, + ): bool { + $verifyKey = ($key instanceof X509Certificate) ? $key->getPublicKey() : $key; + return $this->localBackend->verify($verifyKey, $plaintext, $signature); + } +} diff --git a/src/Backend/PrivateKeyAgent/StaticKeyNameResolver.php b/src/Backend/PrivateKeyAgent/StaticKeyNameResolver.php new file mode 100644 index 00000000..bc9c8fbd --- /dev/null +++ b/src/Backend/PrivateKeyAgent/StaticKeyNameResolver.php @@ -0,0 +1,51 @@ +keyName; + } +} diff --git a/src/Backend/PrivateKeyAgent/TokenProvider.php b/src/Backend/PrivateKeyAgent/TokenProvider.php new file mode 100644 index 00000000..7a8333ef --- /dev/null +++ b/src/Backend/PrivateKeyAgent/TokenProvider.php @@ -0,0 +1,24 @@ +getEncryptionMethod(); + $digestAlg = null; + $mgf = null; + + if ($encryptionMethod !== null) { + foreach ($encryptionMethod->getElements() as $child) { + if ($child instanceof DigestMethod) { + $digestAlg = $child->getAlgorithm()->getValue(); + } elseif ($child instanceof MGF) { + $mgf = $child->getAlgorithm()->getValue(); + } elseif ($child instanceof Chunk) { + if ($child->getNamespaceURI() === C::NS_XDSIG && $child->getLocalName() === 'DigestMethod') { + $digestAlg = self::getChunkAlgorithm($child); + } elseif ($child->getNamespaceURI() === C::NS_XENC11 && $child->getLocalName() === 'MGF') { + $mgf = self::getChunkAlgorithm($child); + } + } + } + } + + $decryptor->setOAEPParams($digestAlg, $mgf); + } + return $decryptor->decrypt(base64_decode($cipherValue->getContent()->getValue(), true)); } + /** + * Read the 'Algorithm' attribute directly off an unregistered DigestMethod/MGF Chunk, + * so XML-declared OAEP parameters are honoured even when the xml-common element + * registry has not registered the typed ds\DigestMethod / xenc11\MGF classes. + */ + private static function getChunkAlgorithm(Chunk $chunk): ?string + { + $xml = $chunk->getXML(); + return $xml->hasAttribute('Algorithm') ? $xml->getAttribute('Algorithm') : null; + } + + /** * Create an EncryptedKey by encrypting a given key. * diff --git a/tests/Alg/KeyTransport/KeyTransportAlgorithmFactoryTest.php b/tests/Alg/KeyTransport/KeyTransportAlgorithmFactoryTest.php index 4781d699..384d37e9 100644 --- a/tests/Alg/KeyTransport/KeyTransportAlgorithmFactoryTest.php +++ b/tests/Alg/KeyTransport/KeyTransportAlgorithmFactoryTest.php @@ -6,10 +6,13 @@ use PHPUnit\Framework\TestCase; use SimpleSAML\XMLSecurity\Alg\KeyTransport\KeyTransportAlgorithmFactory; +use SimpleSAML\XMLSecurity\Alg\KeyTransport\PrivateKeyAgentRSA; use SimpleSAML\XMLSecurity\Alg\KeyTransport\RSA; +use SimpleSAML\XMLSecurity\Backend\EncryptionBackend; use SimpleSAML\XMLSecurity\Constants as C; use SimpleSAML\XMLSecurity\Exception\BlacklistedAlgorithmException; use SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException; +use SimpleSAML\XMLSecurity\Key\KeyInterface; use SimpleSAML\XMLSecurity\Key\PublicKey; use SimpleSAML\XMLSecurity\TestUtils\PEMCertificatesMock; @@ -30,6 +33,15 @@ public static function setUpBeforeClass(): void } + protected function tearDown(): void + { + $ref = new \ReflectionClass(KeyTransportAlgorithmFactory::class); + $ref->getProperty('cache')->setValue(null, []); + $ref->getProperty('initialized')->setValue(null, false); + KeyTransportAlgorithmFactory::clearAlgorithmFactories(); + } + + /** * Test for unsupported algorithms. */ @@ -78,4 +90,115 @@ public function testBlacklistedAlgorithm(): void $this->expectException(BlacklistedAlgorithmException::class); $factory->getAlgorithm(C::KEY_TRANSPORT_OAEP_MGF1P, self::$pkey); } + + + public function testRegisterAlgorithmFactoryClosureYieldsWrapperOnGetAlgorithm(): void + { + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $pkaBackend = $this->createStub(EncryptionBackend::class); + + KeyTransportAlgorithmFactory::registerAlgorithmFactory( + C::KEY_TRANSPORT_OAEP_MGF1P, + static function (KeyInterface $key, string $algId) use ($pkaBackend): PrivateKeyAgentRSA { + return new PrivateKeyAgentRSA($key, $algId, $pkaBackend); + }, + ); + + $factory = new KeyTransportAlgorithmFactory([]); + $alg = $factory->getAlgorithm(C::KEY_TRANSPORT_OAEP_MGF1P, $cert); + + $this->assertSame(C::KEY_TRANSPORT_OAEP_MGF1P, $alg->getAlgorithmId()); + $this->assertInstanceOf(PrivateKeyAgentRSA::class, $alg); + } + + + public function testBlacklistBlocksClosureRegisteredAlgorithm(): void + { + $pkaBackend = $this->createStub(EncryptionBackend::class); + + KeyTransportAlgorithmFactory::registerAlgorithmFactory( + C::KEY_TRANSPORT_OAEP_MGF1P, + static fn(KeyInterface $key, string $algId): PrivateKeyAgentRSA => + new PrivateKeyAgentRSA($key, $algId, $pkaBackend), + ); + + $factory = new KeyTransportAlgorithmFactory([C::KEY_TRANSPORT_OAEP_MGF1P]); + $this->expectException(BlacklistedAlgorithmException::class); + $factory->getAlgorithm(C::KEY_TRANSPORT_OAEP_MGF1P, self::$pkey); + } + + + public function testUnregisterAlgorithmFactoryRestoresTheBuiltinAlgorithm(): void + { + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $pkaBackend = $this->createStub(EncryptionBackend::class); + + KeyTransportAlgorithmFactory::registerAlgorithmFactory( + C::KEY_TRANSPORT_OAEP_MGF1P, + static fn(KeyInterface $key, string $algId): PrivateKeyAgentRSA => + new PrivateKeyAgentRSA($key, $algId, $pkaBackend), + ); + + $factory = new KeyTransportAlgorithmFactory([]); + $this->assertInstanceOf( + PrivateKeyAgentRSA::class, + $factory->getAlgorithm(C::KEY_TRANSPORT_OAEP_MGF1P, $cert), + ); + + KeyTransportAlgorithmFactory::unregisterAlgorithmFactory(C::KEY_TRANSPORT_OAEP_MGF1P); + + // The class-string registration populated at initialization takes over again. + $alg = $factory->getAlgorithm(C::KEY_TRANSPORT_OAEP_MGF1P, self::$pkey); + $this->assertInstanceOf(RSA::class, $alg); + $this->assertSame(C::KEY_TRANSPORT_OAEP_MGF1P, $alg->getAlgorithmId()); + } + + + public function testUnregisterAlgorithmFactoryForUnknownAlgorithmIsANoop(): void + { + KeyTransportAlgorithmFactory::unregisterAlgorithmFactory(C::KEY_TRANSPORT_OAEP); + + $factory = new KeyTransportAlgorithmFactory([]); + $this->assertInstanceOf(RSA::class, $factory->getAlgorithm(C::KEY_TRANSPORT_OAEP, self::$pkey)); + } + + + public function testClearAlgorithmFactoriesRemovesEveryRegistration(): void + { + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $pkaBackend = $this->createStub(EncryptionBackend::class); + $closure = static fn(KeyInterface $key, string $algId): PrivateKeyAgentRSA => + new PrivateKeyAgentRSA($key, $algId, $pkaBackend); + + KeyTransportAlgorithmFactory::registerAlgorithmFactory(C::KEY_TRANSPORT_OAEP, $closure); + KeyTransportAlgorithmFactory::registerAlgorithmFactory(C::KEY_TRANSPORT_OAEP_MGF1P, $closure); + + $factory = new KeyTransportAlgorithmFactory([]); + $this->assertInstanceOf(PrivateKeyAgentRSA::class, $factory->getAlgorithm(C::KEY_TRANSPORT_OAEP, $cert)); + $this->assertInstanceOf( + PrivateKeyAgentRSA::class, + $factory->getAlgorithm(C::KEY_TRANSPORT_OAEP_MGF1P, $cert), + ); + + KeyTransportAlgorithmFactory::clearAlgorithmFactories(); + + $this->assertInstanceOf(RSA::class, $factory->getAlgorithm(C::KEY_TRANSPORT_OAEP, self::$pkey)); + $this->assertInstanceOf(RSA::class, $factory->getAlgorithm(C::KEY_TRANSPORT_OAEP_MGF1P, self::$pkey)); + } + + + public function testBlacklistStillAppliesAfterReregistration(): void + { + $pkaBackend = $this->createStub(EncryptionBackend::class); + $closure = static fn(KeyInterface $key, string $algId): PrivateKeyAgentRSA => + new PrivateKeyAgentRSA($key, $algId, $pkaBackend); + + KeyTransportAlgorithmFactory::registerAlgorithmFactory(C::KEY_TRANSPORT_OAEP_MGF1P, $closure); + KeyTransportAlgorithmFactory::unregisterAlgorithmFactory(C::KEY_TRANSPORT_OAEP_MGF1P); + KeyTransportAlgorithmFactory::registerAlgorithmFactory(C::KEY_TRANSPORT_OAEP_MGF1P, $closure); + + $factory = new KeyTransportAlgorithmFactory([C::KEY_TRANSPORT_OAEP_MGF1P]); + $this->expectException(BlacklistedAlgorithmException::class); + $factory->getAlgorithm(C::KEY_TRANSPORT_OAEP_MGF1P, self::$pkey); + } } diff --git a/tests/Alg/KeyTransport/PrivateKeyAgentRSATest.php b/tests/Alg/KeyTransport/PrivateKeyAgentRSATest.php new file mode 100644 index 00000000..e33e73cd --- /dev/null +++ b/tests/Alg/KeyTransport/PrivateKeyAgentRSATest.php @@ -0,0 +1,83 @@ +createMock(EncryptionBackend::class); + $pkaBackend->expects($this->once())->method('decrypt')->willReturn('decrypted'); + + $wrapper = new PrivateKeyAgentRSA(self::$certificate, C::KEY_TRANSPORT_OAEP_MGF1P, $pkaBackend); + $result = $wrapper->decrypt('ciphertext'); + + $this->assertSame('decrypted', $result); + } + + + public function testEncryptWithCertificateDelegatesToPkaBackend(): void + { + $pkaBackend = $this->createMock(EncryptionBackend::class); + $pkaBackend->expects($this->once())->method('encrypt')->willReturn('encrypted'); + $pkaBackend->expects($this->never())->method('decrypt'); + + $wrapper = new PrivateKeyAgentRSA(self::$certificate, C::KEY_TRANSPORT_OAEP_MGF1P, $pkaBackend); + $result = $wrapper->encrypt('plaintext'); + + $this->assertSame('encrypted', $result); + } + + + public function testConstructWithPrivateKeyDoesNotUsePkaBackend(): void + { + $pkaBackend = $this->createMock(EncryptionBackend::class); + $pkaBackend->expects($this->never())->method('decrypt'); + $pkaBackend->expects($this->never())->method('encrypt'); + $pkaBackend->expects($this->never())->method('setCipher'); + + $privateKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); + new PrivateKeyAgentRSA($privateKey, C::KEY_TRANSPORT_OAEP_MGF1P, $pkaBackend); + + $this->addToAssertionCount(1); // Constructor succeeded without calling pkaBackend. + } + + + public function testConstructWithForeignKeyTypeThrowsInvalidArgumentException(): void + { + $pkaBackend = $this->createStub(EncryptionBackend::class); + $this->expectException(InvalidArgumentException::class); + new PrivateKeyAgentRSA(SymmetricKey::generate(16), C::KEY_TRANSPORT_OAEP_MGF1P, $pkaBackend); + } + + + public function testGetSupportedAlgorithmsMirrorsKeyTransportAlgorithms(): void + { + $this->assertSame(C::$KEY_TRANSPORT_ALGORITHMS, PrivateKeyAgentRSA::getSupportedAlgorithms()); + } +} diff --git a/tests/Alg/Signature/PrivateKeyAgentRSATest.php b/tests/Alg/Signature/PrivateKeyAgentRSATest.php new file mode 100644 index 00000000..1c7314ec --- /dev/null +++ b/tests/Alg/Signature/PrivateKeyAgentRSATest.php @@ -0,0 +1,176 @@ +createMock(SignatureBackend::class); + $pkaBackend->expects($this->once())->method('sign')->willReturn('fakesig'); + + $wrapper = new PrivateKeyAgentRSA(self::$certificate, C::SIG_RSA_SHA256, $pkaBackend); + $result = $wrapper->sign('test data'); + + $this->assertSame('fakesig', $result); + } + + + public function testVerifyWithCertificateDelegatesToPkaBackend(): void + { + $pkaBackend = $this->createMock(SignatureBackend::class); + $pkaBackend->expects($this->once())->method('verify')->willReturn(true); + $pkaBackend->expects($this->never())->method('sign'); + + $wrapper = new PrivateKeyAgentRSA(self::$certificate, C::SIG_RSA_SHA256, $pkaBackend); + $result = $wrapper->verify('data', 'sig'); + + $this->assertTrue($result); + } + + + public function testSignWithPrivateKeyUsesLocalOpenSslNotPkaBackend(): void + { + $pkaBackend = $this->createMock(SignatureBackend::class); + $pkaBackend->expects($this->never())->method('sign'); + $pkaBackend->expects($this->never())->method('setDigestAlg'); + + $privateKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); + $wrapper = new PrivateKeyAgentRSA($privateKey, C::SIG_RSA_SHA256, $pkaBackend); + + $signature = $wrapper->sign('test data'); + $this->assertNotEmpty($signature); + } + + + public function testVerifyWithPublicKeyUsesLocalOpenSslNotPkaBackend(): void + { + $pkaBackend = $this->createMock(SignatureBackend::class); + $pkaBackend->expects($this->never())->method('verify'); + $pkaBackend->expects($this->never())->method('setDigestAlg'); + + // Produce a real signature with the private key for the verify assertion. + $privateKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); + $localBackend = new OpenSSL(); + $localBackend->setDigestAlg(C::DIGEST_SHA256); + $plaintext = 'data for verify'; + $signature = $localBackend->sign($privateKey, $plaintext); + + $publicKey = PEMCertificatesMock::getPublicKey(PEMCertificatesMock::PUBLIC_KEY); + $wrapper = new PrivateKeyAgentRSA($publicKey, C::SIG_RSA_SHA256, $pkaBackend); + + $this->assertTrue($wrapper->verify($plaintext, $signature)); + } + + + public function testConstructWithForeignKeyTypeThrowsInvalidArgumentException(): void + { + $pkaBackend = $this->createStub(SignatureBackend::class); + $this->expectException(InvalidArgumentException::class); + new PrivateKeyAgentRSA(SymmetricKey::generate(16), C::SIG_RSA_SHA256, $pkaBackend); + } + + + /** + * @return array + */ + public static function providePssAndUnsupportedRsaAlgorithms(): array + { + return [ + 'PSS SHA1' => [C::SIG_RSA_PSS_SHA1], + 'PSS SHA224' => [C::SIG_RSA_PSS_SHA224], + 'PSS SHA256' => [C::SIG_RSA_PSS_SHA256], + 'PSS SHA384' => [C::SIG_RSA_PSS_SHA384], + 'PSS SHA512' => [C::SIG_RSA_PSS_SHA512], + 'RIPEMD160' => [C::SIG_RSA_RIPEMD160], + ]; + } + + + #[DataProvider('providePssAndUnsupportedRsaAlgorithms')] + public function testConstructWithCertificateAndNonV15AlgorithmThrowsUnsupportedAlgorithmException( + string $algId, + ): void { + $pkaBackend = $this->createMock(SignatureBackend::class); + $pkaBackend->expects($this->never())->method('sign'); + $pkaBackend->expects($this->never())->method('verify'); + $pkaBackend->expects($this->never())->method('setDigestAlg'); + + $this->expectException(UnsupportedAlgorithmException::class); + new PrivateKeyAgentRSA(self::$certificate, $algId, $pkaBackend); + } + + + public function testConstructWithPrivateKeyAndPssAlgorithmStillSignsLocally(): void + { + $pkaBackend = $this->createMock(SignatureBackend::class); + $pkaBackend->expects($this->never())->method('sign'); + $pkaBackend->expects($this->never())->method('setDigestAlg'); + + $privateKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); + $wrapper = new PrivateKeyAgentRSA($privateKey, C::SIG_RSA_PSS_SHA256, $pkaBackend); + + $signature = $wrapper->sign('test data'); + $this->assertNotEmpty($signature); + } + + + /** + * @return array + */ + public static function provideV15Algorithms(): array + { + return [ + 'SHA1' => [C::SIG_RSA_SHA1], + 'SHA224' => [C::SIG_RSA_SHA224], + 'SHA256' => [C::SIG_RSA_SHA256], + 'SHA384' => [C::SIG_RSA_SHA384], + 'SHA512' => [C::SIG_RSA_SHA512], + ]; + } + + + #[DataProvider('provideV15Algorithms')] + public function testConstructWithCertificateAndV15AlgorithmStillCallsPkaBackend(string $algId): void + { + $pkaBackend = $this->createMock(SignatureBackend::class); + $pkaBackend->expects($this->once())->method('sign')->willReturn('fakesig'); + + $wrapper = new PrivateKeyAgentRSA(self::$certificate, $algId, $pkaBackend); + $this->assertSame('fakesig', $wrapper->sign('test data')); + } + + + public function testGetSupportedAlgorithmsMirrorsRsaDigests(): void + { + $this->assertSame(array_keys(C::$RSA_DIGESTS), PrivateKeyAgentRSA::getSupportedAlgorithms()); + } +} diff --git a/tests/Alg/Signature/SignatureAlgorithmFactoryTest.php b/tests/Alg/Signature/SignatureAlgorithmFactoryTest.php index 0fda60c7..ad414ca5 100644 --- a/tests/Alg/Signature/SignatureAlgorithmFactoryTest.php +++ b/tests/Alg/Signature/SignatureAlgorithmFactoryTest.php @@ -6,10 +6,13 @@ use PHPUnit\Framework\TestCase; use SimpleSAML\XMLSecurity\Alg\Signature\HMAC; +use SimpleSAML\XMLSecurity\Alg\Signature\PrivateKeyAgentRSA; use SimpleSAML\XMLSecurity\Alg\Signature\SignatureAlgorithmFactory; +use SimpleSAML\XMLSecurity\Backend\SignatureBackend; use SimpleSAML\XMLSecurity\Constants as C; use SimpleSAML\XMLSecurity\Exception\BlacklistedAlgorithmException; use SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException; +use SimpleSAML\XMLSecurity\Key\KeyInterface; use SimpleSAML\XMLSecurity\Key\PublicKey; use SimpleSAML\XMLSecurity\Key\SymmetricKey; use SimpleSAML\XMLSecurity\TestUtils\PEMCertificatesMock; @@ -35,6 +38,15 @@ public static function setUpBeforeClass(): void } + protected function tearDown(): void + { + $ref = new \ReflectionClass(SignatureAlgorithmFactory::class); + $ref->getProperty('cache')->setValue(null, []); + $ref->getProperty('initialized')->setValue(null, false); + SignatureAlgorithmFactory::clearAlgorithmFactories(); + } + + /** * Test obtaining the digest algorithm from a signature algorithm. */ @@ -79,4 +91,118 @@ public function testBlacklistedAlgorithm(): void $this->expectException(BlacklistedAlgorithmException::class); $factory->getAlgorithm(C::SIG_RSA_SHA1, self::$pkey); } + + + public function testRegisterAlgorithmFactoryClosureYieldsWrapperOnGetAlgorithm(): void + { + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $pkaBackend = $this->createStub(SignatureBackend::class); + + SignatureAlgorithmFactory::registerAlgorithmFactory( + C::SIG_RSA_SHA256, + static function (KeyInterface $key, string $algId) use ($pkaBackend): PrivateKeyAgentRSA { + return new PrivateKeyAgentRSA($key, $algId, $pkaBackend); + }, + ); + + $factory = new SignatureAlgorithmFactory([]); + $alg = $factory->getAlgorithm(C::SIG_RSA_SHA256, $cert); + + $this->assertSame(C::SIG_RSA_SHA256, $alg->getAlgorithmId()); + $this->assertInstanceOf(PrivateKeyAgentRSA::class, $alg); + } + + + public function testBlacklistBlocksClosureRegisteredAlgorithm(): void + { + $pkaBackend = $this->createStub(SignatureBackend::class); + + SignatureAlgorithmFactory::registerAlgorithmFactory( + C::SIG_RSA_SHA256, + static fn(KeyInterface $key, string $algId): PrivateKeyAgentRSA => + new PrivateKeyAgentRSA($key, $algId, $pkaBackend), + ); + + $factory = new SignatureAlgorithmFactory([C::SIG_RSA_SHA256]); + $this->expectException(BlacklistedAlgorithmException::class); + $factory->getAlgorithm(C::SIG_RSA_SHA256, self::$pkey); + } + + + public function testUnregisterAlgorithmFactoryRestoresTheBuiltinAlgorithm(): void + { + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $pkaBackend = $this->createStub(SignatureBackend::class); + + SignatureAlgorithmFactory::registerAlgorithmFactory( + C::SIG_RSA_SHA256, + static fn(KeyInterface $key, string $algId): PrivateKeyAgentRSA => + new PrivateKeyAgentRSA($key, $algId, $pkaBackend), + ); + + $factory = new SignatureAlgorithmFactory([]); + $this->assertInstanceOf(PrivateKeyAgentRSA::class, $factory->getAlgorithm(C::SIG_RSA_SHA256, $cert)); + + SignatureAlgorithmFactory::unregisterAlgorithmFactory(C::SIG_RSA_SHA256); + + // The class-string registration populated at initialization takes over again. + $alg = $factory->getAlgorithm(C::SIG_RSA_SHA256, self::$pkey); + $this->assertNotInstanceOf(PrivateKeyAgentRSA::class, $alg); + $this->assertSame(C::SIG_RSA_SHA256, $alg->getAlgorithmId()); + } + + + public function testUnregisterAlgorithmFactoryForUnknownAlgorithmIsANoop(): void + { + SignatureAlgorithmFactory::unregisterAlgorithmFactory(C::SIG_RSA_SHA512); + + $factory = new SignatureAlgorithmFactory([]); + $this->assertSame( + C::SIG_RSA_SHA512, + $factory->getAlgorithm(C::SIG_RSA_SHA512, self::$pkey)->getAlgorithmId(), + ); + } + + + public function testClearAlgorithmFactoriesRemovesEveryRegistration(): void + { + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $pkaBackend = $this->createStub(SignatureBackend::class); + $closure = static fn(KeyInterface $key, string $algId): PrivateKeyAgentRSA => + new PrivateKeyAgentRSA($key, $algId, $pkaBackend); + + SignatureAlgorithmFactory::registerAlgorithmFactory(C::SIG_RSA_SHA256, $closure); + SignatureAlgorithmFactory::registerAlgorithmFactory(C::SIG_RSA_SHA384, $closure); + + $factory = new SignatureAlgorithmFactory([]); + $this->assertInstanceOf(PrivateKeyAgentRSA::class, $factory->getAlgorithm(C::SIG_RSA_SHA256, $cert)); + $this->assertInstanceOf(PrivateKeyAgentRSA::class, $factory->getAlgorithm(C::SIG_RSA_SHA384, $cert)); + + SignatureAlgorithmFactory::clearAlgorithmFactories(); + + $this->assertNotInstanceOf( + PrivateKeyAgentRSA::class, + $factory->getAlgorithm(C::SIG_RSA_SHA256, self::$pkey), + ); + $this->assertNotInstanceOf( + PrivateKeyAgentRSA::class, + $factory->getAlgorithm(C::SIG_RSA_SHA384, self::$pkey), + ); + } + + + public function testBlacklistStillAppliesAfterReregistration(): void + { + $pkaBackend = $this->createStub(SignatureBackend::class); + $closure = static fn(KeyInterface $key, string $algId): PrivateKeyAgentRSA => + new PrivateKeyAgentRSA($key, $algId, $pkaBackend); + + SignatureAlgorithmFactory::registerAlgorithmFactory(C::SIG_RSA_SHA256, $closure); + SignatureAlgorithmFactory::unregisterAlgorithmFactory(C::SIG_RSA_SHA256); + SignatureAlgorithmFactory::registerAlgorithmFactory(C::SIG_RSA_SHA256, $closure); + + $factory = new SignatureAlgorithmFactory([C::SIG_RSA_SHA256]); + $this->expectException(BlacklistedAlgorithmException::class); + $factory->getAlgorithm(C::SIG_RSA_SHA256, self::$pkey); + } } diff --git a/tests/Backend/OpenSSLTest.php b/tests/Backend/OpenSSLTest.php index e4282ec0..40d50283 100644 --- a/tests/Backend/OpenSSLTest.php +++ b/tests/Backend/OpenSSLTest.php @@ -10,6 +10,7 @@ use SimpleSAML\XMLSecurity\Constants as C; use SimpleSAML\XMLSecurity\Exception\InvalidArgumentException; use SimpleSAML\XMLSecurity\Exception\RuntimeException; +use SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException; use SimpleSAML\XMLSecurity\Key\PrivateKey; use SimpleSAML\XMLSecurity\Key\PublicKey; use SimpleSAML\XMLSecurity\Key\SymmetricKey; @@ -328,4 +329,89 @@ public function testSetUnknownCipher(): void $this->expectException(InvalidArgumentException::class); $backend->setCipher('foo'); } + + + /** + * Test that no setOAEPParams() call keeps the default SHA-1 OAEP round-trip. + */ + public function testOAEPDefaultNoParamsRoundTrip(): void + { + $backend = new OpenSSL(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + $ciphertext = $backend->encrypt(self::$pubKey, 'Plaintext'); + $this->assertEquals('Plaintext', $backend->decrypt(self::$privKey, $ciphertext)); + } + + + /** + * Test that explicit SHA-1 digest and MGF1-SHA-1 are accepted and leave behaviour unchanged. + */ + public function testOAEPExplicitSha1Accepted(): void + { + $backend = new OpenSSL(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + $backend->setOAEPParams( + 'http://www.w3.org/2000/09/xmldsig#sha1', + 'http://www.w3.org/2009/xmlenc11#mgf1sha1', + ); + $ciphertext = $backend->encrypt(self::$pubKey, 'Plaintext'); + $this->assertEquals('Plaintext', $backend->decrypt(self::$privKey, $ciphertext)); + } + + + /** + * Test that a non-SHA-1 digest throws UnsupportedAlgorithmException. + */ + public function testOAEPNonSha1DigestThrows(): void + { + $backend = new OpenSSL(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $this->expectException(UnsupportedAlgorithmException::class); + $backend->setOAEPParams('http://www.w3.org/2001/04/xmlenc#sha256'); + } + + + /** + * Test that a non-MGF1-SHA-1 MGF throws UnsupportedAlgorithmException. + */ + public function testOAEPNonMgf1Sha1Throws(): void + { + $backend = new OpenSSL(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $this->expectException(UnsupportedAlgorithmException::class); + $backend->setOAEPParams(null, 'http://www.w3.org/2009/xmlenc11#mgf1sha256'); + } + + + /** + * Test that setCipher() after setOAEPParams() resets parameters (next encrypt uses defaults). + */ + public function testOAEPResetByCipher(): void + { + $backend = new OpenSSL(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + $backend->setOAEPParams( + 'http://www.w3.org/2000/09/xmldsig#sha1', + 'http://www.w3.org/2009/xmlenc11#mgf1sha1', + ); + // Reset by calling setCipher again; subsequent encrypt/decrypt should still work with SHA-1 defaults. + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $ciphertext = $backend->encrypt(self::$pubKey, 'Plaintext'); + $this->assertEquals('Plaintext', $backend->decrypt(self::$privKey, $ciphertext)); + } + + + /** + * Test that setOAEPParams() before setCipher() throws RuntimeException. + */ + public function testOAEPBeforeCipherThrows(): void + { + $backend = new OpenSSL(); + // Re-create without going through constructor's setCipher call by reflection + $r = new \ReflectionProperty(OpenSSL::class, 'cipherConfigured'); + $r->setValue($backend, false); + + $this->expectException(RuntimeException::class); + $backend->setOAEPParams(null, null); + } } diff --git a/tests/Backend/PrivateKeyAgent/AlgorithmMapTest.php b/tests/Backend/PrivateKeyAgent/AlgorithmMapTest.php new file mode 100644 index 00000000..adb52332 --- /dev/null +++ b/tests/Backend/PrivateKeyAgent/AlgorithmMapTest.php @@ -0,0 +1,145 @@ + */ + public static function signingPositiveProvider(): array + { + return [ + 'sha1' => [C::DIGEST_SHA1, 'rsa-pkcs1-v1_5-sha1'], + 'sha256' => [C::DIGEST_SHA256, 'rsa-pkcs1-v1_5-sha256'], + 'sha384' => [C::DIGEST_SHA384, 'rsa-pkcs1-v1_5-sha384'], + 'sha512' => [C::DIGEST_SHA512, 'rsa-pkcs1-v1_5-sha512'], + ]; + } + + + #[DataProvider('signingPositiveProvider')] + public function testSigningAlgorithmPositive(string $digest, string $expected): void + { + $this->assertSame($expected, AlgorithmMap::getSigningAlgorithm($digest)); + } + + + /** @return array */ + public static function signingNegativeProvider(): array + { + return [ + 'sha224' => [C::DIGEST_SHA224], + 'unknown' => ['http://example.com/unknown-hash'], + ]; + } + + + #[DataProvider('signingNegativeProvider')] + public function testSigningAlgorithmNegative(string $digest): void + { + $this->expectException(UnsupportedAlgorithmException::class); + AlgorithmMap::getSigningAlgorithm($digest); + } + + + /** @return array */ + public static function keyTransportPositiveProvider(): array + { + return [ + 'rsa-1_5' => [C::KEY_TRANSPORT_RSA_1_5, null, null, 'rsa-pkcs1-v1_5'], + 'mgf1p no params' => [C::KEY_TRANSPORT_OAEP_MGF1P, null, null, 'rsa-pkcs1-oaep-mgf1-sha1'], + 'mgf1p explicit sha1 digest' => [ + C::KEY_TRANSPORT_OAEP_MGF1P, C::DIGEST_SHA1, null, 'rsa-pkcs1-oaep-mgf1-sha1', + ], + 'mgf1p explicit sha1 digest and mgf' => [ + C::KEY_TRANSPORT_OAEP_MGF1P, C::DIGEST_SHA1, self::MGF1_SHA1, 'rsa-pkcs1-oaep-mgf1-sha1', + ], + 'oaep both absent (default sha256)' => [ + C::KEY_TRANSPORT_OAEP, null, null, 'rsa-pkcs1-oaep-mgf1-sha256', + ], + 'oaep sha1/mgf1sha1' => [ + C::KEY_TRANSPORT_OAEP, C::DIGEST_SHA1, self::MGF1_SHA1, 'rsa-pkcs1-oaep-mgf1-sha1', + ], + 'oaep sha224/mgf1sha224' => [ + C::KEY_TRANSPORT_OAEP, C::DIGEST_SHA224, self::MGF1_SHA224, 'rsa-pkcs1-oaep-mgf1-sha224', + ], + 'oaep sha256/mgf1sha256' => [ + C::KEY_TRANSPORT_OAEP, C::DIGEST_SHA256, self::MGF1_SHA256, 'rsa-pkcs1-oaep-mgf1-sha256', + ], + 'oaep sha384/mgf1sha384' => [ + C::KEY_TRANSPORT_OAEP, C::DIGEST_SHA384, self::MGF1_SHA384, 'rsa-pkcs1-oaep-mgf1-sha384', + ], + 'oaep sha512/mgf1sha512' => [ + C::KEY_TRANSPORT_OAEP, C::DIGEST_SHA512, self::MGF1_SHA512, 'rsa-pkcs1-oaep-mgf1-sha512', + ], + ]; + } + + + #[DataProvider('keyTransportPositiveProvider')] + public function testKeyTransportAlgorithmPositive( + string $cipherUri, + ?string $digestAlg, + ?string $mgf, + string $expected, + ): void { + $this->assertSame($expected, AlgorithmMap::getKeyTransportAlgorithm($cipherUri, $digestAlg, $mgf)); + } + + + /** @return array */ + public static function keyTransportNegativeProvider(): array + { + return [ + 'unknown cipher' => ['http://example.com/unknown', null, null], + 'mgf1p with sha256 digest' => [C::KEY_TRANSPORT_OAEP_MGF1P, C::DIGEST_SHA256, null], + 'mgf1p with mgf1sha256' => [C::KEY_TRANSPORT_OAEP_MGF1P, null, self::MGF1_SHA256], + 'mgf1p with sha256 digest + mgf1sha256' => [ + C::KEY_TRANSPORT_OAEP_MGF1P, C::DIGEST_SHA256, self::MGF1_SHA256, + ], + 'oaep digest only (no mgf)' => [C::KEY_TRANSPORT_OAEP, C::DIGEST_SHA256, null], + 'oaep mgf only (no digest)' => [C::KEY_TRANSPORT_OAEP, null, self::MGF1_SHA256], + 'oaep mismatched sha256/mgf1sha512' => [C::KEY_TRANSPORT_OAEP, C::DIGEST_SHA256, self::MGF1_SHA512], + 'oaep unknown digest' => [ + C::KEY_TRANSPORT_OAEP, 'http://example.com/hash', self::MGF1_SHA256, + ], + 'oaep unknown mgf' => [ + C::KEY_TRANSPORT_OAEP, C::DIGEST_SHA256, 'http://example.com/mgf', + ], + ]; + } + + + #[DataProvider('keyTransportNegativeProvider')] + public function testKeyTransportAlgorithmNegative( + string $cipherUri, + ?string $digestAlg, + ?string $mgf, + ): void { + $this->expectException(UnsupportedAlgorithmException::class); + AlgorithmMap::getKeyTransportAlgorithm($cipherUri, $digestAlg, $mgf); + } +} diff --git a/tests/Backend/PrivateKeyAgent/FingerprintKeyNameResolverTest.php b/tests/Backend/PrivateKeyAgent/FingerprintKeyNameResolverTest.php new file mode 100644 index 00000000..6b70384e --- /dev/null +++ b/tests/Backend/PrivateKeyAgent/FingerprintKeyNameResolverTest.php @@ -0,0 +1,152 @@ + 'signing-key', + ]); + $this->assertSame('signing-key', $resolver->resolve(self::$certificate)); + } + + + /** + * Test that multiple fingerprints resolve to their respective key names. + */ + public function testResolveMultipleFingerprints(): void + { + $resolver = new FingerprintKeyNameResolver([ + self::CERT_FP => 'key-a', + self::OTHER_FP => 'key-b', + ]); + $this->assertSame('key-a', $resolver->resolve(self::$certificate)); + $this->assertSame('key-b', $resolver->resolve(self::$otherCertificate)); + } + + + /** + * Test that resolving an unknown fingerprint throws UnknownKeyException. + */ + public function testUnknownFingerprintThrows(): void + { + $resolver = new FingerprintKeyNameResolver([ + self::OTHER_FP => 'key-b', + ]); + $this->expectException(UnknownKeyException::class); + $resolver->resolve(self::$certificate); + } + + + /** + * Test that an invalid key name in the map throws InvalidArgumentException at construction. + */ + public function testInvalidKeyNameThrowsAtConstruction(): void + { + $this->expectException(InvalidArgumentException::class); + new FingerprintKeyNameResolver([ + self::CERT_FP => 'has/slash', + ]); + } + + + /** + * Test that a key name exceeding 64 characters throws InvalidArgumentException at construction. + */ + public function testTooLongKeyNameThrowsAtConstruction(): void + { + $this->expectException(InvalidArgumentException::class); + new FingerprintKeyNameResolver([ + self::CERT_FP => str_repeat('x', 65), + ]); + } + + + /** + * Test that an empty map is valid (no fingerprints → any resolve() call throws UnknownKeyException). + */ + public function testEmptyMapResolvesUnknown(): void + { + $resolver = new FingerprintKeyNameResolver([]); + $this->expectException(UnknownKeyException::class); + $resolver->resolve(self::$certificate); + } + + + /** + * Test that an uppercase fingerprint in the map resolves the same certificate as its lowercase form. + */ + public function testUppercaseFingerprintResolves(): void + { + $resolver = new FingerprintKeyNameResolver([ + strtoupper(self::CERT_FP) => 'signing-key', + ]); + $this->assertSame('signing-key', $resolver->resolve(self::$certificate)); + } + + + /** + * Test that a malformed fingerprint (not 64 hex characters) throws InvalidArgumentException at construction. + */ + public function testMalformedFingerprintThrowsAtConstruction(): void + { + $this->expectException(InvalidArgumentException::class); + new FingerprintKeyNameResolver([ + 'AB:CD:' . substr(self::CERT_FP, 6) => 'signing-key', + ]); + } + + + /** + * Test that two fingerprints differing only in case throw InvalidArgumentException at construction. + */ + public function testCaseInsensitiveDuplicateFingerprintThrowsAtConstruction(): void + { + $this->expectException(InvalidArgumentException::class); + new FingerprintKeyNameResolver([ + self::CERT_FP => 'key-a', + strtoupper(self::CERT_FP) => 'key-b', + ]); + } +} diff --git a/tests/Backend/PrivateKeyAgent/PrivateKeyAgentEncryptionBackendTest.php b/tests/Backend/PrivateKeyAgent/PrivateKeyAgentEncryptionBackendTest.php new file mode 100644 index 00000000..0fb1efa5 --- /dev/null +++ b/tests/Backend/PrivateKeyAgent/PrivateKeyAgentEncryptionBackendTest.php @@ -0,0 +1,418 @@ +httpClient = $this->createStub(ClientInterface::class); + $this->requestFactory = $this->createStub(RequestFactoryInterface::class); + $this->streamFactory = $this->createStub(StreamFactoryInterface::class); + $this->request = $this->createStub(RequestInterface::class); + + $this->request->method('withHeader')->willReturn($this->request); + $this->request->method('withBody')->willReturn($this->request); + $this->requestFactory->method('createRequest')->willReturn($this->request); + $this->streamFactory->method('createStream')->willReturn($this->createStub(StreamInterface::class)); + + $this->tokenProvider = $this->createStub(TokenProvider::class); + $this->tokenProvider->method('getToken')->willReturn(self::TOKEN); + + $this->keyNameResolver = $this->createStub(KeyNameResolver::class); + $this->keyNameResolver->method('resolve')->willReturn(self::KEY_NAME); + } + + + private function makeBackend(?ClientInterface $httpClient = null): PrivateKeyAgentEncryptionBackend + { + return new PrivateKeyAgentEncryptionBackend( + $httpClient ?? $this->httpClient, + $this->requestFactory, + $this->streamFactory, + self::BASE_URL, + $this->tokenProvider, + $this->keyNameResolver, + ); + } + + + private function makeResponse(int $status, string $body): ResponseInterface + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('__toString')->willReturn($body); + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn($status); + $response->method('getBody')->willReturn($stream); + return $response; + } + + + public function testSetCipherAcceptsKeyTransportAlgorithms(): void + { + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $backend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + $backend->setCipher(C::KEY_TRANSPORT_RSA_1_5); + $this->addToAssertionCount(1); // Verifies none of the above threw an exception. + } + + + public function testSetCipherRejectsBlockCiphers(): void + { + $this->expectException(InvalidArgumentException::class); + $this->makeBackend()->setCipher(C::BLOCK_ENC_AES256_GCM); + } + + + public function testSetCipherRejectsUnknownCipher(): void + { + $this->expectException(InvalidArgumentException::class); + $this->makeBackend()->setCipher('http://example.com/unknown-cipher'); + } + + + public function testSetOAEPParamsBeforeCipherThrowsRuntimeException(): void + { + $this->expectException(RuntimeException::class); + $this->makeBackend()->setOAEPParams(C::DIGEST_SHA256, self::MGF1_SHA256); + } + + + public function testSetOAEPParamsWithNullsIsAlwaysAccepted(): void + { + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $backend->setOAEPParams(null, null); + $this->addToAssertionCount(1); // Verifies null/null does not throw. + } + + + /** + * Full OAEP matrix: correct agent algorithm per digest/mgf combination. + * + * @dataProvider oaepMatrixProvider + */ + #[DataProvider('oaepMatrixProvider')] + public function testSetOAEPParamsAndDecryptUsesCorrectAgentAlgorithm( + string $cipher, + ?string $digestAlg, + ?string $mgf, + string $expectedAgentAlgorithm, + ): void { + $plaintext = 'decrypted-key-bytes'; + $capturedBody = null; + $this->streamFactory->method('createStream')->willReturnCallback( + function (string $body) use (&$capturedBody): StreamInterface { + $capturedBody = $body; + return $this->createStub(StreamInterface::class); + }, + ); + $this->httpClient->method('sendRequest')->willReturn( + $this->makeResponse(200, json_encode(['decrypted_data' => base64_encode($plaintext)])), + ); + + $backend = $this->makeBackend(); + $backend->setCipher($cipher); + $backend->setOAEPParams($digestAlg, $mgf); + $backend->decrypt(self::$certificate, 'fake-ciphertext'); + + $decoded = json_decode($capturedBody, true); + $this->assertSame($expectedAgentAlgorithm, $decoded['algorithm']); + } + + + /** @return array */ + public static function oaepMatrixProvider(): array + { + $sha1 = C::DIGEST_SHA1; + $sha224 = C::DIGEST_SHA224; + $sha256 = C::DIGEST_SHA256; + $sha384 = C::DIGEST_SHA384; + $sha512 = C::DIGEST_SHA512; + + $mgf1 = self::MGF1_SHA1; + $mgf224 = self::MGF1_SHA224; + $mgf256 = self::MGF1_SHA256; + $mgf384 = self::MGF1_SHA384; + $mgf512 = self::MGF1_SHA512; + + return [ + // rsa-oaep-mgf1p: absent or SHA-1 only + 'mgf1p-no-params' => [C::KEY_TRANSPORT_OAEP_MGF1P, null, null, 'rsa-pkcs1-oaep-mgf1-sha1'], + 'mgf1p-explicit-sha1' => [C::KEY_TRANSPORT_OAEP_MGF1P, $sha1, null, 'rsa-pkcs1-oaep-mgf1-sha1'], + 'mgf1p-sha1-mgf1sha1' => [C::KEY_TRANSPORT_OAEP_MGF1P, $sha1, $mgf1, 'rsa-pkcs1-oaep-mgf1-sha1'], + // rsa-oaep (xmlenc11): both absent → default SHA-256 + 'oaep-both-absent' => [C::KEY_TRANSPORT_OAEP, null, null, 'rsa-pkcs1-oaep-mgf1-sha256'], + // rsa-oaep: explicit digest+mgf pairs + 'oaep-sha1-mgf1sha1' => [C::KEY_TRANSPORT_OAEP, $sha1, $mgf1, 'rsa-pkcs1-oaep-mgf1-sha1'], + 'oaep-sha224-mgf1sha224' => [C::KEY_TRANSPORT_OAEP, $sha224, $mgf224, 'rsa-pkcs1-oaep-mgf1-sha224'], + 'oaep-sha256-mgf1sha256' => [C::KEY_TRANSPORT_OAEP, $sha256, $mgf256, 'rsa-pkcs1-oaep-mgf1-sha256'], + 'oaep-sha384-mgf1sha384' => [C::KEY_TRANSPORT_OAEP, $sha384, $mgf384, 'rsa-pkcs1-oaep-mgf1-sha384'], + 'oaep-sha512-mgf1sha512' => [C::KEY_TRANSPORT_OAEP, $sha512, $mgf512, 'rsa-pkcs1-oaep-mgf1-sha512'], + ]; + } + + + /** + * mgf1p with a non-SHA-1 digest must fail-closed. + */ + public function testOaepMgf1pWithNonSha1DigestThrows(): void + { + $this->expectException(UnsupportedAlgorithmException::class); + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + $backend->setOAEPParams(C::DIGEST_SHA256, null); + } + + + /** + * rsa-oaep with exactly one of digest/mgf must fail-closed. + */ + public function testOaepWithOnlyDigestThrows(): void + { + $this->expectException(UnsupportedAlgorithmException::class); + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $backend->setOAEPParams(C::DIGEST_SHA256, null); + } + + + public function testOaepWithOnlyMgfThrows(): void + { + $this->expectException(UnsupportedAlgorithmException::class); + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $backend->setOAEPParams(null, self::MGF1_SHA256); + } + + + /** + * rsa-oaep with mismatched digest/mgf must fail-closed. + */ + public function testOaepWithMismatchedDigestMgfThrows(): void + { + $this->expectException(UnsupportedAlgorithmException::class); + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + // SHA-256 digest with SHA-384 MGF → mismatch + $backend->setOAEPParams(C::DIGEST_SHA256, self::MGF1_SHA384); + } + + + /** + * setCipher() must reset OAEP params so the next setOAEPParams() starts fresh. + */ + public function testSetCipherResetsOaepParams(): void + { + $capturedBody = null; + $this->streamFactory->method('createStream')->willReturnCallback( + function (string $body) use (&$capturedBody): StreamInterface { + $capturedBody = $body; + return $this->createStub(StreamInterface::class); + }, + ); + $this->httpClient->method('sendRequest')->willReturn( + $this->makeResponse(200, json_encode(['decrypted_data' => base64_encode('plain')])), + ); + + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $backend->setOAEPParams(C::DIGEST_SHA512, self::MGF1_SHA512); + // Re-configure cipher → OAEP params must be reset to null + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + // Now decrypt without calling setOAEPParams() → should use the default (both absent = sha256) + $backend->decrypt(self::$certificate, 'ciphertext'); + + $decoded = json_decode($capturedBody, true); + $this->assertSame('rsa-pkcs1-oaep-mgf1-sha256', $decoded['algorithm']); + } + + + public function testDecryptRejectsNonCertificateKey(): void + { + $this->expectException(InvalidArgumentException::class); + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $backend->decrypt(self::$publicKey, 'ciphertext'); + } + + + public function testDecryptRejectsSymmetricKey(): void + { + $this->expectException(InvalidArgumentException::class); + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $backend->decrypt(SymmetricKey::generate(16), 'ciphertext'); + } + + + public function testDecryptPropagatesAgentException(): void + { + $this->httpClient->method('sendRequest')->willReturn( + $this->makeResponse(503, ''), + ); + + $this->expectException(AgentUnavailableException::class); + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $backend->decrypt(self::$certificate, 'ciphertext'); + } + + + public function testEncryptDelegatesToLocalOpenSslWithPublicKey(): void + { + // The agent HTTP client must not be called during encrypt(); use a mock with expects(). + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + + $backend = $this->makeBackend($httpClient); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + + // Encrypt using the certificate; it must unwrap the public key. + $plaintext = 'key-material-to-encrypt'; + $ciphertext = $backend->encrypt(self::$certificate, $plaintext); + + // The result should be non-empty ciphertext (RSA-encrypted). + $this->assertNotEmpty($ciphertext); + $this->assertNotSame($plaintext, $ciphertext); + } + + + public function testEncryptWorksWithPlainPublicKeyToo(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + + $backend = $this->makeBackend($httpClient); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + + $ciphertext = $backend->encrypt(self::$publicKey, 'test-data'); + $this->assertNotEmpty($ciphertext); + } + + + public function testEncryptWithNonSha1OaepParamsThrows(): void + { + $this->expectException(UnsupportedAlgorithmException::class); + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $backend->setOAEPParams(C::DIGEST_SHA256, self::MGF1_SHA256); + $backend->encrypt(self::$certificate, 'plaintext'); + } + + + public function testEncryptWithSha1OaepParamsSucceeds(): void + { + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP); + $backend->setOAEPParams(C::DIGEST_SHA1, self::MGF1_SHA1); + + $plaintext = 'test-data'; + $ciphertext = $backend->encrypt(self::$certificate, $plaintext); + + $this->assertNotEmpty($ciphertext); + $this->assertNotSame($plaintext, $ciphertext); + } + + + /** + * Cloning must give the copy its own local OpenSSL backend, so reconfiguring the clone + * cannot change what the original encrypts with. + */ + public function testCloneIsolatesLocalBackendState(): void + { + $backend = $this->makeBackend(); + $backend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + + $clone = clone $backend; + $clone->setCipher(C::KEY_TRANSPORT_RSA_1_5); + + $plaintext = 'session key material'; + $ciphertext = $backend->encrypt(self::$certificate, $plaintext); + + // Round-trips only if the original still encrypts with OAEP-MGF1P. + $localBackend = new OpenSSL(); + $localBackend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + $privateKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); + + $this->assertSame($plaintext, $localBackend->decrypt($privateKey, $ciphertext)); + } +} diff --git a/tests/Backend/PrivateKeyAgent/PrivateKeyAgentHttpClientTest.php b/tests/Backend/PrivateKeyAgent/PrivateKeyAgentHttpClientTest.php new file mode 100644 index 00000000..3ddee69f --- /dev/null +++ b/tests/Backend/PrivateKeyAgent/PrivateKeyAgentHttpClientTest.php @@ -0,0 +1,562 @@ +httpClient = $this->createStub(ClientInterface::class); + $this->requestFactory = $this->createStub(RequestFactoryInterface::class); + $this->streamFactory = $this->createStub(StreamFactoryInterface::class); + $this->request = $this->createStub(RequestInterface::class); + + // Default request chain stubs + $this->request->method('withHeader')->willReturn($this->request); + $this->request->method('withBody')->willReturn($this->request); + $this->requestFactory->method('createRequest')->willReturn($this->request); + $this->streamFactory->method('createStream')->willReturn($this->createStub(StreamInterface::class)); + } + + + private function makeClient( + bool $allowInsecureHttp = false, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ): PrivateKeyAgentHttpClient { + return new PrivateKeyAgentHttpClient( + $httpClient ?? $this->httpClient, + $requestFactory ?? $this->requestFactory, + $this->streamFactory, + self::BASE_URL, + $allowInsecureHttp, + ); + } + + + private function mockResponse(int $status, string $body): ResponseInterface + { + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn($status); + $stream = $this->createStub(StreamInterface::class); + $stream->method('__toString')->willReturn($body); + $response->method('getBody')->willReturn($stream); + return $response; + } + + + public function testHttpUrlWithoutOptInThrows(): void + { + $this->expectException(InvalidArgumentException::class); + new PrivateKeyAgentHttpClient( + $this->httpClient, + $this->requestFactory, + $this->streamFactory, + 'http://insecure.example.com', + ); + } + + + public function testHttpUrlWithOptInIsAccepted(): void + { + $rawSignature = 'opt-in-signature'; + $this->httpClient->method('sendRequest')->willReturn( + $this->mockResponse(200, json_encode(['signature' => base64_encode($rawSignature)])), + ); + + $client = new PrivateKeyAgentHttpClient( + $this->httpClient, + $this->requestFactory, + $this->streamFactory, + 'http://localhost:8080', + allowInsecureHttp: true, + ); + + $result = $client->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + $this->assertSame($rawSignature, $result); + } + + + public function testUnsupportedSchemeThrows(): void + { + $this->expectException(InvalidArgumentException::class); + new PrivateKeyAgentHttpClient( + $this->httpClient, + $this->requestFactory, + $this->streamFactory, + 'ftp://agent.example.com', + ); + } + + + public function testInsecureHttpToRemoteHostIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->makeInsecureClient('http://agent.example.com'); + } + + + public function testInsecureHttpToLoopbackIpIsAccepted(): void + { + $this->assertBaseUrlIsUsable('http://127.0.0.1:8080'); + } + + + public function testInsecureHttpToIpv6LoopbackIsAccepted(): void + { + $this->assertBaseUrlIsUsable('http://[::1]:8080'); + } + + + public function testInsecureHttpToLoopbackLikeHostnameIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->makeInsecureClient('http://localhost.evil.example'); + } + + + public function testInsecureHttpWithUserinfoDisguisingHostIsRejected(): void + { + // The host component is "evil.example"; only a prefix check would be fooled by this. + $this->expectException(InvalidArgumentException::class); + $this->makeInsecureClient('http://localhost@evil.example'); + } + + + public function testHttpsToRemoteHostIsUnaffectedByTheLoopbackRule(): void + { + $this->assertBaseUrlIsUsable('https://agent.example.com'); + } + + + private function makeInsecureClient(string $baseUrl): PrivateKeyAgentHttpClient + { + return new PrivateKeyAgentHttpClient( + $this->httpClient, + $this->requestFactory, + $this->streamFactory, + $baseUrl, + allowInsecureHttp: true, + ); + } + + + /** + * Assert that the base URL is accepted at construction and that the resulting client works. + */ + private function assertBaseUrlIsUsable(string $baseUrl): void + { + $rawSignature = 'accepted-signature'; + $this->httpClient->method('sendRequest')->willReturn( + $this->mockResponse(200, json_encode(['signature' => base64_encode($rawSignature)])), + ); + + $result = $this->makeInsecureClient($baseUrl)->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + + $this->assertSame($rawSignature, $result); + } + + + public function testInvalidKeyNameThrowsBeforeHttpCall(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + $client = $this->makeClient(httpClient: $httpClient); + $this->expectException(InvalidArgumentException::class); + $client->sign('../escape', self::TOKEN, 'rsa-pkcs1-v1_5-sha256', 'hashbytes'); + } + + + public function testEmptyKeyNameThrows(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + $client = $this->makeClient(httpClient: $httpClient); + $this->expectException(InvalidArgumentException::class); + $client->decrypt('', self::TOKEN, 'rsa-pkcs1-oaep-mgf1-sha256', 'data'); + } + + + public function testTooLongKeyNameThrows(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + $client = $this->makeClient(httpClient: $httpClient); + $this->expectException(InvalidArgumentException::class); + $client->sign(str_repeat('x', 65), self::TOKEN, 'rsa-pkcs1-v1_5-sha256', 'hashbytes'); + } + + + public function testSignHappyPath(): void + { + $rawSignature = 'binary-signature-bytes'; + $response = $this->mockResponse(200, json_encode([ + 'signature' => base64_encode($rawSignature), + ])); + $this->httpClient->method('sendRequest')->willReturn($response); + + $client = $this->makeClient(); + $result = $client->sign(self::KEY_NAME, self::TOKEN, 'rsa-pkcs1-v1_5-sha256', 'hashbytes'); + + $this->assertSame($rawSignature, $result); + } + + + public function testSignRequestUsesCorrectUrlAndMethod(): void + { + $rawSignature = 'sig'; + $response = $this->mockResponse(200, json_encode([ + 'signature' => base64_encode($rawSignature), + ])); + $this->httpClient->method('sendRequest')->willReturn($response); + + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $requestFactory + ->expects($this->once()) + ->method('createRequest') + ->with('POST', self::BASE_URL . '/v1/sign/' . self::KEY_NAME) + ->willReturn($this->request); + + $this->makeClient(requestFactory: $requestFactory)->sign( + self::KEY_NAME, + self::TOKEN, + 'rsa-pkcs1-v1_5-sha256', + 'hashbytes', + ); + } + + + public function testSignRequestHasBearerToken(): void + { + $request = $this->createMock(RequestInterface::class); + $request->method('withBody')->willReturn($request); + $requestFactory = $this->createStub(RequestFactoryInterface::class); + $requestFactory->method('createRequest')->willReturn($request); + + $this->httpClient->method('sendRequest')->willReturn( + $this->mockResponse(200, json_encode(['signature' => base64_encode('sig')])), + ); + + $request + ->expects($this->atLeastOnce()) + ->method('withHeader') + ->willReturnCallback(function (string $name, string $value) use ($request) { + if ($name === 'Authorization') { + $this->assertSame('Bearer ' . self::TOKEN, $value); + } + return $request; + }); + + $this->makeClient(requestFactory: $requestFactory)->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testDecryptHappyPath(): void + { + $plaintext = 'decrypted-session-key'; + $response = $this->mockResponse(200, json_encode([ + 'decrypted_data' => base64_encode($plaintext), + ])); + $this->httpClient->method('sendRequest')->willReturn($response); + + $client = $this->makeClient(); + $result = $client->decrypt(self::KEY_NAME, self::TOKEN, 'rsa-pkcs1-oaep-mgf1-sha256', 'ciphertext'); + + $this->assertSame($plaintext, $result); + } + + + public function testDecryptRequestUsesCorrectUrl(): void + { + $this->httpClient->method('sendRequest')->willReturn( + $this->mockResponse(200, json_encode(['decrypted_data' => base64_encode('plain')])), + ); + + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $requestFactory + ->expects($this->once()) + ->method('createRequest') + ->with('POST', self::BASE_URL . '/v1/decrypt/' . self::KEY_NAME) + ->willReturn($this->request); + + $this->makeClient(requestFactory: $requestFactory)->decrypt( + self::KEY_NAME, + self::TOKEN, + 'algo', + 'cipher', + ); + } + + + public function testHttp400ThrowsInvalidRequestException(): void + { + $this->httpClient->method('sendRequest')->willReturn($this->mockResponse(400, '{}')); + $this->expectException(InvalidRequestException::class); + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testHttp401ThrowsAuthenticationException(): void + { + $this->httpClient->method('sendRequest')->willReturn($this->mockResponse(401, '{}')); + $this->expectException(AuthenticationException::class); + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testHttp403ThrowsAuthorizationException(): void + { + $this->httpClient->method('sendRequest')->willReturn($this->mockResponse(403, '{}')); + $this->expectException(AuthorizationException::class); + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testHttp404ThrowsUnknownKeyException(): void + { + $this->httpClient->method('sendRequest')->willReturn($this->mockResponse(404, '{}')); + $this->expectException(UnknownKeyException::class); + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testHttp429ThrowsAgentUnavailableException(): void + { + $this->httpClient->method('sendRequest')->willReturn($this->mockResponse(429, '{}')); + $this->expectException(AgentUnavailableException::class); + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testHttp500ThrowsAgentUnavailableException(): void + { + $this->httpClient->method('sendRequest')->willReturn($this->mockResponse(500, '{}')); + $this->expectException(AgentUnavailableException::class); + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testHttp503ThrowsAgentUnavailableException(): void + { + $this->httpClient->method('sendRequest')->willReturn($this->mockResponse(503, '{}')); + $this->expectException(AgentUnavailableException::class); + $this->makeClient()->decrypt(self::KEY_NAME, self::TOKEN, 'algo', 'cipher'); + } + + + public function testHttp405ThrowsAgentProtocolException(): void + { + $this->httpClient->method('sendRequest')->willReturn($this->mockResponse(405, '{}')); + $this->expectException(AgentProtocolException::class); + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testNetworkErrorThrowsAgentUnavailableException(): void + { + $networkError = new class ('Connection refused') extends \RuntimeException implements ClientExceptionInterface { + }; + $this->httpClient->method('sendRequest')->willThrowException($networkError); + $this->expectException(AgentUnavailableException::class); + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testNetworkErrorDoesNotLeakTokenOrChainException(): void + { + $networkError = new class ( + 'Connection refused while sending Authorization: Bearer ' . self::TOKEN, + ) extends \RuntimeException implements ClientExceptionInterface { + }; + $this->httpClient->method('sendRequest')->willThrowException($networkError); + + try { + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + $this->fail('Expected AgentUnavailableException was not thrown.'); + } catch (AgentUnavailableException $e) { + $this->assertSame('Agent is unreachable.', $e->getMessage()); + $this->assertNull($e->getPrevious()); + } + } + + + public function testMissingSignatureFieldThrowsAgentProtocolException(): void + { + $this->httpClient->method('sendRequest')->willReturn( + $this->mockResponse(200, json_encode(['other' => 'field'])), + ); + $this->expectException(AgentProtocolException::class); + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testInvalidBase64InSignatureThrowsAgentProtocolException(): void + { + $this->httpClient->method('sendRequest')->willReturn( + $this->mockResponse(200, json_encode(['signature' => '!!!not-base64!!!'])), + ); + $this->expectException(AgentProtocolException::class); + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testMissingDecryptedDataFieldThrowsAgentProtocolException(): void + { + $this->httpClient->method('sendRequest')->willReturn( + $this->mockResponse(200, json_encode(['signature' => base64_encode('wrong')])), + ); + $this->expectException(AgentProtocolException::class); + $this->makeClient()->decrypt(self::KEY_NAME, self::TOKEN, 'algo', 'cipher'); + } + + + public function testInvalidJsonResponseThrowsAgentProtocolException(): void + { + $this->httpClient->method('sendRequest')->willReturn( + $this->mockResponse(200, 'not-json'), + ); + $this->expectException(AgentProtocolException::class); + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + } + + + public function testTokenNotInAuthenticationExceptionMessage(): void + { + $this->httpClient->method('sendRequest')->willReturn($this->mockResponse(401, '{}')); + + try { + $this->makeClient()->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + $this->fail('Expected AuthenticationException was not thrown.'); + } catch (AuthenticationException $e) { + $this->assertStringNotContainsString(self::TOKEN, $e->getMessage()); + } + } + + + public function testTokenNotInAuthorizationExceptionMessage(): void + { + $this->httpClient->method('sendRequest')->willReturn($this->mockResponse(403, '{}')); + + try { + $this->makeClient()->decrypt(self::KEY_NAME, self::TOKEN, 'algo', 'cipher'); + $this->fail('Expected AuthorizationException was not thrown.'); + } catch (AuthorizationException $e) { + $this->assertStringNotContainsString(self::TOKEN, $e->getMessage()); + } + } + + + public function testTokenWithNewlineThrowsBeforeHttpCall(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + $token = "tok\n"; + + try { + $this->makeClient(httpClient: $httpClient)->sign(self::KEY_NAME, $token, 'algo', 'hash'); + $this->fail('Expected InvalidArgumentException was not thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertStringNotContainsString($token, $e->getMessage()); + } + } + + + public function testTokenWithSpaceThrowsBeforeHttpCall(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + $token = 'tok with space'; + + try { + $this->makeClient(httpClient: $httpClient)->decrypt(self::KEY_NAME, $token, 'algo', 'cipher'); + $this->fail('Expected InvalidArgumentException was not thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertStringNotContainsString($token, $e->getMessage()); + } + } + + + public function testNonAsciiTokenThrowsBeforeHttpCall(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + $token = "tok\xC3\xA9"; + + try { + $this->makeClient(httpClient: $httpClient)->sign(self::KEY_NAME, $token, 'algo', 'hash'); + $this->fail('Expected InvalidArgumentException was not thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertStringNotContainsString($token, $e->getMessage()); + } + } + + + public function testValidTokenPassingRegexButRejectedByRequestFactoryDoesNotLeak(): void + { + // Simulate a third-party PSR-7 implementation rejecting a token that + // nevertheless passes our own TOKEN_PATTERN validation, to prove the + // defense-in-depth try/catch around the withHeader() chain works + // independently of the regex. + $request = $this->createStub(RequestInterface::class); + $request->method('withBody')->willReturn($request); + $request->method('withHeader')->willThrowException( + new \InvalidArgumentException('Invalid header value: Bearer ' . self::TOKEN), + ); + + $requestFactory = $this->createStub(RequestFactoryInterface::class); + $requestFactory->method('createRequest')->willReturn($request); + + try { + $this->makeClient(requestFactory: $requestFactory)->sign(self::KEY_NAME, self::TOKEN, 'algo', 'hash'); + $this->fail('Expected InvalidArgumentException was not thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertStringNotContainsString(self::TOKEN, $e->getMessage()); + } + } +} diff --git a/tests/Backend/PrivateKeyAgent/PrivateKeyAgentNoFallbackTest.php b/tests/Backend/PrivateKeyAgent/PrivateKeyAgentNoFallbackTest.php new file mode 100644 index 00000000..5bfc6eac --- /dev/null +++ b/tests/Backend/PrivateKeyAgent/PrivateKeyAgentNoFallbackTest.php @@ -0,0 +1,428 @@ +requestFactory = $this->createStub(RequestFactoryInterface::class); + $this->streamFactory = $this->createStub(StreamFactoryInterface::class); + $this->request = $this->createStub(RequestInterface::class); + + $this->request->method('withHeader')->willReturn($this->request); + $this->request->method('withBody')->willReturn($this->request); + $this->requestFactory->method('createRequest')->willReturn($this->request); + $this->streamFactory->method('createStream')->willReturn($this->createStub(StreamInterface::class)); + + $this->tokenProvider = $this->createStub(TokenProvider::class); + $this->tokenProvider->method('getToken')->willReturn(self::TOKEN); + + $this->keyNameResolver = $this->createStub(KeyNameResolver::class); + $this->keyNameResolver->method('resolve')->willReturn(self::KEY_NAME); + } + + + private function makeSignatureBackend(?ClientInterface $httpClient = null): PrivateKeyAgentSignatureBackend + { + return new PrivateKeyAgentSignatureBackend( + $httpClient ?? $this->createStub(ClientInterface::class), + $this->requestFactory, + $this->streamFactory, + self::BASE_URL, + $this->tokenProvider, + $this->keyNameResolver, + ); + } + + + private function makeEncryptionBackend(?ClientInterface $httpClient = null): PrivateKeyAgentEncryptionBackend + { + return new PrivateKeyAgentEncryptionBackend( + $httpClient ?? $this->createStub(ClientInterface::class), + $this->requestFactory, + $this->streamFactory, + self::BASE_URL, + $this->tokenProvider, + $this->keyNameResolver, + ); + } + + + private function makeResponse(int $status, string $body): ResponseInterface + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('__toString')->willReturn($body); + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn($status); + $response->method('getBody')->willReturn($stream); + return $response; + } + + + public function testSignRoundTripWithSimulatedAgentResponse(): void + { + $plaintext = 'canonicalized SignedInfo content'; + + // Compute the real RSA-PKCS1v1.5-SHA256 signature the agent would produce. + $privateKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); + $localBackend = new OpenSSL(); + $localBackend->setDigestAlg(C::DIGEST_SHA256); + $realSignature = $localBackend->sign($privateKey, $plaintext); + + // HTTP client must be called exactly once (the sign POST). + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once())->method('sendRequest') + ->willReturn( + $this->makeResponse(200, json_encode(['signature' => base64_encode($realSignature)])), + ); + + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $backend = $this->makeSignatureBackend($httpClient); + $backend->setDigestAlg(C::DIGEST_SHA256); + + $signature = $backend->sign($cert, $plaintext); + + // Verify locally with the public key extracted from the certificate. + $this->assertTrue($localBackend->verify($cert->getPublicKey(), $plaintext, $signature)); + } + + + public function testDecryptRoundTripWithSimulatedAgentResponse(): void + { + $plaintext = 'secret symmetric key material'; + + // HTTP client must be called exactly once (the decrypt POST). + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once())->method('sendRequest') + ->willReturn( + $this->makeResponse(200, json_encode(['decrypted_data' => base64_encode($plaintext)])), + ); + + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $backend = $this->makeEncryptionBackend($httpClient); + $backend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + + // Encrypt ciphertext locally with the public key (the actual value doesn't matter here; + // the simulated agent always returns the fixed plaintext regardless of input). + $localBackend = new OpenSSL(); + $localBackend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + $ciphertext = $localBackend->encrypt($cert->getPublicKey(), $plaintext); + + $result = $backend->decrypt($cert, $ciphertext); + + $this->assertSame($plaintext, $result); + } + + + public function testVerifyNeverCallsAgent(): void + { + $privateKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); + $localBackend = new OpenSSL(); + $localBackend->setDigestAlg(C::DIGEST_SHA256); + $plaintext = 'data for verify'; + $signature = $localBackend->sign($privateKey, $plaintext); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $backend = $this->makeSignatureBackend($httpClient); + $backend->setDigestAlg(C::DIGEST_SHA256); + + $this->assertTrue($backend->verify($cert, $plaintext, $signature)); + } + + + public function testEncryptNeverCallsAgent(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $backend = $this->makeEncryptionBackend($httpClient); + $backend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + + $result = $backend->encrypt($cert, 'plaintext'); + $this->assertNotEmpty($result); + } + + + public function testPkaBackendSourceFilesDoNotLoadPrivateKeys(): void + { + $dir = __DIR__ . '/../../../src/Backend/PrivateKeyAgent/'; + $files = glob($dir . '*.php') ?: []; + + $this->assertNotEmpty($files, 'Expected PHP source files in ' . $dir); + + foreach ($files as $file) { + $source = file_get_contents($file); + $this->assertIsString($source); + + $basename = basename($file); + $this->assertStringNotContainsString( + 'PrivateKey::fromFile(', + $source, + "Found PrivateKey::fromFile() (private-key loading) in {$basename}", + ); + $this->assertStringNotContainsString( + 'new PrivateKey(', + $source, + "Found new PrivateKey() (private-key instantiation) in {$basename}", + ); + $this->assertStringNotContainsString( + 'openssl_pkey_get_private(', + $source, + "Found openssl_pkey_get_private() (raw private-key loading) in {$basename}", + ); + } + } + + + public function testFailingAgentThrowsAgentUnavailableExceptionForCertKey(): void + { + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest') + ->willReturn($this->makeResponse(503, 'Service Unavailable')); + + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $backend = $this->makeSignatureBackend($httpClient); + $backend->setDigestAlg(C::DIGEST_SHA256); + + $this->expectException(AgentUnavailableException::class); + $backend->sign($cert, 'test data'); + } + + + public function testFailingAgentDecryptThrowsAgentUnavailableExceptionForCertKey(): void + { + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest') + ->willReturn($this->makeResponse(503, '')); + + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $backend = $this->makeEncryptionBackend($httpClient); + $backend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + + $this->expectException(AgentUnavailableException::class); + $backend->decrypt($cert, 'ciphertext'); + } + + + /** + * Verify that the PrivateKeyAgentRSA signature wrapper never falls back to a local + * private-key operation when the agent fails: a certificate key always routes to the + * PKA backend, so a backend failure must propagate as-is. + */ + public function testSignatureWrapperWithCertKeyNeverFallsBackToLocalOnAgentFailure(): void + { + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest') + ->willReturn($this->makeResponse(503, '')); + + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $pkaBackend = $this->makeSignatureBackend($httpClient); + $pkaBackend->setDigestAlg(C::DIGEST_SHA256); + + $wrapper = new SignaturePrivateKeyAgentRSA($cert, C::SIG_RSA_SHA256, $pkaBackend); + + $this->expectException(AgentUnavailableException::class); + $wrapper->sign('data'); + } + + + /** + * Verify that the PrivateKeyAgentRSA key-transport wrapper never falls back to a local + * private-key operation when the agent fails. + */ + public function testKeyTransportWrapperWithCertKeyNeverFallsBackToLocalOnAgentFailure(): void + { + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest') + ->willReturn($this->makeResponse(503, '')); + + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $pkaBackend = $this->makeEncryptionBackend($httpClient); + $pkaBackend->setCipher(C::KEY_TRANSPORT_OAEP_MGF1P); + + $wrapper = new KeyTransportPrivateKeyAgentRSA($cert, C::KEY_TRANSPORT_OAEP_MGF1P, $pkaBackend); + + $this->expectException(AgentUnavailableException::class); + $wrapper->decrypt('ciphertext'); + } + + + /** + * Replace the stream factory with one that records every body handed to it, so the JSON + * request sent to the agent can be inspected. + * + * @param string|null $capturedBody Receives the last body passed to createStream(). + */ + private function captureRequestBody(?string &$capturedBody): void + { + $this->streamFactory->method('createStream')->willReturnCallback( + function (string $body) use (&$capturedBody): StreamInterface { + $capturedBody = $body; + return $this->createStub(StreamInterface::class); + }, + ); + } + + + /** + * Two signature wrappers built from a single registered backend must not share its state. + * + * This is the boot-time registration pattern: one backend instance is captured in the closure + * passed to registerAlgorithmFactory() and reused for every algorithm the factory hands out. + * Because SignableElementTrait::sign() only stores the signer and the signature is produced + * later, at toXML() time, several signers are routinely alive at once. A shared backend would + * let the last one constructed dictate the digest for all of them, producing a signature that + * does not match the SignatureMethod declared in the XML. + */ + public function testSignatureWrappersBuiltFromOneBackendDoNotShareDigestState(): void + { + $plaintext = 'canonicalized SignedInfo content'; + + $privateKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); + $localBackend = new OpenSSL(); + $localBackend->setDigestAlg(C::DIGEST_SHA256); + $realSignature = $localBackend->sign($privateKey, $plaintext); + + $capturedBody = null; + $this->captureRequestBody($capturedBody); + + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest')->willReturn( + $this->makeResponse(200, json_encode(['signature' => base64_encode($realSignature)])), + ); + + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $pkaBackend = $this->makeSignatureBackend($httpClient); + + $sha256Signer = new SignaturePrivateKeyAgentRSA($cert, C::SIG_RSA_SHA256, $pkaBackend); + // Constructed after the SHA-256 signer, and still pending when that one signs. + new SignaturePrivateKeyAgentRSA($cert, C::SIG_RSA_SHA512, $pkaBackend); + + $signature = $sha256Signer->sign($plaintext); + + $decoded = json_decode($capturedBody, true); + $this->assertSame('rsa-pkcs1-v1_5-sha256', $decoded['algorithm']); + $this->assertSame(base64_encode(hash('sha256', $plaintext, binary: true)), $decoded['hash']); + $this->assertTrue($localBackend->verify($cert->getPublicKey(), $plaintext, $signature)); + } + + + /** + * Two key-transport wrappers built from a single registered backend must not share its cipher. + */ + public function testKeyTransportWrappersBuiltFromOneBackendDoNotShareCipherState(): void + { + $capturedBody = null; + $this->captureRequestBody($capturedBody); + + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest')->willReturn( + $this->makeResponse(200, json_encode(['decrypted_data' => base64_encode('session key')])), + ); + + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $pkaBackend = $this->makeEncryptionBackend($httpClient); + + $mgf1pDecryptor = new KeyTransportPrivateKeyAgentRSA($cert, C::KEY_TRANSPORT_OAEP_MGF1P, $pkaBackend); + // Constructed afterwards; its cipher must not leak into the mgf1p decryptor. + new KeyTransportPrivateKeyAgentRSA($cert, C::KEY_TRANSPORT_OAEP, $pkaBackend); + + $this->assertSame('session key', $mgf1pDecryptor->decrypt('ciphertext')); + + $decoded = json_decode($capturedBody, true); + $this->assertSame('rsa-pkcs1-oaep-mgf1-sha1', $decoded['algorithm']); + } + + + /** + * OAEP parameters read from one EncryptedKey must not leak into another decryptor built from + * the same registered backend. + */ + public function testKeyTransportWrappersBuiltFromOneBackendDoNotShareOaepParameters(): void + { + $capturedBody = null; + $this->captureRequestBody($capturedBody); + + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest')->willReturn( + $this->makeResponse(200, json_encode(['decrypted_data' => base64_encode('session key')])), + ); + + $cert = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); + $pkaBackend = $this->makeEncryptionBackend($httpClient); + + $defaultDecryptor = new KeyTransportPrivateKeyAgentRSA($cert, C::KEY_TRANSPORT_OAEP, $pkaBackend); + $sha512Decryptor = new KeyTransportPrivateKeyAgentRSA($cert, C::KEY_TRANSPORT_OAEP, $pkaBackend); + + // As EncryptedKey::decrypt() would do for an EncryptedKey carrying explicit OAEP parameters. + $sha512Decryptor->setOAEPParams(C::DIGEST_SHA512, 'http://www.w3.org/2009/xmlenc11#mgf1sha512'); + + $defaultDecryptor->decrypt('ciphertext'); + + $decoded = json_decode($capturedBody, true); + $this->assertSame('rsa-pkcs1-oaep-mgf1-sha256', $decoded['algorithm']); + } +} diff --git a/tests/Backend/PrivateKeyAgent/PrivateKeyAgentSignatureBackendTest.php b/tests/Backend/PrivateKeyAgent/PrivateKeyAgentSignatureBackendTest.php new file mode 100644 index 00000000..ae492e4e --- /dev/null +++ b/tests/Backend/PrivateKeyAgent/PrivateKeyAgentSignatureBackendTest.php @@ -0,0 +1,420 @@ +httpClient = $this->createStub(ClientInterface::class); + $this->requestFactory = $this->createStub(RequestFactoryInterface::class); + $this->streamFactory = $this->createStub(StreamFactoryInterface::class); + $this->request = $this->createStub(RequestInterface::class); + + $this->request->method('withHeader')->willReturn($this->request); + $this->request->method('withBody')->willReturn($this->request); + $this->requestFactory->method('createRequest')->willReturn($this->request); + $this->streamFactory->method('createStream')->willReturn($this->createStub(StreamInterface::class)); + + $this->tokenProvider = $this->createStub(TokenProvider::class); + $this->tokenProvider->method('getToken')->willReturn(self::TOKEN); + + $this->keyNameResolver = $this->createStub(KeyNameResolver::class); + $this->keyNameResolver->method('resolve')->willReturn(self::KEY_NAME); + } + + + private function makeBackend(?ClientInterface $httpClient = null): PrivateKeyAgentSignatureBackend + { + return new PrivateKeyAgentSignatureBackend( + $httpClient ?? $this->httpClient, + $this->requestFactory, + $this->streamFactory, + self::BASE_URL, + $this->tokenProvider, + $this->keyNameResolver, + ); + } + + + private function makeResponse(int $status, string $body): ResponseInterface + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('__toString')->willReturn($body); + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn($status); + $response->method('getBody')->willReturn($stream); + return $response; + } + + + public function testSetDigestAlgAcceptsSupportedDigests(): void + { + $backend = $this->makeBackend(); + $backend->setDigestAlg(C::DIGEST_SHA256); + $backend->setDigestAlg(C::DIGEST_SHA384); + $backend->setDigestAlg(C::DIGEST_SHA512); + $backend->setDigestAlg(C::DIGEST_SHA1); + $this->addToAssertionCount(1); // Verifies none of the above threw an exception. + } + + + public function testSetDigestAlgRejectsSha224(): void + { + $this->expectException(UnsupportedAlgorithmException::class); + $this->makeBackend()->setDigestAlg(C::DIGEST_SHA224); + } + + + public function testSetDigestAlgRejectsUnknownDigest(): void + { + $this->expectException(UnsupportedAlgorithmException::class); + $this->makeBackend()->setDigestAlg('http://example.com/unknown-hash'); + } + + + /** + * Produce the signature a correctly configured agent would return: the real + * RSA-PKCS1v1.5 signature made with the private key matching self::$certificate. + */ + private function realSignature( + string $plaintext, + string $digest = C::DIGEST_SHA256, + string $keyFile = PEMCertificatesMock::PRIVATE_KEY, + ): string { + $localBackend = new OpenSSL(); + $localBackend->setDigestAlg($digest); + + return $localBackend->sign(PEMCertificatesMock::getPrivateKey($keyFile), $plaintext); + } + + + public function testSignSendsSha256HashToAgent(): void + { + $plaintext = 'some canonicalized SignedInfo data'; + $signature = $this->realSignature($plaintext); + $this->httpClient->method('sendRequest')->willReturn( + $this->makeResponse(200, json_encode(['signature' => base64_encode($signature)])), + ); + + $backend = $this->makeBackend(); + $backend->setDigestAlg(C::DIGEST_SHA256); + $result = $backend->sign(self::$certificate, $plaintext); + + $this->assertSame($signature, $result); + } + + + /** + * Verify that sign() hashes locally and sends the exact expected hash length. + */ + public function testSignSendsCorrectHashLength(): void + { + $plaintext = 'hello world'; + $expectedHash = hash('sha256', $plaintext, binary: true); + $signature = $this->realSignature($plaintext); + + // Capture the stream body to verify the hash. + $capturedBody = null; + $this->streamFactory->method('createStream')->willReturnCallback( + function (string $body) use (&$capturedBody): StreamInterface { + $capturedBody = $body; + return $this->createStub(StreamInterface::class); + }, + ); + + $this->httpClient->method('sendRequest')->willReturn( + $this->makeResponse(200, json_encode(['signature' => base64_encode($signature)])), + ); + + $backend = $this->makeBackend(); + $backend->setDigestAlg(C::DIGEST_SHA256); + $backend->sign(self::$certificate, $plaintext); + + $this->assertNotNull($capturedBody); + $decoded = json_decode($capturedBody, true); + $this->assertSame(base64_encode($expectedHash), $decoded['hash']); + $this->assertSame('rsa-pkcs1-v1_5-sha256', $decoded['algorithm']); + } + + + /** + * Verify the agent algorithm string for each supported digest. + * + * @dataProvider digestToAgentAlgorithmProvider + */ + #[DataProvider('digestToAgentAlgorithmProvider')] + public function testSignUsesCorrectAgentAlgorithm(string $digest, string $expectedAlgorithm): void + { + $capturedBody = null; + $this->streamFactory->method('createStream')->willReturnCallback( + function (string $body) use (&$capturedBody): StreamInterface { + $capturedBody = $body; + return $this->createStub(StreamInterface::class); + }, + ); + + $this->httpClient->method('sendRequest')->willReturn( + $this->makeResponse( + 200, + json_encode(['signature' => base64_encode($this->realSignature('plaintext', $digest))]), + ), + ); + + $backend = $this->makeBackend(); + $backend->setDigestAlg($digest); + $backend->sign(self::$certificate, 'plaintext'); + + $decoded = json_decode($capturedBody, true); + $this->assertSame($expectedAlgorithm, $decoded['algorithm']); + } + + + /** @return array */ + public static function digestToAgentAlgorithmProvider(): array + { + return [ + 'sha1' => [C::DIGEST_SHA1, 'rsa-pkcs1-v1_5-sha1'], + 'sha256' => [C::DIGEST_SHA256, 'rsa-pkcs1-v1_5-sha256'], + 'sha384' => [C::DIGEST_SHA384, 'rsa-pkcs1-v1_5-sha384'], + 'sha512' => [C::DIGEST_SHA512, 'rsa-pkcs1-v1_5-sha512'], + ]; + } + + + public function testSignRejectsNonCertificateKey(): void + { + $this->expectException(InvalidArgumentException::class); + $backend = $this->makeBackend(); + $backend->sign(self::$publicKey, 'plaintext'); + } + + + public function testSignRejectsSymmetricKey(): void + { + $this->expectException(InvalidArgumentException::class); + $backend = $this->makeBackend(); + $backend->sign(SymmetricKey::generate(16), 'plaintext'); + } + + + public function testSignPropagatesAgentException(): void + { + $this->httpClient->method('sendRequest')->willReturn( + $this->makeResponse(503, ''), + ); + + $this->expectException(AgentUnavailableException::class); + $backend = $this->makeBackend(); + $backend->sign(self::$certificate, 'plaintext'); + } + + + public function testSignRejectsGarbageSignatureFromAgent(): void + { + $this->httpClient->method('sendRequest')->willReturn( + $this->makeResponse(200, json_encode(['signature' => base64_encode('not-a-signature')])), + ); + + $backend = $this->makeBackend(); + $backend->setDigestAlg(C::DIGEST_SHA256); + + $this->expectException(AgentSignatureMismatchException::class); + $backend->sign(self::$certificate, 'plaintext'); + } + + + /** + * The agent signed with a valid key, but not the one belonging to the certificate we passed in. + * This is what a mismatched key name -- or a key-name resolver that does not bind to the + * certificate -- looks like from this side. + */ + public function testSignRejectsSignatureMadeWithAnotherKey(): void + { + $plaintext = 'plaintext'; + $wrongKeySignature = $this->realSignature( + $plaintext, + C::DIGEST_SHA256, + PEMCertificatesMock::OTHER_PRIVATE_KEY, + ); + + $this->httpClient->method('sendRequest')->willReturn( + $this->makeResponse(200, json_encode(['signature' => base64_encode($wrongKeySignature)])), + ); + + $backend = $this->makeBackend(); + $backend->setDigestAlg(C::DIGEST_SHA256); + + $this->expectException(AgentSignatureMismatchException::class); + $backend->sign(self::$certificate, $plaintext); + } + + + public function testSignRejectsSignatureOverDifferentPlaintext(): void + { + $this->httpClient->method('sendRequest')->willReturn( + $this->makeResponse( + 200, + json_encode(['signature' => base64_encode($this->realSignature('some other data'))]), + ), + ); + + $backend = $this->makeBackend(); + $backend->setDigestAlg(C::DIGEST_SHA256); + + $this->expectException(AgentSignatureMismatchException::class); + $backend->sign(self::$certificate, 'the data we asked to be signed'); + } + + + public function testSignReturnsTheAgentSignatureUnaltered(): void + { + $plaintext = 'canonicalized SignedInfo'; + $signature = $this->realSignature($plaintext); + + $this->httpClient->method('sendRequest')->willReturn( + $this->makeResponse(200, json_encode(['signature' => base64_encode($signature)])), + ); + + $backend = $this->makeBackend(); + $backend->setDigestAlg(C::DIGEST_SHA256); + + $this->assertSame($signature, $backend->sign(self::$certificate, $plaintext)); + } + + + public function testVerifyDelegatesToLocalOpenSslWithPublicKey(): void + { + // Produce a real signature with the private key via local OpenSSL. + $privateKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); + $localBackend = new OpenSSL(); + $localBackend->setDigestAlg(C::DIGEST_SHA256); + $plaintext = 'data to sign'; + $signature = $localBackend->sign($privateKey, $plaintext); + + // The agent HTTP client must not be called during verify(); use a mock with expects(). + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + + $backend = $this->makeBackend($httpClient); + $backend->setDigestAlg(C::DIGEST_SHA256); + $result = $backend->verify(self::$certificate, $plaintext, $signature); + + $this->assertTrue($result); + } + + + public function testVerifyReturnsFalseForBadSignature(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + + $backend = $this->makeBackend($httpClient); + $backend->setDigestAlg(C::DIGEST_SHA256); + $result = $backend->verify(self::$certificate, 'plaintext', 'badsig'); + + $this->assertFalse($result); + } + + + public function testVerifyAcceptsPlainPublicKeyDirectly(): void + { + $privateKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); + $localBackend = new OpenSSL(); + $localBackend->setDigestAlg(C::DIGEST_SHA256); + $plaintext = 'verify with plain pubkey'; + $signature = $localBackend->sign($privateKey, $plaintext); + + $backend = $this->makeBackend(); + $backend->setDigestAlg(C::DIGEST_SHA256); + $this->assertTrue($backend->verify(self::$publicKey, $plaintext, $signature)); + } + + + /** + * Cloning must give the copy its own local OpenSSL backend, so reconfiguring the clone + * cannot change the digest the original hashes and verifies with. + */ + public function testCloneIsolatesLocalBackendState(): void + { + $privateKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); + $localBackend = new OpenSSL(); + $localBackend->setDigestAlg(C::DIGEST_SHA256); + $plaintext = 'data signed with sha256'; + $signature = $localBackend->sign($privateKey, $plaintext); + + $backend = $this->makeBackend(); + $backend->setDigestAlg(C::DIGEST_SHA256); + + $clone = clone $backend; + $clone->setDigestAlg(C::DIGEST_SHA512); + + $this->assertTrue($backend->verify(self::$certificate, $plaintext, $signature)); + } +} diff --git a/tests/Backend/PrivateKeyAgent/StaticKeyNameResolverTest.php b/tests/Backend/PrivateKeyAgent/StaticKeyNameResolverTest.php new file mode 100644 index 00000000..2e3110fb --- /dev/null +++ b/tests/Backend/PrivateKeyAgent/StaticKeyNameResolverTest.php @@ -0,0 +1,90 @@ +assertSame('my-signing-key', $resolver->resolve(self::$certificate)); + } + + + /** + * Test that valid edge-case names are accepted by verifying the resolver returns them. + */ + public function testValidKeyNameEdgeCases(): void + { + $this->assertSame('a', (new StaticKeyNameResolver('a'))->resolve(self::$certificate)); + $this->assertSame('A1-_', (new StaticKeyNameResolver('A1-_'))->resolve(self::$certificate)); + $longName = str_repeat('x', 64); + $this->assertSame($longName, (new StaticKeyNameResolver($longName))->resolve(self::$certificate)); + } + + + /** + * Test that an empty key name fails at construction. + */ + public function testEmptyKeyNameThrowsAtConstruction(): void + { + $this->expectException(InvalidArgumentException::class); + new StaticKeyNameResolver(''); + } + + + /** + * Test that a key name exceeding 64 characters fails at construction. + */ + public function testTooLongKeyNameThrowsAtConstruction(): void + { + $this->expectException(InvalidArgumentException::class); + new StaticKeyNameResolver(str_repeat('x', 65)); + } + + + /** + * Test that a key name containing invalid characters fails at construction. + */ + public function testInvalidCharsKeyNameThrowsAtConstruction(): void + { + $this->expectException(InvalidArgumentException::class); + new StaticKeyNameResolver('invalid/name'); + } + + + /** + * Test that a key name with spaces fails at construction. + */ + public function testSpacesInKeyNameThrowsAtConstruction(): void + { + $this->expectException(InvalidArgumentException::class); + new StaticKeyNameResolver('has space'); + } +} diff --git a/tests/Key/X509CertificateTest.php b/tests/Key/X509CertificateTest.php index 3d605d97..012e6328 100644 --- a/tests/Key/X509CertificateTest.php +++ b/tests/Key/X509CertificateTest.php @@ -5,6 +5,8 @@ namespace SimpleSAML\XMLSecurity\Test\Key; use PHPUnit\Framework\TestCase; +use SimpleSAML\XMLSecurity\Alg\KeyTransport\KeyTransportAlgorithmFactory; +use SimpleSAML\XMLSecurity\Alg\Signature\SignatureAlgorithmFactory; use SimpleSAML\XMLSecurity\CryptoEncoding\PEM; use SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException; use SimpleSAML\XMLSecurity\Key\X509Certificate; @@ -101,4 +103,38 @@ public function testFromFile(): void $pubDetails = openssl_pkey_get_details(openssl_pkey_get_public($c->getMaterial())); $this->assertEquals(self::$cert['key'], $pubDetails['key']); } + + + /** + * Test that X509Certificate implements KeyInterface by calling getMaterial(), + * which is defined on that interface. + */ + public function testImplementsKeyInterface(): void + { + $this->assertNotEmpty(self::$c->getMaterial()); + } + + + /** + * Test that passing an X509Certificate to SignatureAlgorithmFactory::getAlgorithm() + * is accepted by the KeyInterface type hint (no TypeError is thrown). + */ + public function testSignatureAlgorithmFactoryAcceptsCertificate(): void + { + $factory = new SignatureAlgorithmFactory([]); + $this->expectException(UnsupportedAlgorithmException::class); + $factory->getAlgorithm('unknown-algorithm', self::$c); + } + + + /** + * Test that passing an X509Certificate to KeyTransportAlgorithmFactory::getAlgorithm() + * is accepted by the KeyInterface type hint (no TypeError is thrown). + */ + public function testKeyTransportAlgorithmFactoryAcceptsCertificate(): void + { + $factory = new KeyTransportAlgorithmFactory([]); + $this->expectException(UnsupportedAlgorithmException::class); + $factory->getAlgorithm('unknown-algorithm', self::$c); + } } diff --git a/tests/XML/xenc/EncryptedKeyTest.php b/tests/XML/xenc/EncryptedKeyTest.php index a63367f3..5a1d3e77 100644 --- a/tests/XML/xenc/EncryptedKeyTest.php +++ b/tests/XML/xenc/EncryptedKeyTest.php @@ -5,21 +5,37 @@ namespace SimpleSAML\XMLSecurity\Test\XML\xenc; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; +use SimpleSAML\XML\Chunk; use SimpleSAML\XML\DOMDocumentFactory; use SimpleSAML\XML\TestUtils\SchemaValidationTestTrait; use SimpleSAML\XML\TestUtils\SerializableElementTestTrait; use SimpleSAML\XMLSchema\Type\AnyURIValue; use SimpleSAML\XMLSchema\Type\IDValue; use SimpleSAML\XMLSchema\Type\StringValue; +use SimpleSAML\XMLSecurity\Alg\KeyTransport\AbstractKeyTransporter; use SimpleSAML\XMLSecurity\Alg\KeyTransport\KeyTransportAlgorithmFactory; +use SimpleSAML\XMLSecurity\Backend\EncryptionBackend; +use SimpleSAML\XMLSecurity\Backend\PrivateKeyAgent\KeyNameResolver; +use SimpleSAML\XMLSecurity\Backend\PrivateKeyAgent\PrivateKeyAgentEncryptionBackend; +use SimpleSAML\XMLSecurity\Backend\PrivateKeyAgent\TokenProvider; use SimpleSAML\XMLSecurity\Constants as C; +use SimpleSAML\XMLSecurity\Exception\UnsupportedAlgorithmException; use SimpleSAML\XMLSecurity\Key\PrivateKey; use SimpleSAML\XMLSecurity\Key\PublicKey; use SimpleSAML\XMLSecurity\Key\SymmetricKey; +use SimpleSAML\XMLSecurity\Key\X509Certificate; use SimpleSAML\XMLSecurity\TestUtils\PEMCertificatesMock; use SimpleSAML\XMLSecurity\Utils\XPath; +use SimpleSAML\XMLSecurity\XML\ds\DigestMethod; use SimpleSAML\XMLSecurity\XML\ds\KeyInfo; use SimpleSAML\XMLSecurity\XML\xenc\AbstractEncryptedType; use SimpleSAML\XMLSecurity\XML\xenc\AbstractXencElement; @@ -30,9 +46,12 @@ use SimpleSAML\XMLSecurity\XML\xenc\EncryptedKey; use SimpleSAML\XMLSecurity\XML\xenc\EncryptionMethod; use SimpleSAML\XMLSecurity\XML\xenc\ReferenceList; +use SimpleSAML\XMLSecurity\XML\xenc11\MGF; +use function base64_encode; use function bin2hex; use function dirname; +use function json_encode; use function strval; /** @@ -56,6 +75,9 @@ final class EncryptedKeyTest extends TestCase /** @var \SimpleSAML\XMLSecurity\Key\PublicKey */ protected static PublicKey $pubKey; + /** @var \SimpleSAML\XMLSecurity\Key\X509Certificate */ + protected static X509Certificate $certificate; + /** */ @@ -69,6 +91,7 @@ public static function setUpBeforeClass(): void self::$privKey = PEMCertificatesMock::getPrivateKey(PEMCertificatesMock::PRIVATE_KEY); self::$pubKey = PEMCertificatesMock::getPublicKey(PEMCertificatesMock::PUBLIC_KEY); + self::$certificate = PEMCertificatesMock::getCertificate(PEMCertificatesMock::CERTIFICATE); } @@ -273,4 +296,358 @@ public function testOAEMGF1PPEncryption(): void $this->assertEquals(bin2hex($symmetricKey->getMaterial()), bin2hex($decryptedKey)); } + + + /** + * Verify that decrypt() forwards the DigestMethod and MGF URIs to the decryptor when present. + * + * @dataProvider oaepReadPathProvider + */ + #[DataProvider('oaepReadPathProvider')] + public function testDecryptForwardsOaepParamsToAwareDecryptor( + string $cipherAlg, + ?string $digestAlg, + ?string $mgfAlg, + ): void { + // Mock a decryptor implementing both EncryptionAlgorithmInterface and OAEPParametersAware. + $decryptor = $this->createMock(OaepAwareDecryptorInterface::class); + $decryptor->method('getAlgorithmId')->willReturn($cipherAlg); + $decryptor->expects($this->once()) + ->method('setOAEPParams') + ->with($digestAlg, $mgfAlg); + $decryptor->method('decrypt')->willReturn('decrypted'); + + $children = []; + if ($digestAlg !== null) { + $children[] = new DigestMethod(AnyURIValue::fromString($digestAlg)); + } + if ($mgfAlg !== null) { + $children[] = new MGF(AnyURIValue::fromString($mgfAlg)); + } + + $encryptedKey = new EncryptedKey( + new CipherData( + CipherValue::fromString(base64_encode('fake-ciphertext')), + ), + null, + null, + null, + null, + null, + null, + new EncryptionMethod( + AnyURIValue::fromString($cipherAlg), + null, + null, + $children, + ), + ); + + $encryptedKey->decrypt($decryptor); + } + + + /** @return array */ + public static function oaepReadPathProvider(): array + { + $sha1 = C::DIGEST_SHA1; + $sha256 = C::DIGEST_SHA256; + $sha384 = C::DIGEST_SHA384; + $sha512 = C::DIGEST_SHA512; + $mgf1 = 'http://www.w3.org/2009/xmlenc11#mgf1sha1'; + $mgf256 = 'http://www.w3.org/2009/xmlenc11#mgf1sha256'; + $mgf384 = 'http://www.w3.org/2009/xmlenc11#mgf1sha384'; + $mgf512 = 'http://www.w3.org/2009/xmlenc11#mgf1sha512'; + + return [ + 'no-children' => [C::KEY_TRANSPORT_OAEP, null, null], + 'sha256-mgf256' => [C::KEY_TRANSPORT_OAEP, $sha256, $mgf256], + 'sha384-mgf384' => [C::KEY_TRANSPORT_OAEP, $sha384, $mgf384], + 'sha512-mgf512' => [C::KEY_TRANSPORT_OAEP, $sha512, $mgf512], + 'sha1-mgf1' => [C::KEY_TRANSPORT_OAEP, $sha1, $mgf1], + 'mgf1p-no-children' => [C::KEY_TRANSPORT_OAEP_MGF1P, null, null], + ]; + } + + + /** + * Verify that decrypt() still forwards the correct DigestMethod/MGF Algorithm URIs when + * those children are unresolved SimpleSAML\XML\Chunk objects (simulating a deployment + * where the xml-common element registry has not registered the typed ds\DigestMethod / + * xenc11\MGF classes), instead of silently leaving digestAlg/mgf null. + */ + public function testDecryptForwardsOaepParamsFromUnregisteredChunkChildren(): void + { + $digestAlg = C::DIGEST_SHA256; + $mgfAlg = 'http://www.w3.org/2009/xmlenc11#mgf1sha256'; + + $decryptor = $this->createMock(OaepAwareDecryptorInterface::class); + $decryptor->method('getAlgorithmId')->willReturn(C::KEY_TRANSPORT_OAEP); + $decryptor->expects($this->once()) + ->method('setOAEPParams') + ->with($digestAlg, $mgfAlg); + $decryptor->method('decrypt')->willReturn('decrypted'); + + $digestChunk = new Chunk(DOMDocumentFactory::fromString( + '', + )->documentElement); + $mgfChunk = new Chunk(DOMDocumentFactory::fromString( + '', + )->documentElement); + + $encryptedKey = new EncryptedKey( + new CipherData( + CipherValue::fromString(base64_encode('fake-ciphertext')), + ), + null, + null, + null, + null, + null, + null, + new EncryptionMethod( + AnyURIValue::fromString(C::KEY_TRANSPORT_OAEP), + null, + null, + [$digestChunk, $mgfChunk], + ), + ); + + $encryptedKey->decrypt($decryptor); + } + + + /** + * Verify that an unrelated Chunk child (neither DigestMethod nor MGF) does not interfere + * with the OAEP read-out, digestAlg/mgf remain null. + */ + public function testDecryptIgnoresUnrelatedChunkChild(): void + { + $decryptor = $this->createMock(OaepAwareDecryptorInterface::class); + $decryptor->method('getAlgorithmId')->willReturn(C::KEY_TRANSPORT_OAEP); + $decryptor->expects($this->once()) + ->method('setOAEPParams') + ->with(null, null); + $decryptor->method('decrypt')->willReturn('decrypted'); + + $unrelatedChunk = new Chunk(DOMDocumentFactory::fromString( + 'Some', + )->documentElement); + + $encryptedKey = new EncryptedKey( + new CipherData( + CipherValue::fromString(base64_encode('fake-ciphertext')), + ), + null, + null, + null, + null, + null, + null, + new EncryptionMethod( + AnyURIValue::fromString(C::KEY_TRANSPORT_OAEP), + null, + null, + [$unrelatedChunk], + ), + ); + + $encryptedKey->decrypt($decryptor); + } + + + /** + * Verify that a non-OAEPParametersAware decryptor is not passed OAEP params + * (existing behavior preserved for such decryptors). + */ + public function testDecryptDoesNotCallSetOaepParamsOnNonAwareDecryptor(): void + { + // A stub that does NOT implement OAEPParametersAware + $decryptor = $this->createStub(NonAwareDecryptorInterface::class); + $decryptor->method('getAlgorithmId')->willReturn(C::KEY_TRANSPORT_OAEP); + $decryptor->method('decrypt')->willReturn('decrypted'); + + $encryptedKey = new EncryptedKey( + new CipherData(CipherValue::fromString(base64_encode('ciphertext'))), + null, + null, + null, + null, + null, + null, + new EncryptionMethod( + AnyURIValue::fromString(C::KEY_TRANSPORT_OAEP), + null, + null, + [ + new DigestMethod(AnyURIValue::fromString(C::DIGEST_SHA256)), + new MGF(AnyURIValue::fromString('http://www.w3.org/2009/xmlenc11#mgf1sha256')), + ], + ), + ); + + // Should not throw; children are simply ignored for non-aware decryptors. + $result = $encryptedKey->decrypt($decryptor); + $this->assertSame('decrypted', $result); + } + + + /** + * Verify that non-null OAEP params on a non-OAEPParametersAware backend + * (set via AbstractKeyTransporter) cause UnsupportedAlgorithmException. + */ + public function testNonAwareBackendWithNonNullOaepParamsThrows(): void + { + // Create a mock backend that does NOT implement OAEPParametersAware. + $nonAwareBackend = $this->createStub(EncryptionBackend::class); + $nonAwareBackend->method('encrypt')->willReturn('enc'); + $nonAwareBackend->method('decrypt')->willReturn('dec'); + + // Create a transporter subclass with X509Certificate support. + $transporter = new class (self::$certificate, C::KEY_TRANSPORT_OAEP) extends AbstractKeyTransporter { + /** @return string[] */ + public static function getSupportedAlgorithms(): array + { + return C::$KEY_TRANSPORT_ALGORITHMS; + } + }; + $transporter->setBackend($nonAwareBackend); + + $encryptedKey = new EncryptedKey( + new CipherData(CipherValue::fromString(base64_encode('ciphertext'))), + null, + null, + null, + null, + null, + null, + new EncryptionMethod( + AnyURIValue::fromString(C::KEY_TRANSPORT_OAEP), + null, + null, + [ + new DigestMethod(AnyURIValue::fromString(C::DIGEST_SHA256)), + new MGF(AnyURIValue::fromString('http://www.w3.org/2009/xmlenc11#mgf1sha256')), + ], + ), + ); + + $this->expectException(UnsupportedAlgorithmException::class); + $encryptedKey->decrypt($transporter); + } + + + /** + * End-to-end: with PrivateKeyAgentEncryptionBackend as the backend of a transporter, + * the correct agent algorithm is selected based on the DigestMethod/MGF children. + * + * @dataProvider endToEndOaepProvider + */ + #[DataProvider('endToEndOaepProvider')] + public function testDecryptWithPkaBackendUsesCorrectAgentAlgorithm( + ?string $digestAlg, + ?string $mgfAlg, + string $expectedAgentAlgorithm, + ): void { + $capturedBody = null; + $httpClient = $this->createStub(ClientInterface::class); + $requestFactory = $this->createStub(RequestFactoryInterface::class); + $streamFactory = $this->createStub(StreamFactoryInterface::class); + $request = $this->createStub(RequestInterface::class); + $request->method('withHeader')->willReturn($request); + $request->method('withBody')->willReturn($request); + $requestFactory->method('createRequest')->willReturn($request); + $streamFactory->method('createStream')->willReturnCallback( + function (string $body) use (&$capturedBody): StreamInterface { + $capturedBody = $body; + return $this->createStub(StreamInterface::class); + }, + ); + + $plaintext = 'symmetric-key-material'; + $responseStream = $this->createStub(StreamInterface::class); + $responseStream->method('__toString')->willReturn( + json_encode(['decrypted_data' => base64_encode($plaintext)]), + ); + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseStream); + $httpClient->method('sendRequest')->willReturn($response); + + $tokenProvider = $this->createStub(TokenProvider::class); + $tokenProvider->method('getToken')->willReturn('test-token'); + $keyNameResolver = $this->createStub(KeyNameResolver::class); + $keyNameResolver->method('resolve')->willReturn('test-key'); + + $pkaBackend = new PrivateKeyAgentEncryptionBackend( + $httpClient, + $requestFactory, + $streamFactory, + 'https://agent.example.com', + $tokenProvider, + $keyNameResolver, + ); + + // Create a transporter with the certificate and PKA backend. + $transporter = new class (self::$certificate, C::KEY_TRANSPORT_OAEP) extends AbstractKeyTransporter { + /** @return string[] */ + public static function getSupportedAlgorithms(): array + { + return C::$KEY_TRANSPORT_ALGORITHMS; + } + }; + $transporter->setBackend($pkaBackend); + + $children = []; + if ($digestAlg !== null) { + $children[] = new DigestMethod(AnyURIValue::fromString($digestAlg)); + } + if ($mgfAlg !== null) { + $children[] = new MGF(AnyURIValue::fromString($mgfAlg)); + } + + $encryptedKey = new EncryptedKey( + new CipherData(CipherValue::fromString(base64_encode('fake-rsa-ciphertext'))), + null, + null, + null, + null, + null, + null, + new EncryptionMethod( + AnyURIValue::fromString(C::KEY_TRANSPORT_OAEP), + null, + null, + $children, + ), + ); + + $encryptedKey->decrypt($transporter); + + $this->assertNotNull($capturedBody); + $decoded = json_decode($capturedBody, true); + $this->assertSame($expectedAgentAlgorithm, $decoded['algorithm']); + } + + + /** @return array */ + public static function endToEndOaepProvider(): array + { + $sha1 = C::DIGEST_SHA1; + $sha256 = C::DIGEST_SHA256; + $sha384 = C::DIGEST_SHA384; + $sha512 = C::DIGEST_SHA512; + $mgf1 = 'http://www.w3.org/2009/xmlenc11#mgf1sha1'; + $mgf256 = 'http://www.w3.org/2009/xmlenc11#mgf1sha256'; + $mgf384 = 'http://www.w3.org/2009/xmlenc11#mgf1sha384'; + $mgf512 = 'http://www.w3.org/2009/xmlenc11#mgf1sha512'; + + return [ + 'both-absent-default-sha256' => [null, null, 'rsa-pkcs1-oaep-mgf1-sha256'], + 'sha1-mgf1' => [$sha1, $mgf1, 'rsa-pkcs1-oaep-mgf1-sha1'], + 'sha256-mgf256' => [$sha256, $mgf256, 'rsa-pkcs1-oaep-mgf1-sha256'], + 'sha384-mgf384' => [$sha384, $mgf384, 'rsa-pkcs1-oaep-mgf1-sha384'], + 'sha512-mgf512' => [$sha512, $mgf512, 'rsa-pkcs1-oaep-mgf1-sha512'], + ]; + } } diff --git a/tests/XML/xenc/NonAwareDecryptorInterface.php b/tests/XML/xenc/NonAwareDecryptorInterface.php new file mode 100644 index 00000000..04c9b7d4 --- /dev/null +++ b/tests/XML/xenc/NonAwareDecryptorInterface.php @@ -0,0 +1,17 @@ +