DEV Community

Lukas Fruntke
Lukas Fruntke

Posted on • Updated on

Creating an AWS Private Certificate Root Authority with Lambda and Node.js

According to the Security Perspective of the AWS Cloud Adoption Framework, Data has to be safeguarded during its transit.
While it is a common practice to terminate the HTTPS traffic at the Application Load Balancer and forward it to the Application via HTTP, this does not ensure a continuous encryption of possible sensitive data.
The very common architecture pattern of terminating a SSL Connection
When implementing End-to-End encryption in order to protect the data in transit between the Application Load Balancer and the Application, two alternatives need to be considered:

  1. Passing the HTTPS traffic to the Application, where the private key is available too. This has the downside, that the AWS Certificate Manager does not allow the export of private keys, therefore another solution would be needed to store the private key.
  2. Using the AWS Certificate Authority as a Root Authority to sign own certificates for the communication between Application Load Balancer and the Application. Although there is quite a pricetag attached to this solution - 400$ per month per running Certificate Authority and 0,75$ per certificate for the fist thousand certificates - it is AWS native and does not need any creative way of storing the private certificate.

In this blog post the second alternative was chosen - using the Private Certificate Authority to generate the certificates for the communication between ALB and the Application.

Adding AWS Certificate Manager - Private Certificate Authority to the picture

To automate the process of creating the Private Certificate Authority and issuing a certificate, a Lambda function written in JavaScript is utilized here. In the process, a mixture of AWS PCA API calls and OpenSSL calls are used. As AWS Lambda removed the OpenSSL binaries from recent Node.JS Lambda Runtimes, Node.JS 8.10 has to be used, which will reach its End of Life at the 31st December 2019. Therefore, the OpenSSL binary will need to be added as a Lambda Layer if the function is used in 2020. This is pretty straightforward, one just needs to spin up an EC2 instance and zip the executable with the correct permissions and in the folder bin, upload it as a Layer and reconfigure the function to use it.

Creating a PCA with the aws-sdk is pretty straightforward:

const AWS = require('aws-sdk');
const pca = new AWS.ACMPCA();

async function createCA() {
    const caParams = {
      CertificateAuthorityConfiguration: {
        KeyAlgorithm: "RSA_2048",
        SigningAlgorithm: "SHA256WITHRSA",
        Subject: {
          Country: 'DE',
          Organization: 'SPIRIT21',
        }
      },
      CertificateAuthorityType: "ROOT",
      RevocationConfiguration: {
        CrlConfiguration: {
          Enabled: false,
        }
      }
    }
    const {CertificateAuthorityArn} = await pca.createCertificateAuthority(caParams).promise();
}
Enter fullscreen mode Exit fullscreen mode

It takes some time, until the creation is done, so the waitFor() method of the SDK has to be used, in order to await the completion of the PCA's creation like this:

const AWS = require('aws-sdk');
const pca = new AWS.ACMPCA();

async function wait(CertificateAuthorityArn){
    await pca.waitFor('certificateAuthorityCSRCreated', { CertificateAuthorityArn }).promise();
}
Enter fullscreen mode Exit fullscreen mode

When the PCA is created, it should be visible in the console:
Waiting State of the PCA
As displayed, a CA Certificate needs to be installed, before the PCA is ready to use. To sign the PCA certificate, the Certificate Signing Request of the Certificate Authority is required, which can be retrieved via the AWS SDK:

const AWS = require('aws-sdk');
const pca = new AWS.ACMPCA();

async function getCSR(CertificateAuthorityArn){
    const { CSR } = await pca.getCertificateAuthorityCsr({ CertificateAuthorityArn }).promise();
    return CSR;
}
Enter fullscreen mode Exit fullscreen mode

To sign the Root CA Certificate, a issue request of a root CA Certificate has to be performed against the Certificate Authority. Previous attempts of issuing the certificate with OpenSSL failed, because AWS would not accept the generated certificate as a CA Root Certificate, hence the issuing is done via the API. The Certificate Authority needs the Certificate Signing Request and a few other parameters arranged like this:

const AWS = require('aws-sdk');
const pca = new AWS.ACMPCA();

async function issueRootCertificate(CertificateAuthorityArn, CSR) {
  const CACertParams = {
    CertificateAuthorityArn,
    Csr: Buffer.from(CSR),
    SigningAlgorithm: "SHA256WITHRSA",
    TemplateArn: "arn:aws:acm-pca:::template/RootCACertificate/V1",
    Validity: {
      Type: "YEARS",
      Value: 10
    }
  }

  const {CertificateArn} = await pca.issueCertificate(CACertParams).promise();
}
Enter fullscreen mode Exit fullscreen mode

After the Certificate is issued, it has to be imported in the CA:

const AWS = require('aws-sdk');
const pca = new AWS.ACMPCA();

async function importRootCertificate(CertificateAuthorityArn, CertificateArn) {
  const {Certificate} = await pca.getCertificate({
    CertificateAuthorityArn,
    CertificateArn
  }).promise();

  return await pca.importCertificateAuthorityCertificate({
    CertificateAuthorityArn,
    Certificate: Buffer.from(Certificate)
  }).promise();
}
Enter fullscreen mode Exit fullscreen mode

Now the CA should be ready for usage, which should be visible in the console:
Ready State of the PCA

Now, the CA is finally ready to issue certificates, which can be used for encrypting traffic. A certificate is issued like this (with the help of OpenSSL for generating the Certificate Signing Request):

const AWS = require('aws-sdk');
const pca = new AWS.ACMPCA();
const util = require('util');
const exec = util.promisify(require('child_process').exec);
const read = util.promisify(require('fs').readFile);
const write = util.promisify(require('fs').writeFile);

const privateKeyFile = "/tmp/private.key"
const CSRFile = "/tmp/CSR.csr";

// This is important for OpenSSL, otherwise it would exit with an error, because the .rnd File in the old Home dir is not writeable
process.env.HOME = "/tmp";

async function issueCertificate(CertificateAuthorityArn) {

  await exec(`openssl req -nodes -newkey rsa:4096 -days 3600 -keyout ${privateKeyFile} -out ${CSRFile} -subj "/C=DE/O=SPIRIT21/CN=ExampleInternalCA"`)

  const csr = await read(CSRFile);

  const certParams = {
    CertificateAuthorityArn,
    Csr: Buffer.from(csr),
    SigningAlgorithm: "SHA256WITHRSA",
    Validity: {
      Type: "DAYS",
      Value: 3600
    }
  }
  const certData = await pca.issueCertificate(certParams).promise();

  // Sometimes the CA isn't finished with issuing the cert, 
  // which is why we have to wait here, before getting the cert
  await sleep(500);

  const cert = await pca.getCertificate({
    CertificateArn: certData.CertificateArn,
    CertificateAuthorityArn
  }).promise();

  return {
    CertificateArn: certData.CertificateArn,
    Certificate: Buffer.from(cert.Certificate).toString("base64")
  };
}
Enter fullscreen mode Exit fullscreen mode

When everything is tied together and packaged as a handler it might look like this:

const AWS = require('aws-sdk');
const pca = new AWS.ACMPCA();
const util = require('util');
const exec = util.promisify(require('child_process').exec);
const read = util.promisify(require('fs').readFile);
const write = util.promisify(require('fs').writeFile);
const exists = require('fs').existsSync;

const privateKeyFile = "/tmp/private.key"
const CSRFile = "/tmp/CSR.csr";
process.env.HOME = "/tmp";

const caParams = {
  CertificateAuthorityConfiguration: {
    KeyAlgorithm: "RSA_2048",
    SigningAlgorithm: "SHA256WITHRSA",
    Subject: {
      Country: 'DE',
      Organization: 'SPIRIT21',
    }
  },
  CertificateAuthorityType: "ROOT",
  RevocationConfiguration: {
    CrlConfiguration: {
      Enabled: false,
    }
  }
}


async function testPCA(arn) {
  var params = {
    CertificateAuthorityArn: arn
  };
  try {
    await pca.getCertificateAuthorityCsr(params);
    return true;
  } catch (e) {
    return false;
  }

}


const sleep = m => new Promise(r => setTimeout(r, m))

async function handler(event, context) {
  try {
    let CertificateAuthorityArn = "";

    if (event.hasOwnProperty("arn")) {
      CertificateAuthorityArn = event.arn;
    }


    if (!await testPCA(CertificateAuthorityArn)) {
      console.log('Generating PCA', caParams);
      const {
        CertificateAuthorityArn
      } = await pca.createCertificateAuthority(caParams).promise();
      console.log(CertificateAuthorityArn);

      console.log("Waiting for the CSR creation..");
      await pca.waitFor('certificateAuthorityCSRCreated', {
        CertificateAuthorityArn
      }).promise();
      console.log("Getting CA-CSR now...");

      const {
        Csr
      } = await pca.getCertificateAuthorityCsr({
        CertificateAuthorityArn
      }).promise();
      console.log('CA-CSR loaded, generating Root CA Cert');

      const CACertParams = {
        CertificateAuthorityArn,
        Csr: Buffer.from(Csr),
        SigningAlgorithm: "SHA256WITHRSA",
        TemplateArn: "arn:aws:acm-pca:::template/RootCACertificate/V1",
        Validity: {
          Type: "YEARS",
          Value: 10
        }
      }

      const {
        CertificateArn
      } = await pca.issueCertificate(CACertParams).promise();
      console.log("Root CA Cert generated");

      // Sometimes the CA is not done with issuing the cert, which is why we have to wait here, before getting the cert
      await sleep(500);

      const CAcert = await pca.getCertificate({
        CertificateAuthorityArn,
        CertificateArn
      }).promise();
      console.log(CAcert);

      await pca.importCertificateAuthorityCertificate({
        CertificateAuthorityArn,
        Certificate: Buffer.from(CAcert.Certificate)
      }).promise();
      console.log("Root CA Cert imported");
    }

    // END CA GENERATION


    // CERTIFICATE GENERATION

    console.log("Generating CSR for new CA Cert");
    await exec(`openssl req -nodes -newkey rsa:4096 -days 3600 -keyout ${privateKeyFile} -out ${CSRFile} -subj "/C=DE/O=SPIRIT21/CN=ExampleInternalCA-Root"`)

    const csr = await read(CSRFile);

    const certParams = {
      CertificateAuthorityArn,
      Csr: Buffer.from(csr),
      SigningAlgorithm: "SHA256WITHRSA",
      Validity: {
        Type: "DAYS",
        Value: 3600
      }
    }

    console.log("Generating Cert in CA");
    const certData = await pca.issueCertificate(certParams).promise();

    // Again, the CA might not be ready.
    await sleep(500);

    const cert = await pca.getCertificate({
      CertificateArn: certData.CertificateArn,
      CertificateAuthorityArn
    }).promise();
    console.log(cert);

    return {
      CertificateArn: certData.CertificateArn,
      Certificate: Buffer.from(cert.Certificate).toString("base64")
    };

  } catch (e) {
    console.error(e);
  }

}

module.exports = {
  handler
};
Enter fullscreen mode Exit fullscreen mode

Pay attention to setting the Lambda timeout on a greater value than 10 seconds, which was the mean execution time during testing. Also do not forget to set the Runtime to Node.js 8.10 or use a Lambda Layer with OpenSSL. Contrary to what one might expect the certificates issued by the Private Certificate Authority are not visible in the normal AWS Certificate Manager, so it is important to store the ARNs of the Certificates created too.

Discussion (0)