Skip to content
getnextpdf.com

Enterprise edition

HSM signing — Deep Reference

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.

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.

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.

SymbolParametersDefault behaviorReturnsThrows or fails withNotes
Pkcs11Signer::__construct()string $libraryPath, int $slotId, string $pin, string $certLabel, ?string $keyLabel = null, array $chainDer = [], bool $enablePostQuantum = false, ?FipsSignatureEnforcer $fipsEnforcer = nullOpens the vendor library, logs into the slot, and loads the certificate and key-algorithm metadata from the tokenHsmOperationException when ext-pkcs11 is absent or token access failsOne 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-Valuestring raw signature bytesHsmOperationException (key not found, token failure); InvalidArgumentException (unmapped algorithm); FIPS-gate exceptions before signing when an enforcer is wiredClosed algorithm set; see Behavior contract
Pkcs11Signer::signPqs()string $data, Pkcs11PqsAlgorithm $algorithm, string $context = '', bool $randomized = trueRefused unless $enablePostQuantum was set; dispatches the provisional PKCS#11 PQ mechanismstring raw signature bytesHsmOperationException (disabled, token failure, signature-length mismatch); InvalidArgumentException (context over 255 bytes)Preview; mechanism identifiers are provisional
Pkcs11Signer::isPostQuantumEnabled()NoneReports the constructor opt-in flagboolNone
Pkcs11Signer::getCertificateDer()NoneReturns the signer certificate read from the tokenstring (DER)NoneLoaded once at construction
Pkcs11Signer::getCertificateChainDer()NoneReturns the constructor-supplied intermediatesarray<string> (DER)NoneExcludes 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 = nullVerifies proc_open, probes the binary and version, resolves the backend, and loads the certificatesHsmOperationException (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 defaultstring raw signature bytesHsmOperationException (timeout, PIN rejected, key not found, module load failure, empty output, pin-file failure); InvalidArgumentException (unmapped algorithm); FIPS-gate exceptions before signingThe subprocess is killed after $timeoutSeconds; stderr is redacted before it reaches messages
OpenSslCliSigner accessor surfaceNoneRead-only construction resultsstring / array<string> / OpenSslCliBackendNonegetCertificateDer, getCertificateChainDer, getPublicKeyAlgorithm, getCertificatePem, getResolvedBackend, getOpensslVersion
HsmSignerProviderAdapter::__construct()HsmSignerInterface $hsm, string $providerId, SignatureAlgorithm $algorithm = SignatureAlgorithm::Pkcs1v15Wraps an HSM concrete as a SignerProviderInterfaceNoneProvider id conventions: pkcs11-{module-id}, openssl-cli
HsmSignerProviderAdapter::providerId()NoneReturns the constructor-supplied idnon-empty-stringNone
HsmSignerProviderAdapter::supportsAlgorithm()SignatureAlgorithm $algoMaps the enum to an OpenSSL-style name, then intersects the backend allow-setboolNoneDeclines digest-only algorithms; openssl-engine ids advertise nothing
HsmSignerProviderAdapter::sign()string $data, ?string $keyVersion = nullDispatches through the wrapped signer with the configured algorithmnon-empty-stringKeyManagementException (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'): string
public function signPqs(string $data, Pkcs11PqsAlgorithm $algorithm, string $context = '', bool $randomized = true): string
public function isPostQuantumEnabled(): bool
public function getCertificateDer(): string
public function getCertificateChainDer(): array
public 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'): string
public function getCertificateDer(): string
public function getCertificateChainDer(): array
public function getPublicKeyAlgorithm(): string
public function getCertificatePem(): string
public function getResolvedBackend(): OpenSslCliBackend
public function getOpensslVersion(): string
public function __construct(private HsmSignerInterface $hsm, private string $providerId, private SignatureAlgorithm $algorithm = SignatureAlgorithm::Pkcs1v15)
public function providerId(): string
public function supportsAlgorithm(SignatureAlgorithm $algo): bool
public function sign(string $data, ?string $keyVersion = null): string
  • Key custody. The private key never leaves the token boundary. Pkcs11Signer delegates the operation to the token; OpenSslCliSigner passes a key reference — a PKCS#11 URI — to the openssl subprocess. Neither signer can export the key.
  • Session and login. Pkcs11Signer caches 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. Pkcs11Signer additionally accepts ecdsa-raw. Any other identifier raises InvalidArgumentException — 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-encoded ECDSA-Sig-Value form 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-source attribute, 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 as pin-value in the URI, which is observable in the process command line; this mode is opt-in only.
  • Subprocess discipline. OpenSslCliSigner spawns 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 $keyVersion with KeyManagementException instead 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 raises SignatureFailedException.
  • Post-quantum preview. signPqs() is gated behind the $enablePostQuantum constructor 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 selected Pkcs11PqsAlgorithm parameter 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.
  • Constructing Pkcs11Signer without ext-pkcs11 raises HsmOperationException immediately; the extension is not bundled with standard PHP distributions.
  • A certificate or private-key label that matches no object on the token raises HsmOperationException naming 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).
  • OpenSslCliSigner refuses a $keyUri that already contains pin-value at 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 HsmOperationException rather than deferring the failure to sign time.
  • A subprocess that exceeds $timeoutSeconds is 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.
  • HsmSignerProviderAdapter with the retired openssl-engine provider id advertises no algorithms, so a stale configuration fails at provider selection instead of at sign time.

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.

ClaimStandardClause
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.1CKA_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 204HashML-DSA context handling
FIPS 140-3 validation attaches to cryptographic modules through the CMVP.FIPS 140-3CMVP 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.

  • The PIN-delivery mechanism follows the PKCS#11 URI pin-source convention (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-pkcs11 before constructing Pkcs11Signer; construction fails fast when the extension is absent. The CLI signer needs proc_open enabled and an openssl binary 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 HsmSignerProviderAdapter when the caller works through SignerProviderInterface. Pass the canonical provider id for the wrapped class — pkcs11-{module-id} or openssl-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() and getOpensslVersion() exist for evidence recording; persist them with signing evidence when your compliance program requires reproducibility.

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.