How to Integrate AWS CloudHSM to Sign Documents
PSPDFKit for Web can be used to digitally sign documents, and this blog post will look at how to implement a workflow in which the signing and all the related cryptographic operations are done via a hardware security model (HSM) offered by the AWS CloudHSM service and then applied by PSPDFKit for Web to a PDF document. In addition to a walkthrough, I’ll talk about some of the things I did when I implemented this workflow myself.
Topics We’ll Explore
-
Setting up an HSM via AWS CloudHSM
-
Bootstrapping a Node.js project for signing via HSMs
-
Establishing a PKCS #11 connection to the HSM via the
graphene-pk11
package -
Generating RSA key pairs on the HSM via
graphene-pk11
-
Creating self-signed X.509 certificates using Forge
-
Defining a custom signing function in Forge to defer signing to the HSM
-
Producing PKCS #7 containers for digital signatures
-
Using the PSPDFKit for Web API to start the signing process
What Is AWS CloudHSM?
AWS CloudHSM is a cryptographic service that gives us convenient access to hardware security modules, or HSMs. An HSM is a device that offers protected storage of cryptographic keys, in addition to handling a variety of cryptographic tasks. For our use case, we’ll use an HSM to perform the following tasks:
-
Generating asymmetric key pairs via the RSA algorithm.
-
Storing RSA key pairs.
-
Signing data using a private key.
Let’s get started.
The Signing Service We’ll Build
We published a full Node.js example project for digitally signing documents using CloudHSM on GitHub. We’ll explain the important bits in the following sections, but we suggest you refer to the full implementation if you’re trying to implement something like this.
Please note that this example shows a demo service that’s only meant to serve as a basic reference about the steps to follow, and it isn’t fully production-ready. For real-life signing scenarios, you’ll need to take extra steps to ensure the entire flow is end-to-end secure and that the best security practices are followed along the way.
Setting Up Our Cloud
AWS provides thorough documentation for getting started with CloudHSM. Follow it to ensure you have a reproducible environment for the rest of the tutorial.
For this tutorial, we’ll connect to our HSM cluster via an AWS EC2 instance hosted in the same virtual private cloud (VPC) and Availability Zone (AZ) in which the cluster of HSMs is created.
Here’s a summary of the steps I performed as part of the setup of my AWS environment:
-
Created a new VPC, which I named
CloudHSM
. -
Created an HSM cluster in CloudHSM, making sure the VPC matched the one I created earlier.
-
Created a new EC2 instance to use as a client of my HSM cluster and in the same VPC as my HSM cluster. In my case, I chose Amazon Linux 2 as its platform.
-
Configured the EC2 client instance security group as suggested in the configuration guide.
-
Created an initial HSM in the cluster.
-
Initialized the HSM cluster.
-
Installed the CloudHSM CLI in my EC2 client instance.
-
Activated my HSM cluster.
To complete the steps above, follow the official CloudHSM getting started guide.
After I followed all the steps, I installed the PKCS #11 library in my EC2 client instance. To do this, you can follow the official PKCS #11 installation guide in AWS. In my case, I followed the specific instructions for Amazon Linux 2 and the Client SDK 5.
Now we’re almost ready to build our signing application. Our example app will be a Node.js web server built using the Express framework. So, we first need to get a Node.js installation ready in our EC2 instance. I used this Node installation guide to get it installed.
At the time of writing, Node 18 isn’t compatible with Amazon Linux 2. So, for better results, install Node 16.
The rest of this tutorial will focus on the key aspects needed for the signing service to work. For a full reference, please refer to the full example project on GitHub.
Setting Up the Server
Our application will consist of a web server that will listen on HTTP connections and feature a single /sign
POST endpoint for handling signing requests. For this, we’ll use Express, to which I also added the cors
middleware, in order to offer full CORS support.
Create a new directory to host the application, and run npm init -y
to quickly create a basic structure that will allow you to add dependencies through npm
.
Because it’s a more idiomatic approach for modern JavaScript, our application will make use of the modern ES modules syntax instead of the old CommonJS imports. For this reason, go to the created package.json
file and add the "type": "module"
declaration to it:
"main": "index.js",
+"type": "module"
Then, run:
npm install express cors
To set up the Express web server, create an index.js
file and add the following:
// index.js import express from 'express'; import cors from 'cors'; const app = express(); const PORT = 3756; app.use(cors()); app.use(express.json()); app.listen(PORT, () => { console.log('Server running on PORT ', PORT); });
Try running the application with node index.js
. You’ll see a message in the console letting you know the server is running. I picked 3756
as the port number, but it can be a different value.
Now, add a /sign
endpoint that will handle HTTP requests made with the POST method for signing. The endpoint will receive a Base64-encoded representation of the PDF contents to sign, which is provided by PSPDFKit for Web, and for now, it’ll simply return a JSON representation of it:
// index.js app.post('/sign', (req, res) => { const { encodedContents } = req.body; res.json({ encodedContents }); });
Now that we have our basic server running, let’s move to the interesting stuff: HSM integration.
PKCS #11 in Node.js with graphene-pk11
PKCS #11, also known as Cryptoki, is a standard that defines an API to communicate with hardware devices — such as smart cards or HSMs — that can store keys and perform cryptographic operations. Using PKCS #11 implementations, we’re able to instruct the HSM to store/retrieve objects, encrypt/decrypt, sign/verify messages, derive keys, etc.
Follow the steps to install Client SDK 5 for the PKCS #11 library if you haven’t already. This is a preliminary step in enabling communication with the HSM via Node.js.
We’ll use an npm package called graphene-pk11
to communicate with the HSM via Node.js.
We’ll create an hsm.js
file that will contain all of our logic for handling communication with the HSM, starting with a function — which we’ll call initHSM
— to initiate communication with the device:
// hsm.js import graphene from 'graphene-pk11'; export function initHSM() { const Module = graphene.Module; const mod = Module.load( '/opt/cloudhsm/lib/libcloudhsm_pkcs11.so', 'CloudHSM', ); mod.initialize(); return mod; }
The /opt/cloudhsm/lib/libcloudhsm_pkcs11.so
path is the default location of the PSPDFKit#11 native library in Amazon Linux 2 after installation.
We return a mod
object from the initHSM
function, from which we can access different functionalities offered by the HSM.
HSM User Management
Before continuing with the code, we need to set up a crypto user (CU) that will be authorized to execute cryptographic operations within the HSM. Please read the official guide on HSM users to learn about the different roles and how to create new ones. To interact with the HSM from the Node.js application, you’ll need to create a CU account. Refer to the guide on how to create users for instructions.
Once you’ve created a CU, modify hsm.js
and add support for logging in to the HSM:
// hsm.js const slot = mod.getSlots(0); export function loginHSMCU(slot) { const session = slot.open( graphene.SessionFlag.SERIAL_SESSION | graphene.SessionFlag.RW_SESSION, ); session.login(process.env.PIN, graphene.UserType.User); return session; }
We get the first slot
from the HSM module and start a new session, which we explicitly mark as a read-write session. We expect to receive a single string that we’ll pass with the credentials for logging in. In this implementation, this will be received in a PIN
environment variable. AWS tells us to send the login credentials as a single string of the form "user:password"
. If, for instance, our CU was named cu_user
, with a password of test
, we’d need to set the PIN
environment variable as PIN=cu_user:test
.
Once we’re logged in and have obtained a session
reference, we can create a pair of keys (public and private) to use for building the kind of digital signatures required by PSPDFKit. In this particular example, we’ll create a pair of keys using the RSA mechanism. Since the HSMs provided by CloudHSM are able to store key pairs, we’ll first check the HSM to find existing keys. If they’re found, we’ll use them. Otherwise, we’ll create and store a new pair.
For the sake of simplicity, I’m grabbing the first private and public keys stored in the HSM. However, for production, additional checks are necessary to ensure you’re holding a reference to the keys you need.
// hsm.js export function getRSAKeyPair(session) { const privateKeys = session.find({ class: graphene.ObjectClass.PRIVATE_KEY, }); if (privateKeys.length > 0) { console.log('Existing private key found in the HSM...'); // Get the first private key and the public key. const privateKey = privateKeys.items(0); const publicKey = session .find({ class: graphene.ObjectClass.PUBLIC_KEY }) .items(0); return { privateKey, publicKey }; } console.log( 'No key pair found. Will use the HSM to create a new one...', ); return session.generateKeyPair( graphene.KeyGenMechanism.RSA, { keyType: graphene.KeyType.RSA, modulusBits: 2048, // Set the size of the key to 2048 bits. publicExponent: new Uint8Array([1, 0, 1]), // Set the public exponent to 65537 (0x010001) token: true, // Store the key on the HSM. verify: true, // The key can be used for verification. encrypt: true, // The key can be used for encryption. wrap: true, // The key can be used for wrapping other keys. extractable: true, // The key can be extracted. }, { keyType: graphene.KeyType.RSA, token: true, // Store the key on the HSM. sign: true, // The key can be used for signing. decrypt: true, // The key can be used for decryption. unwrap: true, // The key can be used for unwrapping other keys. extractable: false, // Important: we don't want to allow this key to be extracted. }, ); }
The session.generateKeyPair
function takes the algorithm to create the keys as the first argument, the public key properties as the second argument, and the properties of the private key as the last one.
I want to highlight the use of token: true
for both keys. This instructs the HSM to store the created keys in the device. This will enable us to reuse them in the future. Additionally, notice how I specified extractable: true
for the public key, but extractable: false
for the private key. This is because I want to be able to export the public key into a different file, for later use, whereas for security, we’d like to not let the private key be accessed from outside the HSM at all.
Some HSMs, such as SoftHSMv2 (a popular software implementation of an HSM), don’t allow the usage of certain parameters, such as
extractable
. Check the documentation of your HSM so you’re aware of the available parameters in case you’re trying to make it work outside of CloudHSM.
Although we can now create RSA key pairs using graphene-pk11
, we still haven’t seen how to use graphene-pk11
to sign data. We’ll come back to that later.
Creating Self-Signed X.509 Certificates
Now that we have a mechanism by which we can create an RSA key pair in the HSM, let’s see how we can create a standard X.509 certificate for the public key and sign it ourselves using the private key.
Self-signed certificates aren’t meant to be used in production. We’re only doing it this way for test purposes.
For creating the certificate in Node.js, we’ll make use of the Forge crypto library:
$ npm install node-forge
I added the logic related to the self-signed certificate into a self-sign
directory with an index.js
file. Here’s a generateCertificate
function implementation that we can put into that file:
// self-sign/index.js import forge from 'node-forge'; import { getRSAKeyPair, initHSM, loginHSMCU } from '../hsm.js'; export default function generateCertificate() { const mod = initHSM(); const slot = mod.getSlots(0); let forgeCert = null; // Check if the slot is initialized and a token is present in it. if (slot.flags & graphene.SlotFlag.TOKEN_PRESENT) { const session = loginHSMCU(slot); const keys = getRSAKeyPair(session); // Extract the public key from the generated key pair and convert it to a PEM format. const pemPublicKey = extractPublicKeyAsPEM(keys.publicKey); const publicKey = forge.pki.publicKeyFromPem(pemPublicKey); const signerFn = getSignerFn(session, keys); // Create the X.509 certificate. forgeCert = createSelfSignedX509Cert(publicKey, signerFn); // Export the created certificate if needed for verification. const certPem = forge.pki.certificateToPem(forgeCert); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); fs.writeFileSync( path.join(__dirname, 'cert.pem'), certPem, 'utf-8', ); // Log out of the HSM and close the session. session.logout(); session.close(); } mod.finalize(); return forgeCert; }
Notice how we initialize and log in to the HSM, as seen before (initHSM
and loginHSMCU
). We also get the RSA keys (getRSAKeyPair
). Let’s focus on the following part of the previous code:
// self-sign/index.js const pemPublicKey = extractPublicKeyAsPEM(keys.publicKey); const publicKey = forge.pki.publicKeyFromPem(pemPublicKey);
Forge has its own data structure to represent the public key, and since it’s different than the graphene-pk11
representation of the public key, I went with an approach in which I encode the public key in a PEM container and then use an API provided by Forge (forge.pki.publicKeyFromPem
) to parse it in the format required by Forge.
To extract the public key as a PEM, I’m using another dependency, called node-rsa
. We’ll install it as part of our project by running the following command in the terminal:
$ npm install node-rsa
Here’s the implementation of the logic, which we’ll store in another JavaScript module, called util.js
:
// util.js import NodeRSA from 'node-rsa'; export function extractPublicKeyAsPEM(publicKey) { const pubKey = publicKey.getAttribute({ modulus: null, publicExponent: null, }); const rsaPubKey = new NodeRSA(); rsaPubKey.importKey({ n: pubKey.modulus, e: pubKey.publicExponent, }); // Export the public key in PKCS #8 PEM format. return rsaPubKey.exportKey('pkcs8-public-pem'); }
RSA public keys consist of a modulus and an exponent, both of which are values. We extract these values and use NodeRSA to finally get the key as a PEM string.
Now, let’s go back to analyzing the certificate generation logic:
// self-sign/index.js const signerFn = getSignerFn(session, keys); // Create the X.509 certificate. forgeCert = createSelfSignedX509Cert(publicKey, signerFn);
We’ll skip signerFn
for now and come back to it in a bit. Focus instead on the call to a createSelfSignedX509Cert
function that uses the publicKey
(already in the format required by Forge) and the signerFn
. For now, keep in mind that signerFn
contains a callback that will be invoked when the moment of signing the certificate arrives.
Once we get our certificate in forgeCert
, we write it to the disk (in case we want to use it elsewhere, but that step is optional), and we finalize our HSM session.
This is the createSelfSignedX509Cert
implementation:
// self-sign/index.js function createSelfSignedX509Cert(publicKey, signerFn) { const forgeCert = forge.pki.createCertificate(); forgeCert.publicKey = publicKey; forgeCert.serialNumber = '01'; forgeCert.validity.notBefore = new Date(); forgeCert.validity.notAfter = new Date(); forgeCert.validity.notAfter.setFullYear( forgeCert.validity.notBefore.getFullYear() + 1, ); const attrs = [ { name: 'commonName', value: 'Sample Name', }, // Additional attributes omitted for brevity. ]; forgeCert.setSubject(attrs); forgeCert.setIssuer(attrs); forgeCert.setExtensions([ { // ...ommited for brevity... }, ]); // Important: Make sure to use a SHA-256 message digest // instead of the default SHA-1. Adobe Acrobat won't // treat a SHA-1 signed certificate as valid. forgeCert.sign(signerFn, forge.md.sha256.create()); return forgeCert; }
There’s a lot going on here, but we’re basically populating a series of properties that we want to store in the X.509 certificate. Naturally, the public key, which is the vital part of the certificate, is added. Additionally, we’re setting attributes, such as the common name, to the certificate. I’ve skipped some bits for the sake of brevity, but you can see the full certification creation logic on GitHub.
Let’s focus on the actual line in which the signing takes place:
// self-sign/index.js // Important: Make sure to use a SHA-256 message digest // instead of the default SHA-1. Adobe Acrobat won't // treat a SHA-1 signed certificate as valid. forgeCert.sign(signerFn, forge.md.sha256.create());
Here, we’re telling Forge to sign our certificate — hence why it’s a self-signed certificate; a proper trusted certificate would be signed by a certificate authority (CA) instead.
As the comment mentions, we’re explicitly using SHA-256 as the hashing function to produce the message digest that we’re going to encrypt. By default, Forge would use SHA-1 for producing the message digest, which is a discouraged function because of concerns with collision attacks.
The Signing Function
Remember that we briefly mentioned signerFn
before and stated that it contains a callback in which the actual signing takes place? Well, it’s time to take a closer look at it:
// hsm.js export function getSignerFn(session, keys) { return { sign: (md) => { // Create a signature prefix (ASN.1 sequence) to indicate that the signature is a digest // of a previously hashed message. const prefix = Buffer.from([ // ...omitted for brevity... ]); // Concatenate the prefix and the message digest. let buf = Buffer.concat([ prefix, Buffer.from(md.digest().toHex(), 'hex'), ]); let sign = session.createSign('RSA_PKCS', keys.privateKey); return sign.once(buf).toString('binary'); }, }; }
Since here is where the actual cryptographic operation of signing is taking place, we need to offload the work to the HSM, hence we rely on the graphene-pk11
library once again.
Here, the signer function passed to Forge is a synchronous function. There doesn’t seem to be any documented supported method in Forge for asynchronous key signing. However, if your signing function needs to be asynchronous, you can follow this GitHub issue for a workaround for asynchronous key signing.
Let’s take a look at the arguments of the getSignerFn
function: session
is our initialized graphene-pk11
session, and keys
is the reference to our previously retrieved key pair.
This function returns an object with a sign
property that is, in turn, a function that takes a single argument: md
. md
is the message digest we’ll sign. This function is the one that Forge calls behind the scenes when it’s time to sign calls, and md
is provided in its format.
This is a key aspect to consider: What we receive is the message digest (i.e. the hashed message), and the reminder step is to encrypt this digest. A full signing process usually involves two steps:
-
The message to sign is taken as an input, and a hash function is used to produce a message digest as an output.
-
This message digest is encrypted using the private key of our key pair.
The receiver of the signed message would in turn hash the message, use our provided public key to decrypt the message digest, and check whether their hash matches the output after decrypting.
I’m mentioning this important aspect because we need to be careful to not hash the particular input (md
) we’re receiving from Forge, since it’s already the message digest. Furthermore, what we need to end up encrypting is not solely the message digest, but rather an ASN.1 sequence called DigestInfo. To learn more about this, I highly recommend this StackExchange answer explaining what DigestInfo is.
DigestInfo
is defined in ASN.1 syntax as follows:
DigestInfo::=SEQUENCE{ digestAlgorithm AlgorithmIdentifier, digest OCTET STRING }
Here, digestAlgorithm
is an identifier of which algorithm has been used to produce the digest, and digest
is the actual resulting hash.
Why is this tangent is relevant to us? Well, we already have the digest (the md
argument), but we also need to encrypt the identifier for the hashing algorithm used (which, in our case, is SHA-256). This identifier should be prefixed before the bytes of our md
value, and that’s what’s present in the prefix
bytes buffer. In the particular case of SHA-256, we can prepend a sequence of bytes, as further explained in this StackOverflow answer, which is what we ended up doing in this case. These bytes were omitted in the snippet, but you can check the algorithm identifier prefix used on our GitHub sample.
Once we concatenate the SHA-256 identifier prefix with the digest, we call session.createSign
to create the signing reference, and then sign.once(buf)
to create the signature in a single step, which we then return as a binary string.
This is the heart of all of this; we’re effectively using our own custom signing logic to be able to produce the signature, instead of directly using a private key, as Forge usually expects.
Producing a Digital Signature
OK, let’s recap a bit. Thus far, we’ve covered how to log in to our HSM, how to create key pairs, how to sign using the HSM, and how to create a self-signed X.509 certificate that contains some metadata and our public key. Now, we need to take PDF documents as input, sign them, and then return the signature and the certificate in a sensible format.
Let’s generate the self-signed certificate to use for subsequent signing requests as soon as our server starts listening for requests:
// index.js let certificate = null; app.listen(PORT, () => { certificate = generateSelfSignedCert(); });
Notice how the certificate is stored in a module-level variable called certificate
so that it’s easily accessible later on.
Let’s modify our /sign
endpoint in the following way:
// index.js import generateSelfSignedCert from './self-sign/index.js'; // ... app.post('/sign', (req, res) => { const { encodedContents } = req.body; const fileContents = Buffer.from(encodedContents, 'base64'); const result = generateSignature(fileContents, certificate); res.json({ p7: result }); });
In this code, we first decode the PDF binary contents that we received over the network. Then, we call the generateSignature
function, passing it two arguments: the decoded PDF contents, and a reference to our self-signed certificate.
generateSignature
returns a PKCS #7 container as a PEM-encoded string that we return as part of the HTTP response. Here’s the full generateSignature
implementation:
// index.js function generateSignature(fileContents, certificate) { const mod = initHSM(); const slot = mod.getSlots(0); let result = null; if (slot.flags & graphene.SlotFlag.TOKEN_PRESENT) { const session = loginHSMCU(slot); const keys = getRSAKeyPair(session); const signerFn = getSignerFn(session, keys); // Create a PKCS #7 container of the signature and the certificate. const p7 = createPKCS7Signature( fileContents, certificate, signerFn, ); // Convert the PKCS #7 to a Base64-encoded string for sending it over the network. result = Buffer.from( forge.asn1.toDer(p7.toAsn1()).getBytes(), 'binary', ).toString('base64'); // Log out of the HSM and close the session. session.logout(); session.close(); } else { console.error('Slot is not initialized'); } mod.finalize(); return result; }
First, we initiate our HSM connection and log in to it. We retrieve our RSA key pair and get the signer function that will sign the digest using the HSM. Then, we call to a function that will create the PKCS #7 container and convert it to a Base-64 encoded string. Before returning it as a result, we log out of the HSM since all of our cryptographic operations are complete.
Here’s the implementation of createPKCS7Signature
:
// index.js function createPKCS7Signature(fileContents, cert, signerFn) { const p7 = forge.pkcs7.createSignedData(); p7.content = new forge.util.ByteBuffer(fileContents); p7.addCertificate(cert); p7.addSigner({ key: signerFn, certificate: cert, // This bit is important; you must choose an algorithm supported by the key vault. // SHA1 isn't supported, for example. digestAlgorithm: forge.pki.oids.sha256, authenticatedAttributes: [ { type: forge.pki.oids.contentType, value: forge.pki.oids.data, }, { type: forge.pki.oids.messageDigest, // Value will be auto-populated at signing time. }, { type: forge.pki.oids.signingTime, value: new Date(), }, ], }); p7.sign({ detached: true }); return p7; }
We initialize a Forge container for PKCS #7 signatures, in which we specify the content to sign, the X.509 certificate to use, our signerFn
reference instead of a private key (again, to perform the signing portion in the HSM), SHA-256 as the hashing algorithm (this is important, since using SHA-1 won’t produce a valid signature), and additional attributes.
Then, we invoke p7.sign({ detached: true });
to produce a detached signature (i.e. a PKCS #7 container in which the contents that were signed aren’t included).
And… that’s it! We’ve created an entire workflow in which we return a PKCS #7 containing a digital signature and the X.509 certificate when signing requests are performed.
Invocation from PSPDFKit for Web
Let’s revisit how to perform a signature by calling our /sign
POST endpoint from PSPDFKit for Web Standalone:
async function generatePKCS7({ fileContents, hash }) { const encodedContents = btoa( String.fromCharCode.apply(null, new Uint8Array(fileContents)), ); const response = await fetch('http://localhost:3756/sign', { method: 'POST', body: JSON.stringify({ hash, encodedContents, }), headers: { 'Content-Type': 'application/json', }, }); const json = await response.json(); const arrayBuffer = base64ToArrayBuffer(json.p7); return arrayBuffer; }
PSPDFKit provides us with the binary range to sign, along with the digest, if needed, for verification purposes. We encode the contents and send it to our HSM signing service (in this example, I have it running in http://localhost:3756
). Then, we decode the Base64-encoded PKCS #7 container and return the final ArrayBuffer
from the callback. With this, PSPDFKit for Web is able to produce a valid digitally signed document.
Whenever we want to start the signing process, we can use the PSPDFKit.instance#signDocument
API as follows:
instance.signDocument(null, generatePKCS7);
This is how it would be used in Standalone, but our service could easily be adapted to work on Server-backed deployments of PSPDFKit for Web as well. Refer to our Server signing service guide for more information about this approach. Conversely, we also have a specific Standalone guide on the same topic.
Conclusion
This has been a very long blog post, so thank you for staying with me until the end of this; it’s been quite the journey!
I hope this has helped clarifying how to use the CloudHSM service for signing documents and that it gives you resources for building your signing solutions on top of PSPDFKit for Web and AWS CloudHSM.
Please check out the complete implementation in our CloudHSM signing example service. There, you’ll find additional code for using a leaf X.509 certificate signed by a CA (which, in this case, is a self-signed root certificate) instead of the self-signed certificate shown in this tutorial. Check out the "ca-sign"
directory for more details about that.
Also, please note that although this example works with AWS CloudHSM, different HSM implementations work differently and support a diverse subset of capabilities. Be sure to check out the documentation specific to your implementation for more detailed information.