Skip to content

Commit

Permalink
feat(core): Add configurable expiry to X.509 certificates (#242)
Browse files Browse the repository at this point in the history
  • Loading branch information
ddneilson authored Nov 26, 2020
1 parent bdef391 commit ae7c153
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 8 deletions.
13 changes: 13 additions & 0 deletions packages/aws-rfdk/lib/core/lib/x509-certificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { RetentionDays } from '@aws-cdk/aws-logs';
import { ISecret, Secret } from '@aws-cdk/aws-secretsmanager';
import {
Annotations,
Construct,
CustomResource,
Duration,
Expand Down Expand Up @@ -88,6 +89,13 @@ export interface X509CertificatePemProps {
* @default: None. The generated certificate will be self-signed
*/
readonly signingCertificate?: X509CertificatePem;

/**
* The number of days that the generated certificate will be valid for.
*
* @default 1095 days (3 years)
*/
readonly validFor?: number;
}

/**
Expand Down Expand Up @@ -263,6 +271,10 @@ export class X509CertificatePem extends X509CertificateBase implements IX509Cert
encryptionKey: props.encryptionKey,
});

if ((props.validFor ?? 1) < 1 && !Token.isUnresolved(props.validFor)) {
Annotations.of(this).addError('Certificates must be valid for at least one day.');
}

props.signingCertificate?.cert.grantRead(this.lambdaFunc);
props.signingCertificate?.key.grantRead(this.lambdaFunc);
props.signingCertificate?.passphrase.grantRead(this.lambdaFunc);
Expand Down Expand Up @@ -295,6 +307,7 @@ export class X509CertificatePem extends X509CertificateBase implements IX509Cert
],
},
SigningCertificate: signingCertificate,
CertificateValidFor: props.validFor?.toString(),
};
const resource = new CustomResource(this, 'Default', {
serviceToken: this.lambdaFunc.functionArn,
Expand Down
44 changes: 44 additions & 0 deletions packages/aws-rfdk/lib/core/test/x509-certificate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import {
anything,
expect as expectCDK,
haveResource,
haveResourceLike,
Expand Down Expand Up @@ -40,6 +41,12 @@ test('Generate cert', () => {
OU: 'Thinkbox',
},
}));
// Cannot have a CertificateValidFor property if not given one. Adding one
// would cause existing certificates to be re-generated on re-deploy, and thus
// risk breaking customer's setups.
expectCDK(stack).notTo(haveResourceLike('Custom::RFDK_X509Generator', {
CertificateValidFor: anything(),
}));
// Expect the resource for converting to PKCS #12 not to be created
expectCDK(stack).notTo(haveResource('Custom::RFDK_X509_PKCS12'));
// Expect the DynamoDB table used for custom resource tracking
Expand Down Expand Up @@ -122,6 +129,9 @@ test('Generate cert', () => {
}
return true;
}));

// Should not be any errors.
expect(cert.node.metadata.length).toBe(0);
});

test('Generate cert, all options set', () => {
Expand All @@ -139,6 +149,7 @@ test('Generate cert, all options set', () => {
subject,
encryptionKey,
signingCertificate,
validFor: 3000,
});

const certPassphraseID = stack.getLogicalId(cert.passphrase.node.defaultChild as CfnSecret);
Expand Down Expand Up @@ -168,6 +179,7 @@ test('Generate cert, all options set', () => {
},
CertChain: '',
},
CertificateValidFor: '3000',
}));
// Expect the resource for converting to PKCS #12 not to be created
expectCDK(stack).notTo(haveResource('Custom::RFDK_X509_PKCS12'));
Expand Down Expand Up @@ -453,6 +465,38 @@ test('Grant full read', () => {
expectCDK(stack).notTo(haveResource('Custom::RFDK_X509_PKCS12'));
});

test('Validating expiry', () => {
// GIVEN
const stack = new Stack(undefined, 'Stack', { env: { region: 'us-west-2' } });
const subject = { cn: 'testCN' };

// WHEN
const cert = new X509CertificatePem(stack, 'Cert', {
subject,
validFor: 0,
});

// THEN
expect(cert.node.metadata.length).toBe(1);
});

test('Validating expiry with token', () => {
// GIVEN
const stack = new Stack(undefined, 'Stack', { env: { region: 'us-west-2' } });
const subject = { cn: 'testCN' };
// A numeric CDK token (see: https://docs.aws.amazon.com/cdk/latest/guide/tokens.html#tokens_number)
const CDK_NUMERIC_TOKEN = -1.8881545897087626e+289;

// WHEN
const cert = new X509CertificatePem(stack, 'Cert', {
subject,
validFor: CDK_NUMERIC_TOKEN,
});

// THEN
expect(cert.node.metadata.length).toBe(0);
});

test('Convert to PKCS #12', () => {
const stack = new Stack();
const subject = { cn: 'testCN' };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class Certificate implements ICertificate {
public static async fromGenerated(
subject: DistinguishedName,
passphrase: string,
certValidFor?: number,
signingCertificate?: Certificate,
): Promise<Certificate> {
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'tmp.'));
Expand All @@ -39,11 +40,11 @@ export class Certificate implements ICertificate {
let key: string;
let certChain: string;
if (!signingCertificate) {
[cert, key] = await Certificate.generateSelfSigned(tmpDir, subject, passphrase);
[cert, key] = await Certificate.generateSelfSigned(tmpDir, subject, passphrase, certValidFor ?? 1095);
// certChain cannot be left undefined. CFN expects that attributes will *always* have values.
certChain = '';
} else {
[cert, key, certChain] = await Certificate.generateSigned(tmpDir, subject, passphrase, signingCertificate);
[cert, key, certChain] = await Certificate.generateSigned(tmpDir, subject, passphrase, certValidFor ?? 1095, signingCertificate);
}
return new Certificate(cert, key, passphrase, certChain);
} finally {
Expand Down Expand Up @@ -93,14 +94,15 @@ export class Certificate implements ICertificate {
tmpDir: string,
subject: DistinguishedName,
passphrase: string,
certValidFor: number,
): Promise<[string, string]> {
const crtFile: string = path.join(tmpDir, 'crt');
const keyFile: string = path.join(tmpDir, 'key');
const cmd: string =
'openssl req -x509 ' +
'-passout env:CERT_PASSPHRASE ' +
'-newkey rsa:2048 ' +
'-days 1095 ' +
`-days ${certValidFor} ` +
'-extensions v3_ca ' +
`-keyout ${keyFile} -out ${crtFile} ` +
`-subj ${subject.toString()}`;
Expand All @@ -118,6 +120,7 @@ export class Certificate implements ICertificate {
tmpDir: string,
subject: DistinguishedName,
passphrase: string,
certValidFor: number,
signingCertificate: Certificate,
): Promise<[string, string, string]> {
const signingCertFile = path.join(tmpDir, 'signing.crt');
Expand All @@ -134,13 +137,13 @@ export class Certificate implements ICertificate {
'openssl req ' +
'-passout env:CERT_PASSPHRASE ' +
'-newkey rsa:2048 ' +
'-days 1095 ' +
`-days ${certValidFor} ` +
`-out ${csrFile} -keyout ${keyFile} ` +
`-subj ${subject.toString()}`;
const crtCreate =
'openssl x509 -sha256 -req ' +
'-passin env:SIGNING_PASSPHRASE ' +
'-days 1095 ' +
`-days ${certValidFor} ` +
`-in ${csrFile} ` +
`-CA ${signingCertFile} -CAkey ${signingKeyFile} -CAcreateserial ` +
`-out ${crtFile}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ test('generate self-signed', async () => {
const certVerification: string = certOut.stdout;
const keyOut = await exec(`openssl rsa -noout -text -check -passin env:PW -in ${keyFileName}`, { env: { PW: passphrase }});
const keyVerification = keyOut.stdout;
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 3*365);

// THEN
expect(certificate.cert).toContain('-----BEGIN CERTIFICATE-----');
Expand All @@ -71,11 +73,39 @@ test('generate self-signed', async () => {
expect(certVerification).toContain('Subject: CN=TestCN, O=TestO, OU=TestOU');
expect(certVerification).toContain('Version: 3 (0x2)');
expect(certVerification).toContain('Public-Key: (2048 bit)');
// ex: Not After : May 22 22:13:24 2023 GMT
expect(certVerification).toMatch(new RegExp(`Not After.*${expiryDate.getFullYear()} GMT`));

expect(keyVerification).toContain('RSA key ok');
expect(keyVerification).toContain('Private-Key: (2048 bit)');
});

test('generate self-signed with expiry', async () => {
// GIVEN
const name: DistinguishedName = new DistinguishedName({
CN: 'TestCN',
O: 'TestO',
OU: 'TestOU',
});
const passphrase = 'test_passphrase';

// WHEN
const certificate = await Certificate.fromGenerated(name, passphrase, 5*365);

const crtFileName = path.join(tmpDir, 'ss-ca.crt');
const keyFileName = path.join(tmpDir, 'ss-ca.key');
await writeAsciiFile(crtFileName, certificate.cert);
await writeAsciiFile(keyFileName, certificate.key);
const certOut = await exec(`openssl x509 -noout -text -in ${crtFileName}`);
const certVerification: string = certOut.stdout;
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 5*365);

// THEN
// ex: Not After : May 22 22:13:24 2023 GMT
expect(certVerification).toMatch(new RegExp(`Not After.*${expiryDate.getFullYear()} GMT`));
});

test('generate signed certificate', async () => {
// GIVEN
const caName: DistinguishedName = new DistinguishedName({
Expand All @@ -92,7 +122,7 @@ test('generate signed certificate', async () => {
const passphrase: string = 'test_passphrase';

// WHEN
const certificate = await Certificate.fromGenerated(certName, passphrase, ca);
const certificate = await Certificate.fromGenerated(certName, passphrase, undefined, ca);

const crtFileName = path.join(tmpDir, 'signed.crt');
const crtChainFileName = path.join(tmpDir, 'chain.crt');
Expand All @@ -111,6 +141,8 @@ test('generate signed certificate', async () => {
{ env: { PATH: process.env.PATH, PW: passphrase }},
);
const keyVerification = keyOut.stdout;
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 3*365);

// THEN
expect(certificate.cert).toContain('-----BEGIN CERTIFICATE-----');
Expand All @@ -128,6 +160,8 @@ test('generate signed certificate', async () => {
expect(certVerification).toContain('Issuer: CN=TestCN, O=TestO, OU=TestOU');
expect(certVerification).toContain('Subject: CN=CertCN, O=CertO, OU=CertOU');
expect(certVerification).toContain('Public-Key: (2048 bit)');
// ex: Not After : May 22 22:13:24 2023 GMT
expect(certVerification).toMatch(new RegExp(`Not After.*${expiryDate.getFullYear()} GMT`));

expect(certChainVerification).toContain('Issuer: CN=TestCN, O=TestO, OU=TestOU');
expect(certChainVerification).toContain('Subject: CN=TestCN, O=TestO, OU=TestOU');
Expand All @@ -137,6 +171,43 @@ test('generate signed certificate', async () => {
expect(keyVerification).toContain('Private-Key: (2048 bit)');
});

test('generate signed certificate with expiry', async () => {
// GIVEN
const caName: DistinguishedName = new DistinguishedName({
CN: 'TestCN',
O: 'TestO',
OU: 'TestOU',
});
const certName: DistinguishedName = new DistinguishedName({
CN: 'CertCN',
O: 'CertO',
OU: 'CertOU',
});
const ca = await Certificate.fromGenerated(caName, 'signing_passphrase');
const passphrase: string = 'test_passphrase';

// WHEN
const certificate = await Certificate.fromGenerated(certName, passphrase, 5*365, ca);

const crtFileName = path.join(tmpDir, 'signed.crt');
const crtChainFileName = path.join(tmpDir, 'chain.crt');
const keyFileName = path.join(tmpDir, 'signed.key');
await writeAsciiFile(crtFileName, certificate.cert);
if (certificate.certChain) {
await writeAsciiFile(crtChainFileName, certificate.certChain);
}
await writeAsciiFile(keyFileName, certificate.key);
const certOut = await exec(`openssl x509 -noout -text -in ${crtFileName}`);
const certVerification: string = certOut.stdout;
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 5*365);

// THEN
// ex: Not After : May 22 22:13:24 2023 GMT
expect(certVerification).toMatch(new RegExp(`Not After.*${expiryDate.getFullYear()} GMT`));
});


test('convert to PKCS #12', async () => {
const caName: DistinguishedName = new DistinguishedName({
CN: 'TestCN',
Expand All @@ -150,7 +221,7 @@ test('convert to PKCS #12', async () => {
});
const ca: Certificate = await Certificate.fromGenerated(caName, 'signing_passphrase');
const passphrase: string = 'test_passphrase';
const certificate: Certificate = await Certificate.fromGenerated(certName, passphrase, ca);
const certificate: Certificate = await Certificate.fromGenerated(certName, passphrase, undefined, ca);
const pkcs12Passphrase: string = 'test_passphrase';

// WHEN
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export class X509CertificateGenerator extends X509Common {

const subject = new DistinguishedName(resourceProperties.DistinguishedName);
const passphrase = await Secret.fromArn(resourceProperties.Passphrase, this.secretsManagerClient).getValue() as string;
let certExpiry: number = resourceProperties.CertificateValidFor ? Number(resourceProperties.CertificateValidFor) : 1095;
let signingCert: Certificate | undefined;
if (resourceProperties.SigningCertificate) {
const signCert = resourceProperties.SigningCertificate;
Expand All @@ -185,7 +186,7 @@ export class X509CertificateGenerator extends X509Common {
: '';
signingCert = new Certificate(cert, key, pass, certChain);
}
const newCert = await Certificate.fromGenerated(subject, passphrase, signingCert);
const newCert = await Certificate.fromGenerated(subject, passphrase, certExpiry, signingCert);

const now = new Date(Date.now());
// timeSuffix = "<year>-<month>-<day>-<time since epoch>" -- to disambiguate secrets
Expand Down
10 changes: 10 additions & 0 deletions packages/aws-rfdk/lib/lambdas/nodejs/x509-certificate/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export interface IX509CertificateGenerate extends IX509ResourceProperties {
* @default None; we generate a self-signed certificate
*/
readonly SigningCertificate?: ISecretCertificate;

/**
* The number of days for which the generated certificate should be valid.
* @default 1095 days (i.e. 3 years)
*/
readonly CertificateValidFor?: string;
}

/**
Expand Down Expand Up @@ -165,6 +171,10 @@ export function implementsIX509CertificateGenerate(value: any): boolean {
if (!implementsIX509ResourceProperties(value)) { return false; }
if (!implementsDistinguishedNameProps(value.DistinguishedName)) { return false; }
if (value.SigningCertificate && !implementsISecretCertificate(value.SigningCertificate)) { return false; }
if (value.CertificateValidFor) {
if (typeof(value.CertificateValidFor) !== 'string') { return false; }
if (value.CertificateValidFor === '' || isNaN(Number(value.CertificateValidFor))) { return false; }
}
return true;
}

Expand Down

0 comments on commit ae7c153

Please sign in to comment.