Enterprise edition
HSM signing — Deep Reference
At a glance
Section titled “At a glance”This page is the deep reference for the NextPDF Enterprise HSM signing surface. It covers three public types. NextPDF\Enterprise\Security\Signature\Hsm\Pkcs11Signer signs through a PKCS#11 token via the ext-pkcs11 extension. NextPDF\Enterprise\Security\Signature\Hsm\OpenSslCliSigner signs through the openssl binary in a subprocess, for provider- or engine-backed keys that PHP ext-openssl cannot load. NextPDF\Enterprise\Security\Signature\Hsm\Provider\HsmSignerProviderAdapter exposes either concrete as a unified SignerProviderInterface. In every path the private key stays inside the token boundary; NextPDF hands over the bytes to sign and receives the signature. The post-quantum path (signPqs) is a preview: it is disabled by default and has no supported verification path in current PDF validators.
Availability & licensing
Section titled “Availability & licensing”This capability ships in NextPDF Enterprise (nextpdf/enterprise) and activates with an Enterprise-tier license envelope. A deployment without that entitlement does not load the capability’s classes. Compare editions and get a license.
Public API surface
Section titled “Public API surface”All three types live in NextPDF\Enterprise\Security\Signature\Hsm; the adapter sits in its Provider sub-namespace. Both signers implement the Core NextPDF\Contracts\HsmSignerInterface contract.
| Symbol | Parameters | Default behavior | Returns | Throws or fails with | Notes |
|---|---|---|---|---|---|
Pkcs11Signer::__construct() | string $libraryPath, int $slotId, string $pin, string $certLabel, ?string $keyLabel = null, array $chainDer = [], bool $enablePostQuantum = false, ?FipsSignatureEnforcer $fipsEnforcer = null | Opens the vendor library, logs into the slot, and loads the certificate and key-algorithm metadata from the token | — | HsmOperationException when ext-pkcs11 is absent or token access fails | One module handle is cached per library path per process; PIN and labels are #[SensitiveParameter] |
Pkcs11Signer::sign() | string $data, string $algorithm = 'sha256WithRSAEncryption' | Signs on the token; raw ECDSA output is converted to DER ECDSA-Sig-Value | string raw signature bytes | HsmOperationException (key not found, token failure); InvalidArgumentException (unmapped algorithm); FIPS-gate exceptions before signing when an enforcer is wired | Closed algorithm set; see Behavior contract |
Pkcs11Signer::signPqs() | string $data, Pkcs11PqsAlgorithm $algorithm, string $context = '', bool $randomized = true | Refused unless $enablePostQuantum was set; dispatches the provisional PKCS#11 PQ mechanism | string raw signature bytes | HsmOperationException (disabled, token failure, signature-length mismatch); InvalidArgumentException (context over 255 bytes) | Preview; mechanism identifiers are provisional |
Pkcs11Signer::isPostQuantumEnabled() | None | Reports the constructor opt-in flag | bool | None | — |
Pkcs11Signer::getCertificateDer() | None | Returns the signer certificate read from the token | string (DER) | None | Loaded once at construction |
Pkcs11Signer::getCertificateChainDer() | None | Returns the constructor-supplied intermediates | array<string> (DER) | None | Excludes the signer certificate |
OpenSslCliSigner::__construct() | string $keyUri, string $certPath, string $pin, array $extraCertPaths = [], OpenSslCliBackend $backend = OpenSslCliBackend::Auto, string $opensslBinary = 'openssl', int $timeoutSeconds = 30, ?string $modulePath = null, ?string $configPath = null, bool $legacyPinDelivery = false, ?FipsSignatureEnforcer $fipsEnforcer = null | Verifies proc_open, probes the binary and version, resolves the backend, and loads the certificates | — | HsmOperationException (proc_open disabled, missing module/config/certificate file, binary failure, no backend); InvalidArgumentException (pin-value inside $keyUri) | OpenSslCliBackend::Auto prefers the OpenSSL 3.x provider, then the engine |
OpenSslCliSigner::sign() | string $data, string $algorithm = 'sha256WithRSAEncryption' | Runs openssl dgst in a subprocess; the PIN travels through an ephemeral 0600 pin-source file by default | string raw signature bytes | HsmOperationException (timeout, PIN rejected, key not found, module load failure, empty output, pin-file failure); InvalidArgumentException (unmapped algorithm); FIPS-gate exceptions before signing | The subprocess is killed after $timeoutSeconds; stderr is redacted before it reaches messages |
OpenSslCliSigner accessor surface | None | Read-only construction results | string / array<string> / OpenSslCliBackend | None | getCertificateDer, getCertificateChainDer, getPublicKeyAlgorithm, getCertificatePem, getResolvedBackend, getOpensslVersion |
HsmSignerProviderAdapter::__construct() | HsmSignerInterface $hsm, string $providerId, SignatureAlgorithm $algorithm = SignatureAlgorithm::Pkcs1v15 | Wraps an HSM concrete as a SignerProviderInterface | — | None | Provider id conventions: pkcs11-{module-id}, openssl-cli |
HsmSignerProviderAdapter::providerId() | None | Returns the constructor-supplied id | non-empty-string | None | — |
HsmSignerProviderAdapter::supportsAlgorithm() | SignatureAlgorithm $algo | Maps the enum to an OpenSSL-style name, then intersects the backend allow-set | bool | None | Declines digest-only algorithms; openssl-engine ids advertise nothing |
HsmSignerProviderAdapter::sign() | string $data, ?string $keyVersion = null | Dispatches through the wrapped signer with the configured algorithm | non-empty-string | KeyManagementException (non-null $keyVersion); SignatureFailedException (unmappable algorithm, driver failure, empty signature) | Fail-closed SPI contract; every driver error surfaces typed |
public function __construct(private readonly string $libraryPath, private readonly int $slotId, #[SensitiveParameter] private readonly string $pin, #[SensitiveParameter] private readonly string $certLabel, #[SensitiveParameter] private readonly ?string $keyLabel = null, array $chainDer = [], private readonly bool $enablePostQuantum = false, ?FipsSignatureEnforcer $fipsEnforcer = null)public function sign(string $data, string $algorithm = 'sha256WithRSAEncryption'): stringpublic function signPqs(string $data, Pkcs11PqsAlgorithm $algorithm, string $context = '', bool $randomized = true): stringpublic function isPostQuantumEnabled(): boolpublic function getCertificateDer(): stringpublic function getCertificateChainDer(): arraypublic function __construct(private string $keyUri, string $certPath, #[SensitiveParameter] private string $pin, array $extraCertPaths = [], private OpenSslCliBackend $backend = OpenSslCliBackend::Auto, private string $opensslBinary = 'openssl', private int $timeoutSeconds = 30, private ?string $modulePath = null, private ?string $configPath = null, private bool $legacyPinDelivery = false, private ?FipsSignatureEnforcer $fipsEnforcer = null)public function sign(string $data, string $algorithm = 'sha256WithRSAEncryption'): stringpublic function getCertificateDer(): stringpublic function getCertificateChainDer(): arraypublic function getPublicKeyAlgorithm(): stringpublic function getCertificatePem(): stringpublic function getResolvedBackend(): OpenSslCliBackendpublic function getOpensslVersion(): stringpublic function __construct(private HsmSignerInterface $hsm, private string $providerId, private SignatureAlgorithm $algorithm = SignatureAlgorithm::Pkcs1v15)public function providerId(): stringpublic function supportsAlgorithm(SignatureAlgorithm $algo): boolpublic function sign(string $data, ?string $keyVersion = null): stringBehavior contract
Section titled “Behavior contract”- Key custody. The private key never leaves the token boundary.
Pkcs11Signerdelegates the operation to the token;OpenSslCliSignerpasses a key reference — a PKCS#11 URI — to theopensslsubprocess. Neither signer can export the key. - Session and login.
Pkcs11Signercaches one PKCS#11 module handle per library path per process, because the token interface must be initialized exactly once per process. Each operation opens a session and logs in with the PIN; the login authenticates the user before any private-key use (PKCS#11 v3.1 §5.6.8). When the slot reports an existing login, the signer logs out and logs in again, so tokens that demand a fresh PIN per operation receive one. - Algorithm set (closed). Both signers accept exactly:
sha256WithRSAEncryption,sha384WithRSAEncryption,sha512WithRSAEncryption;RSASSA-PSS,RSASSA-PSS-SHA256,RSASSA-PSS-SHA384,RSASSA-PSS-SHA512;ecdsa-with-SHA256,ecdsa-with-SHA384,ecdsa-with-SHA512.Pkcs11Signeradditionally acceptsecdsa-raw. Any other identifier raisesInvalidArgumentException— no substitute algorithm is ever signed. - PSS salt binding. For every PSS variant the salt length equals the digest length — 32, 48, or 64 bytes — and the hash and MGF parameters match the chosen digest. This follows the PSS mechanism-parameter structure, where the salt length is typically the message-hash length (PKCS#11 v3.1 §6.1.9). Both signers apply the same pairing, so a configuration valid on one backend is valid on the other.
- ECDSA conversion. A token returns an ECDSA signature as the raw, zero-padded concatenation of r and s (PKCS#11 v3.1 §6.3.1).
Pkcs11Signer::sign()converts that output to the DER-encodedECDSA-Sig-Valueform that PDF validators and OpenSSL expect. The caller never handles the raw form. - PIN delivery (CLI path). In the secure default, the PIN is written to an ephemeral file created exclusively with owner-only permissions, referenced through the PKCS#11 URI
pin-sourceattribute, and unlinked after the subprocess exits. The PIN is not placed in the command line and not exported to the subprocess environment in this mode. With$legacyPinDelivery = true, the PIN is embedded aspin-valuein the URI, which is observable in the process command line; this mode is opt-in only. - Subprocess discipline.
OpenSslCliSignerspawns the binary with an argument array — no shell interpolation — enforces$timeoutSeconds, kills the subprocess on expiry, and classifies stderr into typed errors. Secrets are redacted from stderr before it is quoted in an exception message. - Adapter semantics. An HSM token has no managed key-version concept; the key on the token is the version.
HsmSignerProviderAdapter::sign()therefore rejects any non-null$keyVersionwithKeyManagementExceptioninstead of ignoring it.supportsAlgorithm()intersects the enum mapping with the wrapped backend’s accepted set, so the adapter never advertises a mechanism the backend would reject at sign time. An empty signature from the driver raisesSignatureFailedException. - Post-quantum preview.
signPqs()is gated behind the$enablePostQuantumconstructor flag and refuses to run otherwise. The context string is limited to 255 bytes, matching the ML-DSA context bound (FIPS 204). The returned signature must match the exact byte length of the selectedPkcs11PqsAlgorithmparameter set, or the call fails. The mechanism identifiers follow a provisional PKCS#11 PQ extension and are not final. PAdES profiles do not recognize post-quantum suites, most PDF validators reject such signatures, and NextPDF provides no verification path for them.
Edge cases & failure modes
Section titled “Edge cases & failure modes”- Constructing
Pkcs11Signerwithoutext-pkcs11raisesHsmOperationExceptionimmediately; the extension is not bundled with standard PHP distributions. - A certificate or private-key label that matches no object on the token raises
HsmOperationExceptionnaming the missing object class. The key label may legitimately differ from the certificate label on some tokens. - Repeated failed logins can lock the PIN at the token; the token enforces that policy, not NextPDF. Tokens whose keys require authentication on every use receive a fresh login through the logout-and-retry path (PKCS#11 v3.1, always-authenticate semantics).
OpenSslCliSignerrefuses a$keyUrithat already containspin-valueat construction, fail-closed, because that delivery would bypass the secure PIN path.- On Windows, the secure pin-file mode fails closed with
HsmOperationException: file-permission bits cannot restrict ACL read grants there, so the signer refuses to leave a cleartext PIN at the temp-directory ACL. Legacy PIN delivery is the documented, opt-in alternative for trusted Windows hosts. - Backend auto-detection requires OpenSSL 3.x for the provider path; LibreSSL never resolves to the provider. When neither a provider nor an engine probe succeeds, construction fails with
HsmOperationExceptionrather than deferring the failure to sign time. - A subprocess that exceeds
$timeoutSecondsis terminated and reported as a timeout; a subprocess that exits cleanly with empty output is reported as an empty-signature failure. Neither condition can produce a partially signed document. - A post-quantum signature whose byte length does not match the selected parameter set is rejected before it can reach CMS encoding.
HsmSignerProviderAdapterwith the retiredopenssl-engineprovider id advertises no algorithms, so a stale configuration fails at provider selection instead of at sign time.
FIPS-mode behavior
Section titled “FIPS-mode behavior”Both signers accept an optional FipsSignatureEnforcer. When one is wired, FIPS mode is active for that signer: sign() rejects a disallowed signature algorithm or a sub-floor key before any token or subprocess signing occurs. The floors follow the signature-generation table — RSA moduli under 2048 bits and ECDSA orders under 224 bits are disallowed (NIST SP 800-131A Rev.2 §3 Table 2). With no enforcer, behavior is unchanged. The gate covers the classical sign() path only; signPqs() is governed by its own preview flag. These are capability claims about NextPDF code: FIPS 140-3 validation attaches to a cryptographic module through the CMVP, which in this deployment is the operator’s HSM or provider.
Conformance
Section titled “Conformance”| Claim | Standard | Clause |
|---|---|---|
| Login authenticates the user to the token before private-key operations; a wrong PIN denies access. | PKCS#11 v3.1 | §5.6.8 |
| Always-authenticate keys need a fresh login per use; repeated failed re-authentication can lock the PIN. | PKCS#11 v3.1 | CKA_ALWAYS_AUTHENTICATE re-authentication |
| A token ECDSA signature is the raw r‖s concatenation; the signer converts it to DER for PDF interop. | PKCS#11 v3.1 | §6.3.1 |
| PSS parameters bind hash, MGF, and salt length; the signers set the salt equal to the digest length. | PKCS#11 v3.1 | §6.1.9 |
| The FIPS gate denies signature generation with RSA under 2048 bits or ECDSA order under 224 bits. | NIST SP 800-131A Rev.2 | §3 Table 2 |
| The post-quantum context string is limited to 255 bytes. | FIPS 204 | HashML-DSA context handling |
| FIPS 140-3 validation attaches to cryptographic modules through the CMVP. | FIPS 140-3 | CMVP program scope |
All clauses are paraphrased; no normative text is reproduced. The signers align their behavior with the cited clauses as a capability. Whether a produced signature verifies is the verifier’s decision against its trust anchors; key security depends on the token, the HSM, and the operator.
Development notes
Section titled “Development notes”-
The PIN-delivery mechanism follows the PKCS#11 URI
pin-sourceconvention (RFC 7512); that RFC is outside the cited corpus, so the behavior above is grounded from the product source, not a spec citation. -
Confirm the runtime loads
ext-pkcs11before constructingPkcs11Signer; construction fails fast when the extension is absent. The CLI signer needsproc_openenabled and anopensslbinary with a PKCS#11 provider or engine installed. -
The PIN, certificate label, and key label are
#[SensitiveParameter], so they are excluded from stack traces. Supply the PIN from a secret manager; never write it to source, configuration committed to version control, or logs. -
Construction is the expensive step on both signers: the PKCS#11 path logs in and reads the certificate, and the CLI path probes the binary and backend. Construct once and reuse the instance; the per-library module cache makes repeated construction against the same library safe.
-
Wrap a signer in
HsmSignerProviderAdapterwhen the caller works throughSignerProviderInterface. Pass the canonical provider id for the wrapped class —pkcs11-{module-id}oropenssl-cli— so capability checks use the correct backend allow-set. -
Before enabling the post-quantum preview, verify the token firmware’s mechanism identifiers against the provisional values NextPDF registers; a mismatch fails at sign time. Do not enable the preview for production PAdES output.
-
getResolvedBackend()andgetOpensslVersion()exist for evidence recording; persist them with signing evidence when your compliance program requires reproducibility.
See also
Section titled “See also”- Hardware security module signing (PKCS#11) — the capability page with setup, configuration, and verification steps.
- Security — Deep Reference — the combined Enterprise security surface.
- Signature — Deep Reference — the PAdES B-LT / B-LTA long-term producer.
- FIPS 140 — Deep Reference — the crypto policy, self-test battery, and the
FipsSignatureEnforcergate. - PQC preview — Deep Reference — the post-quantum preview surface and its boundaries.
- Security / Signing (Core) — the Core CMS signer and the signing contracts.
Publication boundary
Section titled “Publication boundary”This page documents externally observable behavior and the supported public API surface only. Internal namespace paths, helper classes, mechanism tables, runbook filenames, and ticket prefixes are out of scope.