This commit is contained in:
Wildan M 2021-08-22 11:41:39 +07:00
commit fca036f7e3
5 changed files with 167 additions and 96 deletions

11
package-lock.json generated
View file

@ -8,11 +8,17 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"await-lock": "^2.1.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"jose-node-cjs-runtime": "^3.12.2", "jose-node-cjs-runtime": "^3.12.2",
"pem": "^1.14.4" "pem": "^1.14.4"
} }
}, },
"node_modules/await-lock": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.1.0.tgz",
"integrity": "sha512-t7Zm5YGgEEc/3eYAicF32m/TNvL+XOeYZy9CvBUeJY/szM7frLolFylhrlZNWV/ohWhcUXygrBGjYmoQdxF4CQ=="
},
"node_modules/axios": { "node_modules/axios": {
"version": "0.21.1", "version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
@ -127,6 +133,11 @@
} }
}, },
"dependencies": { "dependencies": {
"await-lock": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.1.0.tgz",
"integrity": "sha512-t7Zm5YGgEEc/3eYAicF32m/TNvL+XOeYZy9CvBUeJY/szM7frLolFylhrlZNWV/ohWhcUXygrBGjYmoQdxF4CQ=="
},
"axios": { "axios": {
"version": "0.21.1", "version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",

View file

@ -10,6 +10,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"await-lock": "^2.1.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"jose-node-cjs-runtime": "^3.12.2", "jose-node-cjs-runtime": "^3.12.2",
"pem": "^1.14.4" "pem": "^1.14.4"

View file

@ -1,12 +1,24 @@
const fs = require('fs') const fs = require('fs')
const http = require('http') const http = require('http')
const path = require('path') const path = require('path')
const { promisify } = require('util') const {
const { fromKeyLike } = require('jose-node-cjs-runtime/jwk/from_key_like') promisify
const { generateKeyPair } = require('jose-node-cjs-runtime/util/generate_key_pair') } = require('util')
const { calculateThumbprint } = require('jose-node-cjs-runtime/jwk/thumbprint') const {
const { SignJWT } = require('jose-node-cjs-runtime/jwt/sign') fromKeyLike
const { CompactSign } = require('jose-node-cjs-runtime/jws/compact/sign') } = require('jose-node-cjs-runtime/jwk/from_key_like')
const {
generateKeyPair
} = require('jose-node-cjs-runtime/util/generate_key_pair')
const {
calculateThumbprint
} = require('jose-node-cjs-runtime/jwk/thumbprint')
const {
SignJWT
} = require('jose-node-cjs-runtime/jwt/sign')
const {
CompactSign
} = require('jose-node-cjs-runtime/jws/compact/sign')
const pem = require('pem') const pem = require('pem')
const common = require('./common') const common = require('./common')
const request = require('./request') const request = require('./request')
@ -21,13 +33,13 @@ class Client {
/** /**
* @param {String} [directoryUrl] * @param {String} [directoryUrl]
*/ */
constructor (directoryUrl = common.DIRECTORY_URL) { constructor(directoryUrl = common.DIRECTORY_URL) {
this.accountPrivateJwk = null this.accountPrivateJwk = null
this.accountPrivateKey = null this.accountPrivateKey = null
this.accountPublicJwk = null this.accountPublicJwk = null
this.accountPublicKey = null this.accountPublicKey = null
this.directoryUrl = directoryUrl this.directoryUrl = directoryUrl
this.challengeCallbacks = {} this.challengeCallbacks = null
this.hasDirectory = false this.hasDirectory = false
this.myAccountUrl = '' this.myAccountUrl = ''
this.newAccountUrl = '' this.newAccountUrl = ''
@ -45,7 +57,7 @@ class Client {
* *
* @return {Promise} * @return {Promise}
*/ */
exportAccountKeyPair (dirname, passphrase) { exportAccountKeyPair(dirname, passphrase) {
const privateKeyFile = path.join(dirname, 'privateKey.pem') const privateKeyFile = path.join(dirname, 'privateKey.pem')
const publicKeyFile = path.join(dirname, 'publicKey.pem') const publicKeyFile = path.join(dirname, 'publicKey.pem')
@ -60,8 +72,11 @@ class Client {
* *
* @return {Promise} * @return {Promise}
*/ */
async generateAccountKeyPair () { async generateAccountKeyPair() {
const { privateKey, publicKey } = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM) const {
privateKey,
publicKey
} = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM)
this.accountPrivateKey = privateKey this.accountPrivateKey = privateKey
this.accountPublicKey = publicKey this.accountPublicKey = publicKey
@ -77,19 +92,34 @@ class Client {
* *
* @return {Promise} * @return {Promise}
*/ */
async generateCertificate (domain, email) { async generateCertificate(domain, email) {
await this.directory() await this.directory()
await this.newNonce() await this.newNonce()
await this.newAccount(email) await this.newAccount(email)
const { authzUrls, finalizeUrl } = await this.newOrder(domain) const {
const { challenge } = await this.authz(authzUrls[0]) authzUrls,
finalizeUrl
} = await this.newOrder(domain)
const {
challenge
} = await this.authz(authzUrls[0])
console.log('step 1');
await this.completeChallenge(challenge) await this.completeChallenge(challenge)
console.log('step 2');
await this.pollAuthz(authzUrls[0]) await this.pollAuthz(authzUrls[0])
const { certificate, privateKeyData } = await this.finalizeOrder(finalizeUrl, domain, email) console.log('step 3');
const {
certificate,
privateKeyData
} = await this.finalizeOrder(finalizeUrl, domain, email)
console.log('step 4');
return { certificate, privateKeyData } return {
certificate,
privateKeyData
}
} }
/** /**
@ -100,7 +130,7 @@ class Client {
* *
* @return {Promise} * @return {Promise}
*/ */
async importAccountKeyPair (dirname, passphrase) { async importAccountKeyPair(dirname, passphrase) {
const [privateKeyData, publicKeyData] = await Promise.all([ const [privateKeyData, publicKeyData] = await Promise.all([
fs.promises.readFile(path.join(dirname, 'privateKey.pem'), 'utf8'), fs.promises.readFile(path.join(dirname, 'privateKey.pem'), 'utf8'),
fs.promises.readFile(path.join(dirname, 'publicKey.pem'), 'utf8') fs.promises.readFile(path.join(dirname, 'publicKey.pem'), 'utf8')
@ -112,14 +142,12 @@ class Client {
await this.initAccountJwks() await this.initAccountJwks()
} }
async authz (authzUrl) { async authz(authzUrl) {
const data = await this.sign( const data = await this.sign({
{
kid: this.myAccountUrl, kid: this.myAccountUrl,
nonce: this.replayNonce, nonce: this.replayNonce,
url: authzUrl url: authzUrl
} })
)
const res = await request(authzUrl, { const res = await request(authzUrl, {
method: 'POST', method: 'POST',
@ -137,8 +165,14 @@ class Client {
throw new Error(`authz() Status Code: ${res.statusCode} Data: ${res.data}`) throw new Error(`authz() Status Code: ${res.statusCode} Data: ${res.data}`)
} }
const { challenges, identifier, ...rest } = res.data const {
const challenge = challenges.find(({ type }) => type === 'http-01') challenges,
identifier,
...rest
} = res.data
const challenge = challenges.find(({
type
}) => type === 'http-01')
return { return {
challenge, challenge,
@ -147,12 +181,12 @@ class Client {
} }
} }
async completeChallenge (challenge, cb) { async completeChallenge(challenge) {
await this.readyChallenge(challenge) await this.readyChallenge(challenge)
await this.receiveServerRequest(challenge, cb) await this.receiveServerRequest(challenge)
} }
async directory () { async directory() {
if (this.hasDirectory) return false if (this.hasDirectory) return false
const res = await request(this.directoryUrl) const res = await request(this.directoryUrl)
@ -169,7 +203,7 @@ class Client {
return true return true
} }
async fetchCertificate (certificateUrl) { async fetchCertificate(certificateUrl) {
const data = await this.sign({ const data = await this.sign({
kid: this.myAccountUrl, kid: this.myAccountUrl,
nonce: this.replayNonce, nonce: this.replayNonce,
@ -196,10 +230,18 @@ class Client {
return res.data return res.data
} }
async finalizeOrder (finalizeUrl, domain, email) { async finalizeOrder(finalizeUrl, domain, email) {
const { privateKey } = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM) const {
privateKey
} = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM)
const clientKey = common.exportPrivateKey(privateKey) const clientKey = common.exportPrivateKey(privateKey)
let { csr } = await createCsr({ clientKey, commonName: domain, email }) let {
csr
} = await createCsr({
clientKey,
commonName: domain,
email
})
// "The CSR is sent in the base64url-encoded version of the DER format. // "The CSR is sent in the base64url-encoded version of the DER format.
// (Note: Because this field uses base64url, and does not include headers, // (Note: Because this field uses base64url, and does not include headers,
@ -213,16 +255,13 @@ class Client {
.replace(/\//g, '_') .replace(/\//g, '_')
.replace(/=/g, '') .replace(/=/g, '')
const data = await this.sign( const data = await this.sign({
{
kid: this.myAccountUrl, kid: this.myAccountUrl,
nonce: this.replayNonce, nonce: this.replayNonce,
url: finalizeUrl url: finalizeUrl
}, }, {
{
csr csr
} })
)
const res = await request(finalizeUrl, { const res = await request(finalizeUrl, {
method: 'POST', method: 'POST',
@ -242,10 +281,13 @@ class Client {
const certificate = await this.fetchCertificate(res.data.certificate) const certificate = await this.fetchCertificate(res.data.certificate)
return { certificate, privateKeyData: clientKey } return {
certificate,
privateKeyData: clientKey
}
} }
async initAccountJwks () { async initAccountJwks() {
const [publicJwk, accountPrivateJwk] = await Promise.all([ const [publicJwk, accountPrivateJwk] = await Promise.all([
fromKeyLike(this.accountPublicKey), fromKeyLike(this.accountPublicKey),
fromKeyLike(this.accountPrivateKey) fromKeyLike(this.accountPrivateKey)
@ -256,18 +298,15 @@ class Client {
this.thumbprint = await calculateThumbprint(publicJwk) this.thumbprint = await calculateThumbprint(publicJwk)
} }
async newAccount (...emails) { async newAccount(...emails) {
const data = await this.sign( const data = await this.sign({
{
jwk: this.accountPublicJwk, jwk: this.accountPublicJwk,
nonce: this.replayNonce, nonce: this.replayNonce,
url: this.newAccountUrl url: this.newAccountUrl
}, }, {
{
contact: emails.map(email => 'mailto:' + email), contact: emails.map(email => 'mailto:' + email),
termsOfServiceAgreed: true termsOfServiceAgreed: true
} })
)
const res = await request(this.newAccountUrl, { const res = await request(this.newAccountUrl, {
method: 'POST', method: 'POST',
@ -290,10 +329,12 @@ class Client {
return res.statusCode === 201 return res.statusCode === 201
} }
async newNonce () { async newNonce() {
if (this.replayNonce) return false if (this.replayNonce) return false
const res = await request(this.newNonceUrl, { method: 'HEAD' }) const res = await request(this.newNonceUrl, {
method: 'HEAD'
})
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
throw new Error(`newNonce() Status Code: ${res.statusCode} Data: ${res.data}`) throw new Error(`newNonce() Status Code: ${res.statusCode} Data: ${res.data}`)
@ -304,19 +345,19 @@ class Client {
return true return true
} }
async newOrder (...domains) { async newOrder(...domains) {
const identifiers = domains.map(domain => ({ type: 'dns', value: domain })) const identifiers = domains.map(domain => ({
type: 'dns',
value: domain
}))
const data = await this.sign( const data = await this.sign({
{
kid: this.myAccountUrl, kid: this.myAccountUrl,
nonce: this.replayNonce, nonce: this.replayNonce,
url: this.newOrderUrl url: this.newOrderUrl
}, }, {
{
identifiers identifiers
} })
)
const res = await request(this.newOrderUrl, { const res = await request(this.newOrderUrl, {
method: 'POST', method: 'POST',
@ -335,7 +376,10 @@ class Client {
} }
const orderUrl = res.headers.location const orderUrl = res.headers.location
const { authorizations: authzUrls, finalize: finalizeUrl } = res.data const {
authorizations: authzUrls,
finalize: finalizeUrl
} = res.data
return { return {
authzUrls, authzUrls,
@ -345,7 +389,7 @@ class Client {
} }
} }
async pollAuthz (authzUrl) { async pollAuthz(authzUrl) {
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const result = await this.authz(authzUrl) const result = await this.authz(authzUrl)
@ -364,15 +408,12 @@ class Client {
throw new Error('pollAuthz() timed out') throw new Error('pollAuthz() timed out')
} }
async readyChallenge (challenge) { async readyChallenge(challenge) {
const data = await this.sign( const data = await this.sign({
{
kid: this.myAccountUrl, kid: this.myAccountUrl,
nonce: this.replayNonce, nonce: this.replayNonce,
url: challenge.url url: challenge.url
}, }, {})
{}
)
const res = await request(challenge.url, { const res = await request(challenge.url, {
method: 'POST', method: 'POST',
@ -391,21 +432,25 @@ class Client {
} }
} }
receiveServerRequest (challenge, cb) { receiveServerRequest(challenge) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const time = setTimeout(() => { const time = setTimeout(() => {
reject(new Error('Timed out waiting for server request')) reject(new Error('Timed out waiting for server request'))
}, 10e3); }, 10e3);
this.challengeCallbacks[challenge.token] = function () { let hasResolved = false;
setTimeout(cb, 100); this.challengeCallbacks = function () {
if (!hasResolved)
setTimeout(resolve, 100);
else
return challenge.token + '.' + this.thumbprint;
hasResolved = true;
clearTimeout(time); clearTimeout(time);
resolve();
return challenge.token + '.' + this.thumbprint; return challenge.token + '.' + this.thumbprint;
} }
}); });
} }
setReplayNonce (res) { setReplayNonce(res) {
const replayNonce = (res.headers['replay-nonce'] || '').trim() const replayNonce = (res.headers['replay-nonce'] || '').trim()
if (!replayNonce) { if (!replayNonce) {
@ -415,18 +460,24 @@ class Client {
this.replayNonce = replayNonce this.replayNonce = replayNonce
} }
async sign (header, payload) { async sign(header, payload) {
let data let data
if (payload) { if (payload) {
data = await new SignJWT(payload) data = await new SignJWT(payload)
.setProtectedHeader({ alg: common.ACCOUNT_KEY_ALGORITHM, ...header }) .setProtectedHeader({
alg: common.ACCOUNT_KEY_ALGORITHM,
...header
})
.sign(this.accountPrivateKey) .sign(this.accountPrivateKey)
} else { } else {
// SignJWT constructor only accepts object but RFC8555 requires empty payload // SignJWT constructor only accepts object but RFC8555 requires empty payload
// Workaround: manually pass empty Uint8Array to CompactSign constructor // Workaround: manually pass empty Uint8Array to CompactSign constructor
const sig = new CompactSign(new Uint8Array()) const sig = new CompactSign(new Uint8Array())
sig.setProtectedHeader({ alg: common.ACCOUNT_KEY_ALGORITHM, ...header }) sig.setProtectedHeader({
alg: common.ACCOUNT_KEY_ALGORITHM,
...header
})
data = await sig.sign(this.accountPrivateKey) data = await sig.sign(this.accountPrivateKey)
} }

View file

@ -34,9 +34,11 @@ const acme_prefix = '/.well-known/acme-challenge/';
const listener = async function (/** @type {import('http').IncomingMessage} */ req, /** @type {import('http').ServerResponse} */ res) { const listener = async function (/** @type {import('http').IncomingMessage} */ req, /** @type {import('http').ServerResponse} */ res) {
try { try {
if (req.url.startsWith(acme_prefix)) { if (req.url.startsWith(acme_prefix)) {
const token = req.url.slice(acme_prefix.length); if (client.challengeCallbacks) {
if (client.challengeCallbacks[token]) { res.writeHead(200, {
res.write(client.challengeCallbacks[token]()); 'content-type': 'application/octet-stream'
});
res.write(client.challengeCallbacks());
} else { } else {
res.writeHead(404) res.writeHead(404)
} }

View file

@ -7,6 +7,7 @@ const {
ensureDir, ensureDir,
findTxtRecord findTxtRecord
} = require('./util'); } = require('./util');
const { default: AwaitLock } = require('await-lock');
const record_email_prefix = 'forward-domain-cert-maintainer='; const record_email_prefix = 'forward-domain-cert-maintainer=';
const client = new certnode.Client(); const client = new certnode.Client();
const certsDir = path.join(__dirname, '.certs'); const certsDir = path.join(__dirname, '.certs');
@ -37,7 +38,7 @@ async function buildCache(host) {
await fs.promises.access(extP, fs.constants.R_OK | fs.constants.W_OK); await fs.promises.access(extP, fs.constants.R_OK | fs.constants.W_OK);
const expire = parseInt((await fs.promises.readFile(extP)).toString('utf8')); const expire = parseInt((await fs.promises.readFile(extP)).toString('utf8'));
if (Date.now() > expire) if (Date.now() > expire)
throw null; // expired throw new Error('expired'); // expired
const cert = await fs.promises.readFile(certP, 'utf8'); const cert = await fs.promises.readFile(certP, 'utf8');
const key = await fs.promises.readFile(keyP, 'utf8') const key = await fs.promises.readFile(keyP, 'utf8')
return { return {
@ -76,13 +77,18 @@ async function getKeyCert(servername) {
} }
} }
let lock = new AwaitLock();
const SniListener = async (servername, ctx) => { const SniListener = async (servername, ctx) => {
// Generate fresh account keys for Let's Encrypt // Generate fresh account keys for Let's Encrypt
await lock.acquireAsync();
try { try {
ctx(null, tls.createSecureContext(await getKeyCert(servername))); ctx(null, tls.createSecureContext(await getKeyCert(servername)));
} catch (error) { } catch (error) {
console.log(JSON.stringify(error));
ctx(error, null); ctx(error, null);
} finally {
lock.release();
} }
} }