Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<ds:DigestMethod>` and
`<xenc11:MGF>` children of the `<xenc:EncryptionMethod>` 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.
150 changes: 150 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -51,7 +55,7 @@
},
"extra": {
"branch-alias": {
"dev-master": "v2.4.x-dev"
"dev-master": "v3.1.x-dev"
}
},
"config": {
Expand Down
53 changes: 52 additions & 1 deletion src/Alg/KeyTransport/AbstractKeyTransporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -15,14 +16,20 @@
*
* @package simplesamlphp/xml-security
*/
abstract class AbstractKeyTransporter implements KeyTransportAlgorithmInterface
abstract class AbstractKeyTransporter implements KeyTransportAlgorithmInterface, OAEPParametersAware
{
protected const string DEFAULT_BACKEND = Backend\OpenSSL::class;


/** @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.
Expand Down Expand Up @@ -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);
}
}


Expand Down
Loading