├── .eslintrc ├── .gitignore ├── AWS.md ├── LICENSE.md ├── README.md ├── ROADMAP.md ├── app.js ├── bin ├── importToACM.js ├── local.js └── write_pems.js ├── config.js ├── deploy.sh ├── package-lock.json ├── package.json ├── src ├── acme │ ├── authorize │ │ ├── getChallenges.js │ │ ├── sendDNSChallengeValidation.js │ │ ├── updateDNSChallenge.js │ │ └── validateChallenges.js │ ├── certUtils.js │ ├── certify │ │ ├── createCertificate.js │ │ └── newCertificate.js │ ├── generateCertificate.js │ ├── getDiscoveryUrls.js │ ├── getNonce.js │ ├── register │ │ ├── createAccount.js │ │ ├── getAccount.js │ │ └── register.js │ ├── sendSignedRequest.js │ ├── toAgreement.js │ ├── urlB64.js │ └── v2 │ │ ├── createV2Certificate.js │ │ ├── getV2AntiReplayNonce.js │ │ ├── getV2Order.js │ │ ├── newAccount.js │ │ ├── newCertificate.js │ │ ├── performAuthorizations.js │ │ ├── sendSignedRequestV2.js │ │ └── sendV2DNSChallengeValidation.js ├── aws │ ├── route53 │ │ ├── getHostedZoneId.js │ │ └── updateTXTRecord.js │ ├── s3 │ │ ├── readFile.js │ │ └── saveFile.js │ └── sdk │ │ ├── getACM.js │ │ ├── getRoute53.js │ │ └── getS3.js ├── retry.js └── util │ ├── downloadBinary.js │ ├── downloadText.js │ ├── generateCSR.js │ ├── generateRSAKeyPair.js │ └── isExpired.js ├── test ├── proxyquire.js ├── setup.js └── unit │ ├── appSpec.js │ └── src │ └── acme │ ├── authorize │ └── updateDNSChallengeSpec.js │ ├── certUtilsSpec.js │ └── generateCertificateSpec.js └── zip.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | #sonar 21 | .sonar 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (http://nodejs.org/api/addons.html) 30 | build/Release 31 | dist 32 | dist.* 33 | 34 | # Dependency directories 35 | node_modules 36 | jspm_packages 37 | 38 | # Optional npm cache directory 39 | .npm 40 | 41 | # Optional REPL history 42 | .node_repl_history 43 | 44 | .idea 45 | .DS_Store 46 | -------------------------------------------------------------------------------- /AWS.md: -------------------------------------------------------------------------------- 1 | # AWS Configuration 2 | 3 | ## IAM 4 | In addition to the AWSLambdaBasicExecutionRole (for CloudWatch logging), the 5 | lambda function also needs to be assigned a role which 6 | has permissions to write to Route53 (to satisfy ACME DNS challenge) and 7 | to read/write to the S3 buckets it is configured for for user registration and 8 | domain certificate files. 9 | 10 | Here's an example policy which uses buckets called "acme-account.MYWEBSITE.com" 11 | and "acme-certs.MYWEBSITE.com" to store files, and gives access to two hosted zones: 12 | 13 | ```json 14 | { 15 | "Version": "2012-10-17", 16 | "Statement": [ 17 | { 18 | "Effect": "Allow", 19 | "Action": [ 20 | "s3:ListBucket" 21 | ], 22 | "Resource": [ 23 | "arn:aws:s3:::acme-account.MYWEBSITE.com", 24 | "arn:aws:s3:::acme-certs.MYWEBSITE.com" 25 | ] 26 | }, 27 | { 28 | "Effect": "Allow", 29 | "Action": [ 30 | "s3:PutObject", 31 | "s3:GetObject" 32 | ], 33 | "Resource": [ 34 | "arn:aws:s3:::acme-account.MYWEBSITE.com/*", 35 | "arn:aws:s3:::acme-certs.MYWEBSITE.com/*" 36 | ] 37 | }, 38 | { 39 | "Effect": "Allow", 40 | "Action": [ 41 | "route53:ChangeResourceRecordSets", 42 | "route53:GetHostedZone", 43 | "route53:ListResourceRecordSets" 44 | ], 45 | "Resource": [ 46 | "arn:aws:route53:::hostedzone/EXAMPLE1AAEFEG", 47 | "arn:aws:route53:::hostedzone/EXAMPLE2AOMWDM" 48 | ] 49 | }, 50 | { 51 | "Effect": "Allow", 52 | "Action": [ 53 | "route53:GetChange", 54 | "route53:ListHostedZones" 55 | ], 56 | "Resource": "*" 57 | } 58 | ] 59 | } 60 | ``` 61 | 62 | ## Lambda Execution 63 | The Lambda function needs to run periodically as a scheduled function, preferably 64 | every day or perhaps every few days. 65 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright `2016` `Larry Anderson` 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node Lambda ACME (Let's Encrypt-compatible) 2 | 3 | [![](https://sonarcloud.io/api/project_badges/measure?project=node-acme-lambda&metric=alert_status)](https://sonarcloud.io/dashboard?id=node-acme-lambda) 4 | 5 | Use [AWS Lambda](https://aws.amazon.com/lambda/) to manage SSL certificates for 6 | ACME providers. 7 | 8 | # How does it work? 9 | This project utilizes AWS Lambda to periodically (once per day) check a set of 10 | certificates for expiration, and then if they're about to expire or 11 | invalid/missing, it will request a new certificate from the ACME 12 | infrastructure. 13 | 14 | Certificates are stored in S3, which can easily be configured to send an SNS 15 | notification based upon a PUT event into the configured bucket. 16 | 17 | ## Project status 18 | Please see the [roadmap](ROADMAP.md) for a sorted list of upcoming features by priority. 19 | 20 | ## AWS Configuration 21 | This project requires a little [configuration](AWS.md) to be used in AWS. 22 | 23 | ## General configuration 24 | Modify the [configuration file](./config.js) with the values needed for 25 | your environment: 26 | 27 | | *Variable* | *Description* | 28 | | :--------------------- |:--------------| 29 | | `acme-directory-url` | Change to production url - https://acme-v01.api.letsencrypt.org if ready for real certificate. | 30 | | `acme-account-email` | Email of user requesting certificate. | 31 | | `s3-account-bucket` | An S3 bucket to place account keys/config data into. You will need to create this bucket and assign the [IAM role](AWS.md) to read/write. | 32 | | `s3-cert-bucket` | An S3 bucket to place domain certificate data into. You will need to create this bucket and assign the [IAM role](AWS.md) to read/write. | 33 | | `s3-folder` | A folder within the above buckets to place the files under, in case there are other contents of these buckets. | 34 | | `certificate-info` | Object containing [certificate information mapping](https://github.com/ocelotconsulting/node-acme-lambda#certificate-info-field-of-configuration-file) certificate names to domains. | 35 | 36 | ## ACME v2 Support 37 | Change the `acme-directory-url` to one of the v2 urls: 38 | 39 | * stage: https://acme-staging-v02.api.letsencrypt.org 40 | * prod: https://acme-v02.api.letsencrypt.org 41 | 42 | and you will be able to request wildcarded certificates. 43 | 44 | ## Execution 45 | Follow these steps to get started: 46 | 47 | 1. Git-clone this repository. 48 | 49 | $ git clone git@github.com:ocelotconsulting/node-acme-lambda.git 50 | 51 | 2. Modify configuration (as above). 52 | 53 | 3. Create S3 buckets, IAM role, then test locally: 54 | 55 | $ npm run local-cert 56 | 57 | 4. Package lambda zip: 58 | 59 | $ npm run dist 60 | 61 | 5. Create lambda by uploading zip, set the handler to "app.handler", and establish your desired trigger (i.e. periodic). 62 | 63 | *Optional*: You can write your certificates to a PEM file by executing: 64 | 65 | $ npm run pems 66 | 67 | ### `certificate-info` field of [configuration file](./config.js) 68 | 69 | - Certificate names are keys of JSON object, denoting sets of sub/domains to use as SAN names in certificate. 70 | - Value of certificate name keys is array of sub/domains, which can contain either: 71 | - a string (default, looks for route53 hosted zone with 2 levels **this is all that is currently supported for v2/wildcard certificates currently**) 72 | - or an object, with both `name` and `zoneLevels` defined, allowing hosted zones at levels greater than 2 (i.e. `host.at.longer.domain.com` could specify 4 zone levels, which would require proper NS records in parent Route53 hosted zone or other DNS). 73 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | An overview of where I'd like the functionality to go over the next few versions. 4 | 5 | Please feel free to file issues on this repository if you have questions, concerns, or suggestions. 6 | 7 | ## Features/Fixes 8 | 9 | * Tests 10 | * Refactoring to reduce code duplication between acme v1/v2 11 | * Webpack/babel to add ES6 support and allow newer language functionality 12 | * Enable other DNS providers (or other challenges) besides Route53 13 | * Possibly separate to lib/cli repo + lambda repo 14 | * ~~Multiple domain support~~ 15 | * ~~Actually convert to lambda (express/handler)~~ 16 | * ~~Sister project for responding to SNS notification and configuring IAM.~~ 17 | * ~~" " ELB's.~~ 18 | ~~* Different run modes: local file writing via nodejs vs Lambda + S3.~~ 19 | * ~~Support SAN in same certificate (let's encrypt allows up to 100 names per certificate)~~ 20 | * ~~ wildcard certs~~ 21 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const generateCertificate = require('./src/acme/generateCertificate') 2 | const isExpired = require('./src/util/isExpired') 3 | const config = require('./config') 4 | 5 | const single = (key, domains) => 6 | isExpired(key) 7 | .then(expired => 8 | (expired 9 | ? generateCertificate({key, domains}) 10 | : { 11 | err: false, 12 | msg: `Certificate for ${key} is still valid, going back to bed.` 13 | } 14 | ) 15 | ) 16 | .catch(err => ({ 17 | err: true, 18 | msg: `Updating cert for ${key}, received err ${err}, ${err.stack}` 19 | })) 20 | 21 | const certificates = (certDefinitions) => 22 | Object.keys(certDefinitions) 23 | .map(certKey => 24 | single(certKey, certDefinitions[certKey]) 25 | ) 26 | 27 | const updateCertificates = (options, context) => 28 | Promise.all(certificates(config['certificate-info'])) 29 | .then(context.succeed) 30 | 31 | module.exports = { handler: updateCertificates } 32 | -------------------------------------------------------------------------------- /bin/importToACM.js: -------------------------------------------------------------------------------- 1 | const readFile = require('../src/aws/s3/readFile') 2 | const getACM = require('../src/aws/sdk/getACM') 3 | const config = require('../config') 4 | 5 | const testContext = { 6 | succeed: (data) => { 7 | console.log(data) 8 | process.exit(0) 9 | } 10 | } 11 | const getPEMsForImport = (key) => 12 | readFile( 13 | config['s3-cert-bucket'], 14 | config['s3-folder'], 15 | `${key}.json` 16 | ) 17 | .then((data) => JSON.parse(data.Body.toString())) 18 | .then(({cert, issuerCert, key: {privateKeyPem}}) => { 19 | console.log(`About to import PEM files for ${key} to ACM..`) 20 | return getACM().importCertificate(Object.assign({ 21 | Certificate: cert.toString(), 22 | PrivateKey: privateKeyPem.toString()}, {CertificateChain: issuerCert ? issuerCert.toString() : undefined})).promise() 23 | .then(({CertificateArn}) => console.log(`Successfully imported certificate, ARN: ${CertificateArn}`)) 24 | .catch(err => console.error(`Error importing certificate to ACM.`, err)) 25 | }) 26 | 27 | const importAllPEMs = (sync) => 28 | Promise.all(Object.keys(config['certificate-info']).map(getPEMsForImport)) 29 | .then(() => sync.succeed('Imported PEM files..')) 30 | 31 | importAllPEMs(testContext) 32 | -------------------------------------------------------------------------------- /bin/local.js: -------------------------------------------------------------------------------- 1 | const cert = require('../app.js') 2 | 3 | const testContext = { 4 | succeed: (data) => { 5 | console.log(`Results are ${JSON.stringify(data)}`) 6 | process.exit(0) 7 | } 8 | } 9 | 10 | cert.handler({}, testContext) 11 | -------------------------------------------------------------------------------- /bin/write_pems.js: -------------------------------------------------------------------------------- 1 | const readFile = require('../src/aws/s3/readFile') 2 | const config = require('../config') 3 | const fs = require('fs') 4 | 5 | const testContext = { 6 | succeed: (data) => { 7 | console.log(data) 8 | process.exit(0) 9 | } 10 | } 11 | 12 | const getPEMsForCertInfo = (key) => 13 | readFile( 14 | config['s3-cert-bucket'], 15 | config['s3-folder'], 16 | `${key}.json` 17 | ) 18 | .then((data) => JSON.parse(data.Body.toString())) 19 | .then((certJSON) => { 20 | console.log(`About to write PEM files for ${key}..`) 21 | try { 22 | fs.writeFileSync(`./${key}.pem`, certJSON.cert.toString()) 23 | if (certJSON.issuerCert) { 24 | fs.writeFileSync(`./${key}-chain.pem`, certJSON.issuerCert.toString()) 25 | } 26 | fs.writeFileSync(`./${key}-key.pem`, certJSON.key.privateKeyPem.toString()) 27 | } catch (e) { 28 | console.error('Error writing pem files', e) 29 | } 30 | }) 31 | .catch() 32 | 33 | const getAllPEMs = (sync) => 34 | Promise.all(Object.keys(config['certificate-info']).map(getPEMsForCertInfo)) 35 | .then(() => sync.succeed('Wrote PEM files..')) 36 | 37 | getAllPEMs(testContext) 38 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | const defaultCertInfo = { 2 | 'cert-name1': ['', '', {'name': '', 'zoneLevels': 3}], 3 | 'cert-name2': ['', ''] 4 | } 5 | 6 | const productionDirectoryUrl = process.env.ACME_DIRECTORY_URL || 'https://acme-v01.api.letsencrypt.org' 7 | const stagingDirectoryUrl = process.env.ACME_DIRECTORY_URL || 'https://acme-staging.api.letsencrypt.org' 8 | 9 | module.exports = { 10 | 's3-account-bucket': process.env.S3_ACCOUNT_BUCKET || '', 11 | 's3-cert-bucket': process.env.S3_CERT_BUCKET || '', 12 | 's3-folder': process.env.S3_CERT_FOLDER || '', 13 | 'certificate-info': process.env.S3_CERT_INFO ? JSON.parse(process.env.S3_CERT_INFO) : defaultCertInfo, 14 | 'acme-dns-retry': 30, 15 | 'acme-dns-retry-delay-ms': 2000, 16 | 'acme-account-file': process.env.ACME_ACCOUNT_FILE || '', 17 | 'acme-account-email': process.env.ACME_ACCOUNT_EMAIL || '', 18 | 'acme-account-key-bits': 2048, 19 | 'acme-directory-url': process.env.USE_PRODUCTION ? productionDirectoryUrl : stagingDirectoryUrl, 20 | 'region': process.env.AWS_REGION || 'us-east-1' 21 | } 22 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | timestamp() { 4 | date +"%b-%d-%Y-%H_%M_%S" 5 | } 6 | 7 | dist_file=dist-`timestamp`.zip 8 | npm run clean 9 | npm run dist 10 | echo $cf_template > cf_template.json 11 | echo $cf_parameters | sed "s|dist.zip|${LAMBCI_REPO}/${dist_file}|g" > cf_parameters.json 12 | aws-sdk-cli cp dist.zip s3://lambci-build-artifacts/$LAMBCI_REPO/$dist_file 13 | aws-sdk-cli update-stack -t cf_template.json -p cf_parameters.json acme-stack 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-acme-lambda", 3 | "version": "1.0.0", 4 | "description": "Free ACME certificate management for CloudFront/AWS written in nodejs", 5 | "main": "app.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/ocelotconsulting/node-acme-lambda.git" 9 | }, 10 | "scripts": { 11 | "clean": "rm dist*.zip || true && rm -rf build", 12 | "dist": "mkdir build && npm install && npm prune --production && mv node_modules ./build && npm install && node zip.js", 13 | "pems": "node bin/write_pems.js", 14 | "import": "node bin/importToACM.js", 15 | "local-cert": "node bin/local.js", 16 | "test": "mocha test/unit --recursive --require test/setup --colors --timeout 15000", 17 | "test:coverage": "nyc --reporter=html --reporter=lcov --reporter=text mocha test/unit --recursive --require test/setup", 18 | "deploy": ". ./deploy.sh" 19 | }, 20 | "keywords": [ 21 | "letsencrypt", 22 | "acme", 23 | "nodejs", 24 | "aws" 25 | ], 26 | "author": "Larry Anderson", 27 | "license": "ISC", 28 | "dependencies": { 29 | "aws-sdk": "^2.168.0", 30 | "es6-promisify": "^4.1.0", 31 | "node-forge": "^0.6.45", 32 | "rsa-compat": "^1.3.2", 33 | "superagent": "^3.5.0" 34 | }, 35 | "devDependencies": { 36 | "archiver": "^1.2.0", 37 | "aws-sdk-cli": "^0.0.3", 38 | "chai": "^4.1.2", 39 | "chai-as-promised": "^7.1.1", 40 | "eslint": "^3.8.0", 41 | "eslint-config": "^0.3.0", 42 | "eslint-config-standard": "^6.2.0", 43 | "eslint-plugin-promise": "^3.0.0", 44 | "eslint-plugin-standard": "^2.0.1", 45 | "mocha": "^5.0.5", 46 | "nyc": "^11.6.0", 47 | "proxyquire": "^2.0.1", 48 | "sinon": "^4.5.0", 49 | "sinon-chai": "^3.0.0" 50 | }, 51 | "engines": { 52 | "npm": "4.3.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/acme/authorize/getChallenges.js: -------------------------------------------------------------------------------- 1 | const sendSignedRequest = require('../sendSignedRequest') 2 | const updateDNSChallenge = require('./updateDNSChallenge') 3 | const sendDNSChallengeValidation = require('./sendDNSChallengeValidation') 4 | 5 | const getDNSChallenge = (challenges) => challenges.find((challenge) => challenge.type === 'dns-01') 6 | 7 | const validateChallenges = (domain, accountKeyPair, challengeResponse) => { 8 | const dnsChallenge = getDNSChallenge(challengeResponse.challenges) 9 | return Promise.all([ 10 | updateDNSChallenge(domain, [dnsChallenge], accountKeyPair) 11 | .then(() => sendDNSChallengeValidation(dnsChallenge, accountKeyPair)) 12 | ]) 13 | } 14 | 15 | const getChallenges = (domains, keypair, authzUrl) => 16 | Promise.all( 17 | domains.map((domain) => { 18 | const domainName = (typeof domain === 'string') ? domain : domain.name 19 | console.log(`Sending challenge request for ${domainName}`) 20 | return sendSignedRequest({ 21 | resource: 'new-authz', 22 | identifier: { 23 | type: 'dns', 24 | value: domainName 25 | } 26 | }, keypair, authzUrl) 27 | .then(data => validateChallenges(domain, keypair, data.body)) 28 | }) 29 | ) 30 | .catch((err) => { 31 | console.error('Experienced error getting challenges', err) 32 | throw err 33 | }) 34 | 35 | module.exports = getChallenges 36 | -------------------------------------------------------------------------------- /src/acme/authorize/sendDNSChallengeValidation.js: -------------------------------------------------------------------------------- 1 | const RSA = require('rsa-compat').RSA 2 | const sendSignedRequest = require('../sendSignedRequest') 3 | 4 | const sendDNSChallengeValidation = (dnsChallenge, acctKeyPair) => { 5 | console.log(`Sending DNS challenge validation`) 6 | return sendSignedRequest({ 7 | resource: 'challenge', 8 | keyAuthorization: `${dnsChallenge.token}.${RSA.thumbprint(acctKeyPair)}` 9 | }, acctKeyPair, dnsChallenge.uri) 10 | .then((data) => data.body) 11 | .catch((e) => { 12 | console.error(`Couldn't send DNS challenge verification.`, e) 13 | throw e 14 | }) 15 | } 16 | 17 | module.exports = sendDNSChallengeValidation 18 | -------------------------------------------------------------------------------- /src/acme/authorize/updateDNSChallenge.js: -------------------------------------------------------------------------------- 1 | const updateTXTRecord = require('../../aws/route53/updateTXTRecord') 2 | const getHostedZoneId = require('../../aws/route53/getHostedZoneId') 3 | const RSA = require('rsa-compat').RSA 4 | const crypto = require('crypto') 5 | const dns = require('dns') 6 | const config = require('../../../config') 7 | const {promisify} = require('util') 8 | const resolveTxt = promisify(dns.resolveTxt) 9 | const urlB64 = require('../urlB64') 10 | const retry = require('../../retry')(config['acme-dns-retry-delay-ms'], config['acme-dns-retry']) 11 | 12 | const getTokenDigest = (dnsChallenge, acctKeyPair) => 13 | crypto.createHash('sha256').update(`${dnsChallenge.token}.${RSA.thumbprint(acctKeyPair)}`).digest() 14 | 15 | const arrayContainsArray = (superset, subset) => 16 | subset.every(value => superset.indexOf(value) >= 0) 17 | 18 | const flatten = input => Array.prototype.concat.apply([], input) 19 | 20 | const dnsPreCheck = (domain, expect) => (tryCount) => { 21 | console.log(`Attempt ${tryCount + 1} to resolve TXT record for ${domain}`) 22 | return resolveTxt(`_acme-challenge.${domain}`) 23 | .then(data => { 24 | ++tryCount 25 | return { 26 | tryCount, 27 | result: arrayContainsArray(flatten(data), expect) 28 | } 29 | }) 30 | .catch(e => { 31 | if (e.code === 'ENODATA' || e.code === 'ENOTFOUND') { 32 | ++tryCount 33 | return { tryCount, result: false } 34 | } else { throw e } 35 | }) 36 | } 37 | 38 | const validateDNSChallenge = (domain, dnsChallengeTexts) => 39 | retry(0, dnsPreCheck(domain, dnsChallengeTexts)) 40 | .then(data => { 41 | if (data.result) { 42 | return data.result 43 | } else { 44 | throw new Error(`Could not pre-validate DNS TXT record. Didn't find ${dnsChallengeTexts} in _acme-challenge.${domain}`) 45 | } 46 | }) 47 | 48 | const updateDNSChallenge = (domain, dnsChallenges, acctKeyPair) => { 49 | const domainName = (typeof domain === 'string') ? domain : domain.name 50 | const dnsChallengeTexts = dnsChallenges.map(dnsChallenge => urlB64(getTokenDigest(dnsChallenge, acctKeyPair))) 51 | return getHostedZoneId(domain) 52 | .then(id => { 53 | console.log(`Updating DNS TXT Record for ${domainName} to contain ${dnsChallengeTexts} in Route53 hosted zone ${id}`) 54 | return updateTXTRecord(id, domainName, dnsChallengeTexts) 55 | }) 56 | .then(updated => validateDNSChallenge(domainName, dnsChallengeTexts)) 57 | .catch(e => { 58 | console.error(`Couldn't write token digest to DNS record.`, e) 59 | throw e 60 | }) 61 | } 62 | 63 | module.exports = updateDNSChallenge 64 | -------------------------------------------------------------------------------- /src/acme/authorize/validateChallenges.js: -------------------------------------------------------------------------------- 1 | module.exports = domain => { 2 | const domainName = (typeof domain === 'string') ? domain : domain.name 3 | console.log(`Sending challenge request for ${domainName}`) 4 | return sendSignedRequest({ 5 | resource: 'new-authz', 6 | identifier: { 7 | type: 'dns', 8 | value: domainName 9 | } 10 | }, keypair, authzUrl) 11 | .then(data => validateChallenges(domain, keypair, data.body)) 12 | } 13 | -------------------------------------------------------------------------------- /src/acme/certUtils.js: -------------------------------------------------------------------------------- 1 | const toIssuerCert = links => 2 | /.*<(.*)>;rel="up".*/.exec(links)[1] 3 | 4 | const toStandardB64 = str => { 5 | var b64 = str.replace(/-/g, '+').replace(/_/g, '/').replace(/=/g, '') 6 | switch (b64.length % 4) { 7 | case 2: 8 | b64 += '==' 9 | break 10 | case 3: 11 | b64 += '=' 12 | break 13 | } 14 | return b64 15 | } 16 | 17 | const toPEM = cert => 18 | `-----BEGIN CERTIFICATE-----\n${toStandardB64(cert.toString('base64')).match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE-----\n` 19 | 20 | module.exports = {toIssuerCert, toPEM, toStandardB64} 21 | -------------------------------------------------------------------------------- /src/acme/certify/createCertificate.js: -------------------------------------------------------------------------------- 1 | const generateRSAKeyPair = require('../../util/generateRSAKeyPair') 2 | const newCertificate = require('./newCertificate') 3 | const generateCSR = require('../../util/generateCSR') 4 | const config = require('../../../config') 5 | const saveFile = require('../../aws/s3/saveFile') 6 | 7 | const saveCertificate = (data) => 8 | saveFile( 9 | config['s3-cert-bucket'], 10 | config['s3-folder'], 11 | `${data.key}.json`, 12 | JSON.stringify({ 13 | key: data.keypair, 14 | cert: data.cert, 15 | issuerCert: data.issuerCert 16 | }) 17 | ) 18 | 19 | const createCertificate = (certUrl, certInfo, acctKeyPair) => (authorizations) => 20 | generateRSAKeyPair() 21 | .then(domainKeypair => 22 | generateCSR(domainKeypair, certInfo.domains) 23 | .then(newCertificate(acctKeyPair, authorizations, certUrl)) 24 | .then((certData) => 25 | saveCertificate({ 26 | key: certInfo.key, 27 | keypair: domainKeypair, 28 | cert: certData.cert, 29 | issuerCert: certData.issuerCert 30 | }) 31 | ) 32 | ) 33 | 34 | module.exports = createCertificate 35 | -------------------------------------------------------------------------------- /src/acme/certify/newCertificate.js: -------------------------------------------------------------------------------- 1 | const sendSignedRequest = require('../sendSignedRequest') 2 | const downloadBinary = require('../../util/downloadBinary') 3 | const {toIssuerCert, toPEM, toStandardB64} = require('../certUtils') 4 | 5 | const newCertificate = (keypair, authorizations, certUrl) => (csr) => { 6 | console.log('Requesting certificate from ACME provider') 7 | return sendSignedRequest({ 8 | resource: 'new-cert', 9 | csr, 10 | authorizations 11 | }, keypair, certUrl) 12 | .then((data) => 13 | downloadBinary(data.header['location']) 14 | .then((certificate) => 15 | downloadBinary(toIssuerCert(data.header['link'])) 16 | .then((issuerCert) => { 17 | console.log('Downloaded certificate.') 18 | return ({ 19 | cert: toPEM(certificate), 20 | issuerCert: toPEM(issuerCert) 21 | }) 22 | }) 23 | ) 24 | ) 25 | } 26 | 27 | module.exports = newCertificate 28 | -------------------------------------------------------------------------------- /src/acme/generateCertificate.js: -------------------------------------------------------------------------------- 1 | const getDiscoveryUrls = require('./getDiscoveryUrls') 2 | const getAccount = require('./register/getAccount') 3 | const getChallenges = require('./authorize/getChallenges') 4 | const createCertificate = require('./certify/createCertificate') 5 | const getV2Order = require('./v2/getV2Order') 6 | const newAccount = require('./v2/newAccount') 7 | const register = require('./register/register') 8 | const performAuthorizations = require('./v2/performAuthorizations') 9 | const createV2Certificate = require('./v2/createV2Certificate') 10 | 11 | const v1ACME = (urls, certInfo) => 12 | getAccount(register(urls['new-reg'])) 13 | .then(account => 14 | getChallenges(certInfo.domains, account.key, urls['new-authz']) 15 | .then(createCertificate(urls['new-cert'], certInfo, account.key)) 16 | ) 17 | 18 | const v2ACME = (urls, certInfo) => 19 | getAccount(newAccount(urls['newAccount'], urls['newNonce'])) 20 | .then(account => 21 | getV2Order(certInfo.domains, account.key, urls['newOrder'], urls['newNonce'], account.url) 22 | .then(performAuthorizations(account.key, urls['newNonce'], account.url)) 23 | .then(createV2Certificate(certInfo, account.key, urls['newNonce'], account.url)) 24 | ) 25 | 26 | module.exports = certInfo => 27 | getDiscoveryUrls() 28 | .then(urls => 29 | Object.keys(urls).includes('new-authz') 30 | ? v1ACME(urls, certInfo) 31 | : v2ACME(urls, certInfo) 32 | ) 33 | -------------------------------------------------------------------------------- /src/acme/getDiscoveryUrls.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config') 2 | const agent = require('superagent') 3 | 4 | const getDiscoveryUrls = (discoveryUrl) => 5 | agent.get(`${config['acme-directory-url']}/directory`) 6 | .then((data) => data.body) 7 | 8 | module.exports = getDiscoveryUrls 9 | -------------------------------------------------------------------------------- /src/acme/getNonce.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config') 2 | const agent = require('superagent') 3 | 4 | const getNonce = () => 5 | agent.get(`${config['acme-directory-url']}/directory`) 6 | .then((data) => data.header['replay-nonce']) 7 | .catch((e) => { 8 | console.error(`Error getting nonce`, e) 9 | throw e 10 | }) 11 | 12 | module.exports = getNonce 13 | -------------------------------------------------------------------------------- /src/acme/register/createAccount.js: -------------------------------------------------------------------------------- 1 | const generateRSAKeyPair = require('../../util/generateRSAKeyPair') 2 | const register = require('./register') 3 | const saveFile = require('../../aws/s3/saveFile') 4 | const config = require('../../../config') 5 | 6 | const saveAccount = data => { 7 | const account = { 8 | key: data.keypair, 9 | 'url': data.location, 10 | 'agreement': data.agreement 11 | } 12 | return saveFile( 13 | config['s3-account-bucket'], 14 | config['s3-folder'], 15 | config['acme-account-file'], 16 | JSON.stringify(account) 17 | ) 18 | .then(() => account) 19 | } 20 | 21 | const createAccount = acctPromise => 22 | generateRSAKeyPair() 23 | .then(acctPromise) 24 | .then(saveAccount) 25 | 26 | module.exports = createAccount 27 | -------------------------------------------------------------------------------- /src/acme/register/getAccount.js: -------------------------------------------------------------------------------- 1 | const config = require('../../../config') 2 | const readFile = require('../../aws/s3/readFile') 3 | const createAccount = require('./createAccount') 4 | 5 | const getAccount = acctPromise => 6 | readFile( 7 | config['s3-account-bucket'], 8 | config['s3-folder'], 9 | config['acme-account-file'] 10 | ) 11 | .then((data) => JSON.parse(data.Body.toString())) 12 | .catch(() => { 13 | console.log(`Creating user config file since couldn't read s3://${config['s3-account-bucket']}/${config['s3-folder']}/${config['acme-account-file']}`) 14 | return createAccount(acctPromise) 15 | }) 16 | 17 | module.exports = getAccount 18 | -------------------------------------------------------------------------------- /src/acme/register/register.js: -------------------------------------------------------------------------------- 1 | const sendSignedRequest = require('../sendSignedRequest') 2 | const config = require('../../../config') 3 | const toAgreement = require('../toAgreement') 4 | 5 | const sendRefresh = (registration) => 6 | sendSignedRequest({ 7 | resource: 'reg', 8 | agreement: registration.agreement 9 | }, registration.keypair, registration.location) 10 | 11 | const checkRefresh = (registration) => (data) => { 12 | const refreshedAgreement = toAgreement(data.header['link']) 13 | return ((registration.agreement !== refreshedAgreement.agreement) 14 | ? refreshRegistration({ //NOSONAR 15 | keypair: registration.keypair, 16 | location: registration.location, 17 | agreement: refreshedAgreement.agreement 18 | }) 19 | : Promise.resolve({ 20 | keypair: registration.keypair, 21 | location: registration.location, 22 | agreement: registration.agreement 23 | })) 24 | } 25 | 26 | const refreshRegistration = (registration) => 27 | sendRefresh(registration) 28 | .then(checkRefresh(registration)) //NOSONAR 29 | 30 | const register = regUrl => keypair => 31 | sendSignedRequest({ 32 | resource: 'new-reg', 33 | contact: [ `mailto:${config['acme-account-email']}` ] 34 | }, keypair, regUrl) 35 | .then(data => 36 | refreshRegistration( 37 | Object.assign({ 38 | keypair: keypair, 39 | location: data.header['location'] 40 | }, 41 | toAgreement(data.header['link'])) 42 | ) 43 | ) 44 | 45 | module.exports = register 46 | -------------------------------------------------------------------------------- /src/acme/sendSignedRequest.js: -------------------------------------------------------------------------------- 1 | const getNonce = require('./getNonce') 2 | const RSA = require('rsa-compat').RSA 3 | const agent = require('superagent') 4 | 5 | const sendSignedRequest = (payload, keypair, url) => 6 | getNonce() 7 | .then((data) => 8 | agent.post(url) 9 | .send(RSA.signJws(keypair, new Buffer(JSON.stringify(payload)), data)) 10 | ) 11 | 12 | module.exports = sendSignedRequest 13 | -------------------------------------------------------------------------------- /src/acme/toAgreement.js: -------------------------------------------------------------------------------- 1 | module.exports = links => { 2 | const match = /.*<(.*)>;rel="terms-of-service".*/.exec(links) 3 | return (Array.isArray(match) ? {agreement: match[1]} : {}) 4 | } 5 | -------------------------------------------------------------------------------- /src/acme/urlB64.js: -------------------------------------------------------------------------------- 1 | module.exports = buffer => 2 | buffer.toString('base64').replace(/[+]/g, '-').replace(/\//g, '_').replace(/=/g, '') 3 | -------------------------------------------------------------------------------- /src/acme/v2/createV2Certificate.js: -------------------------------------------------------------------------------- 1 | const generateRSAKeyPair = require('../../util/generateRSAKeyPair') 2 | const newCertificate = require('./newCertificate') 3 | const generateCSR = require('../../util/generateCSR') 4 | const config = require('../../../config') 5 | const saveFile = require('../../aws/s3/saveFile') 6 | 7 | const saveCertificate = (data) => 8 | saveFile( 9 | config['s3-cert-bucket'], 10 | config['s3-folder'], 11 | `${data.key}.json`, 12 | JSON.stringify({ 13 | key: data.keypair, 14 | cert: data.cert, 15 | issuerCert: data.issuerCert 16 | }) 17 | ) 18 | 19 | const createCertificate = (certInfo, acctKeyPair, nonceUrl, url) => finalizeUrl => 20 | generateRSAKeyPair() 21 | .then(domainKeypair => 22 | generateCSR(domainKeypair, certInfo.domains) 23 | .then(newCertificate(acctKeyPair, finalizeUrl, nonceUrl, url)) 24 | .then(cert => 25 | saveCertificate({ 26 | key: certInfo.key, 27 | keypair: domainKeypair, 28 | cert, 29 | }) 30 | ) 31 | ) 32 | 33 | module.exports = createCertificate 34 | -------------------------------------------------------------------------------- /src/acme/v2/getV2AntiReplayNonce.js: -------------------------------------------------------------------------------- 1 | const agent = require('superagent') 2 | 3 | module.exports = url => 4 | agent.head(url) 5 | .then(data => data.header['replay-nonce']) 6 | -------------------------------------------------------------------------------- /src/acme/v2/getV2Order.js: -------------------------------------------------------------------------------- 1 | const sendSignedRequestV2 = require('./sendSignedRequestV2') 2 | 3 | module.exports = (domains, keypair, orderUrl, nonceUrl, url) => { 4 | console.log(`Submitting new order to ${orderUrl} for ${JSON.stringify(domains)}`) 5 | return sendSignedRequestV2({ 6 | "identifiers": domains.map(domain => 7 | typeof domain === 'object' 8 | ? ({"type": "dns", "value": domain.name}) 9 | : ({"type": "dns", "value": domain}) 10 | ) 11 | }, keypair, orderUrl, nonceUrl, url) 12 | .then(data => data.header['location']) 13 | .catch(err => { 14 | if (err.response && err.response.text) { 15 | const detailObj = JSON.parse(err.response.text) 16 | if (detailObj.detail && detailObj.detail.indexOf('redundant') > -1) { 17 | console.log(`Fatal error, thought we would show it to you: ${detailObj.detail}`) 18 | } 19 | } 20 | throw err 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/acme/v2/newAccount.js: -------------------------------------------------------------------------------- 1 | const config = require('../../../config') 2 | const sendSignedRequestV2 = require('./sendSignedRequestV2') 3 | const toAgreement = require('../toAgreement') 4 | 5 | module.exports = (acctUrl, nonceUrl) => keypair => { 6 | console.log(`Creating new account with url ${acctUrl}`) 7 | 8 | return sendSignedRequestV2({ 9 | "termsOfServiceAgreed": true, 10 | "contact": [ 11 | `mailto:${config['acme-account-email']}` 12 | ] 13 | }, keypair, acctUrl, nonceUrl) 14 | .then(data => 15 | Object.assign({ 16 | keypair: keypair, 17 | location: data.header['location'] 18 | }, 19 | toAgreement(data.header['link'])) 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/acme/v2/newCertificate.js: -------------------------------------------------------------------------------- 1 | const sendSignedRequestV2 = require('./sendSignedRequestV2') 2 | const downloadtext = require('../../util/downloadText') 3 | const {toIssuerCert, toPEM, toStandardB64} = require('../certUtils') 4 | 5 | const newCertificate = (keypair, finalizeUrl, nonceUrl, url) => (csr) => { 6 | console.log('Requesting certificate.') 7 | return sendSignedRequestV2({ 8 | csr 9 | }, keypair, finalizeUrl, nonceUrl, url) 10 | .then(({body}) => 11 | downloadtext(body.certificate) 12 | ) 13 | .catch(err => { 14 | if (err.response && err.response.text) { 15 | const detailObj = JSON.parse(err.response.text) 16 | if (detailObj.type && detailObj.type.indexOf('serverInternal') > -1) { 17 | console.log(`Encountered an internal acme server error, will need to retry this configuration (re-run lambda).`) 18 | } 19 | } 20 | throw err 21 | }) 22 | } 23 | 24 | module.exports = newCertificate 25 | -------------------------------------------------------------------------------- /src/acme/v2/performAuthorizations.js: -------------------------------------------------------------------------------- 1 | const agent = require('superagent') 2 | const {validateV2Challenges} = require('../authorize/getChallenges') 3 | const updateDNSChallenge = require('../authorize/updateDNSChallenge') 4 | const sendV2DNSChallengeValidation = require('./sendV2DNSChallengeValidation') 5 | 6 | const getDNSChallenge = (challenges) => challenges.find((challenge) => challenge.type === 'dns-01') 7 | 8 | const consolidateChallenges = authBodies => 9 | authBodies.reduce((acc, curr) => { 10 | const dnsChallenge = getDNSChallenge(curr.challenges) 11 | if (acc[curr.identifier.value]) { 12 | acc[curr.identifier.value].push(dnsChallenge) 13 | } else { 14 | acc[curr.identifier.value] = [dnsChallenge] 15 | } 16 | return acc 17 | }, {}) 18 | 19 | const validateChallenges = (accountKeyPair, nonceUrl, url) => challenges => 20 | Promise.all(Object.keys(challenges).map(txtName => 21 | updateDNSChallenge(txtName, challenges[txtName], accountKeyPair) 22 | .then(() => sendV2DNSChallengeValidation(challenges[txtName], accountKeyPair, nonceUrl, url)) 23 | )) 24 | 25 | module.exports = (keypair, nonceUrl, url) => orderInfoUrl => 26 | agent.get(orderInfoUrl) 27 | .then(({body}) => 28 | Promise.all(body.authorizations.map(authUrl => 29 | agent.get(authUrl) 30 | .then(({body: authBody}) => authBody) 31 | )) 32 | .then(consolidateChallenges) 33 | .then(validateChallenges(keypair, nonceUrl, url)) 34 | .then(() => body.finalize) 35 | ) 36 | .catch((err) => { 37 | console.error('Experienced error getting challenges', err) 38 | throw err 39 | }) 40 | -------------------------------------------------------------------------------- /src/acme/v2/sendSignedRequestV2.js: -------------------------------------------------------------------------------- 1 | const getV2AntiReplayNonce = require('./getV2AntiReplayNonce') 2 | const RSA = require('rsa-compat').RSA 3 | const agent = require('superagent') 4 | 5 | const sendSignedRequest = (payload, keypair, url, nonceUrl, kid = null) => 6 | getV2AntiReplayNonce(nonceUrl) 7 | .then(nonce => { 8 | const {header} = RSA.signJws(keypair, new Buffer(JSON.stringify(payload)), nonce) 9 | const toSend = RSA.signJws(keypair, undefined, Object.assign(kid ? {kid, alg: header.alg} : header, {nonce, url}), new Buffer(JSON.stringify(payload))) 10 | return agent.post(url) 11 | .type('application/jose+json') 12 | .send(toSend) 13 | // .catch(err => { for extra debug you can uncomment this block for v2 cases 14 | // console.log(`The error was ${JSON.stringify(err.response)}`) 15 | // throw err 16 | // }) 17 | }) 18 | 19 | module.exports = sendSignedRequest 20 | -------------------------------------------------------------------------------- /src/acme/v2/sendV2DNSChallengeValidation.js: -------------------------------------------------------------------------------- 1 | const RSA = require('rsa-compat').RSA 2 | const sendSignedRequestV2 = require('./sendSignedRequestV2') 3 | 4 | const sendDNSChallengeValidation = (dnsChallenges, acctKeyPair, nonceUrl, url) => 5 | Promise.all(dnsChallenges.map(dnsChallenge => 6 | sendSignedRequestV2({ 7 | keyAuthorization: `${dnsChallenge.token}.${RSA.thumbprint(acctKeyPair)}` 8 | }, acctKeyPair, dnsChallenge.url, nonceUrl, url) 9 | .then(({body}) => body) 10 | .catch((e) => { 11 | console.error(`Couldn't send DNS challenge verification.`, e) 12 | throw e 13 | }) 14 | )) 15 | 16 | module.exports = sendDNSChallengeValidation 17 | -------------------------------------------------------------------------------- /src/aws/route53/getHostedZoneId.js: -------------------------------------------------------------------------------- 1 | const getRoute53 = require('../sdk/getRoute53') 2 | 3 | const getDomainZone = (domain, zones) => 4 | zones.HostedZones.find((zone) => zone.Name === `${domain}.`) 5 | 6 | const getHostedZoneId = (domain) => { 7 | const domainName = (typeof domain === 'string') ? domain : domain.name 8 | const zoneLevels = (typeof domain === 'object' && domain.zoneLevels) 9 | ? domain.zoneLevels 10 | : 2 11 | return getRoute53().listHostedZones().promise() 12 | .then((zones) => getDomainZone(domainName.split('.').slice(-1 * zoneLevels).join('.'), zones).Id) 13 | .catch((e) => { 14 | console.error(`Couldn't retrieve hosted zones from Route53`, e) 15 | throw e 16 | }) 17 | } 18 | 19 | module.exports = getHostedZoneId 20 | -------------------------------------------------------------------------------- /src/aws/route53/updateTXTRecord.js: -------------------------------------------------------------------------------- 1 | const getRoute53 = require('../sdk/getRoute53') 2 | 3 | const updateTXTRecord = (hostedZoneId, domain, digests) => { 4 | const toSend = { 5 | ChangeBatch: { 6 | Changes: [ 7 | { 8 | Action: 'UPSERT', 9 | ResourceRecordSet: { 10 | Name: `_acme-challenge.${domain}`, 11 | Type: 'TXT', 12 | ResourceRecords: digests.map(digest => ({Value: JSON.stringify(digest)})), 13 | TTL: 1 14 | } 15 | } 16 | ], 17 | Comment: 'This value is a computed digest of the token received from the ACME challenge.' 18 | }, 19 | HostedZoneId: hostedZoneId 20 | } 21 | return getRoute53().changeResourceRecordSets(toSend).promise() 22 | .catch((e) => { 23 | console.error(`Couldn't write TXT record _acme-challenge.${domain}`, e) 24 | throw e 25 | }) 26 | } 27 | module.exports = updateTXTRecord 28 | -------------------------------------------------------------------------------- /src/aws/s3/readFile.js: -------------------------------------------------------------------------------- 1 | const getS3 = require('../sdk/getS3') 2 | 3 | const readFile = (bucket, siteId, fileName) => 4 | getS3().getObject({ 5 | Bucket: bucket, 6 | Key: `${siteId}/${fileName}` 7 | }).promise() 8 | .catch(e => { 9 | if (e.message.indexOf('does not exist')) { 10 | console.log(`s3://${bucket}/${siteId}/${fileName} does not exist.`) 11 | } else { 12 | console.error(`Couldn't read s3://${bucket}/${siteId}/${fileName}`, e) 13 | } 14 | throw e 15 | }) 16 | 17 | module.exports = readFile 18 | -------------------------------------------------------------------------------- /src/aws/s3/saveFile.js: -------------------------------------------------------------------------------- 1 | const getS3 = require('../sdk/getS3') 2 | 3 | const saveFile = (bucket, siteId, fileName, fileData, options) => 4 | getS3().putObject(Object.assign({ 5 | Bucket: bucket, 6 | Key: `${siteId}/${fileName}`, 7 | Body: new Buffer(fileData) 8 | }, options)).promise() 9 | .catch((e) => { 10 | console.error(`Couldn't write s3://${bucket}/${siteId}/${fileName}`, e) 11 | throw e 12 | }) 13 | 14 | module.exports = saveFile 15 | -------------------------------------------------------------------------------- /src/aws/sdk/getACM.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const config = require('../../../config') 3 | 4 | module.exports = () => new AWS.ACM({region: config.region}) 5 | -------------------------------------------------------------------------------- /src/aws/sdk/getRoute53.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | module.exports = () => new AWS.Route53() 4 | -------------------------------------------------------------------------------- /src/aws/sdk/getS3.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | // see https://github.com/ocelotconsulting/node-acme-lambda/issues/9 3 | module.exports = () => new AWS.S3({signatureVersion: 'v4'}) 4 | -------------------------------------------------------------------------------- /src/retry.js: -------------------------------------------------------------------------------- 1 | const delayPromise = delay => data => 2 | new Promise((resolve, reject) => { 3 | setTimeout(() => { resolve(data) }, delay) 4 | }) 5 | 6 | const retry = (delay, howManyTimes) => (tryCount, promise) => 7 | promise(tryCount).then(delayPromise(delay)) 8 | .then((data) => 9 | (tryCount < howManyTimes && !data.result) 10 | ? retry(delay, howManyTimes)(data.tryCount, promise) 11 | : data 12 | ) 13 | 14 | module.exports = retry 15 | -------------------------------------------------------------------------------- /src/util/downloadBinary.js: -------------------------------------------------------------------------------- 1 | const agent = require('superagent') 2 | 3 | const parser = (res, callback) => { 4 | res.data = '' 5 | res.setEncoding('binary') 6 | res.on('data', (chunk) => { 7 | res.data += chunk 8 | }) 9 | res.on('end', () => { 10 | callback(null, new Buffer(res.data, 'binary')) 11 | }) 12 | } 13 | 14 | module.exports = (url) => 15 | agent.get(url) 16 | .buffer(true).parse(parser) 17 | .then((data) => data.body) 18 | -------------------------------------------------------------------------------- /src/util/downloadText.js: -------------------------------------------------------------------------------- 1 | const agent = require('superagent') 2 | 3 | const parser = (res, callback) => { 4 | res.data = '' 5 | res.on('data', (chunk) => { 6 | res.data += chunk 7 | }) 8 | res.on('end', () => { 9 | callback(null, res.data) 10 | }) 11 | } 12 | 13 | module.exports = (url) => 14 | agent.get(url) 15 | .buffer(true).parse(parser) 16 | .then((data) => data.body) 17 | -------------------------------------------------------------------------------- /src/util/generateCSR.js: -------------------------------------------------------------------------------- 1 | const RSA = require('rsa-compat').RSA 2 | 3 | const generateCSR = (domainKeypair, domains) => { 4 | const domainNames = domains.map((domain) => (typeof domain === 'string') ? domain : domain.name) 5 | console.log(`Creating CSR for ${JSON.stringify(domainNames)}`) 6 | return Promise.resolve(RSA.generateCsrDerWeb64(domainKeypair, domainNames)) 7 | } 8 | 9 | module.exports = generateCSR 10 | -------------------------------------------------------------------------------- /src/util/generateRSAKeyPair.js: -------------------------------------------------------------------------------- 1 | const RSA = require('rsa-compat').RSA 2 | const promisify = require('es6-promisify') 3 | const config = require('../../config') 4 | 5 | const bitlen = config['acme-account-key-bits'] 6 | const exp = 65537 7 | const options = { public: true, pem: true, internal: true } 8 | const generateKeyPair = promisify(RSA.generateKeypair) 9 | 10 | const generatePair = () => 11 | generateKeyPair(bitlen, exp, options) 12 | .catch((e) => { 13 | console.error(`Couldn't generate RSA keypair`, e) 14 | throw e 15 | }) 16 | 17 | module.exports = generatePair 18 | -------------------------------------------------------------------------------- /src/util/isExpired.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config') 2 | const readFile = require('../aws/s3/readFile') 3 | const forge = require('node-forge') 4 | 5 | const diffDays = (certExpiration, now) => 6 | Math.round(Math.abs((certExpiration.getTime() - now.getTime()) / (24 * 60 * 60 * 1000))) 7 | 8 | const certInValid = (cert, date) => 9 | (cert.notBefore > date > cert.notAfter || diffDays(new Date(cert.validity.notAfter), date) < 30) 10 | 11 | module.exports = certKey => 12 | readFile( 13 | config['s3-cert-bucket'], 14 | config['s3-folder'], 15 | `${certKey}.json` 16 | ) 17 | .then((data) => 18 | certInValid(forge.pki.certificateFromPem(JSON.parse(data.Body.toString()).cert), new Date()) 19 | ) 20 | .catch((e) => { 21 | if (e.statusCode === 404) { 22 | console.log(`Certificate with key ${certKey} is missing, going to regenerate.`) 23 | return true 24 | } 25 | console.error('Error while calculating cert expiration', e) 26 | throw e 27 | }) 28 | -------------------------------------------------------------------------------- /test/proxyquire.js: -------------------------------------------------------------------------------- 1 | const pq = require('proxyquire').noCallThru() 2 | 3 | module.exports = (...args) => { 4 | args[0] = `../${args[0]}` 5 | return pq.apply(pq, args) 6 | } 7 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai') 2 | const chaiAsPromised = require('chai-as-promised') 3 | const sinonChai = require('sinon-chai') 4 | const sinon= require('sinon') 5 | 6 | global.should = chai.should() 7 | chai.use(chaiAsPromised) 8 | chai.use(sinonChai) 9 | 10 | global.sinon = sinon 11 | -------------------------------------------------------------------------------- /test/unit/appSpec.js: -------------------------------------------------------------------------------- 1 | const proxyquire = require('../proxyquire') 2 | 3 | describe('app', () => { 4 | let isExpiredStub, generateCertificateStub, app, config, context 5 | 6 | beforeEach(() => { 7 | 8 | context = { succeed: sinon.spy() } 9 | 10 | config = { 11 | 'certificate-info': { 12 | 'rick': ['riggity.wrecked.com', 'shutup.morty.com', 'you.pass.butter', {'name': 'love.is.a.chemical.reaction', 'zoneLevels': 5}], 13 | 'morty': ['put.in.backpack', 'thats.my.grave', {'name': 'why.would.a.poptart.want.to.live.inside.a.toaster', 'zoneLevels': 10}] 14 | } 15 | } 16 | 17 | isExpiredStub = sinon.stub() 18 | generateCertificateStub = sinon.stub() 19 | 20 | app = proxyquire('app', { 21 | './src/acme/generateCertificate': generateCertificateStub, 22 | './src/util/isExpired': isExpiredStub, 23 | './config': config 24 | }) 25 | }) 26 | 27 | it('generates certificate(s) if expired', () => { 28 | isExpiredStub.resolves(true) 29 | generateCertificateStub.resolves({stuff: true}) 30 | return app.handler({}, context) 31 | .then(stuff => { 32 | isExpiredStub.callCount.should.equal(2) 33 | generateCertificateStub.callCount.should.equal(2) 34 | isExpiredStub.should.have.been.calledWithExactly('rick') 35 | generateCertificateStub.should.have.been.calledWithExactly({key: 'rick', domains: config['certificate-info']['rick']}) 36 | isExpiredStub.should.have.been.calledWithExactly('morty') 37 | generateCertificateStub.should.have.been.calledWithExactly({key: 'morty', domains: config['certificate-info']['morty']}) 38 | context.succeed.should.have.been.calledWith(sinon.match.array) 39 | }) 40 | }) 41 | 42 | it('does nothing if not expired', () => { 43 | const msg = key => `Certificate for ${key} is still valid, going back to bed.` 44 | isExpiredStub.resolves(false) 45 | return app.handler({}, context) 46 | .then(stuff => { 47 | isExpiredStub.callCount.should.equal(2) 48 | generateCertificateStub.should.not.have.been.called 49 | isExpiredStub.should.have.been.calledWithExactly('rick') 50 | isExpiredStub.should.have.been.calledWithExactly('morty') 51 | context.succeed.should.have.been.calledWith([{ 52 | err: false, 53 | msg: msg('rick') 54 | }, { 55 | err: false, 56 | msg: msg('morty') 57 | }]) 58 | }) 59 | }) 60 | 61 | it('does something if subset expired', () => { 62 | const msg = key => `Certificate for ${key} is still valid, going back to bed.` 63 | isExpiredStub.onCall(0).resolves(false) 64 | isExpiredStub.onCall(1).resolves(true) 65 | generateCertificateStub.resolves({stuff: true}) 66 | return app.handler({}, context) 67 | .then(stuff => { 68 | isExpiredStub.callCount.should.equal(2) 69 | generateCertificateStub.should.have.been.calledWithExactly({key: 'morty', domains: config['certificate-info']['morty']}) 70 | isExpiredStub.should.have.been.calledWithExactly('rick') 71 | isExpiredStub.should.have.been.calledWithExactly('morty') 72 | context.succeed.should.have.been.calledWith([{ 73 | err: false, 74 | msg: msg('rick') 75 | }, { 76 | stuff: true 77 | }]) 78 | }) 79 | }) 80 | 81 | it('returns error on error', () => { 82 | const msg = (key, err) => `Updating cert for ${key}, received err ${err}, ${err.stack}` 83 | const err = new Error('boom') 84 | isExpiredStub.rejects(err) 85 | return app.handler({}, context) 86 | .then(stuff => { 87 | isExpiredStub.callCount.should.equal(2) 88 | generateCertificateStub.should.not.have.been.called 89 | isExpiredStub.should.have.been.calledWithExactly('rick') 90 | isExpiredStub.should.have.been.calledWithExactly('morty') 91 | context.succeed.should.have.been.calledWith([{ 92 | err: true, 93 | msg: msg('rick', err) 94 | }, { 95 | err: true, 96 | msg: msg('morty', err) 97 | }]) 98 | }) 99 | }) 100 | 101 | it('returns error for error but gets cert for non-error', () => { 102 | const msg = (key, err) => `Updating cert for ${key}, received err ${err}, ${err.stack}` 103 | const err = new Error('boom') 104 | isExpiredStub.onCall(0).rejects(err) 105 | isExpiredStub.onCall(1).resolves(true) 106 | generateCertificateStub.resolves({stuff: true}) 107 | return app.handler({}, context) 108 | .then(stuff => { 109 | isExpiredStub.callCount.should.equal(2) 110 | generateCertificateStub.should.have.been.calledWithExactly({key: 'morty', domains: config['certificate-info']['morty']}) 111 | isExpiredStub.should.have.been.calledWithExactly('rick') 112 | isExpiredStub.should.have.been.calledWithExactly('morty') 113 | context.succeed.should.have.been.calledWith([{ 114 | err: true, 115 | msg: msg('rick', err) 116 | }, { 117 | stuff: true 118 | }]) 119 | }) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /test/unit/src/acme/authorize/updateDNSChallengeSpec.js: -------------------------------------------------------------------------------- 1 | const proxyquire = require('../../../../proxyquire') 2 | 3 | // const updateTXTRecord = require('../../aws/route53/updateTXTRecord') 4 | // const getHostedZoneId = require('../../aws/route53/getHostedZoneId') 5 | // const RSA = require('rsa-compat').RSA 6 | // const crypto = require('crypto') 7 | // const dns = require('dns') 8 | // const config = require('../../../config') 9 | // const promisify = require('es6-promisify') 10 | // const resolveTxt = promisify(dns.resolveTxt) 11 | // const urlB64 = require('../urlB64') 12 | // const retry = require('../../retry')(config['acme-dns-retry-delay-ms'], config['acme-dns-retry']) 13 | 14 | describe('updateDNSChallenge', () => { 15 | let updateDNSChallenge, updateTXTRecordStub, getHostedZoneIdStub, rsaStub, 16 | dnsStub, config, retryStub, dnsChallenges, acctKeyPair 17 | 18 | beforeEach(() => { 19 | 20 | updateTXTRecordStub = sinon.stub() 21 | getHostedZoneIdStub = sinon.stub() 22 | dnsStub = { 23 | resolveTxt: sinon.stub() 24 | } 25 | retryStub = sinon.stub().returns(() => Promise.resolve({result: 'blah'})) 26 | 27 | rsaStub = { 28 | RSA: { 29 | thumbprint: sinon.stub().returns('thumbs') 30 | } 31 | } 32 | dnsChallenges = [{ 33 | 34 | }] 35 | 36 | config = { 37 | 'acme-dns-retry-delay-ms': 100, 38 | 'acme-dns-retry': 3 39 | } 40 | 41 | acctKeyPair = {} 42 | 43 | updateDNSChallenge = proxyquire('src/acme/authorize/updateDNSChallenge', { 44 | '../../aws/route53/updateTXTRecord': updateTXTRecordStub, 45 | '../../aws/route53/getHostedZoneId': getHostedZoneIdStub, 46 | 'rsa-compat': rsaStub, 47 | 'dns': dnsStub, 48 | '../../../config': config, 49 | '../../retry': retryStub 50 | }) 51 | }) 52 | 53 | it('challenge for regular name', () => { 54 | const domain = 'mad.morty.com' 55 | updateTXTRecordStub.resolves('blah') 56 | getHostedZoneIdStub.resolves(1) 57 | dnsStub.resolveTxt.returns(['blah1']) 58 | updateDNSChallenge(domain, dnsChallenges, acctKeyPair) 59 | .then(stuff => { 60 | getHostedZoneIdStub.should.have.been.calledWithExactly(domain) 61 | updateTXTRecordStub.should.have.been.calledWithExactly(1, domain, ['5fU7Inztx9XRYgSTX9m1WpubgNZcxVGvyql5vOXV6XM']) 62 | retryStub.callCount.should.equal(1) 63 | stuff.should.eql('blah') 64 | }) 65 | }) 66 | 67 | }) 68 | -------------------------------------------------------------------------------- /test/unit/src/acme/certUtilsSpec.js: -------------------------------------------------------------------------------- 1 | const proxyquire = require('../../../proxyquire') 2 | const {toIssuerCert, toStandardB64, toPEM} = require('../../../../src/acme/certUtils') 3 | 4 | describe('certUtils', () => { 5 | 6 | it('toIssuerCert functions as expected', () => { 7 | const result = toIssuerCert('blah;rel="up"someotherstuff"') 8 | result.should.eql('haha') 9 | }) 10 | 11 | it('toIssuerCert error throws error', () => { 12 | (() => toIssuerCert('blah')).should.throw(TypeError) 13 | }) 14 | 15 | it('toPEM functions as expected', () => { 16 | const result = toPEM('blah') 17 | result.should.eql('-----BEGIN CERTIFICATE-----\nblah\n-----END CERTIFICATE-----\n') 18 | }) 19 | 20 | it('toStandardB64 functions as expected', () => { 21 | const result = toStandardB64('somewords=plus_equals-this') 22 | result.should.eql('somewordsplus/equals+this') 23 | }) 24 | 25 | it('toStandardB64 functions as expected mod 2', () => { 26 | const result = toStandardB64('somewords=plus_equals-thiss') 27 | result.should.eql('somewordsplus/equals+thiss==') 28 | }) 29 | 30 | it('toStandardB64 functions as expected mod 3', () => { 31 | const result = toStandardB64('somewords=plus_equals-th') 32 | result.should.eql('somewordsplus/equals+th=') 33 | }) 34 | 35 | }) 36 | -------------------------------------------------------------------------------- /test/unit/src/acme/generateCertificateSpec.js: -------------------------------------------------------------------------------- 1 | const proxyquire = require('../../../proxyquire') 2 | 3 | 4 | describe('generateCertificate', () => { 5 | let getAccountStub, getChallengesStub, createCertificateStub, 6 | getDiscoveryUrlsStub, generateCertificate, certInfo, v1Urls, v2Urls, 7 | registerStub, account, newAccountStub, getV2OrderStub, performAuthorizationsStub, 8 | createV2CertificateStub 9 | 10 | beforeEach(() => { 11 | 12 | v1Urls = { 13 | 'new-reg': 'http://blah.reg/blah', 14 | 'new-authz': 'http://blah.auth/blah', 15 | 'new-cert': 'http://blah.cert/blah' 16 | } 17 | 18 | v2Urls = { 19 | 'newOrder': 'http://blah.order/blah', 20 | 'newAccount': 'http://blah.acct/blah', 21 | 'newNonce': 'http://blah.nonce/blah' 22 | } 23 | 24 | certInfo = { 25 | domains: ['doesnt matter'] 26 | } 27 | 28 | account = { 29 | key: 'a-key', 30 | url: 'http://account/or/something' 31 | } 32 | 33 | registerStub = sinon.stub() 34 | getAccountStub = sinon.stub() 35 | getChallengesStub = sinon.stub() 36 | createCertificateStub = sinon.stub() 37 | createV2CertificateStub = sinon.stub() 38 | getDiscoveryUrlsStub = sinon.stub() 39 | newAccountStub = sinon.stub() 40 | getV2OrderStub = sinon.stub() 41 | performAuthorizationsStub = sinon.stub() 42 | 43 | generateCertificate = proxyquire('src/acme/generateCertificate', { 44 | './register/getAccount': getAccountStub, 45 | './authorize/getChallenges': getChallengesStub, 46 | './certify/createCertificate': () => createCertificateStub, 47 | './getDiscoveryUrls': getDiscoveryUrlsStub, 48 | './register/register': registerStub, 49 | './v2/newAccount': newAccountStub, 50 | './v2/getV2Order': getV2OrderStub, 51 | './v2/performAuthorizations': () => performAuthorizationsStub, 52 | './v2/createV2Certificate': () => createV2CertificateStub 53 | }) 54 | }) 55 | 56 | it('v1 happy path', () => { 57 | getDiscoveryUrlsStub.resolves(v1Urls) 58 | getAccountStub.resolves(account) 59 | getChallengesStub.resolves('no challenge') 60 | registerStub.returns({registration: 'fake'}) 61 | createCertificateStub.resolves('the end') 62 | return generateCertificate(certInfo) 63 | .then(stuff => { 64 | registerStub.should.have.been.calledWithExactly(v1Urls['new-reg']) 65 | getAccountStub.should.have.been.calledWith({registration: 'fake'}) 66 | getChallengesStub.should.have.been.calledWithExactly(certInfo.domains, account.key, v1Urls['new-authz']) 67 | createCertificateStub.should.have.been.calledWithExactly('no challenge') 68 | stuff.should.eql('the end') 69 | }) 70 | }) 71 | 72 | it('v2 happy path', () => { 73 | getDiscoveryUrlsStub.resolves(v2Urls) 74 | getAccountStub.resolves(account) 75 | getV2OrderStub.resolves('http://order/info') 76 | newAccountStub.returns({registration: 'fake'}) 77 | performAuthorizationsStub.resolves('create a cert') 78 | createV2CertificateStub.resolves('the end') 79 | return generateCertificate(certInfo) 80 | .then(stuff => { 81 | newAccountStub.should.have.been.calledWithExactly(v2Urls['newAccount'], v2Urls['newNonce']) 82 | getAccountStub.should.have.been.calledWith({registration: 'fake'}) 83 | getV2OrderStub.should.have.been.calledWith(certInfo.domains, account.key, v2Urls['newOrder'], v2Urls['newNonce'], account.url) 84 | performAuthorizationsStub.should.have.been.calledWithExactly('http://order/info') 85 | createV2CertificateStub.should.have.been.calledWithExactly('create a cert') 86 | stuff.should.eql('the end') 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /zip.js: -------------------------------------------------------------------------------- 1 | const archiver = require('archiver') 2 | const fs = require('fs') 3 | 4 | const output = fs.createWriteStream('dist.zip') 5 | output.on('close', () => { 6 | console.log('dist.zip created') 7 | }) 8 | const zipfile = archiver('zip') 9 | zipfile.on('error', (err) => { 10 | throw err 11 | }) 12 | zipfile.pipe(output) 13 | zipfile.bulk([ 14 | { expand: true, cwd: './/', src: ['app.js', 'config.js'] }, 15 | { expand: true, cwd: './', src: ['src/**'] }, 16 | { expand: true, cwd: './build', src: ['**'] } 17 | ]) 18 | zipfile.finalize() 19 | --------------------------------------------------------------------------------