A REST API service that performs RSA signing and decryption operations using protected private keys — without ever exposing the keys to callers. The agent runs in a separate process and user context, optionally on a separate host, from the services that consume it.
Design specifications: See DESIGN-SPECIFICATION.md for the full architecture, API contract, configuration reference, and implementation details.
Initial draft: The original problem statement and rationale are in DRAFT-SPEC.md.
Services like SimpleSAMLphp need to sign SAML assertions and decrypt RSA-encrypted session keys. The standard approach loads the private key into the PHP process — meaning the key is accessible to every piece of code in that process. The Private Key Agent moves the key material into an isolated service: clients send only hashes (for signing) or ciphertext (for decryption) and receive back only the result.
| Operation | Client sends | Agent returns |
|---|---|---|
POST /sign/{key_name} |
Base64-encoded hash + algorithm | Base64-encoded RSA signature |
POST /decrypt/{key_name} |
Base64-encoded ciphertext + algorithm | Base64-encoded plaintext (symmetric key) |
GET /health |
— | Backend health status |
The private key never leaves the agent. Only cryptographic inputs and outputs cross the network boundary.
Signing:
rsa-pkcs1-v1_5-sha1rsa-pkcs1-v1_5-sha256rsa-pkcs1-v1_5-sha384rsa-pkcs1-v1_5-sha512
Decryption:
rsa-pkcs1-v1_5rsa-pkcs1-oaep-mgf1-sha1rsa-pkcs1-oaep-mgf1-sha224rsa-pkcs1-oaep-mgf1-sha256rsa-pkcs1-oaep-mgf1-sha384rsa-pkcs1-oaep-mgf1-sha512
- Two cryptographic backends: OpenSSL (software PEM keys) and PKCS#11 (hardware security modules, tested with SoftHSM2).
- Multiple backends per key: configure several backend groups for the same logical key to get round-robin load distribution or HSM redundancy.
- Static bearer-token authentication (RFC 6750): each client has a pre-shared token; tokens are compared with
hash_equals()to prevent timing attacks. - Per-client key authorisation: each client declares the key names it may use.
- Fail-fast configuration: invalid or missing config prevents the PHP-FPM worker from starting.
- Health endpoints:
/healthand/health/backend/{name}for liveness probes and monitoring.
| Component | Choice |
|---|---|
| Language | PHP 8.5 (required — see note below) |
| Framework | Symfony 7.4 |
| PKCS#11 bridge | gamringer/php-pkcs11 |
| Logging | Monolog → JSON → stdout |
| HTTP server | PHP-FPM + Caddy (TLS) |
| Deployment | Docker Compose |
PHP 8.5 is a hard requirement. PHP 8.5 adds the
digest_algoparameter toopenssl_private_decrypt(), which is the only way to select non-SHA-1 OAEP hash algorithms. Earlier PHP versions cannot implementrsa-pkcs1-oaep-mgf1-sha256/384/512via OpenSSL.
- Docker with Compose v2
opensslCLI (for key generation)bash(for helper scripts)
git clone <repo-url> openconext-private-key-agent
cd openconext-private-key-agent
docker compose up -dThis builds the dev Docker image (PHP-FPM + SoftHSM2) and starts the Caddy TLS proxy.
Run the setup script from the project root (not inside the container):
./tools/setup-dev.shThe script:
- Generates RSA-2048 PEM keys in
config/keys/for the OpenSSL backend. - Detects the SoftHSM2 slot from the running container.
- Writes a fresh
config/private-key-agent.yamlwith a randomly generated bearer token.
The setup is idempotent — running it again is safe. Use --force to regenerate keys and token:
./tools/setup-dev.sh --forcedocker compose exec app composer installSmoke-test all endpoints (reads the bearer token from the config file automatically):
./tools/test-endpoints.shVerbose mode shows every response body:
./tools/test-endpoints.sh -vRun a single group:
./tools/test-endpoints.sh sign
./tools/test-endpoints.sh decrypt
./tools/test-endpoints.sh health
./tools/test-endpoints.sh authAfter running setup-dev.sh the project has three logical keys, each backed by a different backend. This deliberately exercises all supported backend types in a single dev environment.
| Logical key name | Backend | Type | Allowed operations |
|---|---|---|---|
dev-signing-key |
OpenSSL (openssl-signing) |
Software PEM | signing |
dev-decryption-key |
OpenSSL (openssl-decryption) |
Software PEM | decryption |
hsm-key |
SoftHSM (softhsm) |
PKCS#11 (emulated HSM) | signing + decryption |
setup-dev.sh generates two unencrypted RSA-2048 PEM key pairs under config/keys/:
config/keys/
├── dev-signing.pem ← private key (loaded by the agent)
├── dev-signing.pub.pem ← public key (for clients: verify signatures)
├── dev-decryption.pem ← private key (loaded by the agent)
└── dev-decryption.pub.pem ← public key (for clients: encrypt session keys)
The private key files are unencrypted and ephemeral — suitable only for local development. They are listed in .gitignore and must never be committed.
Production equivalent: replace the PEM file with a properly protected key (file permissions restricted to the service account, or a key stored in a secrets manager and written to a tmpfs mount at deploy time). The agent config just needs
key_pathupdated to point to the production key file.
SoftHSM2 is a software implementation of a PKCS#11 token, used here so that HSM code paths can be exercised without physical hardware. It is installed and initialised inside the dev Docker image during the build, so no host-side setup is required.
Token details (baked into the dev image):
| Property | Value |
|---|---|
| Token label | test-token |
| Slot index | 0 |
Key label (CKA_LABEL) |
test-signing-key |
Key ID (CKA_ID) |
01 |
| User PIN | 1234 |
| SO PIN | 5678 |
| Key type | RSA-2048 |
The public key is exported to config/keys/hsm-signing.pub.pem by setup-dev.sh after the container starts. Clients use this file to encrypt data or verify signatures produced by hsm-key.
Inspect the token directly from inside the container:
docker compose exec app pkcs11-tool \
--module /usr/lib/softhsm/libsofthsm2.so \
--slot-index 0 --pin 1234 --list-objectsProduction equivalent: replace
softhsmwith a real HSM backend. Updatepkcs11_libto the vendor's.so, setpkcs11_slot,pkcs11_pin, and eitherpkcs11_key_labelorpkcs11_key_idto match the key provisioned on the HSM. The REST API and agent behaviour are identical — only the backend config changes.
setup-dev.sh generates a random 256-bit hex token per run and writes it into config/private-key-agent.yaml. It is printed to the terminal on completion.
Production equivalent: generate a cryptographically random token of at least 256 bits and inject it into the config file via your secrets management solution (Docker secrets, Kubernetes secret, Vault, etc.). Each client should have its own token. Never reuse the development token in production.
| Dev resource | Production replacement |
|---|---|
Unencrypted PEM key in config/keys/ |
PEM key with restricted filesystem permissions, or secrets-manager-mounted key |
| SoftHSM2 in the Docker image | Vendor HSM (e.g. Thales, Utimaco, YubiHSM); update pkcs11_lib, slot, PIN, key label/ID |
Hardcoded token in config/private-key-agent.yaml |
Randomly generated token injected at deploy time via secrets management |
Single dev-client with access to all keys |
One client entry per consuming service, with allowed_keys scoped to only the keys that service needs |
All composer and PHP commands run inside the app container. Start the stack first if it is not already running:
docker compose up -d| Script | Command | What it runs |
|---|---|---|
lint |
composer lint |
phplint → PHPStan → PHP_CodeSniffer |
test |
composer test |
PHPUnit (Unit + Integration suites) |
check |
composer check |
phplint + PHPStan + composer audit + PHPUnit |
phpstan |
composer phpstan |
Static analysis only |
phpcs |
composer phpcs |
Code style check only |
phpcbf |
composer phpcbf |
Auto-fix code style violations |
phplint |
composer phplint |
PHP syntax check on src/ and tests/ |
PHPStan runs at level 8 and covers src/ and tests/:
docker compose exec app composer phpstanThe configuration is in phpstan.neon. A phpstan-baseline.neon file tracks any accepted false positives (currently just PKCS#11 extension classes that are unavailable on the host). To regenerate the baseline after deliberate changes:
docker compose exec app vendor/bin/phpstan analyse --generate-baselinePHPStan runs inside the container because the
Pkcs11PHP extension is only available there.
The project follows the Doctrine Coding Standard. Configuration is in phpcs.xml.
Check for violations:
docker compose exec app composer phpcsAuto-fix what can be fixed automatically:
docker compose exec app composer phpcbf# Run all tests (Unit + Integration)
docker compose exec app composer test
# Run the Unit suite only
docker compose exec app vendor/bin/phpunit --testsuite Unit
# Run the Integration suite only
docker compose exec app vendor/bin/phpunit --testsuite Integration
# Run a single test file
docker compose exec app vendor/bin/phpunit tests/Unit/Controller/SignControllerTest.php
# Run a single test method
docker compose exec app vendor/bin/phpunit --filter testSignReturnsSignature
# Show test progress (dots → verbose)
docker compose exec app vendor/bin/phpunit --testdoxTest suites:
tests/Unit/— fast, isolated tests with mocked dependencies. No network or filesystem access.tests/Integration/Backend/— backend tests that use real OpenSSL keys or SoftHSM2. These require the Docker container (keys and PKCS#11 library must be present).
PHPUnit configuration is in phpunit.xml.dist. The APP_ENV=test environment is set automatically.
Runs everything the CI pipeline checks, in order:
docker compose exec app composer checkThis executes: phplint → phpstan → composer audit → phpunit.
End-to-end HTTP tests against the running stack (run from the host, not inside the container):
# Run all test groups
./tools/test-endpoints.sh
# Verbose — print every response body
./tools/test-endpoints.sh -v
# Run a single group
./tools/test-endpoints.sh health
./tools/test-endpoints.sh auth
./tools/test-endpoints.sh sign
./tools/test-endpoints.sh decrypt
# Verbose + single group
./tools/test-endpoints.sh -v sign
# Target a different host
BASE_URL=https://agent.example.com ./tools/test-endpoints.shThe script reads the bearer token from config/private-key-agent.yaml automatically. Docker Compose must be running.
Load-tests the sign and decrypt endpoints using hey:
# Install hey (macOS)
brew install hey
# Run all benchmarks (default: 10 concurrent workers, 10s per endpoint)
./tools/perf-test.sh
# Tune concurrency and duration
./tools/perf-test.sh -c 20 -d 30s
# Benchmark a single group
./tools/perf-test.sh sign
./tools/perf-test.sh decrypt
# Combined options
./tools/perf-test.sh -c 10 -d 15s sign
# Target a different host
BASE_URL=https://agent.example.com ./tools/perf-test.shThe script runs a sanity check (HTTP 200) before each benchmark and skips the endpoint if the check fails. It tests both the OpenSSL backend keys and the SoftHSM backend key.
Parse and validate a config file without starting the server:
docker compose exec app bin/console app:validate-config /path/to/config.yamlExit code 0 means the config is structurally valid. Errors are printed to stderr. This does not open key files or HSM sessions — it validates the YAML structure and cross-references only.
docker compose exec app composer auditReports known vulnerabilities in installed packages via the Packagist Security Advisories database.
The agent is configured from a single YAML file. The path is set via the PRIVATE_KEY_AGENT_CONFIG environment variable (default in Docker Compose: /etc/private-key-agent/config.yaml).
agent_name: my-private-key-agent
backend_groups:
- name: software-backend
type: openssl
key_path: /etc/private-key-agent/keys/signing.pem
keys:
- name: my-signing-key
signing_backends:
- software-backend
clients:
- name: simplesamlphp
token: "your-secret-bearer-token"
allowed_keys:
- my-signing-keyagent_name: my-private-key-agent
backend_groups:
- name: hsm-signing
type: pkcs11
pkcs11_lib: /usr/lib/softhsm/libsofthsm2.so
pkcs11_slot: 0
pkcs11_pin: "1234"
pkcs11_key_label: signing-key
environment:
SOFTHSM2_CONF: /etc/softhsm2.conf
- name: hsm-decryption
type: pkcs11
pkcs11_lib: /usr/lib/softhsm/libsofthsm2.so
pkcs11_slot: 1
pkcs11_pin: "1234"
pkcs11_key_id: "02"
- name: openssl-fallback
type: openssl
key_path: /etc/private-key-agent/keys/decryption.pem
keys:
- name: saml-key
signing_backends:
- hsm-signing
decryption_backends:
- hsm-decryption
- openssl-fallback # round-robin across both
clients:
- name: simplesamlphp-idp
token: "bearer-token-here"
allowed_keys:
- saml-keyFor the full configuration reference (all fields, validation rules, secrets handling) see DESIGN-SPECIFICATION.md — Configuration.
docker compose exec app bin/console app:validate-config /path/to/config.yamlThe agent is designed to be used with the simplesamlphp/xml-security library via two adapter classes — one implementing SignatureBackend, one implementing EncryptionBackend.
When SimpleSAMLphp signs a SAML Response:
xml-securityC14N-transforms the element, computes a SHA digest of the result, buildsds:SignedInfo, and callsSignatureBackend::sign($key, $plaintext)with the canonicalizedds:SignedInfobytes.- The adapter hashes the plaintext locally (e.g. SHA-256) and calls
POST /sign/{key_name}with the Base64-encoded hash and algorithm. - The agent constructs the DigestInfo ASN.1 structure internally and returns the RSA signature.
- The adapter returns the raw signature bytes;
xml-securityembeds them inds:SignatureValue.
When SimpleSAMLphp decrypts an encrypted SAML Assertion:
xml-securityextracts the RSA-encrypted session key fromxenc:CipherValueand callsEncryptionBackend::decrypt($key, $ciphertext)with those bytes.- The adapter calls
POST /decrypt/{key_name}with the Base64-encoded ciphertext and algorithm. - The agent RSA-decrypts the session key and returns it.
xml-securityuses the session key to AES-decrypt the assertion content.
The symmetric session key and the assertion content are never sent to the agent.
use SimpleSAML\XMLSecurity\Backend\SignatureBackend;
class PrivateKeyAgentSignatureBackend implements SignatureBackend
{
public function __construct(
private readonly string $baseUrl,
private readonly string $bearerToken,
private readonly string $keyName,
) {}
public function sign(PrivateKey $key, string $plaintext): string
{
// Determine algorithm from $key (e.g. RSA + SHA-256 → rsa-pkcs1-v1_5-sha256)
$algorithm = 'rsa-pkcs1-v1_5-sha256';
$hash = base64_encode(hash('sha256', $plaintext, true));
$response = $this->post("/sign/{$this->keyName}", [
'algorithm' => $algorithm,
'hash' => $hash,
]);
return base64_decode($response['signature']);
}
// … HTTP helper, EncryptionBackend adapter follows the same pattern
}For the full sequence diagrams and integration notes see DESIGN-SPECIFICATION.md — SimpleSAML integration.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/sign/{key_name} |
Bearer token | Sign a hash |
POST |
/decrypt/{key_name} |
Bearer token | Decrypt ciphertext |
GET |
/health |
None | Overall health |
GET |
/health/backend/{name} |
None | Per-backend health |
Error responses follow RFC 6750 and always include status, error, and an optional message field. On 401 a WWW-Authenticate header is also returned.
The full OpenAPI spec is served at /api/doc when the application is running.
src/
├── Backend/ # OpenSSL and PKCS#11 backend implementations
├── Command/ # CLI commands (validate-config)
├── Config/ # Config loading and validation
├── Controller/ # Sign, Decrypt, Health endpoints
├── Crypto/ # DigestInfo ASN.1 builder
├── Dto/ # Request DTOs
├── EventSubscriber/# Exception → JSON error response mapping
├── Exception/ # Domain exceptions
├── Security/ # Bearer-token authenticator and access control
├── Service/ # KeyRegistry (runtime key → backend mapping)
└── Validator/ # Custom Symfony validators (Base64)
Apache-2.0 — see LICENSE.