Fix SNI lock, update deps, ESM refactor
This commit is contained in:
parent
88662b49bc
commit
447b1b735d
13 changed files with 1204 additions and 1144 deletions
|
|
@ -1,494 +1,388 @@
|
|||
const fs = require('fs')
|
||||
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)
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { promisify } from "util";
|
||||
import fromKeyLike from "jose/jwk/from_key_like";
|
||||
import generateKeyPair from "jose/util/generate_key_pair";
|
||||
import calculateThumbprint from "jose/jwk/thumbprint";
|
||||
import SignJWT from "jose/jwt/sign";
|
||||
import CompactSign from "jose/jws/compact/sign";
|
||||
import pem from "pem";
|
||||
import * as common from "./common.js";
|
||||
import request from "./request.js";
|
||||
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
|
||||
/** @type {import('crypto').KeyObject} */
|
||||
this.accountPrivateKey = null
|
||||
this.accountPublicJwk = null
|
||||
/** @type {import('crypto').KeyObject} */
|
||||
this.accountPublicKey = null
|
||||
this.directoryUrl = directoryUrl
|
||||
this.challengeCallbacks = null
|
||||
this.hasDirectory = false
|
||||
this.myAccountUrl = ''
|
||||
this.newAccountUrl = ''
|
||||
this.newNonceUrl = ''
|
||||
this.newOrderUrl = ''
|
||||
this.replayNonce = ''
|
||||
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)
|
||||
|
||||
// @ts-ignore
|
||||
this.accountPrivateKey = privateKey
|
||||
// @ts-ignore
|
||||
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
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
async generateCertificate(domain) {
|
||||
await this.directory()
|
||||
await this.newNonce()
|
||||
if (!this.myAccountUrl)
|
||||
await this.newAccount()
|
||||
|
||||
const {
|
||||
authzUrls,
|
||||
finalizeUrl
|
||||
} = await this.newOrder(domain)
|
||||
const {
|
||||
challenge
|
||||
} = await this.authz(authzUrls[0])
|
||||
|
||||
await this.completeChallenge(challenge, domain)
|
||||
await this.pollAuthz(authzUrls[0])
|
||||
const {
|
||||
certificate,
|
||||
privateKeyData
|
||||
} = await this.finalizeOrder(finalizeUrl, domain)
|
||||
|
||||
return {
|
||||
certificate,
|
||||
privateKeyData
|
||||
/**
|
||||
* @param {String} [directoryUrl]
|
||||
*/
|
||||
constructor(directoryUrl = common.DIRECTORY_URL) {
|
||||
this.accountPrivateJwk = null;
|
||||
/** @type {import('crypto').KeyObject|null} */
|
||||
this.accountPrivateKey = null;
|
||||
/** @type {import("jose/types.js").JWK | undefined} */
|
||||
this.accountPublicJwk = undefined;
|
||||
/** @type {import('crypto').KeyObject|null} */
|
||||
this.accountPublicKey = null;
|
||||
this.directoryUrl = directoryUrl;
|
||||
this.challengeCallbacks = null;
|
||||
this.hasDirectory = false;
|
||||
this.myAccountUrl = '';
|
||||
this.newAccountUrl = '';
|
||||
this.newNonceUrl = '';
|
||||
this.newOrderUrl = '';
|
||||
this.replayNonce = '';
|
||||
this.thumbprint = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`)
|
||||
/**
|
||||
* 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) {
|
||||
if (this.accountPrivateKey == null || this.accountPublicKey == null) {
|
||||
return Promise.reject(new Error('Account key pair not generated'));
|
||||
}
|
||||
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)
|
||||
]);
|
||||
}
|
||||
|
||||
const {
|
||||
challenges,
|
||||
identifier,
|
||||
...rest
|
||||
} = res.data
|
||||
const challenge = challenges.find(({
|
||||
type
|
||||
}) => type === 'http-01')
|
||||
|
||||
return {
|
||||
challenge,
|
||||
domain: identifier.value,
|
||||
...rest
|
||||
/**
|
||||
* Generate new account public and private keys.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
async generateAccountKeyPair() {
|
||||
const { privateKey, publicKey } = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM);
|
||||
// @ts-ignore
|
||||
this.accountPrivateKey = privateKey;
|
||||
// @ts-ignore
|
||||
this.accountPublicKey = publicKey;
|
||||
await this.initAccountJwks();
|
||||
}
|
||||
}
|
||||
|
||||
async completeChallenge(challenge, domain) {
|
||||
await this.readyChallenge(challenge)
|
||||
await this.receiveServerRequest(challenge, domain)
|
||||
}
|
||||
|
||||
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}`)
|
||||
/**
|
||||
* Generate a certificate from Let's Encrypt for your domain.
|
||||
*
|
||||
* @param {String} domain - the domain you want a certificate for
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
async generateCertificate(domain) {
|
||||
await this.directory();
|
||||
await this.newNonce();
|
||||
if (!this.myAccountUrl)
|
||||
await this.newAccount();
|
||||
const { authzUrls, finalizeUrl } = await this.newOrder(domain);
|
||||
const { challenge } = await this.authz(authzUrls[0]);
|
||||
await this.completeChallenge(challenge, domain);
|
||||
await this.pollAuthz(authzUrls[0]);
|
||||
const { certificate, privateKeyData } = await this.finalizeOrder(finalizeUrl, domain);
|
||||
return {
|
||||
certificate,
|
||||
privateKeyData
|
||||
};
|
||||
}
|
||||
|
||||
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}`)
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
return res.data
|
||||
}
|
||||
|
||||
async finalizeOrder(finalizeUrl, domain) {
|
||||
const {
|
||||
privateKey
|
||||
} = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM)
|
||||
// @ts-ignore
|
||||
const clientKey = common.exportPrivateKey(privateKey)
|
||||
let {
|
||||
csr
|
||||
// @ts-ignore
|
||||
} = await createCsr({
|
||||
clientKey,
|
||||
commonName: domain,
|
||||
})
|
||||
|
||||
// "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}`)
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
const certificate = await this.fetchCertificate(res.data.certificate)
|
||||
|
||||
return {
|
||||
certificate,
|
||||
privateKeyData: clientKey
|
||||
async completeChallenge(challenge, domain) {
|
||||
await this.readyChallenge(challenge);
|
||||
await this.receiveServerRequest(challenge, domain);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}, {
|
||||
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}`)
|
||||
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;
|
||||
}
|
||||
|
||||
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}`)
|
||||
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;
|
||||
}
|
||||
|
||||
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}`)
|
||||
async finalizeOrder(finalizeUrl, domain) {
|
||||
const { privateKey } = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM);
|
||||
// @ts-ignore
|
||||
const clientKey = common.exportPrivateKey(privateKey);
|
||||
let { csr
|
||||
// @ts-ignore
|
||||
} = await createCsr({
|
||||
clientKey,
|
||||
commonName: domain,
|
||||
});
|
||||
// "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
|
||||
};
|
||||
}
|
||||
|
||||
const orderUrl = res.headers.location
|
||||
const {
|
||||
authorizations: authzUrls,
|
||||
finalize: finalizeUrl
|
||||
} = res.data
|
||||
|
||||
return {
|
||||
authzUrls,
|
||||
domains,
|
||||
finalizeUrl,
|
||||
orderUrl
|
||||
async initAccountJwks() {
|
||||
if (this.accountPrivateKey == null || this.accountPublicKey == null) {
|
||||
return Promise.reject(new Error('Account key pair not generated'));
|
||||
}
|
||||
const [publicJwk, accountPrivateJwk] = await Promise.all([
|
||||
fromKeyLike(this.accountPublicKey),
|
||||
fromKeyLike(this.accountPrivateKey)
|
||||
]);
|
||||
this.accountPublicJwk = publicJwk;
|
||||
this.accountPrivateJwk = accountPrivateJwk;
|
||||
this.thumbprint = await calculateThumbprint(publicJwk);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
async newAccount(...emails) {
|
||||
const data = await this.sign({
|
||||
jwk: this.accountPublicJwk,
|
||||
nonce: this.replayNonce,
|
||||
url: this.newAccountUrl
|
||||
}, {
|
||||
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;
|
||||
}
|
||||
|
||||
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}`)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
receiveServerRequest(challenge, domain) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const time = setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for server request'))
|
||||
}, 10e3);
|
||||
let hasResolved = false;
|
||||
this.challengeCallbacks = () => {
|
||||
if (!hasResolved)
|
||||
setTimeout(resolve, 100);
|
||||
else
|
||||
return challenge.token + '.' + this.thumbprint;
|
||||
hasResolved = true;
|
||||
clearTimeout(time);
|
||||
// wanted to clear callbacks here but LE does the call multiple times.
|
||||
// remember we're in mutex lock so no worries for racing.
|
||||
return challenge.token + '.' + this.thumbprint;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setReplayNonce(res) {
|
||||
const replayNonce = (res.headers['replay-nonce'] || '').trim()
|
||||
|
||||
if (!replayNonce) {
|
||||
throw new Error('No Replay-Nonce header in response')
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
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, domain) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const time = setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for server request'));
|
||||
}, 10e3);
|
||||
let hasResolved = false;
|
||||
this.challengeCallbacks = () => {
|
||||
if (!hasResolved)
|
||||
setTimeout(resolve, 100);
|
||||
else
|
||||
return challenge.token + '.' + this.thumbprint;
|
||||
hasResolved = true;
|
||||
clearTimeout(time);
|
||||
// wanted to clear callbacks here but LE does the call multiple times.
|
||||
// remember we're in mutex lock so no worries for racing.
|
||||
return challenge.token + '.' + this.thumbprint;
|
||||
};
|
||||
});
|
||||
}
|
||||
setReplayNonce(res) {
|
||||
const replayNonce = (res.headers['replay-nonce'] || '').trim();
|
||||
if (!replayNonce) {
|
||||
throw new Error('No Replay-Nonce header in response');
|
||||
}
|
||||
this.replayNonce = replayNonce;
|
||||
}
|
||||
/**
|
||||
* @param {import("jose/types.js").JWSHeaderParameters} header
|
||||
* @param {import("jose/types.js").JWTPayload | undefined} [payload]
|
||||
*/
|
||||
async sign(header, payload) {
|
||||
if (this.accountPrivateKey == null) {
|
||||
return Promise.reject(new Error('Account key pair not generated'));
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
const [b64Header, b64Payload, b64Signature] = data.split('.')
|
||||
|
||||
return JSON.stringify({
|
||||
protected: b64Header,
|
||||
payload: b64Payload,
|
||||
signature: b64Signature
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Client
|
||||
export default Client;
|
||||
|
|
|
|||
|
|
@ -1,55 +1,45 @@
|
|||
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()
|
||||
|
||||
import crypto from "crypto";
|
||||
import fs from "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'
|
||||
|
||||
? '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]
|
||||
*
|
||||
*/
|
||||
const exportPrivateKey = (privateKey, passphrase) => {
|
||||
/** @type {crypto.KeyExportOptions<'pem'>} */
|
||||
const privateKeyOpts = {
|
||||
type: PRIVATE_KEY_TYPE,
|
||||
format: PRIVATE_KEY_FORMAT
|
||||
}
|
||||
|
||||
if (passphrase) {
|
||||
privateKeyOpts.cipher = PRIVATE_KEY_CIPHER
|
||||
privateKeyOpts.passphrase = passphrase
|
||||
}
|
||||
|
||||
return privateKey.export(privateKeyOpts)
|
||||
}
|
||||
|
||||
/** @type {crypto.KeyExportOptions<'pem'>} */
|
||||
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
|
||||
*/
|
||||
const exportPublicKey = publicKey => {
|
||||
/** @type {crypto.KeyExportOptions<'pem'>} */
|
||||
return publicKey.export({
|
||||
type: PUBLIC_KEY_TYPE,
|
||||
format: PUBLIC_KEY_FORMAT
|
||||
})
|
||||
}
|
||||
|
||||
/** @type {crypto.KeyExportOptions<'pem'>} */
|
||||
return publicKey.export({
|
||||
type: PUBLIC_KEY_TYPE,
|
||||
format: PUBLIC_KEY_FORMAT
|
||||
});
|
||||
};
|
||||
/**
|
||||
* @param {String} privateKeyData
|
||||
* @param {String} [passphrase]
|
||||
|
|
@ -57,41 +47,39 @@ const exportPublicKey = publicKey => {
|
|||
* @return {crypto.KeyObject}
|
||||
*/
|
||||
const importPrivateKey = (privateKeyData, passphrase) => {
|
||||
/** @type {crypto.KeyExportOptions<'pem'>} */
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {crypto.PrivateKeyInput} */
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
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} filename
|
||||
* @param {crypto.KeyObject|string} key
|
||||
|
|
@ -100,40 +88,19 @@ const importPublicKey = publicKeyData => {
|
|||
* @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
|
||||
}
|
||||
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 });
|
||||
};
|
||||
export { 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 };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
module.exports = {
|
||||
...require('./common'),
|
||||
Client: require('./client')
|
||||
}
|
||||
import Client from './client.js';
|
||||
export * from './common.js';
|
||||
export { Client };
|
||||
|
|
|
|||
|
|
@ -1,46 +1,40 @@
|
|||
const https = require('https')
|
||||
|
||||
import https from "https";
|
||||
const request = (/** @type {string | import("url").URL} */ url, /** @type {https.RequestOptions&{data?: string}} */ { data = '', ...options } = {}, /** @type {() => any} */ 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
|
||||
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('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
|
||||
.once('error', reject)
|
||||
.end(data);
|
||||
setTimeout(() => {
|
||||
const method = options.method || 'GET';
|
||||
reject(new Error(`${method} request to "${url}" timed out`));
|
||||
}, 10e3);
|
||||
cb && cb();
|
||||
});
|
||||
};
|
||||
export default request;
|
||||
|
|
|
|||
Loading…
Add table
editor.link_modal.header
Reference in a new issue