Skip to content

Instantly share code, notes, and snippets.

@tavisrudd
Created November 29, 2019 22:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tavisrudd/accb06787aa4323c920daa616e313256 to your computer and use it in GitHub Desktop.
Save tavisrudd/accb06787aa4323c920daa616e313256 to your computer and use it in GitHub Desktop.
import * as http from 'http';
import { URL } from 'url';
import { Handler } from 'aws-lambda'
import S3 from 'aws-sdk/clients/s3';
import SSM from 'aws-sdk/clients/ssm';
import SNS from 'aws-sdk/clients/sns';
import ACME from '@root/acme'; // FIX: use a module with types
import CSR from '@root/csr'; // FIX: use a module with types
import Keypairs from 'keypairs'; // FIX: use a module with types
import punycode from 'punycode';
import UUID from 'uuid-random';
if (! process.env.CERTIFICATE_STORAGE_NAME) {
throw new Error('Missing required env var CERTIFICATE_STORAGE_NAME');
}
const CERTIFICATE_STORAGE_NAME = process.env.CERTIFICATE_STORAGE_NAME;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const s3 = new S3({
apiVersion: '2006-03-01',
region: process.env.AWS_REGION,
});
const ssm = new SSM({
apiVersion: '2014-11-06',
region: process.env.AWS_REGION,
});
async function getParameter<T>(name: string, fallback?: T) {
// FIX: replace all these manual Promises with aws-sdk .promise() calls and async/await
// FIX: why are using a fallback to catch all errors when there are many ways in which this could fail that have nothing to do with
return new Promise((resolve, reject) => {
ssm.getParameter(
{
Name: name,
},
(err, data) => {
if (err && fallback) {
resolve(fallback);
}
else if (err) {
reject(err);
}
else {
resolve(data.Parameter!.Value);
}
},
);
});
}
const challengeTopicArn = getParameter(`/${process.env.ENVIRONMENT}/domain-handler/certificate-creation/ChallengeTopicARN`) as Promise<string>;
// TODO why is this initialized here instead of in initializeACME?
const sns = challengeTopicArn
.then((topicArn) => {
return new SNS({
apiVersion: '2010-03-31',
region: topicArn.split(':')[3],
});
});
// WTF was this? It looks like a silent failure that would be cached at the module level.
// .catch(() => {
// return null;
// });
type LetsEncryptConfig = {
// FIX: get rid of all these `any` types
acme: any;
accountKeypair: any;
account: any;
hosts: string[];
challengeTopicArn: string;
sns: SNS;
s3?: any;
certificates?: any;
certificateKeypair?: any;
// FIX: these
}
async function initializeACME(challengeTopicArn: Promise<string>, snsPromise: typeof sns): Promise<LetsEncryptConfig> {
// FIX: move all these paths out to module level and interpolate & validate the environment there:
const hosts = getParameter(`/${process.env.ENVIRONMENT}/domain-handler/certificate-creation/PageServerHosts`) as Promise<string>;
const emailAddress = getParameter(`/${process.env.ENVIRONMENT}/domain-handler/certificate-creation/EmailAddress`) as Promise<string>;
const directoryUrl = getParameter(`/${process.env.ENVIRONMENT}/domain-handler/certificate-creation/DirectoryURL`) as Promise<string>;
// FIX: use the simpler form `const x = await ssm.getParameter(name).promise()` and get rid of awaits below.
const jwkAccountKey = getParameter(`/${process.env.ENVIRONMENT}/domain-handler/certificate-creation/JWKAccountKey`, null);
// FIX: explain why it's ok for this one to be null. What is the lifecyle of the accounts here?
const acme = ACME.create({
maintainerEmail: await emailAddress,
packageAgent: 'domain-handler/1.0 certificate-creation/1.0.0',
notify: console.log, // FIX: ensure we don't leak certificates/keys to cloudwatch logs.
// ^ FIX: This is also the way to pass console.log for usage. `log` to be bound to the console: console.log.bind(console)
});
await acme.init(await directoryUrl);
const accountKeypair = await Keypairs.parseOrGenerate({
key: await jwkAccountKey,
});
accountKeypair.private.alg = 'RS256';
const account = await acme.accounts.create({
// FIX: explain the lifecyle of the accounts here. Are they per customer domain or once per environment?
subscriberEmail: await emailAddress,
agreeToTerms: true,
accountKey: accountKeypair.private,
});
return {
acme,
accountKeypair,
account,
hosts: (await hosts).split(','),
challengeTopicArn: await challengeTopicArn,
sns: await snsPromise,
};
}
type ChallengeParams = {
headers: {
host: string, // FIX: document why host + hostname. Why does one call below omit headers.host?
},
hostname: string,
path: string,
timeout: number,
}
async function challengeReady(params: ChallengeParams, expected: string): Promise<string | null> {
return new Promise((resolve) => {
const request = http.get(
params,
(res) => {
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
if (expected) {
resolve(expected == body ? body : null);
}
else {
resolve(body);
}
});
}
);
request.on('error', _err => {
resolve(null);
});
request.on('timeout', () => {
request.abort();
resolve(null);
});
});
}
async function createCertificate(letsencrypt: LetsEncryptConfig, name: string) {
// FIX: ^ use a more descriptive name than `name`. Is it a domainName?
const certificateKeypair = await Keypairs.generate({
kty: 'RSA',
format: 'jwk',
});
letsencrypt.certificateKeypair = certificateKeypair;
// FIX: ^ this mutates a module level value that persists between lambda calls and thus will leak data from one call
// to the next.
const domains = [
punycode.toASCII(name),
];
const csr = await CSR.csr({
jwk: certificateKeypair.private,
domains,
encoding: 'pem',
});
const challenges = {
'http-01': {
set: async (opts: any) => {
return new Promise((resolve) => {
// FIX: use async/await here not callbacks
letsencrypt.sns.publish(
{
TopicArn: letsencrypt.challengeTopicArn,
Message: JSON.stringify({
type: 'TLSProvisionerCertificateChallengeEventV0.1',
source: 'domain-handler certificate-creation',
id: UUID(),
time_sent: new Date().toISOString(),
path: opts.challenge.challengeUrl,
content: opts.challenge.keyAuthorization,
}),
MessageAttributes: {
type: {
DataType: 'String',
StringValue: 'TLSProvisionerCertificateChallengeEventV0.1',
},
},
},
(err, data) => {
if (err) {
console.log(err, err.stack);
resolve(err);
}
else {
resolve(data);
}
},
);
})
.then(() => {
// FIX: don't mix Promise method calls with async/await. Use async/await throughout.
return Promise.all(
letsencrypt.hosts.map((host) => {
return new Promise(async function(resolve) {
let attempts = 30; // FIX: magic number
const params = {
headers: {
host: opts.challenge.identifier.value,
},
hostname: host,
path: `/.well-known/acme-challenge/${opts.challenge.token}`,
timeout: 30000, // FIX: magic number
};
await sleep(1000); // FIX: magic number
let result = await challengeReady(params, opts.challenge.keyAuthorization);
while (!result) {
attempts -= 1;
if (attempts <= 0) {
console.log(`Challenge was not ready in time for ${host}`);
resolve(null);
return;
}
else {
await sleep(1000); // FIX: magic number
result = await challengeReady(params, opts.challenge.keyAuthorization);
}
}
console.log(`Challenge is ready for ${host}; waiting a little longer for propagation`);
await sleep(5000); // FIX: magic number
resolve(result);
});
}),
);
})
.then((results) => {
if (results.filter((result) => { return result == null; }).length != 0) {
return null;
}
else {
return results[0];
}
});
},
get: async (query: any) => {
const url = new URL(query.challenge.url);
const params = {
headers: {
host: url.hostname,
},
hostname: url.hostname,
path: url.pathname,
timeout: 30000, // FIX: magic number
};
return challengeReady(params, 'FIX-why-was-this-param-missing?')
.then(body => {
// FIX: body can be null, is that valid here???
return {
keyAuthorization: body,
};
});
},
remove: (_opts: any) => Promise.resolve(),
},
};
const certificates = await letsencrypt.acme.certificates.create({
account: letsencrypt.account,
accountKey: letsencrypt.accountKeypair.private,
csr,
domains,
challenges,
});
letsencrypt.certificates = certificates;
// FIX: ^ this mutates a module level value that persists between lambda calls and thus will leak data from one call
// to the next. If there are any bugs now or introduced later that would result in the
return letsencrypt;
}
async function uploadCertificate(letsencrypt: LetsEncryptConfig, name: string): Promise<LetsEncryptConfig> {
// FIX: use async/await rather than callbacks. This file mixes 3 different approaches to async code: callbacks,
// promises, and async/await.
return new Promise((resolve, reject) => {
s3.putObject(
{
Bucket: CERTIFICATE_STORAGE_NAME,
Key: `${name}.crt`,
Body: `${letsencrypt.certificates.cert}\n${letsencrypt.certificates.chain}\n${Keypairs.export({ jwk: letsencrypt.certificateKeypair.private })}\n`,
},
(err, data) => {
if (err) {
reject(err);
}
else {
letsencrypt.s3 = data;
resolve(letsencrypt);
}
},
);
});
}
const letsencryptConfigPromise = initializeACME(challengeTopicArn, sns);
// ^ FIX: doing this at the module level is static and requires a redeploy if the PageServerHosts ssm param changes to
// reliably forget that value. See the note above about mutatation of LetsEncryptConfig. This is not safe. Move out of
// module level and into hander to be done once per call.
export const handler: Handler = (event, _context, callback) => {
// FIX: we're mixing Promise .then/catch and async/await. Use async/await throughout.
letsencryptConfigPromise
.then((letsencrypt) => createCertificate(letsencrypt, event.Name))
.then((letsencrypt) => uploadCertificate(letsencrypt, event.Name))
.then((letsencrypt) => {
console.log(`Successfully created certificate for ${event.Name}`);
const expiry = letsencrypt.certificates.expires;
const offset = Math.floor(Math.random() * 864000000) + 1728000000; // FIX: magic numbers
const renewal = new Date(new Date(expiry).getTime() - offset).toISOString();
callback(
null,
{
Success: true,
Checksum: letsencrypt.s3.ETag,
Expiry: expiry,
Renewal: renewal,
},
);
})
.catch((err) => {
console.log(`Failed to create certificate for ${event.Name}: ${JSON.stringify(err)}`, err.stack);
// FIX: these two lines might leak certs and keys to cloudwatch logs. It might even leak our account level
// keypairs.
callback(err, {Success: false});
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment