/*! * letiny * Copyright(c) 2015 Anatol Sommer * Some code used from https://github.com/letsencrypt/boulder/tree/master/test/js * MPL 2.0 */ 'use strict'; var _DEBUG, NOOP=new Function(), log=NOOP, mkdirp=require('mkdirp').sync, request=require('request'), forge=require('node-forge'), pki=forge.pki, cryptoUtil=require('./crypto-util'), util=require('./acme-util'), fs=require('fs'), path=require('path'); function Acme(privateKey) { this.privateKey=privateKey; this.nonces=[]; } Acme.prototype.getNonce=function(url, cb) { var self=this; request.head({ url:url, }, function(err, res, body) { if (err) { return cb(err); } if (res && 'replay-nonce' in res.headers) { log('Storing nonce: '+res.headers['replay-nonce']); self.nonces.push(res.headers['replay-nonce']); cb(); return; } cb(new Error('Failed to get nonce for request')); }); }; Acme.prototype.post=function(url, body, cb) { var self=this, payload, jws, signed; if (this.nonces.length===0) { this.getNonce(url, function(err) { if (err) { return cb(err); } self.post(url, body, cb); }); return; } log('Using nonce: '+this.nonces[0]); payload=JSON.stringify(body, null, 2); jws=cryptoUtil.generateSignature( this.privateKey, new Buffer(payload), this.nonces.shift() ); signed=JSON.stringify(jws, null, 2); log('Posting to '+url); log(signed.green); log('Payload:'+payload.blue); return request.post({ url:url, body:signed, encoding:null }, function(err, res, body) { var parsed; if (err) { console.error(err); return cb(err); } if (res) { log(('HTTP/1.1 '+res.statusCode).yellow); } Object.keys(res.headers).forEach(function(key) { var value, upcased; value=res.headers[key]; upcased=key.charAt(0).toUpperCase()+key.slice(1); log((upcased+': '+value).yellow); }); if (body && !body.toString().match(/[^\x00-\x7F]/)) { try { parsed=JSON.parse(body); log(JSON.stringify(parsed, null, 2).cyan); } catch(err) { log(body.toString().cyan); } } if ('replay-nonce' in res.headers) { log('Storing nonce: '+res.headers['replay-nonce']); self.nonces.push(res.headers['replay-nonce']); } cb(err, res, body); }); }; function getCert(options, cb) { var state={ validatedDomains:[], validAuthorizationURLs:[] }; options.newReg=options.newReg || 'https://acme-v01.api.letsencrypt.org/acme/new-reg'; if (!options.email) { return cb(new Error('No "email" option given!')); } if (typeof options.domains==='string') { state.domains=options.domains.split(/[, ]+/); } else if (options.domains && options.domains instanceof Array) { state.domains=options.domains.slice(); } else { return cb(new Error('No valid "domains" option given!')); } if ((_DEBUG=options.debug)) { if (!''.green) { require('colors'); } log=console.log.bind(console); } else { log=NOOP; } makeAccountKeyPair(); function makeAccountKeyPair() { var keypair; log('Generating account keypair...'); keypair=pki.rsa.generateKeyPair(2048); state.accountKeyPair=cryptoUtil.importPemPrivateKey(pki.privateKeyToPem(keypair.privateKey)); state.acme=new Acme(state.accountKeyPair); makeKeyPair(); } function makeKeyPair() { var keypair; log('Generating cert keypair...'); keypair=pki.rsa.generateKeyPair(2048); state.certPrivateKeyPEM=pki.privateKeyToPem(keypair.privateKey); state.certPrivateKey=cryptoUtil.importPemPrivateKey(state.certPrivateKeyPEM); register(); } function register() { post(options.newReg, { resource:'new-reg', contact:['mailto:'+options.email] }, getTerms); } function getTerms(err, res) { var links; if (err || Math.floor(res.statusCode/100)!==2) { return handleErr(err, 'Registration request failed'); } links=parseLink(res.headers['link']); if (!links || !('next' in links)) { return handleErr(err, 'Server didn\'t provide information to proceed (1)'); } state.registrationURL=res.headers['location']; state.newAuthorizationURL=links['next']; state.termsRequired=('terms-of-service' in links); if (state.termsRequired) { state.termsURL=links['terms-of-service']; log(state.termsURL); request.get(state.termsURL, getAgreement); } else { getChallenges(); } } function getAgreement(err, res, body) { if (err) { return handleErr(err, 'Couldn\'t get agreement'); } log('The CA requires your agreement to terms:\n'+state.termsURL); sendAgreement(); } function sendAgreement() { if (state.termsRequired && !options.agreeTerms) { return handleErr(null, 'The CA requires your agreement to terms: '+state.termsURL); } log('Posting agreement to: '+state.registrationURL); post(state.registrationURL, { resource:'reg', agreement:state.termsURL }, function(err, res, body) { if (err || Math.floor(res.statusCode/100)!==2) { return handleErr(err, 'Couldn\'t POST agreement back to server', body); } else { nextDomain(); } }); } function nextDomain() { if (state.domains.length > 0) { getChallenges(state.domains.shift()); return; } else { getCertificate(); } } function getChallenges(domain) { state.domain=domain; post(state.newAuthorizationURL, { resource:'new-authz', identifier:{ type:'dns', value:state.domain, } }, getReadyToValidate); } function getReadyToValidate(err, res, body) { var links, authz, httpChallenges, challenge, thumbprint, keyAuthorization, challengePath; if (err || Math.floor(res.statusCode/100)!==2) { return handleErr(err, 'Authorization request failed with code '+res.statusCode); } links=parseLink(res.headers['link']); if (!links || !('next' in links)) { return handleErr(err, 'Server didn\'t provide information to proceed (2)'); } state.authorizationURL=res.headers['location']; state.newCertificateURL=links['next']; authz=JSON.parse(body); httpChallenges=authz.challenges.filter(function(x) { return x.type==='http-01'; }); if (httpChallenges.length===0) { return handleErr(null, 'Server didn\'t offer any challenge we can handle.'); } challenge=httpChallenges[0]; thumbprint=cryptoUtil.thumbprint(state.accountKeyPair.publicKey); keyAuthorization=challenge.token+'.'+thumbprint; challengePath='.well-known/acme-challenge/'+challenge.token; state.responseURL=challenge['uri']; state.path=challengePath; if (options.webroot) { try { mkdirp(path.dirname(options.webroot+'/'+challengePath)); fs.writeFileSync(path.normalize(options.webroot+'/'+challengePath), keyAuthorization); challengeDone(); } catch(err) { handleErr(err, 'Could not write challange file to disk'); } } else if (typeof options.challenge==='function') { options.challenge(state.domain, '/'+challengePath, keyAuthorization, challengeDone); } else { return handleErr(null, 'No "challenge" function or "webroot" option given.'); } function challengeDone() { post(state.responseURL, { resource:'challenge', keyAuthorization:keyAuthorization }, function(err, res, body) { ensureValidation(err, res, body, function unlink() { if (options.webroot) { fs.unlinkSync(path.normalize(options.webroot+'/'+challengePath)); } }); }); } } function ensureValidation(err, res, body, unlink) { var authz; if (err || Math.floor(res.statusCode/100)!==2) { unlink(); return handleErr(err, 'Authorization status request failed ('+res.statusCode+')'); } authz=JSON.parse(body); if (authz.status==='pending') { setTimeout(function() { request.get(state.authorizationURL, {}, function(err, res, body) { ensureValidation(err, res, body, unlink); }); }, 1000); } else if (authz.status==='valid') { log('Validating domain ... done'); state.validatedDomains.push(state.domain); state.validAuthorizationURLs.push(state.authorizationURL); unlink(); nextDomain(); } else if (authz.status==='invalid') { unlink(); return handleErr(null, 'The CA was unable to validate the file you provisioned', body); } else { unlink(); return handleErr(null, 'CA returned an authorization in an unexpected state', authz); } } function getCertificate() { var csr=cryptoUtil.generateCSR(state.certPrivateKey, state.validatedDomains); log('Requesting certificate...'); post(state.newCertificateURL, { resource:'new-cert', csr:csr, authorizations:state.validAuthorizationURLs }, downloadCertificate); } function downloadCertificate(err, res, body) { var links, certURL; if (err || Math.floor(res.statusCode/100)!==2) { log('Certificate request failed with error ', err); if (body) { log(body.toString()); } return handleErr(err, 'Certificate request failed'); } links=parseLink(res.headers['link']); if (!links || !('up' in links)) { return handleErr(err, 'Failed to fetch CA certificate'); } log('Requesting certificate: done'); state.certificate=body; certURL=res.headers['location']; request.get({ url:certURL, encoding:null }, function(err, res, body) { if (err) { return handleErr(err, 'Failed to fetch cert from '+certURL); } if (res.statusCode!==200) { return handleErr(err, 'Failed to fetch cert from '+certURL, res.body.toString()); } if (body.toString()!==state.certificate.toString()) { handleErr(null, 'Cert at '+certURL+' did not match returned cert'); } else { log('Successfully verified cert at '+certURL); log('Requesting CA certificate...'); request.get({ url:links['up'], encoding:null }, function(err, res, body) { if (err || res.statusCode!==200) { return handleErr(err, 'Failed to fetch CA certificate'); } state.caCert=certBufferToPEM(body); log('Requesting CA certificate: done'); done(); }); } }); } function done() { var cert, pfx; try { cert=certBufferToPEM(state.certificate); if (options.certFile) { fs.writeFileSync(options.certFile, cert); } if (options.keyFile) { fs.writeFileSync(options.keyFile, state.certPrivateKeyPEM); } if (options.caFile) { fs.writeFileSync(options.caFile, state.caCert); } if (options.pfxFile) { try { pfx=forge.pkcs12.toPkcs12Asn1( pki.privateKeyFromPem(state.certPrivateKeyPEM), [pki.certificateFromPem(cert), pki.certificateFromPem(state.caCert)], options.pfxPassword || '', options.aes ? {} : {algorithm:'3des'} ); pfx=new Buffer(forge.asn1.toDer(pfx).toHex(), 'hex'); } catch(err) { handleErr(err, 'Could not convert to PKCS#12'); } fs.writeFileSync(options.pfxFile, pfx); } cb(null, cert, state.certPrivateKeyPEM, state.caCert); } catch(err) { handleErr(err, 'Could not write output files'); } } function post(url, body, cb) { return state.acme.post(url, body, cb); } function handleErr(err, text, info) { log(text, err, info); cb(err || new Error(text)); } } function certBufferToPEM(cert) { cert=util.toStandardB64(cert.toString('base64')); cert=cert.match(/.{1,64}/g).join('\n'); return '-----BEGIN CERTIFICATE-----\n'+cert+'\n-----END CERTIFICATE-----'; } function parseLink(link) { var links; try { links=link.split(',').map(function(link) { var parts, url, info; parts=link.trim().split(';'); url=parts.shift().replace(/[<>]/g, ''); info=parts.reduce(function(acc, p) { var m=p.trim().match(/(.+) *= *"(.+)"/); if (m) { acc[m[1]]=m[2]; } return acc; }, {}); info['url']=url; return info; }).reduce(function(acc, link) { if ('rel' in link) { acc[link['rel']]=link['url']; } return acc; }, {}); return links; } catch(err) { return null; } } exports.getCert=getCert;