Created
November 29, 2019 22:03
-
-
Save tavisrudd/accb06787aa4323c920daa616e313256 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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