Extract certnode & stuffs out of app.js

This commit is contained in:
Wildan M 2021-08-22 09:10:25 +07:00
commit 43841ff081
13 changed files with 868 additions and 184 deletions

5
src/certnode/LICENSE Normal file
View file

@ -0,0 +1,5 @@
Copyright 2021 Zachary Balder
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.
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.

3
src/certnode/README.md Normal file
View file

@ -0,0 +1,3 @@
# certnode
Modified certnode. Please see https://github.com/zbo14/certnode

469
src/certnode/lib/client.js Normal file
View file

@ -0,0 +1,469 @@
const fs = require('fs')
const http = require('http')
const path = require('path')
const { promisify } = require('util')
const { fromKeyLike } = 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 common = require('./common')
const request = require('./request')
const createCsr = promisify(pem.createCSR)
/**
* Represents a Let's Encrypt account and
* sends requests to get valid TLS certificates.
*/
class Client {
/**
* @param {String} [directoryUrl]
*/
constructor (directoryUrl = common.DIRECTORY_URL) {
this.accountPrivateJwk = null
this.accountPrivateKey = null
this.accountPublicJwk = null
this.accountPublicKey = null
this.directoryUrl = directoryUrl
this.hasDirectory = false
this.myAccountUrl = ''
this.newAccountUrl = ''
this.newNonceUrl = ''
this.newOrderUrl = ''
this.replayNonce = ''
this.server = null
this.thumbprint = ''
}
/**
* Export account public and private keys to a directory.
*
* @param {String} dirname - name of directory to write key files to
* @param {String} [passphrase] - optional passphrase to encrypt private key with
*
* @return {Promise}
*/
exportAccountKeyPair (dirname, passphrase) {
const privateKeyFile = path.join(dirname, 'privateKey.pem')
const publicKeyFile = path.join(dirname, 'publicKey.pem')
return Promise.all([
common.writeKeyToFile(privateKeyFile, this.accountPrivateKey, passphrase),
common.writeKeyToFile(publicKeyFile, this.accountPublicKey)
])
}
/**
* Generate new account public and private keys.
*
* @return {Promise}
*/
async generateAccountKeyPair () {
const { privateKey, publicKey } = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM)
this.accountPrivateKey = privateKey
this.accountPublicKey = publicKey
await this.initAccountJwks()
}
/**
* Generate a certificate from Let's Encrypt for your domain.
*
* @param {String} domain - the domain you want a certificate for
* @param {String} email - the email used to register the certificate
*
* @return {Promise}
*/
async generateCertificate (domain, email) {
await this.directory()
await this.newNonce()
await this.newAccount(email)
const { authzUrls, finalizeUrl } = await this.newOrder(domain)
const { challenge } = await this.authz(authzUrls[0])
await this.completeChallenge(challenge)
await this.pollAuthz(authzUrls[0])
const { certificate, privateKeyData } = await this.finalizeOrder(finalizeUrl, domain, email)
this.server?.close()
return { certificate, privateKeyData }
}
/**
* Import account public and private keys from a directory.
*
* @param {String} dirname - name of directory to read key files from
* @param {String} [passphrase] - optional passphrase to decrypt private key with
*
* @return {Promise}
*/
async importAccountKeyPair (dirname, passphrase) {
const [privateKeyData, publicKeyData] = await Promise.all([
fs.promises.readFile(path.join(dirname, 'privateKey.pem'), 'utf8'),
fs.promises.readFile(path.join(dirname, 'publicKey.pem'), 'utf8')
])
this.accountPrivateKey = common.importPrivateKey(privateKeyData, passphrase)
this.accountPublicKey = common.importPublicKey(publicKeyData)
await this.initAccountJwks()
}
async authz (authzUrl) {
const data = await this.sign(
{
kid: this.myAccountUrl,
nonce: this.replayNonce,
url: authzUrl
}
)
const res = await request(authzUrl, {
method: 'POST',
headers: {
'content-type': 'application/jose+json'
},
data
})
this.setReplayNonce(res)
if (res.statusCode !== 200) {
throw new Error(`authz() Status Code: ${res.statusCode} Data: ${res.data}`)
}
const { challenges, identifier, ...rest } = res.data
const challenge = challenges.find(({ type }) => type === 'http-01')
return {
challenge,
domain: identifier.value,
...rest
}
}
async completeChallenge (challenge, cb) {
await this.readyChallenge(challenge)
await this.receiveServerRequest(challenge, cb)
}
async directory () {
if (this.hasDirectory) return false
const res = await request(this.directoryUrl)
if (res.statusCode !== 200) {
throw new Error(`directory() Status Code: ${res.statusCode} Data: ${res.data}`)
}
this.hasDirectory = true
this.newAccountUrl = res.data.newAccount
this.newNonceUrl = res.data.newNonce
this.newOrderUrl = res.data.newOrder
return true
}
async fetchCertificate (certificateUrl) {
const data = await this.sign({
kid: this.myAccountUrl,
nonce: this.replayNonce,
url: certificateUrl
})
const res = await request(certificateUrl, {
method: 'POST',
headers: {
accept: 'application/pem-certificate-chain',
'content-type': 'application/jose+json'
},
data
})
this.setReplayNonce(res)
if (res.statusCode !== 200) {
throw new Error(`fetchCertificate() Status Code: ${res.statusCode} Data: ${res.data}`)
}
return res.data
}
async finalizeOrder (finalizeUrl, domain, email) {
const { privateKey } = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM)
const clientKey = common.exportPrivateKey(privateKey)
let { csr } = await createCsr({ clientKey, commonName: domain, email })
// "The CSR is sent in the base64url-encoded version of the DER format.
// (Note: Because this field uses base64url, and does not include headers,
// it is different from PEM.)"
csr = csr
.split('\n')
.filter(Boolean)
.slice(1, -1)
.join('')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
const data = await this.sign(
{
kid: this.myAccountUrl,
nonce: this.replayNonce,
url: finalizeUrl
},
{
csr
}
)
const res = await request(finalizeUrl, {
method: 'POST',
headers: {
'content-type': 'application/jose+json'
},
data
})
this.setReplayNonce(res)
if (res.statusCode !== 200) {
throw new Error(`finalizeOrder() Status Code: ${res.statusCode} Data: ${res.data}`)
}
const certificate = await this.fetchCertificate(res.data.certificate)
return { certificate, privateKeyData: clientKey }
}
async initAccountJwks () {
const [publicJwk, accountPrivateJwk] = await Promise.all([
fromKeyLike(this.accountPublicKey),
fromKeyLike(this.accountPrivateKey)
])
this.accountPublicJwk = publicJwk
this.accountPrivateJwk = accountPrivateJwk
this.thumbprint = await calculateThumbprint(publicJwk)
}
async newAccount (...emails) {
const data = await this.sign(
{
jwk: this.accountPublicJwk,
nonce: this.replayNonce,
url: this.newAccountUrl
},
{
contact: emails.map(email => 'mailto:' + email),
termsOfServiceAgreed: true
}
)
const res = await request(this.newAccountUrl, {
method: 'POST',
headers: {
'content-type': 'application/jose+json'
},
data
})
this.setReplayNonce(res)
if (![200, 201].includes(res.statusCode)) {
throw new Error(`newAccount() Status Code: ${res.statusCode} Data: ${res.data}`)
}
this.myAccountUrl = res.headers.location
return res.statusCode === 201
}
async newNonce () {
if (this.replayNonce) return false
const res = await request(this.newNonceUrl, { method: 'HEAD' })
if (res.statusCode !== 200) {
throw new Error(`newNonce() Status Code: ${res.statusCode} Data: ${res.data}`)
}
this.setReplayNonce(res)
return true
}
async newOrder (...domains) {
const identifiers = domains.map(domain => ({ type: 'dns', value: domain }))
const data = await this.sign(
{
kid: this.myAccountUrl,
nonce: this.replayNonce,
url: this.newOrderUrl
},
{
identifiers
}
)
const res = await request(this.newOrderUrl, {
method: 'POST',
headers: {
'content-type': 'application/jose+json'
},
data
})
this.setReplayNonce(res)
if (res.statusCode !== 201) {
throw new Error(`newOrder() Status Code: ${res.statusCode} Data: ${res.data}`)
}
const orderUrl = res.headers.location
const { authorizations: authzUrls, finalize: finalizeUrl } = res.data
return {
authzUrls,
domains,
finalizeUrl,
orderUrl
}
}
async pollAuthz (authzUrl) {
for (let i = 0; i < 10; i++) {
const result = await this.authz(authzUrl)
if (result.status === 'pending') {
await new Promise(resolve => setTimeout(resolve, 1e3))
continue
}
if (result.status === 'invalid') {
throw new Error('pollAuthz() authorization is invalid: ' + JSON.stringify(result, null, 2))
}
return result
}
throw new Error('pollAuthz() timed out')
}
async readyChallenge (challenge) {
const data = await this.sign(
{
kid: this.myAccountUrl,
nonce: this.replayNonce,
url: challenge.url
},
{}
)
const res = await request(challenge.url, {
method: 'POST',
headers: {
'content-type': 'application/jose+json'
},
data
})
this.setReplayNonce(res)
if (res.statusCode !== 200) {
throw new Error(`readyChallenge() Status Code: ${res.statusCode} Data: ${res.data}`)
}
}
receiveServerRequest (challenge, cb) {
this.server?.close()
this.server = http.createServer()
return new Promise((resolve, reject) => {
this.server
.once('error', reject)
.on('request', (req, res) => {
if (req.method !== 'GET') {
res.writeHead(405)
res.writeHead(http.STATUS_CODES[405])
return
}
if (req.url !== '/.well-known/acme-challenge/' + challenge.token) {
res.writeHead(404)
res.end(http.STATUS_CODES[404])
return
}
res.writeHead(200, {
'content-type': 'application/octet-stream'
})
res.end(challenge.token + '.' + this.thumbprint)
resolve()
})
this.server.listen(80, '0.0.0.0')
setTimeout(() => {
reject(new Error('Timed out waiting for server request'))
}, 10e3)
cb && cb()
})
}
setReplayNonce (res) {
const replayNonce = (res.headers['replay-nonce'] || '').trim()
if (!replayNonce) {
throw new Error('No Replay-Nonce header in response')
}
this.replayNonce = replayNonce
}
async sign (header, payload) {
let data
if (payload) {
data = await new SignJWT(payload)
.setProtectedHeader({ alg: common.ACCOUNT_KEY_ALGORITHM, ...header })
.sign(this.accountPrivateKey)
} else {
// SignJWT constructor only accepts object but RFC8555 requires empty payload
// Workaround: manually pass empty Uint8Array to CompactSign constructor
const sig = new CompactSign(new Uint8Array())
sig.setProtectedHeader({ alg: common.ACCOUNT_KEY_ALGORITHM, ...header })
data = await sig.sign(this.accountPrivateKey)
}
const [b64Header, b64Payload, b64Signature] = data.split('.')
return JSON.stringify({
protected: b64Header,
payload: b64Payload,
signature: b64Signature
})
}
}
module.exports = Client

139
src/certnode/lib/common.js Normal file
View file

@ -0,0 +1,139 @@
const crypto = require('crypto')
const fs = require('fs')
const ACCOUNT_KEY_ALGORITHM = 'ES256'
const CERTIFICATE_KEY_ALGORITHM = 'RS256'
const env = (process.env.NODE_ENV || '').trim().toLowerCase()
const DIRECTORY_URL = ['development', 'test'].includes(env)
? 'https://acme-staging-v02.api.letsencrypt.org/directory'
: 'https://acme-v02.api.letsencrypt.org/directory'
const PRIVATE_KEY_CIPHER = 'aes-256-cbc'
const PRIVATE_KEY_FORMAT = 'pem'
const PRIVATE_KEY_PERMISSIONS = 0o600
const PRIVATE_KEY_TYPE = 'pkcs8'
const PUBLIC_KEY_FORMAT = 'pem'
const PUBLIC_KEY_PERMISSIONS = 0o666
const PUBLIC_KEY_TYPE = 'spki'
/**
* @param {crypto.KeyObject} privateKey
* @param {String} [passphrase]
*
* @return {String}
*/
const exportPrivateKey = (privateKey, passphrase) => {
const privateKeyOpts = {
type: PRIVATE_KEY_TYPE,
format: PRIVATE_KEY_FORMAT
}
if (passphrase) {
privateKeyOpts.cipher = PRIVATE_KEY_CIPHER
privateKeyOpts.passphrase = passphrase
}
return privateKey.export(privateKeyOpts)
}
/**
* @param {crypto.KeyObject} publicKey
*
* @return {String}
*/
const exportPublicKey = publicKey => {
return publicKey.export({
type: PUBLIC_KEY_TYPE,
format: PUBLIC_KEY_FORMAT
})
}
/**
* @param {String} privateKeyData
* @param {String} [passphrase]
*
* @return {String}
*/
const importPrivateKey = (privateKeyData, passphrase) => {
const privateKeyOpts = {
key: privateKeyData,
format: PRIVATE_KEY_FORMAT,
type: PRIVATE_KEY_TYPE
}
if (passphrase) {
privateKeyOpts.passphrase = passphrase
}
try {
return crypto.createPrivateKey(privateKeyOpts)
} catch {
throw new Error('Failed to import private key')
}
}
/**
* @param {String} publicKeyData
*
* @return {crypto.KeyObject}
*/
const importPublicKey = publicKeyData => {
try {
return crypto.createPublicKey({
key: publicKeyData,
format: PUBLIC_KEY_FORMAT,
type: PUBLIC_KEY_TYPE
})
} catch {
throw new Error('Failed to import public key')
}
}
/**
* @param {String} dirname
* @param {(crypto.KeyObject|String)} key
* @param {String} [passphrase]
*
* @return {Promise}
*/
const writeKeyToFile = async (filename, key, passphrase) => {
if (typeof key === 'string') {
key = key.includes('PRIVATE KEY')
? importPrivateKey(key, passphrase)
: importPublicKey(key)
} else if (!(key instanceof crypto.KeyObject)) {
throw new Error('Expected "key" to be crypto.KeyObject or string')
}
const isPrivateKey = key.type === 'private'
const keyData = isPrivateKey
? exportPrivateKey(key, passphrase)
: exportPublicKey(key)
const mode = isPrivateKey ? PRIVATE_KEY_PERMISSIONS : PUBLIC_KEY_PERMISSIONS
await fs.promises.writeFile(filename, keyData, { mode })
}
module.exports = {
ACCOUNT_KEY_ALGORITHM,
CERTIFICATE_KEY_ALGORITHM,
DIRECTORY_URL,
PRIVATE_KEY_CIPHER,
PRIVATE_KEY_FORMAT,
PRIVATE_KEY_PERMISSIONS,
PRIVATE_KEY_TYPE,
PUBLIC_KEY_FORMAT,
PUBLIC_KEY_PERMISSIONS,
PUBLIC_KEY_TYPE,
env,
exportPrivateKey,
exportPublicKey,
importPrivateKey,
importPublicKey,
writeKeyToFile
}

View file

@ -0,0 +1,4 @@
module.exports = {
...require('./common'),
Client: require('./client')
}

View file

@ -0,0 +1,46 @@
const https = require('https')
const request = (url, { data = '', ...options } = {}, cb) => {
return new Promise((resolve, reject) => {
try {
url = new URL(url)
} catch (err) {
return reject(err)
}
https.request(url, options, res => {
const { statusCode, headers } = res
let data = ''
res
.on('data', chunk => {
data += chunk
})
.once('end', () => {
if (headers['content-type']?.includes('application/json')) {
try {
data = JSON.parse(data)
} catch (err) {
reject(err)
return
}
}
resolve({ data, headers, statusCode })
})
.once('error', reject)
})
.once('error', reject)
.end(data)
setTimeout(() => {
const method = options.method || 'GET'
reject(new Error(`${method} request to "${url}" timed out`))
}, 10e3)
cb && cb()
})
}
module.exports = request

52
src/client.js Normal file
View file

@ -0,0 +1,52 @@
const record_prefix = 'forward-domain=';
const {
default: axios
} = require('axios');
const path = require('path');
const {
findTxtRecord
} = require('./util');
/**
* @type {Object<string, {expire: number, expand: boolean, url: string}>}
*/
const resolveCache = {};
async function buildCache(host) {
let expand = false;
let url = await findTxtRecord(host, record_prefix);
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
throw new Error(url + ' in TXT record is not an absolute URL');
}
if (url.endsWith('*')) {
url = url.slice(0, -1);
expand = true;
}
return {
url,
expand,
expire: Date.now() + 86400 * 1000,
};
}
const listener = async function (req, res) {
try {
let cache = resolveCache[req.headers.host];
if (!cache || (Date.now() > cache.expire)) {
cache = await buildCache(req.headers.host);
resolveCache[req.headers.host] = cache;
}
res.writeHead(301, {
'Location': cache.expand ? path.join(cache.url, req.url) : cache.url,
});
return;
} catch (error) {
res.writeHead(400);
res.write(error.message || 'Unknown error');
} finally {
res.end();
}
}
module.exports = listener;

98
src/sni.js Normal file
View file

@ -0,0 +1,98 @@
const tls = require('tls');
const certnode = require('./certnode/lib');
const fs = require('fs');
const path = require('path');
const {
md5,
ensureDir,
findTxtRecord
} = require('./util');
const record_email_prefix = 'forward-domain-cert-maintainer=';
const client = new certnode.Client();
const certsDir = path.join(__dirname, '.certs');
/**
* @type {Object<string, {cert: any, key: any, expire: number}>}
*/
const resolveCache = {};
function getCertCachePath(host) {
const hash = md5(host);
return path.join(certsDir, hash.substr(0, 2), hash.substr(2), host);
}
async function findMaintainerEmail(host) {
return await findTxtRecord(host, record_email_prefix);
}
async function buildCache(host) {
const dir = getCertCachePath(host);
const keyP = path.join(dir, 'privateKey.pem');
const certP = path.join(dir, 'publicKey.pem');
const extP = path.join(dir, 'expire');
await ensureDir(dir);
try {
await fs.promises.access(keyP, fs.constants.R_OK | fs.constants.W_OK);
await fs.promises.access(certP, 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'));
if (Date.now() > expire)
throw null; // expired
const cert = await fs.promises.readFile(certP, 'utf8');
const key = await fs.promises.readFile(keyP, 'utf8')
return {
cert,
key,
expire
};
} catch (error) {
const {
certificate,
privateKeyData
} = await client.generateCertificate(host, await findMaintainerEmail(host));
await fs.promises.writeFile(certP, certificate);
await certnode.writeKeyToFile(keyP, privateKeyData, '');
const expire = (Date.now() + 45 * 86400 * 1000);
await fs.promises.writeFile(extP, expire.toString());
return {
cert: certificate,
key: privateKeyData,
expire
};
}
}
async function getKeyCert(servername) {
let cache = resolveCache[servername];
await ensureDir(certsDir);
if (!cache || (Date.now() > cache.expire)) {
cache = await buildCache(servername);
resolveCache[servername] = cache;
}
return {
key: cache.key,
cert: cache.cert,
}
}
const SniListener = async (servername, ctx) => {
// Generate fresh account keys for Let's Encrypt
try {
ctx(null, tls.createSecureContext(await getKeyCert(servername)));
} catch (error) {
ctx(error, null);
}
}
const SniPrepare = async () => {
await client.generateAccountKeyPair()
await ensureDir(certsDir);
}
module.exports = {
SniListener,
SniPrepare,
client,
}

39
src/util.js Normal file
View file

@ -0,0 +1,39 @@
const { default: axios } = require('axios');
var crypto = require('crypto');
const fs = require('fs');
function md5(str) {
return crypto.createHash('md5').update(str).digest('hex');
}
async function ensureDir(dir) {
try {
await fs.promises.access(dir, fs.constants.W_OK | fs.constants.O_DIRECTORY);
} catch (error) {
await fs.promises.mkdir(dir, {
recursive: true
});
}
}
async function findTxtRecord(host, prefix, required = true) {
const resolve = await axios(`https://dns.google/resolve?name=${encodeURIComponent(host)}&type=TXT`);
if (resolve.data.Answer) {
for (const head of resolve.data.Answer) {
if (!head.data.startsWith(prefix))
continue;
return head.data.slice(prefix.length);
}
}
if (required) {
throw new Error(prefix + ' TXT is missing');
}
return null;
}
module.exports = {
md5,
ensureDir,
findTxtRecord,
}