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 @@ +