From b595572f34618d46151e29fc9066ef2e29a8f8ac Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Wed, 14 Jun 2023 15:05:22 -0700 Subject: [PATCH 01/13] njs no longer required as a separate download -- it is included with nginx now --- Dockerfile | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 95b6803..9cbf576 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,5 @@ ARG NGINX_VERSION=1.24.0 -ARG NJS_VERSION=0.7.12 FROM node:20.2.0-bullseye AS builder ENV NODE_ENV=development @@ -17,7 +16,6 @@ RUN npm run build FROM nginx:${NGINX_VERSION} ARG NGINX_VERSION -ARG NJS_VERSION # following installation steps from http://nginx.org/en/linux_packages.html#Debian RUN --mount=type=cache,target=/var/cache/apt </dev/null - gpg --dry-run --quiet --no-keyring --import --import-options import-show \ - /usr/share/keyrings/nginx-archive-keyring.gpg - echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \ - http://nginx.org/packages/mainline/debian $(echo $PKG_RELEASE | cut -f2 -d~) nginx" \ - | tee /etc/apt/sources.list.d/nginx.list - apt-get -qq install --yes --no-install-recommends --no-install-suggests \ - curl nginx-module-njs=${NGINX_VERSION}+${NJS_VERSION}-${PKG_RELEASE} apt-get remove --purge --auto-remove --yes rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/nginx.list EOF From 4525c7bc2bbb8c6dcd82a865ce55298bd4af3d3e Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Wed, 14 Jun 2023 15:32:14 -0700 Subject: [PATCH 02/13] enhance compose with a node container to watch for changes, rebuild the .js, and notify nginx to reload --- Dockerfile | 22 ++-------------------- docker-compose.yml | 25 ++++++++++++++++++++++--- nginx_wait_for_js | 24 ++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 23 deletions(-) create mode 100755 nginx_wait_for_js diff --git a/Dockerfile b/Dockerfile index 9cbf576..46d37b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,4 @@ - -ARG NGINX_VERSION=1.24.0 - -FROM node:20.2.0-bullseye AS builder -ENV NODE_ENV=development -WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm ci -COPY ./.mocharc.js ./ -COPY ./babel.config.js ./ -COPY ./rollup.config.js ./ -COPY ./tsconfig.json ./ -COPY ./src ./src - -RUN npm run build - -FROM nginx:${NGINX_VERSION} -ARG NGINX_VERSION +FROM nginx:1.24.0 # following installation steps from http://nginx.org/en/linux_packages.html#Debian RUN --mount=type=cache,target=/var/cache/apt < Date: Wed, 14 Jun 2023 17:01:15 -0700 Subject: [PATCH 03/13] checkpoint --- src/api.ts | 2 +- src/index.ts | 125 ++++++++++++++++++++++++++++----------------------- src/utils.ts | 60 +++++++++++++++++++++++-- 3 files changed, 127 insertions(+), 60 deletions(-) diff --git a/src/api.ts b/src/api.ts index 64c2eca..7d41a72 100644 --- a/src/api.ts +++ b/src/api.ts @@ -293,7 +293,7 @@ export class HttpClient { // TODO: refactor maybe const respData = await resp.json(); /* Retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 */ - if (respData && respData.type && (respData.type === 'urn:ietf:params:acme:error:badNonce') && (attempts < this.maxBadNonceRetries)) { + if (respData?.type === 'urn:ietf:params:acme:error:badNonce' && (attempts < this.maxBadNonceRetries)) { nonce = resp.headers.get('replay-nonce') || null; attempts += 1; diff --git a/src/index.ts b/src/index.ts index a3c5439..9712ee8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ -import { toPEM, readOrCreateAccountKey, generateKey, createCsr, readCertificateInfo, splitPemChain, pemToBuffer, readCsrDomainNames } from './utils' -import { HttpClient, directories } from './api' +import { toPEM, readOrCreateAccountKey, generateKey, createCsr, readCertificateInfo, getAcmeServerNames, getVariable, joinPaths, acmeDir } from './utils' +import { HttpClient } from './api' import { AcmeClient } from './client' -var fs = require('fs'); +import fs from 'fs'; const KEY_SUFFIX = '.key'; const CERTIFICATE_SUFFIX = '.crt'; @@ -39,7 +39,7 @@ function stringToBoolean(stringValue: String, val = false) { */ async function clientNewAccount(r: NginxHTTPRequest) { const accountKey = await readOrCreateAccountKey(NJS_ACME_ACCOUNT_PRIVATE_JWK); - // /* Create a new ACME account */ + // Create a new ACME account let client = new AcmeClient({ directoryUrl: DIRECTORY_URL, accountKey: accountKey @@ -49,11 +49,15 @@ async function clientNewAccount(r: NginxHTTPRequest) { // do not validate ACME provider cert client.api.setVerify(false); - const account = await client.createAccount({ - termsOfServiceAgreed: true, - contact: ['mailto:test@example.com'] - }); - return r.return(200, JSON.stringify(account)); + try { + const account = await client.createAccount({ + termsOfServiceAgreed: true, + contact: ['mailto:test@example.com'] + }); + return r.return(200, JSON.stringify(account)); + } catch (e) { + ngx.log(ngx.ERR, `Error creating ACME account. Error=${e}`) + } } /** @@ -63,15 +67,19 @@ async function clientNewAccount(r: NginxHTTPRequest) { * @returns void */ async function clientAutoMode(r: NginxHTTPRequest) { - const prefix = r.variables.njs_acme_dir || NJS_ACME_DIR; - const commonName = r.variables.server_name?.toLowerCase() || r.variables.njs_acme_server_name; - const pkeyPath = prefix + commonName + KEY_SUFFIX; - const csrPath = prefix + commonName + '.csr'; - const certPath = prefix + commonName + CERTIFICATE_SUFFIX; - - const email = r.variables.njs_acme_account_email || process.env.NJS_ACME_ACCOUNT_EMAIL; - if (email.length === 0) { - r.return(500, "Nginx variable 'njs_acme_account_email' or 'NJS_ACME_ACCOUNT_EMAIL' environment variable must be set"); + const prefix = acmeDir(r); + const serverNames = getAcmeServerNames(r); + + const commonName = serverNames[0]; + const pkeyPath = joinPaths(prefix, commonName + KEY_SUFFIX); + const csrPath = joinPaths(prefix, commonName + '.csr'); + const certPath = joinPaths(prefix, commonName + CERTIFICATE_SUFFIX); + + let email + try { + email = getVariable(r, 'njs_acme_account_email'); + } catch { + return r.return(500, "Nginx variable 'njs_acme_account_email' or 'NJS_ACME_ACCOUNT_EMAIL' environment variable must be set"); } let certificatePem; @@ -120,16 +128,25 @@ async function clientAutoMode(r: NginxHTTPRequest) { const result = await createCsr(params); fs.writeFileSync(csrPath, toPEM(result.pkcs10Ber, "CERTIFICATE REQUEST"), 'utf-8'); + const privKey = await crypto.subtle.exportKey("pkcs8", result.keys.privateKey); pkeyPem = toPEM(privKey, "PRIVATE KEY"); fs.writeFileSync(pkeyPath, pkeyPem, 'utf-8'); - r.log(`njs-acme: [auto] Wrote Private key to ${pkeyPath}`); + ngx.log(ngx.INFO, `njs-acme: [auto] Wrote Private key to ${pkeyPath}`); - const challengePath = r.variables.njs_acme_challenge_dir!; + // default challengePath = acmeDir/challenge + const challengePath = getVariable(r, 'njs_acme_challenge_dir', joinPaths(acmeDir(r), 'challenge')); if (challengePath === undefined || challengePath.length === 0) { - r.return(500, "Nginx variable 'njs_acme_challenge_dir' must be set"); + return r.return(500, "Nginx variable 'njs_acme_challenge_dir' must be set"); + } + ngx.log(ngx.INFO, `njs-acme: [auto] Issuing a new Certificate: ${JSON.stringify(params)}`); + const fullChallengePath = joinPaths(challengePath, '.well-known/acme-challenge'); + try { + fs.mkdirSync(fullChallengePath, { recursive: true }); + } catch (e) { + ngx.log(ngx.ERR, `Error creating directory to store challenges at ${fullChallengePath}. Ensure the ${challengePath} directory is writable by the nginx user.`) + return r.return(500, "Cannot create challenge directory"); } - r.log(`njs-acme: [auto] Issuing a new Certificate: ${JSON.stringify(params)}`); certificatePem = await client.auto({ csr: result.pkcs10Ber, @@ -138,11 +155,11 @@ async function clientAutoMode(r: NginxHTTPRequest) { challengeCreateFn: async (authz, challenge, keyAuthorization) => { ngx.log(ngx.INFO, `njs-acme: [auto] Challenge Create (authz='${JSON.stringify(authz)}', challenge='${JSON.stringify(challenge)}', keyAuthorization='${keyAuthorization}')`); ngx.log(ngx.INFO, `njs-acme: [auto] Writing challenge file so nginx can serve it via .well-known/acme-challenge/${challenge.token}`); - const path = `${challengePath}/.well-known/acme-challenge/${challenge.token}`; - fs.writeFileSync(path, keyAuthorization, 'utf8'); + const path = joinPaths(fullChallengePath, challenge.token); + fs.writeFileSync(path, keyAuthorization); }, challengeRemoveFn: async (authz, challenge, keyAuthorization) => { - const path = `${challengePath}/.well-known/acme-challenge/${challenge.token}`; + const path = joinPaths(fullChallengePath, challenge.token); try { fs.unlinkSync(path); ngx.log(ngx.INFO, `njs-acme: [auto] removed challenge ${path}`); @@ -152,7 +169,7 @@ async function clientAutoMode(r: NginxHTTPRequest) { } }); certInfo = await readCertificateInfo(certificatePem); - fs.writeFileSync(certPath, certificatePem, 'utf-8'); + fs.writeFileSync(certPath, certificatePem); r.log(`njs-acme: wrote certificate to ${certPath}`); } @@ -167,17 +184,17 @@ async function clientAutoMode(r: NginxHTTPRequest) { async function persistGeneratedKeys(keys: CryptoKeyPair) { crypto.subtle.exportKey("pkcs8", keys.privateKey).then(key => { const pemExported = toPEM(key as ArrayBuffer, "PRIVATE KEY"); - fs.writeFileSync(NJS_ACME_DIR + "/account.private.key", pemExported, 'utf8'); + fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.private.key"), pemExported); }); crypto.subtle.exportKey("spki", keys.publicKey).then(key => { const pemExported = toPEM(key as ArrayBuffer, "PUBLIC KEY"); - fs.writeFileSync(NJS_ACME_DIR + "/account.public.key", pemExported, 'utf8'); + fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.public.key"), pemExported); }); crypto.subtle.exportKey("jwk", keys.privateKey).then(key => { - fs.writeFileSync(NJS_ACME_DIR + "/account.private.json", JSON.stringify(key), 'utf8'); + fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.private.json"), JSON.stringify(key)); }); crypto.subtle.exportKey("jwk", keys.publicKey).then(key => { - fs.writeFileSync(NJS_ACME_DIR + "/account.public.json", JSON.stringify(key), 'utf8'); + fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.public.json"), JSON.stringify(key)); }); } @@ -187,13 +204,9 @@ async function persistGeneratedKeys(keys: CryptoKeyPair) { * @returns */ async function acmeNewAccount(r: NginxHTTPRequest) { - ngx.log(ngx.ERR, `process.env.NJS_ACME_VERIFY_PROVIDER_HTTPS: ${process.env.NJS_ACME_VERIFY_PROVIDER_HTTPS}`); ngx.log(ngx.ERR, `VERIFY_PROVIDER_HTTPS: ${VERIFY_PROVIDER_HTTPS}`); - - - /* Generate a new RSA key pair for ACME account */ const keys = (await generateKey()) as Required; @@ -224,12 +237,13 @@ async function acmeNewAccount(r: NginxHTTPRequest) { } /** - * Create a new certificate Signing Request + * Create a new certificate Signing Request - Example implementation * @param r * @returns */ async function createCsrHandler(r: NginxHTTPRequest) { const { pkcs10Ber, keys } = await createCsr({ + // EXAMPLE VALUES BELOW altNames: ["proxy1.f5.com", "proxy2.f5.com"], commonName: "proxy.f5.com", state: "WA", @@ -245,15 +259,15 @@ async function createCsrHandler(r: NginxHTTPRequest) { return r.return(200, result); } -/** Retrieves the cert based on the Nginx HTTP request. -* -* @param {NginxHTTPRequest} r - The Nginx HTTP request object. -* @returns {string, string} - The path and cert associated with the server name. -*/ +/** + * Retrieves the cert based on the Nginx HTTP request. + * @param {NginxHTTPRequest} r - The Nginx HTTP request object. + * @returns {string, string} - The path and cert associated with the server name. + */ function js_cert(r: NginxHTTPRequest) { - const prefix = r.variables.njs_acme_dir || NJS_ACME_DIR; + const prefix = acmeDir(r); let { path, data } = read_cert_or_key(prefix, r.variables.ssl_server_name?.toLowerCase() || '', CERTIFICATE_SUFFIX); - // r.log(`njs-acme: Loaded cert for ${r.variables.ssl_server_name} from path: ${path}`); + // ngx.log(ngx.INFO, `njs-acme: Loaded cert for ${r.variables.ssl_server_name} from path: ${path}`); if (data.length == 0) { r.log(`njs-acme: seems there is no cert for ${r.variables.ssl_server_name} from path: ${path}`); /* @@ -261,41 +275,42 @@ function js_cert(r: NginxHTTPRequest) { r.subrequest('http://localhost:8000/acme/auto', {detached: true, method: 'GET', body: undefined}); r.log(`njs-acme: notified /acme/auto`); - */ } return path; } -/** Retrieves the key based on the Nginx HTTP request. -* -* @param {NginxHTTPRequest} r - The Nginx HTTP request object. -* @returns {string} - The path and key associated with the server name. -*/ +/** + * Retrieves the key based on the Nginx HTTP request. + * @param {NginxHTTPRequest} r - The Nginx HTTP request object. + * @returns {string} - The path and key associated with the server name. + */ function js_key(r: NginxHTTPRequest) { - const prefix = r.variables.njs_acme_dir || NJS_ACME_DIR; - let { path, data } = read_cert_or_key(prefix, r.variables.ssl_server_name?.toLowerCase() || '', KEY_SUFFIX); + const prefix = acmeDir(r); + const { path } = read_cert_or_key(prefix, r.variables.ssl_server_name?.toLowerCase() || '', KEY_SUFFIX); // r.log(`njs-acme: loaded key for ${r.variables.ssl_server_name} from path: ${path}`); return path } function read_cert_or_key(prefix: string, domain: string, suffix: string) { - var none_wildcard_path = String.prototype.concat(prefix, domain, suffix); - var wildcard_path = String.prototype.concat(prefix, domain.replace(/.*?\./, '*.'), suffix); - var data = ''; + const none_wildcard_path = joinPaths(prefix, domain + suffix); + const wildcard_path = joinPaths(prefix, domain.replace(/.*?\./, '*.') + suffix); + let data = ''; var path = ''; + try { - data = fs.readFileSync(none_wildcard_path); + data = fs.readFileSync(none_wildcard_path, 'utf8'); path = none_wildcard_path; } catch (e) { try { - data = fs.readFileSync(wildcard_path); + data = fs.readFileSync(wildcard_path, 'utf8'); path = wildcard_path; } catch (e) { data = ''; } } + return { path, data }; } diff --git a/src/utils.ts b/src/utils.ts index 97e8cf1..2a42e56 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ import x509 from './x509.js' import * as pkijs from 'pkijs'; import * as asn1js from 'asn1js'; +import fs from 'fs'; const DEFAULT_ACCOUNT_KEY_PATH = `${ngx.conf_prefix || '/etc/nginx'}/account_private_key.json`; @@ -132,9 +133,8 @@ export async function generateKey() { * @throws {Error} - If the account key cannot be read or generated. */ export async function readOrCreateAccountKey(path: string = DEFAULT_ACCOUNT_KEY_PATH): Promise { - const fs = require('fs'); try { - const accountKeyJWK = fs.readFileSync(path); + const accountKeyJWK = fs.readFileSync(path, 'utf8'); ngx.log(ngx.INFO, `acme-njs: [utils] Using account key from ${path}`); return await crypto.subtle.importKey('jwk', JSON.parse(accountKeyJWK), ACCOUNT_KEY_ALG_IMPORT, true, ["sign"]); } catch (e) { @@ -143,7 +143,7 @@ export async function readOrCreateAccountKey(path: string = DEFAULT_ACCOUNT_KEY_ /* Generate a new RSA key pair for ACME account */ const keys = (await generateKey()) as Required; const jwkFormated = await crypto.subtle.exportKey("jwk", keys.privateKey) - fs.writeFileSync(path, JSON.stringify(jwkFormated), 'utf8'); + fs.writeFileSync(path, JSON.stringify(jwkFormated)); ngx.log(ngx.INFO, `acme-njs: [utils] Generated a new account key and saved it to ${path}`); return keys.privateKey; } @@ -703,9 +703,10 @@ export function splitPemChain(chainPem: Buffer | string) { .map((pem) => pem.match(/\s*-----BEGIN ([A-Z0-9- ]+)-----\r?\n?([\S\s]+)\r?\n?-----END \1-----/)) /* Filter out non-matches or empty bodies */ .filter((pem) => pem && pem[2] && pem[2].replace(/[\r\n]+/g, '').trim()) - .map(([pem, header]) => pem); + .map(([pem, _]) => pem); } + /** * Reads the common name and alternative names from a CSR (Certificate Signing Request). * @param csrPem The PEM-encoded CSR string or a Buffer containing the CSR. @@ -722,3 +723,54 @@ export function readCsrDomainNames(csrPem: string | Buffer): { commonName: strin altNames: x509.get_oid_value(csr, "2.5.29.17") }; } + + +/** + * Convenience method to return the value of a given environment variable or + * nginx variable. Will return the environment variable if that is found first. + * Requires that env vars be the uppercase version of nginx vars. + * If no default is given and the variable is not found, throws an error. + * @param r Nginx HTTP Request + * @param varname Name of the variable + * @returns value of the variable + */ +export function getVariable(r: NginxHTTPRequest, varname: string, defaultVal?: string) { + const retval = process.env[varname.toUpperCase()] || r.variables[varname] || defaultVal + if (retval === undefined) { + throw new Error(`Variable ${varname} not found and no default value given.`) + } + return retval +} + + +/** + * Return an array of hostnames specified in the njs_acme_server_names variable + * @param r request + * @returns array of hostnames + */ +export function getAcmeServerNames(r: NginxHTTPRequest) { + const nameStr = getVariable(r, 'njs_acme_server_names') // no default == mandatory + // split string value on comma and/or whitespace and lowercase each element + return nameStr.split(/[,\s]+/).map((n) => n.toLocaleLowerCase()) +} + + +/** + * Return the path where ACME magic happens + * @param r request + * @returns configured path or default + */ +export function acmeDir(r: NginxHTTPRequest) { + return getVariable(r, 'njs_acme_dir', '/etc/acme'); +} + + +/** + * Joins args with slashes and removes duplicate slashes + * @param args path fragments to join + * @returns joined path string + */ +export function joinPaths(...args: string[]) { + // join args with a slash remove duplicate slashes + return args.join('/').replace(/\/+/g, '/') +} \ No newline at end of file From 9293253459d4537877861410f843b466fc1fe78b Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Thu, 15 Jun 2023 10:01:26 -0700 Subject: [PATCH 04/13] consistency changes for file ops --- src/index.ts | 73 +++++++++++----------------------------------------- src/utils.ts | 50 +++++++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 63 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9712ee8..c2153e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,36 +1,10 @@ -import { toPEM, readOrCreateAccountKey, generateKey, createCsr, readCertificateInfo, getAcmeServerNames, getVariable, joinPaths, acmeDir } from './utils' +import { toPEM, readOrCreateAccountKey, generateKey, createCsr, readCertificateInfo, acmeServerNames, getVariable, joinPaths, acmeDir, acmeAccountPrivateJWKPath, acmeDirectoryURI, acmeVerifyProviderHTTPS } from './utils' import { HttpClient } from './api' import { AcmeClient } from './client' import fs from 'fs'; const KEY_SUFFIX = '.key'; const CERTIFICATE_SUFFIX = '.crt'; -const ACCOUNT_JWK_FILENAME = process.env.NJS_ACME_ACCOUNT_JWK_FILENAME || 'account_private_key.json' -const NJS_ACME_DIR = process.env.NJS_ACME_DIR || ngx.conf_prefix; -const NJS_ACME_ACCOUNT_PRIVATE_JWK = process.env.NJS_ACME_ACCOUNT_PRIVATE_JWK || NJS_ACME_DIR + '/' + ACCOUNT_JWK_FILENAME -const DIRECTORY_URL = process.env.NJS_ACME_DIRECTORY_URI || 'https://acme-staging-v02.api.letsencrypt.org/directory' -const VERIFY_PROVIDER_HTTPS = stringToBoolean(process.env.NJS_ACME_VERIFY_PROVIDER_HTTPS, true); - -function stringToBoolean(stringValue: String, val = false) { - switch (stringValue?.toLowerCase()?.trim()) { - case "true": - case "yes": - case "1": - return true; - - case "false": - case "no": - case "0": - return false; - case null: - case undefined: - return val; - - default: - return val; - } -} - /** * Using AcmeClient to create a new account. It creates an account key if it doesn't exists @@ -38,16 +12,16 @@ function stringToBoolean(stringValue: String, val = false) { * @returns void */ async function clientNewAccount(r: NginxHTTPRequest) { - const accountKey = await readOrCreateAccountKey(NJS_ACME_ACCOUNT_PRIVATE_JWK); + const accountKey = await readOrCreateAccountKey(acmeAccountPrivateJWKPath(r)); // Create a new ACME account let client = new AcmeClient({ - directoryUrl: DIRECTORY_URL, + directoryUrl: acmeDirectoryURI(r), accountKey: accountKey }); // display more logs client.api.setDebug(true); // do not validate ACME provider cert - client.api.setVerify(false); + client.api.setVerify(acmeVerifyProviderHTTPS(r)); try { const account = await client.createAccount({ @@ -68,7 +42,7 @@ async function clientNewAccount(r: NginxHTTPRequest) { */ async function clientAutoMode(r: NginxHTTPRequest) { const prefix = acmeDir(r); - const serverNames = getAcmeServerNames(r); + const serverNames = acmeServerNames(r); const commonName = serverNames[0]; const pkeyPath = joinPaths(prefix, commonName + KEY_SUFFIX); @@ -87,8 +61,8 @@ async function clientAutoMode(r: NginxHTTPRequest) { let renewCertificate = false; let certInfo; try { - const certData = fs.readFileSync(certPath, 'utf-8'); - const privateKeyData = fs.readFileSync(pkeyPath, 'utf-8'); + const certData = fs.readFileSync(certPath, 'utf8'); + const privateKeyData = fs.readFileSync(pkeyPath, 'utf8'); certInfo = await readCertificateInfo(certData); // Calculate the date 30 days before the certificate expiration @@ -107,14 +81,14 @@ async function clientAutoMode(r: NginxHTTPRequest) { } if (renewCertificate) { - const accountKey = await readOrCreateAccountKey(NJS_ACME_ACCOUNT_PRIVATE_JWK); + const accountKey = await readOrCreateAccountKey(acmeAccountPrivateJWKPath(r)); // Create a new ACME client let client = new AcmeClient({ - directoryUrl: DIRECTORY_URL, + directoryUrl: acmeDirectoryURI(r), accountKey: accountKey }); // client.api.setDebug(true); - client.api.setVerify(false); + client.api.setVerify(acmeVerifyProviderHTTPS(r)); // Create a new CSR const params = { @@ -127,11 +101,11 @@ async function clientAutoMode(r: NginxHTTPRequest) { } const result = await createCsr(params); - fs.writeFileSync(csrPath, toPEM(result.pkcs10Ber, "CERTIFICATE REQUEST"), 'utf-8'); + fs.writeFileSync(csrPath, toPEM(result.pkcs10Ber, "CERTIFICATE REQUEST")); const privKey = await crypto.subtle.exportKey("pkcs8", result.keys.privateKey); pkeyPem = toPEM(privKey, "PRIVATE KEY"); - fs.writeFileSync(pkeyPath, pkeyPem, 'utf-8'); + fs.writeFileSync(pkeyPath, pkeyPem); ngx.log(ngx.INFO, `njs-acme: [auto] Wrote Private key to ${pkeyPath}`); // default challengePath = acmeDir/challenge @@ -181,22 +155,6 @@ async function clientAutoMode(r: NginxHTTPRequest) { return r.return(200, JSON.stringify(info)); } -async function persistGeneratedKeys(keys: CryptoKeyPair) { - crypto.subtle.exportKey("pkcs8", keys.privateKey).then(key => { - const pemExported = toPEM(key as ArrayBuffer, "PRIVATE KEY"); - fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.private.key"), pemExported); - }); - crypto.subtle.exportKey("spki", keys.publicKey).then(key => { - const pemExported = toPEM(key as ArrayBuffer, "PUBLIC KEY"); - fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.public.key"), pemExported); - }); - crypto.subtle.exportKey("jwk", keys.privateKey).then(key => { - fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.private.json"), JSON.stringify(key)); - }); - crypto.subtle.exportKey("jwk", keys.publicKey).then(key => { - fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.public.json"), JSON.stringify(key)); - }); -} /** * Demonstrates how to use generate RSA Keys and use HttpClient @@ -204,17 +162,16 @@ async function persistGeneratedKeys(keys: CryptoKeyPair) { * @returns */ async function acmeNewAccount(r: NginxHTTPRequest) { - ngx.log(ngx.ERR, `process.env.NJS_ACME_VERIFY_PROVIDER_HTTPS: ${process.env.NJS_ACME_VERIFY_PROVIDER_HTTPS}`); - ngx.log(ngx.ERR, `VERIFY_PROVIDER_HTTPS: ${VERIFY_PROVIDER_HTTPS}`); + ngx.log(ngx.ERR, `VERIFY_PROVIDER_HTTPS: ${acmeVerifyProviderHTTPS(r)}`); /* Generate a new RSA key pair for ACME account */ const keys = (await generateKey()) as Required; // /* Create a new ACME account */ - let client = new HttpClient(DIRECTORY_URL, keys.privateKey); + let client = new HttpClient(acmeDirectoryURI(r), keys.privateKey); client.setDebug(true); - client.setVerify(false); + client.setVerify(acmeVerifyProviderHTTPS(r)); // Get Terms Of Service link from the ACME provider let tos = await client.getMetaField("termsOfService"); diff --git a/src/utils.ts b/src/utils.ts index 2a42e56..534fe70 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -158,7 +158,9 @@ export async function readOrCreateAccountKey(path: string = DEFAULT_ACCOUNT_KEY_ */ export async function getPublicJwk(privateKey: CryptoKey): Promise { if (!privateKey) { - throw new Error('Invalid or missing private key'); + const errMsg = 'Invalid or missing private key'; + ngx.log(ngx.ERR, errMsg); + throw new Error(errMsg); } const jwk: any = await crypto.subtle.exportKey("jwk", privateKey); @@ -265,7 +267,9 @@ function getSignatureParameters(privateKey: CryptoKey, hashAlgorithm = "SHA-1"): //#region Get a "default parameters" for current algorithm const parameters = pkijs.getAlgorithmParameters(privateKey.algorithm.name, "sign"); if (!Object.keys(parameters.algorithm).length) { - throw new Error("Parameter 'algorithm' is empty"); + const errMsg = 'Parameter `algorithm` is empty'; + ngx.log(ngx.ERR, errMsg); + throw new Error(errMsg); } const algorithm = parameters.algorithm as any; // TODO remove `as any` algorithm.hash.name = hashAlgorithm; @@ -324,7 +328,9 @@ function getSignatureParameters(privateKey: CryptoKey, hashAlgorithm = "SHA-1"): } break; default: - throw new Error(`Unsupported signature algorithm: ${privateKey.algorithm.name}`); + const errMsg = `Unsupported signature algorithm: ${privateKey.algorithm.name}`; + ngx.log(ngx.ERR, errMsg) + throw new Error(errMsg); } //#endregion @@ -737,7 +743,9 @@ export function readCsrDomainNames(csrPem: string | Buffer): { commonName: strin export function getVariable(r: NginxHTTPRequest, varname: string, defaultVal?: string) { const retval = process.env[varname.toUpperCase()] || r.variables[varname] || defaultVal if (retval === undefined) { - throw new Error(`Variable ${varname} not found and no default value given.`) + const errMsg = `Variable ${varname} not found and no default value given.`; + ngx.log(ngx.ERR, errMsg); + throw new Error(errMsg); } return retval } @@ -748,7 +756,7 @@ export function getVariable(r: NginxHTTPRequest, varname: string, defaultVal?: s * @param r request * @returns array of hostnames */ -export function getAcmeServerNames(r: NginxHTTPRequest) { +export function acmeServerNames(r: NginxHTTPRequest) { const nameStr = getVariable(r, 'njs_acme_server_names') // no default == mandatory // split string value on comma and/or whitespace and lowercase each element return nameStr.split(/[,\s]+/).map((n) => n.toLocaleLowerCase()) @@ -765,6 +773,38 @@ export function acmeDir(r: NginxHTTPRequest) { } +/** + * Returns the path for the account private JWK + * @param r {NginxHTTPRequest} + */ +export function acmeAccountPrivateJWKPath(r: NginxHTTPRequest) { + return getVariable(r, 'njs_acme_account_private_jwk', + joinPaths(acmeDir(r), 'account_private_key.json') + ); +} + + +/** + * Returns the ACME directory URI + * @param r {NginxHTTPRequest} + */ +export function acmeDirectoryURI(r: NginxHTTPRequest) { + return getVariable(r, 'njs_acme_directory_uri', 'https://acme-staging-v02.api.letsencrypt.org/directory'); +} + + +/** + * Returns whether to verify the ACME provider HTTPS certificate and chain + * @param r {NginxHTTPRequest} + * @returns boolean + */ +export function acmeVerifyProviderHTTPS(r: NginxHTTPRequest) { + return ['true', 'yes', '1'].indexOf( + getVariable(r, 'njs_acme_verify_provider_https', 'true').toLowerCase().trim() + ) > -1; +} + + /** * Joins args with slashes and removes duplicate slashes * @param args path fragments to join From 1bd67f6e9aa2aaf61df8ef7b691d88a0b6c1f5dd Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Thu, 15 Jun 2023 10:39:50 -0700 Subject: [PATCH 05/13] some typescript improvements --- package-lock.json | 22 ++++++++++++++++++++++ package.json | 1 + src/index.ts | 8 ++++---- src/utils.ts | 43 +++++++++++++++++++++++++------------------ 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index ad926df..1cadfb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "njs-types": "^0.7.12", "npm-run-all": "^4.1.5", "power-assert": "^1.6.1", + "prettier": "^2.8.8", "rimraf": "^3.0.2", "rollup": "^2.79.1", "rollup-plugin-add-git-msg": "^1.1.0", @@ -5166,6 +5167,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -10196,6 +10212,12 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true }, + "prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true + }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", diff --git a/package.json b/package.json index 698df06..3200f4b 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "njs-types": "^0.7.12", "npm-run-all": "^4.1.5", "power-assert": "^1.6.1", + "prettier": "^2.8.8", "rimraf": "^3.0.2", "rollup": "^2.79.1", "rollup-plugin-add-git-msg": "^1.1.0", diff --git a/src/index.ts b/src/index.ts index c2153e2..3394297 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,7 +103,7 @@ async function clientAutoMode(r: NginxHTTPRequest) { const result = await createCsr(params); fs.writeFileSync(csrPath, toPEM(result.pkcs10Ber, "CERTIFICATE REQUEST")); - const privKey = await crypto.subtle.exportKey("pkcs8", result.keys.privateKey); + const privKey = await crypto.subtle.exportKey("pkcs8", result.keys.privateKey) as ArrayBuffer; pkeyPem = toPEM(privKey, "PRIVATE KEY"); fs.writeFileSync(pkeyPath, pkeyPem); ngx.log(ngx.INFO, `njs-acme: [auto] Wrote Private key to ${pkeyPath}`); @@ -132,7 +132,7 @@ async function clientAutoMode(r: NginxHTTPRequest) { const path = joinPaths(fullChallengePath, challenge.token); fs.writeFileSync(path, keyAuthorization); }, - challengeRemoveFn: async (authz, challenge, keyAuthorization) => { + challengeRemoveFn: async (_authz, challenge, _keyAuthorization) => { const path = joinPaths(fullChallengePath, challenge.token); try { fs.unlinkSync(path); @@ -207,8 +207,8 @@ async function createCsrHandler(r: NginxHTTPRequest) { country: "US", organizationUnit: "NGINX" }); - const privkey = await crypto.subtle.exportKey("pkcs8", keys.privateKey); - const pubkey = await crypto.subtle.exportKey("spki", keys.publicKey); + const privkey = await crypto.subtle.exportKey("pkcs8", keys.privateKey) as ArrayBuffer; + const pubkey = await crypto.subtle.exportKey("spki", keys.publicKey) as ArrayBuffer; const privkeyPem = toPEM(privkey, "PRIVATE KEY"); const pubkeyPem = toPEM(pubkey, "PUBLIC KEY"); const csrPem = toPEM(pkcs10Ber, "CERTIFICATE REQUEST"); diff --git a/src/utils.ts b/src/utils.ts index 534fe70..70dc8cc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,11 +2,9 @@ import x509 from './x509.js' import * as pkijs from 'pkijs'; import * as asn1js from 'asn1js'; import fs from 'fs'; - -const DEFAULT_ACCOUNT_KEY_PATH = `${ngx.conf_prefix || '/etc/nginx'}/account_private_key.json`; +import querystring from 'querystring'; // workaround for PKI.JS to work -const querystring = require('querystring'); globalThis.unescape = querystring.unescape; // make PKI.JS to work with webcrypto @@ -132,7 +130,7 @@ export async function generateKey() { * @returns {Promise} - The account key as a CryptoKey object. * @throws {Error} - If the account key cannot be read or generated. */ -export async function readOrCreateAccountKey(path: string = DEFAULT_ACCOUNT_KEY_PATH): Promise { +export async function readOrCreateAccountKey(path: string): Promise { try { const accountKeyJWK = fs.readFileSync(path, 'utf8'); ngx.log(ngx.INFO, `acme-njs: [utils] Using account key from ${path}`); @@ -244,6 +242,10 @@ export function encodeTBS(pkcs10: pkijs.CertificationRequest): asn1js.Sequence { }); } +interface AlgoCryptoKey extends CryptoKey { + algorithm?: pkijs.CryptoEngineAlgorithmParams | { name: string } +} + /** * Returns signature parameters based on the private key and hash algorithm * @@ -251,7 +253,7 @@ export function encodeTBS(pkcs10: pkijs.CertificationRequest): asn1js.Sequence { * @param hashAlgorithm {string} The hash algorithm used for the signature. Default is "SHA-1". * @returns {{signatureAlgorithm: pkijs.AlgorithmIdentifier; parameters: pkijs.CryptoEngineAlgorithmParams;}} An object containing signature algorithm and parameters */ -function getSignatureParameters(privateKey: CryptoKey, hashAlgorithm = "SHA-1"): { +function getSignatureParameters(privateKey: AlgoCryptoKey, hashAlgorithm = "SHA-1"): { signatureAlgorithm: pkijs.AlgorithmIdentifier; parameters: pkijs.CryptoEngineAlgorithmParams; } { // Check hashing algorithm @@ -369,6 +371,7 @@ export async function createCsr(params: { }): Promise<{ pkcs10Ber: ArrayBuffer; keys: Required }> { // TODO: allow to provide keys in addition to always generating one const { privateKey, publicKey } = (await generateKey()) as Required; + const algoPrivateKey = privateKey as AlgoCryptoKey; const pkcs10 = new pkijs.CertificationRequest(); pkcs10.version = 0; @@ -377,7 +380,7 @@ export async function createCsr(params: { await addExtensions(pkcs10, params, publicKey); // FIXME: workaround for PKIS.js - privateKey.algorithm = pkijs.getAlgorithmParameters("RSASSA-PKCS1-v1_5", "sign") + algoPrivateKey.algorithm = pkijs.getAlgorithmParameters("RSASSA-PKCS1-v1_5", "sign") await signCsr(pkcs10, privateKey); const pkcs10Ber = getPkcs10Ber(pkcs10); @@ -458,7 +461,7 @@ function getAltNames(params: { } function createGeneralName( - type: number, + type: 0 | 2 | 1 | 6 | 3 | 4 | 7 | 8 | undefined, value: string ): pkijs.GeneralName { return new pkijs.GeneralName({ type, value }); @@ -468,7 +471,7 @@ async function addExtensions( pkcs10: pkijs.CertificationRequest, params: { commonName?: string; altNames?: string[]; }, publicKey: CryptoKey -): void { +) { const altNames = getAltNames(params); @@ -500,13 +503,13 @@ function createExtension( }); } -async function getSubjectKeyIdentifier(pkcs10: pkijs.CertificationRequest): asn1js.OctetString { +async function getSubjectKeyIdentifier(pkcs10: pkijs.CertificationRequest): Promise { const subjectPublicKeyValue = pkcs10.subjectPublicKeyInfo.subjectPublicKey.valueBlock.valueHex const subjectKeyIdentifier = await crypto.subtle.digest("SHA-256", subjectPublicKeyValue); return new asn1js.OctetString({ valueHex: subjectKeyIdentifier }); } -async function signCsr(pkcs10: pkijs.CertificationRequest, privateKey: CryptoKey): Promise { +async function signCsr(pkcs10: pkijs.CertificationRequest, privateKey: AlgoCryptoKey): Promise { /* Set signatureValue */ pkcs10.tbsView = new Uint8Array(encodeTBS(pkcs10).toBER()); const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, pkcs10.tbsView); @@ -564,6 +567,10 @@ export function formatResponseError(data: any) { * @param {number} [opts.max] Maximum backoff duration in ms */ class Backoff { + min: number + max: number + attempts: number + constructor({ min = 100, max = 10000 } = {}) { this.min = min; this.max = max; @@ -593,7 +600,7 @@ class Backoff { * @param {Backoff} backoff Backoff instance * @returns {Promise} */ -async function retryPromise(fn, attempts, backoff) { +async function retryPromise(fn: Function, attempts: number, backoff: Backoff): Promise { let aborted = false; try { @@ -606,9 +613,9 @@ async function retryPromise(fn, attempts, backoff) { } const duration = backoff.duration(); - ngx.log(ngx.INFO, `acme-js: [utils] Promise rejected attempt #${backoff.attempts}, retrying in ${duration}ms: ${e.message}`); + ngx.log(ngx.INFO, `acme-js: [utils] Promise rejected attempt #${backoff.attempts}, retrying in ${duration}ms: ${e}`); - await new Promise((resolve) => { setTimeout(resolve, duration); }); + await new Promise((resolve) => { setTimeout(resolve, duration, {}); }); return retryPromise(fn, attempts, backoff); } } @@ -624,7 +631,7 @@ async function retryPromise(fn, attempts, backoff) { * @param {number} [backoffOpts.max] Maximum attempt delay in milliseconds, default: `30000` * @returns {Promise} */ -export function retry(fn, { attempts = 5, min = 5000, max = 30000 } = {}) { +export function retry(fn: Function, { attempts = 5, min = 5000, max = 30000 } = {}) { const backoff = new Backoff({ min, max }); return retryPromise(fn, attempts, backoff); } @@ -709,7 +716,7 @@ export function splitPemChain(chainPem: Buffer | string) { .map((pem) => pem.match(/\s*-----BEGIN ([A-Z0-9- ]+)-----\r?\n?([\S\s]+)\r?\n?-----END \1-----/)) /* Filter out non-matches or empty bodies */ .filter((pem) => pem && pem[2] && pem[2].replace(/[\r\n]+/g, '').trim()) - .map(([pem, _]) => pem); + .map((arr) => arr && arr[0]); } @@ -778,7 +785,7 @@ export function acmeDir(r: NginxHTTPRequest) { * @param r {NginxHTTPRequest} */ export function acmeAccountPrivateJWKPath(r: NginxHTTPRequest) { - return getVariable(r, 'njs_acme_account_private_jwk', + return getVariable(r, 'njs_acme_account_private_jwk', joinPaths(acmeDir(r), 'account_private_key.json') ); } @@ -801,7 +808,7 @@ export function acmeDirectoryURI(r: NginxHTTPRequest) { export function acmeVerifyProviderHTTPS(r: NginxHTTPRequest) { return ['true', 'yes', '1'].indexOf( getVariable(r, 'njs_acme_verify_provider_https', 'true').toLowerCase().trim() - ) > -1; + ) > -1; } @@ -813,4 +820,4 @@ export function acmeVerifyProviderHTTPS(r: NginxHTTPRequest) { export function joinPaths(...args: string[]) { // join args with a slash remove duplicate slashes return args.join('/').replace(/\/+/g, '/') -} \ No newline at end of file +} From fcf2a29950ba91f97a490d2623428c44429b52a1 Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Thu, 15 Jun 2023 14:19:25 -0700 Subject: [PATCH 06/13] prettify code --- .eslintrc.json | 18 + .prettierrc.yml | 4 + docker-compose.yml | 5 +- package-lock.json | 502 +++++--------- package.json | 2 + src/api.ts | 1522 ++++++++++++++++++++++------------------- src/client.ts | 1635 +++++++++++++++++++++++--------------------- src/index.ts | 502 ++++++++------ src/utils.ts | 706 +++++++++++-------- 9 files changed, 2569 insertions(+), 2327 deletions(-) create mode 100644 .eslintrc.json create mode 100644 .prettierrc.yml diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..54c8d3c --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended" + ], + "rules": { + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ] + }, + "root": true +} diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 0000000..2b8cb26 --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,4 @@ +# style settings, see https://prettier.io/docs/en/options.html +semi: false +singleQuote: true +trailingComma: es5 diff --git a/docker-compose.yml b/docker-compose.yml index 01cd839..7a837fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,10 +28,10 @@ services: CMD npm run watch volumes: - .:/app - - node_modules:/app/node_modules + - /app/node_modules - node_dist:/app/dist nginx: - image: nginxinc/njs-acme-experemental + image: nginxinc/njs-acme build: . command: /nginx_wait_for_js nginx -c examples/nginx.conf depends_on: @@ -58,5 +58,4 @@ services: start_period: 10s volumes: certs: - node_modules: node_dist: diff --git a/package-lock.json b/package-lock.json index 1cadfb3..47344af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "APACHE-2.0", "dependencies": { "asn1js": "^3.0.5", + "eslint-config-prettier": "^8.8.0", "pkijs": "^3.0.14" }, "devDependencies": { @@ -31,6 +32,7 @@ "babel-preset-njs": "^0.2.1", "babel-register-ts": "^7.0.0", "eslint": "^7.32.0", + "eslint-plugin-prettier": "^4.2.1", "got": "^11.8.6", "mocha": "^10.2.0", "mocha-suite-hooks": "^0.1.0", @@ -362,7 +364,6 @@ "version": "7.19.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -394,7 +395,6 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.18.6", "chalk": "^2.0.0", @@ -964,7 +964,6 @@ "version": "0.4.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", - "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.1.1", @@ -984,7 +983,6 @@ "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", - "dev": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -999,7 +997,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true, "engines": { "node": ">= 4" } @@ -1008,7 +1005,6 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", - "dev": true, "dependencies": { "@humanwhocodes/object-schema": "^1.2.0", "debug": "^4.1.1", @@ -1021,8 +1017,7 @@ "node_modules/@humanwhocodes/object-schema": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==" }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", @@ -1628,7 +1623,6 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -1646,7 +1640,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -1655,7 +1648,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1671,7 +1663,6 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, "engines": { "node": ">=6" } @@ -1680,7 +1671,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -1689,7 +1679,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -1714,7 +1703,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -1764,7 +1752,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, "engines": { "node": ">=8" } @@ -1857,8 +1844,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/binary-extensions": { "version": "2.2.0", @@ -1873,7 +1859,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2016,7 +2001,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -2057,7 +2041,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -2135,7 +2118,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -2143,8 +2125,7 @@ "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/commondir": { "version": "1.0.1", @@ -2155,8 +2136,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/convert-source-map": { "version": "1.9.0", @@ -2176,7 +2156,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2190,7 +2169,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -2262,8 +2240,7 @@ "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, "node_modules/deepmerge": { "version": "4.3.1", @@ -2330,7 +2307,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, "dependencies": { "esutils": "^2.0.2" }, @@ -2353,8 +2329,7 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/empower": { "version": "1.3.1", @@ -2389,7 +2364,6 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", - "dev": true, "dependencies": { "ansi-colors": "^4.1.1" }, @@ -2498,7 +2472,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, "engines": { "node": ">=0.8.0" } @@ -2507,7 +2480,6 @@ "version": "7.32.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", - "dev": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -2560,11 +2532,42 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz", + "integrity": "sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", + "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "eslint": ">=7.28.0", + "prettier": ">=2.0.0" + }, + "peerDependenciesMeta": { + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -2595,7 +2598,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true, "engines": { "node": ">=10" } @@ -2604,7 +2606,6 @@ "version": "7.12.11", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", - "dev": true, "dependencies": { "@babel/highlight": "^7.10.4" } @@ -2613,7 +2614,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2628,7 +2628,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2644,7 +2643,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2655,14 +2653,12 @@ "node_modules/eslint/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -2674,7 +2670,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", - "dev": true, "dependencies": { "eslint-visitor-keys": "^1.1.0" }, @@ -2689,7 +2684,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, "engines": { "node": ">=4" } @@ -2698,7 +2692,6 @@ "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", - "dev": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -2713,7 +2706,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2722,7 +2714,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true, "engines": { "node": ">= 4" } @@ -2731,7 +2722,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -2743,7 +2733,6 @@ "version": "7.5.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -2758,7 +2747,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -2769,8 +2757,7 @@ "node_modules/eslint/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/espower-location-detector": { "version": "1.0.0", @@ -2788,7 +2775,6 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", - "dev": true, "dependencies": { "acorn": "^7.4.0", "acorn-jsx": "^5.3.1", @@ -2802,7 +2788,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, "engines": { "node": ">=4" } @@ -2811,7 +2796,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -2833,7 +2817,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -2845,7 +2828,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "engines": { "node": ">=4.0" } @@ -2854,7 +2836,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -2866,7 +2847,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "engines": { "node": ">=4.0" } @@ -2875,7 +2855,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, "engines": { "node": ">=4.0" } @@ -2890,7 +2869,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -2933,7 +2911,12 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, "node_modules/fast-glob": { @@ -2955,14 +2938,12 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "node_modules/fastq": { "version": "1.15.0", @@ -2977,7 +2958,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, "dependencies": { "flat-cache": "^3.0.4" }, @@ -3040,7 +3020,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, "dependencies": { "flatted": "^3.1.0", "rimraf": "^3.0.2" @@ -3052,8 +3031,7 @@ "node_modules/flatted": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", - "dev": true + "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" }, "node_modules/for-each": { "version": "0.3.3", @@ -3067,8 +3045,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.2", @@ -3111,8 +3088,7 @@ "node_modules/functional-red-black-tree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==" }, "node_modules/functions-have-names": { "version": "1.2.3", @@ -3203,7 +3179,6 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -3223,7 +3198,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -3343,7 +3317,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, "engines": { "node": ">=4" } @@ -3455,7 +3428,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -3471,7 +3443,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, "engines": { "node": ">=0.8.19" } @@ -3486,7 +3457,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -3495,8 +3465,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-slot": { "version": "1.0.5", @@ -3631,7 +3600,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3640,7 +3608,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -3649,7 +3616,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -3851,8 +3817,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/isobject": { "version": "3.0.1", @@ -3866,14 +3831,12 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -3909,14 +3872,12 @@ "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" }, "node_modules/json5": { "version": "2.2.3", @@ -3952,7 +3913,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -4003,14 +3963,12 @@ "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -4206,7 +4164,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4394,8 +4351,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { "version": "3.3.3", @@ -4412,8 +4368,7 @@ "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, "node_modules/nginx-binaries": { "version": "0.7.0", @@ -4738,7 +4693,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -4762,7 +4716,6 @@ "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -4827,7 +4780,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -4861,7 +4813,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -4870,7 +4821,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -5162,7 +5112,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, "engines": { "node": ">= 0.8.0" } @@ -5182,11 +5131,22 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -5205,7 +5165,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "dev": true, "engines": { "node": ">=6" } @@ -5353,7 +5312,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, "engines": { "node": ">=8" }, @@ -5412,7 +5370,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -5444,7 +5401,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { "node": ">=4" } @@ -5475,7 +5431,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -5598,7 +5553,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -5610,7 +5564,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -5657,7 +5610,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -5674,7 +5626,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -5689,7 +5640,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -5700,8 +5650,7 @@ "node_modules/slice-ansi/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/source-map": { "version": "0.5.7", @@ -5773,8 +5722,7 @@ "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, "node_modules/stream-buffers": { "version": "3.0.2", @@ -5789,7 +5737,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -5876,7 +5823,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -5906,7 +5852,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, "engines": { "node": ">=8" }, @@ -5918,7 +5863,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -5942,7 +5886,6 @@ "version": "6.8.1", "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", "integrity": "sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==", - "dev": true, "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", @@ -5958,7 +5901,6 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -5973,14 +5915,12 @@ "node_modules/table/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" }, "node_modules/to-fast-properties": { "version": "2.0.0", @@ -6048,7 +5988,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -6060,7 +5999,6 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, "engines": { "node": ">=10" }, @@ -6201,7 +6139,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -6209,8 +6146,7 @@ "node_modules/v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==" }, "node_modules/validate-npm-package-license": { "version": "3.0.4", @@ -6242,7 +6178,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -6293,7 +6228,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -6357,8 +6291,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/xtend": { "version": "4.0.2", @@ -6674,8 +6607,7 @@ "@babel/helper-validator-identifier": { "version": "7.19.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", - "dev": true + "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==" }, "@babel/helper-validator-option": { "version": "7.21.0", @@ -6698,7 +6630,6 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", - "dev": true, "requires": { "@babel/helper-validator-identifier": "^7.18.6", "chalk": "^2.0.0", @@ -7079,7 +7010,6 @@ "version": "0.4.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", - "dev": true, "requires": { "ajv": "^6.12.4", "debug": "^4.1.1", @@ -7096,7 +7026,6 @@ "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", - "dev": true, "requires": { "type-fest": "^0.20.2" } @@ -7104,8 +7033,7 @@ "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==" } } }, @@ -7113,7 +7041,6 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", - "dev": true, "requires": { "@humanwhocodes/object-schema": "^1.2.0", "debug": "^4.1.1", @@ -7123,8 +7050,7 @@ "@humanwhocodes/object-schema": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==" }, "@jridgewell/gen-mapping": { "version": "0.3.3", @@ -7564,8 +7490,7 @@ "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" }, "acorn-es7-plugin": { "version": "1.1.7", @@ -7577,14 +7502,12 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, "requires": {} }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7595,20 +7518,17 @@ "ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==" }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, "ansi-styles": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -7627,7 +7547,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "requires": { "sprintf-js": "~1.0.2" } @@ -7667,8 +7586,7 @@ "astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==" }, "available-typed-arrays": { "version": "1.0.5", @@ -7744,8 +7662,7 @@ "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "binary-extensions": { "version": "2.2.0", @@ -7757,7 +7674,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7859,8 +7775,7 @@ "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" }, "camelcase": { "version": "6.3.0", @@ -7878,7 +7793,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -7936,7 +7850,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "requires": { "color-name": "1.1.3" } @@ -7944,8 +7857,7 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "commondir": { "version": "1.0.1", @@ -7956,8 +7868,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "convert-source-map": { "version": "1.9.0", @@ -7975,7 +7886,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "requires": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -7986,7 +7896,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "requires": { "ms": "2.1.2" } @@ -8031,8 +7940,7 @@ "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, "deepmerge": { "version": "4.3.1", @@ -8081,7 +7989,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, "requires": { "esutils": "^2.0.2" } @@ -8101,8 +8008,7 @@ "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "empower": { "version": "1.3.1", @@ -8137,7 +8043,6 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", - "dev": true, "requires": { "ansi-colors": "^4.1.1" } @@ -8224,14 +8129,12 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" }, "eslint": { "version": "7.32.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", - "dev": true, "requires": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -8279,7 +8182,6 @@ "version": "7.12.11", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", - "dev": true, "requires": { "@babel/highlight": "^7.10.4" } @@ -8288,7 +8190,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "requires": { "color-convert": "^2.0.1" } @@ -8297,7 +8198,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -8307,7 +8207,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "requires": { "color-name": "~1.1.4" } @@ -8315,20 +8214,17 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, "eslint-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", - "dev": true, "requires": { "eslint-visitor-keys": "^1.1.0" }, @@ -8336,8 +8232,7 @@ "eslint-visitor-keys": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==" } } }, @@ -8345,7 +8240,6 @@ "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", - "dev": true, "requires": { "type-fest": "^0.20.2" } @@ -8353,20 +8247,17 @@ "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==" }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -8375,7 +8266,6 @@ "version": "7.5.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", - "dev": true, "requires": { "lru-cache": "^6.0.0" } @@ -8384,7 +8274,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -8392,16 +8281,29 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } }, + "eslint-config-prettier": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz", + "integrity": "sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==", + "requires": {} + }, + "eslint-plugin-prettier": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", + "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", + "dev": true, + "requires": { + "prettier-linter-helpers": "^1.0.0" + } + }, "eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, "requires": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -8419,8 +8321,7 @@ "eslint-visitor-keys": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==" }, "espower-location-detector": { "version": "1.0.0", @@ -8438,7 +8339,6 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", - "dev": true, "requires": { "acorn": "^7.4.0", "acorn-jsx": "^5.3.1", @@ -8448,16 +8348,14 @@ "eslint-visitor-keys": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==" } } }, "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "espurify": { "version": "1.8.1", @@ -8472,7 +8370,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, "requires": { "estraverse": "^5.1.0" }, @@ -8480,8 +8377,7 @@ "estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" } } }, @@ -8489,7 +8385,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, "requires": { "estraverse": "^5.2.0" }, @@ -8497,16 +8392,14 @@ "estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" } } }, "estraverse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" }, "estree-walker": { "version": "2.0.2", @@ -8517,8 +8410,7 @@ "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, "execa": { "version": "5.1.1", @@ -8548,7 +8440,12 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, "fast-glob": { @@ -8567,14 +8464,12 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "fastq": { "version": "1.15.0", @@ -8589,7 +8484,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, "requires": { "flat-cache": "^3.0.4" } @@ -8634,7 +8528,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, "requires": { "flatted": "^3.1.0", "rimraf": "^3.0.2" @@ -8643,8 +8536,7 @@ "flatted": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", - "dev": true + "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" }, "for-each": { "version": "0.3.3", @@ -8658,8 +8550,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "fsevents": { "version": "2.3.2", @@ -8689,8 +8580,7 @@ "functional-red-black-tree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==" }, "functions-have-names": { "version": "1.2.3", @@ -8751,7 +8641,6 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -8765,7 +8654,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "requires": { "is-glob": "^4.0.1" } @@ -8851,8 +8739,7 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" }, "has-property-descriptors": { "version": "1.0.0", @@ -8928,7 +8815,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, "requires": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -8937,8 +8823,7 @@ "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" }, "indexof": { "version": "0.0.1", @@ -8950,7 +8835,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -8959,8 +8843,7 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "internal-slot": { "version": "1.0.5", @@ -9055,20 +8938,17 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "requires": { "is-extglob": "^2.1.1" } @@ -9204,8 +9084,7 @@ "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "isobject": { "version": "3.0.1", @@ -9216,14 +9095,12 @@ "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "js-yaml": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, "requires": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -9250,14 +9127,12 @@ "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" }, "json5": { "version": "2.2.3", @@ -9284,7 +9159,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, "requires": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -9322,14 +9196,12 @@ "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, "lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==" }, "log-symbols": { "version": "4.1.0", @@ -9478,7 +9350,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -9622,8 +9493,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "nanoid": { "version": "3.3.3", @@ -9634,8 +9504,7 @@ "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, "nginx-binaries": { "version": "0.7.0", @@ -9875,7 +9744,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "requires": { "wrappy": "1" } @@ -9893,7 +9761,6 @@ "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, "requires": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -9937,7 +9804,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "requires": { "callsites": "^3.0.0" } @@ -9961,14 +9827,12 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, "path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" }, "path-parse": { "version": "1.0.7", @@ -10209,8 +10073,7 @@ "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" }, "prettier": { "version": "2.8.8", @@ -10218,11 +10081,19 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true }, + "prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "requires": { + "fast-diff": "^1.1.2" + } + }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==" }, "pump": { "version": "3.0.0", @@ -10237,8 +10108,7 @@ "punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "dev": true + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" }, "pvtsutils": { "version": "1.3.2", @@ -10340,8 +10210,7 @@ "regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==" }, "regexpu-core": { "version": "5.3.2", @@ -10383,8 +10252,7 @@ "require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, "resolve": { "version": "1.22.2", @@ -10406,8 +10274,7 @@ "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" }, "responselike": { "version": "2.0.1", @@ -10428,7 +10295,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, "requires": { "glob": "^7.1.3" } @@ -10502,7 +10368,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "requires": { "shebang-regex": "^3.0.0" } @@ -10510,8 +10375,7 @@ "shebang-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, "shell-quote": { "version": "1.8.1", @@ -10546,7 +10410,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, "requires": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -10557,7 +10420,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "requires": { "color-convert": "^2.0.1" } @@ -10566,7 +10428,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "requires": { "color-name": "~1.1.4" } @@ -10574,8 +10435,7 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" } } }, @@ -10644,8 +10504,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, "stream-buffers": { "version": "3.0.2", @@ -10657,7 +10516,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -10723,7 +10581,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "requires": { "ansi-regex": "^5.0.1" } @@ -10743,14 +10600,12 @@ "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -10765,7 +10620,6 @@ "version": "6.8.1", "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", "integrity": "sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==", - "dev": true, "requires": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", @@ -10778,7 +10632,6 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -10789,16 +10642,14 @@ "json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" } } }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" }, "to-fast-properties": { "version": "2.0.0", @@ -10853,7 +10704,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, "requires": { "prelude-ls": "^1.2.1" } @@ -10861,8 +10711,7 @@ "type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==" }, "type-name": { "version": "2.0.2", @@ -10952,7 +10801,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "requires": { "punycode": "^2.1.0" } @@ -10960,8 +10808,7 @@ "v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==" }, "validate-npm-package-license": { "version": "3.0.4", @@ -10993,7 +10840,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "requires": { "isexe": "^2.0.0" } @@ -11028,8 +10874,7 @@ "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" }, "workerpool": { "version": "6.2.1", @@ -11077,8 +10922,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "xtend": { "version": "4.0.2", diff --git a/package.json b/package.json index 3200f4b..8d900ff 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "asn1js": "^3.0.5", + "eslint-config-prettier": "^8.8.0", "pkijs": "^3.0.14" }, "devDependencies": { @@ -56,6 +57,7 @@ "babel-preset-njs": "^0.2.1", "babel-register-ts": "^7.0.0", "eslint": "^7.32.0", + "eslint-plugin-prettier": "^4.2.1", "got": "^11.8.6", "mocha": "^10.2.0", "mocha-suite-hooks": "^0.1.0", diff --git a/src/api.ts b/src/api.ts index 7d41a72..de4b3d4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,120 +1,135 @@ -import { formatResponseError, getPublicJwk, RsaPublicJwk, EcdsaPublicJwk } from "./utils" -import { version } from '../package.json'; -import { ClientExternalAccountBindingOptions } from "./client"; - - -export type AcmeMethod = "GET" | "HEAD" | "POST" | "POST-as-GET"; -export type AcmeResource = "newNonce" | "newAccount" | "newAuthz" | "newOrder" | "revokeCert" | "keyChange" | "renewalInfo" -export type AcmeSignAlgo = "RS256" | "ES256" | "ES512" | "ES384" +import { + formatResponseError, + getPublicJwk, + RsaPublicJwk, + EcdsaPublicJwk, +} from './utils' +import { version } from '../package.json' +import { ClientExternalAccountBindingOptions } from './client' + +export type AcmeMethod = 'GET' | 'HEAD' | 'POST' | 'POST-as-GET' +export type AcmeResource = + | 'newNonce' + | 'newAccount' + | 'newAuthz' + | 'newOrder' + | 'revokeCert' + | 'keyChange' + | 'renewalInfo' +export type AcmeSignAlgo = 'RS256' | 'ES256' | 'ES512' | 'ES384' /* */ export interface SignedPayload { - payload: string; - protected: string; - signature?: string; + payload: string + protected: string + signature?: string +} + +export type UpdateAuthorizationData = { + status: string } export interface DirectoryMetadata { - /** - * A URL identifying the current terms of service - */ - termsOfService?: string; - - /** - * An HTTP or HTTPS URL locating a website providing more information - * about the ACME server - */ - website?: string; - - /** - * The hostnames that the ACME server recognizes as referring to itself - * for the purposes of CAA record validation - * - * NOTE: - * Each string MUST represent the same sequence of ASCII code points - * that the server will expect to see as the "Issuer Domain Name" - * in a CAA issue or issue wild property tag. This allows clients - * to determine the correct issuer domain name to use - * when configuring CAA records - */ - caaIdentities?: string[]; - - /** - * If this field is present and set to "true", then the CA requires - * that all newAccount requests include an "externalAccountBinding" - * field associating the new account with an external account - */ - externalAccountRequired?: boolean; - - /** - * - */ - endpoints?: string[]; + /** + * A URL identifying the current terms of service + */ + termsOfService?: string + + /** + * An HTTP or HTTPS URL locating a website providing more information + * about the ACME server + */ + website?: string + + /** + * The hostnames that the ACME server recognizes as referring to itself + * for the purposes of CAA record validation + * + * NOTE: + * Each string MUST represent the same sequence of ASCII code points + * that the server will expect to see as the "Issuer Domain Name" + * in a CAA issue or issue wild property tag. This allows clients + * to determine the correct issuer domain name to use + * when configuring CAA records + */ + caaIdentities?: string[] + + /** + * If this field is present and set to "true", then the CA requires + * that all newAccount requests include an "externalAccountBinding" + * field associating the new account with an external account + */ + externalAccountRequired?: boolean + + /** + * + */ + endpoints?: string[] } export interface AcmeDirectory { - /** - * New nonce. - */ - newNonce: string; - - /** - * New account. - */ - newAccount: string; - - /** - * New authorization - */ - newAuthz?: string; - - /** - * New order. - */ - newOrder: string; - - /** - * Revoke certificate - */ - revokeCert: string; - - /** - * Key change - */ - keyChange: string; - - /** - * Metadata object - */ - meta?: DirectoryMetadata; - - /** - * draft-ietf-acme-ari-00 - */ - renewalInfo?: string + /** + * New nonce. + */ + newNonce: string + + /** + * New account. + */ + newAccount: string + + /** + * New authorization + */ + newAuthz?: string + + /** + * New order. + */ + newOrder: string + + /** + * Revoke certificate + */ + revokeCert: string + + /** + * Key change + */ + keyChange: string + + /** + * Metadata object + */ + meta?: DirectoryMetadata + + /** + * draft-ietf-acme-ari-00 + */ + renewalInfo?: string } /** * Directory URLs for various ACME providers */ export const directories = { - buypass: { - staging: 'https://api.test4.buypass.no/acme/directory', - production: 'https://api.buypass.com/acme/directory' - }, - letsencrypt: { - staging: 'https://acme-staging-v02.api.letsencrypt.org/directory', - production: 'https://acme-v02.api.letsencrypt.org/directory' - }, - zerossl: { - production: 'https://acme.zerossl.com/v2/DV90' - }, - pebble: { - // Let's encrypt for testing https://github.com/letsencrypt/pebble - staging: 'https://localhost:14000/dir' - } -}; + buypass: { + staging: 'https://api.test4.buypass.no/acme/directory', + production: 'https://api.buypass.com/acme/directory', + }, + letsencrypt: { + staging: 'https://acme-staging-v02.api.letsencrypt.org/directory', + production: 'https://acme-v02.api.letsencrypt.org/directory', + }, + zerossl: { + production: 'https://acme.zerossl.com/v2/DV90', + }, + pebble: { + // Let's encrypt for testing https://github.com/letsencrypt/pebble + staging: 'https://localhost:14000/dir', + }, +} /** * ACME HTTP client @@ -129,630 +144,753 @@ export const directories = { * @param maxBadNonceRetries {number} (optional) Maximum number of retries when encountering a bad nonce error. Defaults to 5. */ export class HttpClient { - /** - * The URL for the ACME directory. - * @type {string} - */ - directoryUrl: string; - - /** - * The cryptographic key pair used for signing requests. - * @type {CryptoKey} - */ - accountKey: CryptoKey; - - /** - * An object that contains external account binding information. - * @type {ClientExternalAccountBindingOptions} - */ - externalAccountBinding: ClientExternalAccountBindingOptions; - - /** - * The ACME directory. - * @type {?AcmeDirectory} - */ - directory: AcmeDirectory | null; - - /** - * The public key in JWK format. - * @type {?RsaPublicJwk | ?EcdsaPublicJwk} - */ - jwk: RsaPublicJwk | EcdsaPublicJwk | null | undefined; - - /** - * The URL for the ACME account. - * @type {?string} - */ - accountUrl: string | null | undefined; - - /** - * Determines whether to verify the HTTPS server certificate while making requests. - * @type {boolean} - */ - verify: boolean; - - /** - * Determines whether to enable debug mode. - * @type {boolean} - */ - debug: boolean; - - /** - * The maximum number of retries allowed when encountering a bad nonce. - * @type {number} - */ - maxBadNonceRetries: number; - - /** - * Creates an instance of the ACME HTTP client. - * @constructor - * @param {string} directoryUrl - The URL of the ACME directory. - * @param {CryptoKey} accountKey - The private key to use for ACME account operations. - * @param {string} [accountUrl=""] - The URL of the ACME account. If not provided, a new account will be created. - * @param {ClientExternalAccountBindingOptions} [externalAccountBinding={ kid: "", hmacKey: "" }] - The external account binding options for the client. - * @returns {HttpClient} The newly created instance of the ACME HTTP client. - */ - constructor(directoryUrl: string, accountKey: CryptoKey, accountUrl: string = "", externalAccountBinding: ClientExternalAccountBindingOptions = { - kid: "", - hmacKey: "" - }) { - this.directoryUrl = directoryUrl; - this.accountKey = accountKey; - this.externalAccountBinding = externalAccountBinding; - - this.directory = null; - this.jwk = null; - this.accountUrl = accountUrl; - this.verify = true; - this.debug = false; - this.maxBadNonceRetries = 5; + /** + * The URL for the ACME directory. + * @type {string} + */ + directoryUrl: string + + /** + * The cryptographic key pair used for signing requests. + * @type {CryptoKey} + */ + accountKey: CryptoKey + + /** + * An object that contains external account binding information. + * @type {ClientExternalAccountBindingOptions} + */ + externalAccountBinding: ClientExternalAccountBindingOptions + + /** + * The ACME directory. + * @type {?AcmeDirectory} + */ + directory: AcmeDirectory | null + + /** + * The public key in JWK format. + * @type {?RsaPublicJwk | ?EcdsaPublicJwk} + */ + jwk: RsaPublicJwk | EcdsaPublicJwk | null | undefined + + /** + * The URL for the ACME account. + * @type {?string} + */ + accountUrl: string | null | undefined + + /** + * Determines whether to verify the HTTPS server certificate while making requests. + * @type {boolean} + */ + verify: boolean + + /** + * Determines whether to enable debug mode. + * @type {boolean} + */ + debug: boolean + + /** + * The maximum number of retries allowed when encountering a bad nonce. + * @type {number} + */ + maxBadNonceRetries: number + + /** + * Creates an instance of the ACME HTTP client. + * @constructor + * @param {string} directoryUrl - The URL of the ACME directory. + * @param {CryptoKey} accountKey - The private key to use for ACME account operations. + * @param {string} [accountUrl=""] - The URL of the ACME account. If not provided, a new account will be created. + * @param {ClientExternalAccountBindingOptions} [externalAccountBinding={ kid: "", hmacKey: "" }] - The external account binding options for the client. + * @returns {HttpClient} The newly created instance of the ACME HTTP client. + */ + constructor( + directoryUrl: string, + accountKey: CryptoKey, + accountUrl = '', + externalAccountBinding: ClientExternalAccountBindingOptions = { + kid: '', + hmacKey: '', } - - - /** - * HTTP request - * - * @param {string} url HTTP URL - * @param {string} method HTTP method - * @param {object} [body] Request options - * @returns {Promise} HTTP response - */ - async request(url: NjsStringLike, method: AcmeMethod, body: NjsStringLike = "") { - - let options: NgxFetchOptions = { - headers: { - 'user-agent': `njs-acme-v${version}`, - 'Content-Type': 'application/jose+json' - }, - method: method, - body: body, - verify: this.verify || false, - }; - - /* Request */ - if (this.debug) { - ngx.log(ngx.INFO, `njs-acme: [http] Sending a new request: ${method} ${url} ${JSON.stringify(options)}`); - } - const resp = await ngx.fetch(url, options); - if (this.debug) { - ngx.log(ngx.INFO, `njs-acme: [http] Got a response: ${resp.status} ${method} ${url} ${JSON.stringify(resp.headers)}`); - } - return resp; + ) { + this.directoryUrl = directoryUrl + this.accountKey = accountKey + this.externalAccountBinding = externalAccountBinding + + this.directory = null + this.jwk = null + this.accountUrl = accountUrl + this.verify = true + this.debug = false + this.maxBadNonceRetries = 5 + } + + /** + * HTTP request + * + * @param {string} url HTTP URL + * @param {string} method HTTP method + * @param {object} [body] Request options + * @returns {Promise} HTTP response + */ + async request( + url: NjsStringLike, + method: AcmeMethod, + body: NjsStringLike = '' + ) { + const options: NgxFetchOptions = { + headers: { + 'user-agent': `njs-acme-v${version}`, + 'Content-Type': 'application/jose+json', + }, + method: method, + body: body, + verify: this.verify || false, } - - /** - * Sends a signed request to the specified URL with the provided payload. - * https://tools.ietf.org/html/rfc8555#section-6.2 - * - * @async - * @param {string} url - The URL to send the request to. - * @param {object} payload - The request payload to send. - * @param {object} options - An object containing optional parameters. - * @param {string} [options.kid=null] - The kid parameter for the request. - * @param {string} [options.nonce=null] - The nonce parameter for the request. - * @param {boolean} [options.includeExternalAccountBinding=false] - Whether to include the externalAccountBinding parameter in the request. - * @param {number} [attempts=0] - The number of times the request has been attempted. - * @returns {Promise} A Promise that resolves with the Response object for the request. - */ - async signedRequest(url: string, payload: object, { kid = null, nonce = null, includeExternalAccountBinding = false } = {}, attempts = 0): Promise { - if (!nonce) { - nonce = await this.getNonce(); - } - if (!this.jwk) { - await this.getJwk(); - } - - if (this.debug) { - ngx.log(ngx.INFO, `njs-acme: [http] Signing request with kid: ${kid} nonce: ${nonce} jwt: ${JSON.stringify(this.jwk)}`); - } - /* External account binding - - https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.4 - - */ - if (includeExternalAccountBinding && this.externalAccountBinding) { - if (this.externalAccountBinding.kid && this.externalAccountBinding.hmacKey) { - const jwk = this.jwk; - const eabKid = this.externalAccountBinding.kid; - const eabHmacKey = this.externalAccountBinding.hmacKey; - // FIXME - (payload as any).externalAccountBinding = this.createSignedHmacBody(eabHmacKey, url, jwk, { kid: eabKid }); - } - } - - /* Sign body and send request */ - const data = await this.createSignedBody(url, payload, { nonce, kid }); - if (this.debug) { - ngx.log(ngx.INFO, `njs-acme: [http] Signed request body: ${JSON.stringify(data)}`); - } - const resp = await this.request(url, 'POST', JSON.stringify(data)); - - if (resp.status === 400) { - // FIXME: potential issue here as we reading the response body - // TODO: refactor maybe - const respData = await resp.json(); - /* Retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 */ - if (respData?.type === 'urn:ietf:params:acme:error:badNonce' && (attempts < this.maxBadNonceRetries)) { - nonce = resp.headers.get('replay-nonce') || null; - attempts += 1; - - ngx.log(ngx.WARN, `njs-acme: [http] Invalid nonce error, retrying (${attempts}/${this.maxBadNonceRetries}) signed request to: ${url}`); - return this.signedRequest(url, payload, { kid, nonce, includeExternalAccountBinding }, attempts); - } - } - /* Return response */ - return resp; + /* Request */ + if (this.debug) { + ngx.log( + ngx.INFO, + `njs-acme: [http] Sending a new request: ${method} ${url} ${JSON.stringify( + options + )}` + ) } - - /** - * Sends a signed ACME API request with optional JWS authentication, nonce handling, and external account binding - * request to the specified URL with the provided payload, and verifies the response status code. - * - * @param {string} url - The URL to make the API request to. - * @param {any} [payload=null] - The payload to include in the API request. - * @param {number[]} [validStatusCodes=[]] - An array of valid HTTP status codes. - * @param {Object} [options={}] - An object of options for the API request. - * @param {boolean} [options.includeJwsKid=true] - Whether to include the JWS kid header in the API request. - * @param {boolean} [options.includeExternalAccountBinding=false] - Whether to include the external account binding in the API request. - * @returns {Promise} - A promise that resolves with the API response. - * @throws {Error} When an unexpected status code is returned in the HTTP response, with the corresponding error message returned in the response body. - */ - async apiRequest(url: string, payload: any = null, validStatusCodes: number[] = [], { includeJwsKid = true, includeExternalAccountBinding = false } = {}) { - const kid = includeJwsKid ? this.getAccountUrl() : null; - if (this.debug) { - ngx.log(ngx.INFO, `njs-acme: [http] Preparing a new api request kid=${kid}, payload=${JSON.stringify(payload)}`); - } - const resp = await this.signedRequest(url, payload, { kid, includeExternalAccountBinding }); - - if (validStatusCodes.length && (validStatusCodes.indexOf(resp.status) === -1)) { - const b = await resp.json() - ngx.log(ngx.WARN, `njs-acme: [http] Received unexpected status code ${resp.status} for API request ${url}. Expected status codes: ${validStatusCodes.join(', ')}. Body response: ${JSON.stringify(b)}`); - const e = formatResponseError(b); - throw new Error(e); - } - return resp; + const resp = await ngx.fetch(url, options) + if (this.debug) { + ngx.log( + ngx.INFO, + `njs-acme: [http] Got a response: ${resp.status + } ${method} ${url} ${JSON.stringify(resp.headers)}` + ) } - - /** - * ACME API request by resource name helper - * - * @private - * @param {string} resource Request resource name - * @param {object} [payload] Request payload, default: `null` - * @param {array} [validStatusCodes] Array of valid HTTP response status codes, default: `[]` - * @param {object} [opts] - * @param {boolean} [opts.includeJwsKid] Include KID instead of JWK in JWS header, default: `true` - * @param {boolean} [opts.includeExternalAccountBinding] Include EAB in request, default: `false` - * @returns {Promise} HTTP response - */ - async apiResourceRequest(resource: AcmeResource, payload: any = null, validStatusCodes: number[] = [], { includeJwsKid = true, includeExternalAccountBinding = false } = {}) { - const resourceUrl = await this.getResourceUrl(resource); - return this.apiRequest(resourceUrl, payload, validStatusCodes, { includeJwsKid, includeExternalAccountBinding }); + return resp + } + + /** + * Sends a signed request to the specified URL with the provided payload. + * https://tools.ietf.org/html/rfc8555#section-6.2 + * + * @async + * @param {string} url - The URL to send the request to. + * @param {object} payload - The request payload to send. + * @param {object} options - An object containing optional parameters. + * @param {string} [options.kid=null] - The kid parameter for the request. + * @param {string} [options.nonce=null] - The nonce parameter for the request. + * @param {boolean} [options.includeExternalAccountBinding=false] - Whether to include the externalAccountBinding parameter in the request. + * @param {number} [attempts=0] - The number of times the request has been attempted. + * @returns {Promise} A Promise that resolves with the Response object for the request. + */ + async signedRequest( + url: string, + payload: object, + { kid = null, nonce = null, includeExternalAccountBinding = false } = {}, + attempts = 0 + ): Promise { + if (!nonce) { + nonce = await this.getNonce() } - - - /** - * Retrieves the ACME directory from the directory URL specified in the constructor. - * - * @throws {Error} Throws an error if the response status code is not 200 OK or the response body is invalid. - * @returns {Promise} Returns a Promise that resolves to an object representing the ACME directory. - */ - async getDirectory() { - if (!this.directory) { - const resp = await this.request(this.directoryUrl, 'GET'); - - if (resp.status >= 400) { - throw new Error(`Attempting to read ACME directory returned error ${resp.status}: ${this.directoryUrl}`); - } - let data = await resp.json(); - if (!data) { - throw new Error('Attempting to read ACME directory returned no data'); - } - this.directory = data; - if (this.debug) { - ngx.log(ngx.INFO, `njs-acme: [http] Fetched directory: ${JSON.stringify(this.directory)}`); - } - } + if (!this.jwk) { + await this.getJwk() } - - /** - * Retrieves the public key associated with the account key - * - * @async - * @function getJwk - * @returns {Promise} The public key associated with the account key, or null if not found - * @throws {Error} If the account key is not set or is not valid - */ - async getJwk() { - // singleton - if (!this.jwk) { - if (this.debug) { - ngx.log(ngx.INFO, 'njs-acme: [http] Public JWK not set. Obtaining it from Account Private Key...'); - } - this.jwk = await getPublicJwk(this.accountKey); - if (this.debug) { - ngx.log(ngx.INFO, `njs-acme: [http] Obtained Account Public JWK: ${JSON.stringify(this.jwk)}`); - } - } - return this.jwk; + if (this.debug) { + ngx.log( + ngx.INFO, + `njs-acme: [http] Signing request with kid: ${kid} nonce: ${nonce} jwt: ${JSON.stringify( + this.jwk + )}` + ) } - - - /** - * Get nonce from directory API endpoint - * - * https://tools.ietf.org/html/rfc8555#section-7.2 - * - * @returns {Promise} nonce - */ - async getNonce() { - const url = await this.getResourceUrl('newNonce'); - const resp = await this.request(url, 'HEAD'); - if (!resp.headers.get('replay-nonce')) { - ngx.log(ngx.ERR, 'njs-acme: [http] No nonce from ACME provider. "replay-nonce" header found'); - throw new Error('Failed to get nonce from ACME provider'); - } - return resp.headers.get('replay-nonce'); + /* External account binding + + https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.4 + + */ + if (includeExternalAccountBinding && this.externalAccountBinding) { + if ( + this.externalAccountBinding.kid && + this.externalAccountBinding.hmacKey + ) { + const jwk = this.jwk + const eabKid = this.externalAccountBinding.kid + const eabHmacKey = this.externalAccountBinding.hmacKey + // FIXME + ; (payload as any).externalAccountBinding = this.createSignedHmacBody( + eabHmacKey, + url, + jwk, + { kid: eabKid } + ) + } } - - /** - * Get URL for a directory resource - * - * @param {string} resource API resource name - * @returns {Promise} URL - */ - async getResourceUrl(resource: AcmeResource): Promise { - await this.getDirectory(); - - if (this.directory != null && !this.directory[resource]) { - ngx.log(ngx.ERR, `njs-acme: [http] Unable to locate API resource URL in ACME directory: "${resource}"`); - throw new Error(`Unable to locate API resource URL in ACME directory: "${resource}"`); - } - return this.directory![resource] as string; + /* Sign body and send request */ + const data = await this.createSignedBody(url, payload, { nonce, kid }) + if (this.debug) { + ngx.log( + ngx.INFO, + `njs-acme: [http] Signed request body: ${JSON.stringify(data)}` + ) } - - - /** - * Get directory meta field - * - * @param {string} field Meta field name - * @returns {Promise} Meta field value - */ - async getMetaField(field: string): Promise { - await this.getDirectory(); - if (this.directory && 'meta' in this.directory && (field in this.directory.meta)) { - return this.directory.meta[field]; - } - return; + const resp = await this.request(url, 'POST', JSON.stringify(data)) + + if (resp.status === 400) { + // FIXME: potential issue here as we reading the response body + // TODO: refactor maybe + const respData = await resp.json() + /* Retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 */ + if ( + respData?.type === 'urn:ietf:params:acme:error:badNonce' && + attempts < this.maxBadNonceRetries + ) { + nonce = resp.headers.get('replay-nonce') || null + attempts += 1 + + ngx.log( + ngx.WARN, + `njs-acme: [http] Invalid nonce error, retrying (${attempts}/${this.maxBadNonceRetries}) signed request to: ${url}` + ) + return this.signedRequest( + url, + payload, + { kid, nonce, includeExternalAccountBinding }, + attempts + ) + } } - - - /** - * Prepares a signed request body to be sent to an ACME server. - * @param {AcmeSignAlgo|string} alg - The signing algorithm to use. - * @param {NjsStringLike} url - The URL to include in the signed payload. - * @param {Object|null} [payload=null] - The payload to include in the signed payload. - * @param {RsaPublicJwk|EcdsaPublicJwk|null|undefined} [jwk=null] - The JWK to use for signing the payload. - * @param {Object} [options={nonce: null, kid: null}] - Additional options for the signed payload. - * @param {string|null} [options.nonce=null] - The nonce to include in the signed payload. - * @param {string|null} [options.kid=null] - The KID to include in the signed payload. - * @returns {SignedPayload} The signed payload. - */ - prepareSignedBody(alg: AcmeSignAlgo | string, url: NjsStringLike, payload = null, jwk: RsaPublicJwk | EcdsaPublicJwk | null | undefined, { nonce = null, kid = null } = {}): SignedPayload { - const header: any = { alg, url }; - - /* Nonce */ - if (nonce) { - header.nonce = nonce; - } - - /* KID or JWK */ - if (kid) { - header.kid = kid; - } - else { - header.jwk = jwk; - } - - /* Body */ - const body: SignedPayload = { - payload: payload ? Buffer.from(JSON.stringify(payload)).toString('base64url') : '', - protected: Buffer.from(JSON.stringify(header)).toString('base64url') - } - return body; + /* Return response */ + return resp + } + + /** + * Sends a signed ACME API request with optional JWS authentication, nonce handling, and external account binding + * request to the specified URL with the provided payload, and verifies the response status code. + * + * @param {string} url - The URL to make the API request to. + * @param {any} [payload=null] - The payload to include in the API request. + * @param {number[]} [validStatusCodes=[]] - An array of valid HTTP status codes. + * @param {Object} [options={}] - An object of options for the API request. + * @param {boolean} [options.includeJwsKid=true] - Whether to include the JWS kid header in the API request. + * @param {boolean} [options.includeExternalAccountBinding=false] - Whether to include the external account binding in the API request. + * @returns {Promise} - A promise that resolves with the API response. + * @throws {Error} When an unexpected status code is returned in the HTTP response, with the corresponding error message returned in the response body. + */ + async apiRequest( + url: string, + payload: any = null, + validStatusCodes: number[] = [], + { includeJwsKid = true, includeExternalAccountBinding = false } = {} + ) { + const kid = includeJwsKid ? this.getAccountUrl() : null + if (this.debug) { + ngx.log( + ngx.INFO, + `njs-acme: [http] Preparing a new api request kid=${kid}, payload=${JSON.stringify( + payload + )}` + ) } - - - /** - * Creates a signed HMAC body for the given URL and payload, with optional nonce and kid parameters - * - * @param {string} hmacKey The key to use for the HMAC signature. - * @param {string} url The URL to sign. - * @param {object} [payload] The payload to sign. Defaults to null. - * @param {object} [opts] Optional parameters for the signature (nonce and kid). - * @param {string} [opts.nonce] The anti-replay nonce to include in the signature. Defaults to null. - * @param {string} [opts.kid] The kid to include in the signature. Defaults to null. - * @returns {object} Signed HMAC request body - * @throws An error if the HMAC key is not provided. - */ - async createSignedHmacBody(hmacKey: string, url: string, payload = null, { nonce = null, kid = null } = {}) { - if (!hmacKey) { - throw new Error("HMAC key is required."); - } - const result = this.prepareSignedBody('HS256', url, payload, { nonce, kid }); - var h = require('crypto').createHmac('sha256', Buffer.from(hmacKey, 'base64')); - h.update(`${result.protected}.${result.payload}`); - result.signature = h.digest("base64url"); - return result; + const resp = await this.signedRequest(url, payload, { + kid, + includeExternalAccountBinding, + }) + + if ( + validStatusCodes.length && + validStatusCodes.indexOf(resp.status) === -1 + ) { + const b = await resp.json() + ngx.log( + ngx.WARN, + `njs-acme: [http] Received unexpected status code ${resp.status + } for API request ${url}. Expected status codes: ${validStatusCodes.join( + ', ' + )}. Body response: ${JSON.stringify(b)}` + ) + const e = formatResponseError(b) + throw new Error(e) } - - - /** - * Create JWS HTTP request body using RSA or ECC - * - * https://datatracker.ietf.org/doc/html/rfc7515 - * - * @param {string} url Request URL - * @param {object} [payload] Request payload - * @param {object} [opts] - * @param {string} [opts.nonce] JWS nonce - * @param {string} [opts.kid] JWS KID - * @returns {Promise} JWS request body - */ - async createSignedBody(url: NjsStringLike, payload: any = null, { nonce = null, kid = null } = {}): Promise { - const jwk = this.jwk!; - let headerAlg: AcmeSignAlgo = 'RS256'; - let signerAlg = 'SHA256'; - - /* https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 */ - if ('crv' in jwk && jwk.crv && (jwk.kty === 'EC')) { - headerAlg = 'ES256'; - if (jwk.crv === 'P-384') { - headerAlg = 'ES384'; - signerAlg = 'SHA384'; - } - else if (jwk.crv === 'P-521') { - headerAlg = 'ES512'; - signerAlg = 'SHA512'; - } - } - - /* Prepare body and sign it */ - const result = this.prepareSignedBody(headerAlg, url, payload, jwk, { nonce, kid }); - - if (this.debug) { - ngx.log(ngx.INFO, `njs-acme: [http] Prepared signed payload ${JSON.stringify(result)}`); - } - - let sign; - if (jwk.kty === 'EC') { - const hash = await crypto.subtle.digest({ name: signerAlg }, `${result.protected}.${result.payload}`); - sign = await crypto.subtle.sign({ - name: "ECDSA", - hash: hash - }, this.accountKey, hash); - } else { - sign = await crypto.subtle.sign({ "name": "RSASSA-PKCS1-v1_5" }, this.accountKey, `${result.protected}.${result.payload}`); - } - - result.signature = Buffer.from(sign).toString('base64url'); - return result; + return resp + } + + /** + * ACME API request by resource name helper + * + * @private + * @param {string} resource Request resource name + * @param {object} [payload] Request payload, default: `null` + * @param {array} [validStatusCodes] Array of valid HTTP response status codes, default: `[]` + * @param {object} [opts] + * @param {boolean} [opts.includeJwsKid] Include KID instead of JWK in JWS header, default: `true` + * @param {boolean} [opts.includeExternalAccountBinding] Include EAB in request, default: `false` + * @returns {Promise} HTTP response + */ + async apiResourceRequest( + resource: AcmeResource, + payload: any = null, + validStatusCodes: number[] = [], + { includeJwsKid = true, includeExternalAccountBinding = false } = {} + ) { + const resourceUrl = await this.getResourceUrl(resource) + return this.apiRequest(resourceUrl, payload, validStatusCodes, { + includeJwsKid, + includeExternalAccountBinding, + }) + } + + /** + * Retrieves the ACME directory from the directory URL specified in the constructor. + * + * @throws {Error} Throws an error if the response status code is not 200 OK or the response body is invalid. + * @returns {Promise} Returns a Promise that resolves to an object representing the ACME directory. + */ + async getDirectory() { + if (!this.directory) { + const resp = await this.request(this.directoryUrl, 'GET') + + if (resp.status >= 400) { + throw new Error( + `Attempting to read ACME directory returned error ${resp.status}: ${this.directoryUrl}` + ) + } + const data = await resp.json() + if (!data) { + throw new Error('Attempting to read ACME directory returned no data') + } + this.directory = data + if (this.debug) { + ngx.log( + ngx.INFO, + `njs-acme: [http] Fetched directory: ${JSON.stringify( + this.directory + )}` + ) + } } - - /** - * Returns the account URL associated with the current client instance. - * - * @private - * @returns {string} the account URL - * @throws {Error} If no account URL has been set yet - */ - getAccountUrl(): string { - if (!this.accountUrl) { - throw new Error('No account URL found, register account first'); - } - return this.accountUrl; + } + + /** + * Retrieves the public key associated with the account key + * + * @async + * @function getJwk + * @returns {Promise} The public key associated with the account key, or null if not found + * @throws {Error} If the account key is not set or is not valid + */ + async getJwk() { + // singleton + if (!this.jwk) { + if (this.debug) { + ngx.log( + ngx.INFO, + 'njs-acme: [http] Public JWK not set. Obtaining it from Account Private Key...' + ) + } + this.jwk = await getPublicJwk(this.accountKey) + if (this.debug) { + ngx.log( + ngx.INFO, + `njs-acme: [http] Obtained Account Public JWK: ${JSON.stringify( + this.jwk + )}` + ) + } } - - - /** - * Get Terms of Service URL if available - * - * https://tools.ietf.org/html/rfc8555#section-7.1.1 - * - * @returns {Promise} ToS URL - */ - async getTermsOfServiceUrl(): Promise { - return this.getMetaField('termsOfService'); + return this.jwk + } + + /** + * Get nonce from directory API endpoint + * + * https://tools.ietf.org/html/rfc8555#section-7.2 + * + * @returns {Promise} nonce + */ + async getNonce() { + const url = await this.getResourceUrl('newNonce') + const resp = await this.request(url, 'HEAD') + if (!resp.headers.get('replay-nonce')) { + ngx.log( + ngx.ERR, + 'njs-acme: [http] No nonce from ACME provider. "replay-nonce" header found' + ) + throw new Error('Failed to get nonce from ACME provider') } - - - /** - * Create new account - * - * https://tools.ietf.org/html/rfc8555#section-7.3 - * - * @param {object} data Request payload. - * @param {boolean} data.termsOfServiceAgreed Whether the client agrees to the terms of service. - * @param {[]string} data.contact An array of contact info, e.g. ['mailto:admin@example.com']. - * @param {boolean} data.onlyReturnExisting Whether the server should only return an existing account, or create a new one if it does not exist. - * @returns {Promise} HTTP response. - */ - async createAccount(data: object): Promise { - const resp = await this.apiResourceRequest('newAccount', data, [200, 201], { - includeJwsKid: false, - includeExternalAccountBinding: (data.onlyReturnExisting !== true) - }); - - /* Set account URL */ - if (resp.headers.get("location")) { - this.accountUrl = resp.headers.get("location"); - } - - return resp; + return resp.headers.get('replay-nonce') + } + + /** + * Get URL for a directory resource + * + * @param {string} resource API resource name + * @returns {Promise} URL + */ + async getResourceUrl(resource: AcmeResource): Promise { + await this.getDirectory() + + if (this.directory != null && !this.directory[resource]) { + ngx.log( + ngx.ERR, + `njs-acme: [http] Unable to locate API resource URL in ACME directory: "${resource}"` + ) + throw new Error( + `Unable to locate API resource URL in ACME directory: "${resource}"` + ) } - - - /** - * Update account - * - * https://tools.ietf.org/html/rfc8555#section-7.3.2 - * - * @param {object} data Request payload - * @returns {Promise} HTTP response - */ - updateAccount(data:object): Promise { - return this.apiRequest(this.getAccountUrl(), data, [200, 202]); + return this.directory![resource] as string + } + + /** + * Get directory meta field + * + * @param {string} field Meta field name + * @returns {Promise} Meta field value + */ + async getMetaField(field: string): Promise { + await this.getDirectory() + if ( + this.directory && + 'meta' in this.directory && + field in this.directory.meta + ) { + return this.directory.meta[field] } - - - /** - * Update account key - * - * https://tools.ietf.org/html/rfc8555#section-7.3.5 - * - * @param {object} data Request payload - * @returns {Promise} HTTP response - */ - updateAccountKey(data:object): Promise { - return this.apiResourceRequest('keyChange', data, [200]); + return + } + + /** + * Prepares a signed request body to be sent to an ACME server. + * @param {AcmeSignAlgo|string} alg - The signing algorithm to use. + * @param {NjsStringLike} url - The URL to include in the signed payload. + * @param {Object|null} [payload=null] - The payload to include in the signed payload. + * @param {RsaPublicJwk|EcdsaPublicJwk|null|undefined} [jwk=null] - The JWK to use for signing the payload. + * @param {Object} [options={nonce: null, kid: null}] - Additional options for the signed payload. + * @param {string|null} [options.nonce=null] - The nonce to include in the signed payload. + * @param {string|null} [options.kid=null] - The KID to include in the signed payload. + * @returns {SignedPayload} The signed payload. + */ + prepareSignedBody( + alg: AcmeSignAlgo | string, + url: NjsStringLike, + payload = null, + jwk: RsaPublicJwk | EcdsaPublicJwk | null | undefined, + { nonce = null, kid = null } = {} + ): SignedPayload { + const header: any = { alg, url } + + /* Nonce */ + if (nonce) { + header.nonce = nonce } - - /** - * Create new order - * - * https://tools.ietf.org/html/rfc8555#section-7.4 - * - * @param {object} data Request payload - * @returns {Promise} HTTP response - */ - createOrder(data:object): Promise { - return this.apiResourceRequest('newOrder', data, [201]); + /* KID or JWK */ + if (kid) { + header.kid = kid + } else { + header.jwk = jwk } - - /** - * Get order - * - * https://tools.ietf.org/html/rfc8555#section-7.4 - * - * @param {string} url Order URL - * @returns {Promise} HTTP response - */ - getOrder(url: string): Promise { - return this.apiRequest(url, null, [200]); + /* Body */ + const body: SignedPayload = { + payload: payload + ? Buffer.from(JSON.stringify(payload)).toString('base64url') + : '', + protected: Buffer.from(JSON.stringify(header)).toString('base64url'), } - - - /** - * Finalize order - * - * https://tools.ietf.org/html/rfc8555#section-7.4 - * - * @param {string} url Finalization URL - * @param {object} data Request payload - * @returns {Promise} HTTP response - */ - finalizeOrder(url: string, data:object): Promise { - return this.apiRequest(url, data, [200]); + return body + } + + /** + * Creates a signed HMAC body for the given URL and payload, with optional nonce and kid parameters + * + * @param {string} hmacKey The key to use for the HMAC signature. + * @param {string} url The URL to sign. + * @param {object} [payload] The payload to sign. Defaults to null. + * @param {object} [opts] Optional parameters for the signature (nonce and kid). + * @param {string} [opts.nonce] The anti-replay nonce to include in the signature. Defaults to null. + * @param {string} [opts.kid] The kid to include in the signature. Defaults to null. + * @returns {object} Signed HMAC request body + * @throws An error if the HMAC key is not provided. + */ + async createSignedHmacBody( + hmacKey: string, + url: string, + payload = null, + { nonce = null, kid = null } = {} + ) { + if (!hmacKey) { + throw new Error('HMAC key is required.') } - - - /** - * Get identifier authorization - * - * https://tools.ietf.org/html/rfc8555#section-7.5 - * - * @param {string} url Authorization URL - * @returns {Promise} HTTP response - */ - getAuthorization(url: string): Promise { - return this.apiRequest(url, null, [200]); + const result = this.prepareSignedBody('HS256', url, payload, { nonce, kid }) + const h = require('crypto').createHmac( + 'sha256', + Buffer.from(hmacKey, 'base64') + ) + h.update(`${result.protected}.${result.payload}`) + result.signature = h.digest('base64url') + return result + } + + /** + * Create JWS HTTP request body using RSA or ECC + * + * https://datatracker.ietf.org/doc/html/rfc7515 + * + * @param {string} url Request URL + * @param {object} [payload] Request payload + * @param {object} [opts] + * @param {string} [opts.nonce] JWS nonce + * @param {string} [opts.kid] JWS KID + * @returns {Promise} JWS request body + */ + async createSignedBody( + url: NjsStringLike, + payload: any = null, + { nonce = null, kid = null } = {} + ): Promise { + const jwk = this.jwk! + let headerAlg: AcmeSignAlgo = 'RS256' + let signerAlg = 'SHA256' + + /* https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 */ + if ('crv' in jwk && jwk.crv && jwk.kty === 'EC') { + headerAlg = 'ES256' + if (jwk.crv === 'P-384') { + headerAlg = 'ES384' + signerAlg = 'SHA384' + } else if (jwk.crv === 'P-521') { + headerAlg = 'ES512' + signerAlg = 'SHA512' + } } - - /** - * Update identifier authorization - * - * https://tools.ietf.org/html/rfc8555#section-7.5.2 - * - * @param {string} url Authorization URL - * @param {object} data Request payload - * @returns {Promise} HTTP response - */ - updateAuthorization(url: string, data): Promise { - return this.apiRequest(url, data, [200]); + /* Prepare body and sign it */ + const result = this.prepareSignedBody(headerAlg, url, payload, jwk, { + nonce, + kid, + }) + + if (this.debug) { + ngx.log( + ngx.INFO, + `njs-acme: [http] Prepared signed payload ${JSON.stringify(result)}` + ) } - - /** - * Completes a pending challenge with the ACME server by sending a response payload to the challenge URL. - * - * https://tools.ietf.org/html/rfc8555#section-7.5.1 - * - * @param {string} url Challenge URL - * @param {object} data Request payload - * @returns {Promise} HTTP response - */ - completeChallenge(url: string, data:object): Promise { - return this.apiRequest(url, data, [200]); + let sign + if (jwk.kty === 'EC') { + const hash = await crypto.subtle.digest( + { name: signerAlg }, + `${result.protected}.${result.payload}` + ) + sign = await crypto.subtle.sign( + { + name: 'ECDSA', + hash: hash, + }, + this.accountKey, + hash + ) + } else { + sign = await crypto.subtle.sign( + { name: 'RSASSA-PKCS1-v1_5' }, + this.accountKey, + `${result.protected}.${result.payload}` + ) } - - /** - * Revoke certificate - * - * https://tools.ietf.org/html/rfc8555#section-7.6 - * - * - * @param {object} data - An object containing the data needed for revocation: - * @param {string} data.certificate - The certificate to be revoked. - * @param {number} data.reason - An optional reason for revocation (default: 1). - * See this https://datatracker.ietf.org/doc/html/rfc5280#section-5.3.1 - * @returns {Promise} HTTP response - */ - revokeCert(data:object): Promise { - return this.apiResourceRequest('revokeCert', data, [200]); + result.signature = Buffer.from(sign).toString('base64url') + return result + } + + /** + * Returns the account URL associated with the current client instance. + * + * @private + * @returns {string} the account URL + * @throws {Error} If no account URL has been set yet + */ + getAccountUrl(): string { + if (!this.accountUrl) { + throw new Error('No account URL found, register account first') } - - /** - * Set the `verify` property to enable or disable verification of the HTTPS server certificate. - * - * @param {boolean} v - The value to set `verify` to. - */ - setVerify(v: boolean) { - this.verify = v; + return this.accountUrl + } + + /** + * Get Terms of Service URL if available + * + * https://tools.ietf.org/html/rfc8555#section-7.1.1 + * + * @returns {Promise} ToS URL + */ + async getTermsOfServiceUrl(): Promise { + return this.getMetaField('termsOfService') + } + + /** + * Create new account + * + * https://tools.ietf.org/html/rfc8555#section-7.3 + * + * @param {object} data Request payload. + * @param {boolean} data.termsOfServiceAgreed Whether the client agrees to the terms of service. + * @param {[]string} data.contact An array of contact info, e.g. ['mailto:admin@example.com']. + * @param {boolean} data.onlyReturnExisting Whether the server should only return an existing account, or create a new one if it does not exist. + * @returns {Promise} HTTP response. + */ + async createAccount(data: object): Promise { + const resp = await this.apiResourceRequest('newAccount', data, [200, 201], { + includeJwsKid: false, + includeExternalAccountBinding: data.onlyReturnExisting !== true, + }) + + /* Set account URL */ + if (resp.headers.get('location')) { + this.accountUrl = resp.headers.get('location') } - /** - * Sets the debug mode for the HTTP client. - * - * @param {boolean} v - Whether to enable debug mode or not. - */ - setDebug(v: boolean): void { - this.debug = v; - } + return resp + } + + /** + * Update account + * + * https://tools.ietf.org/html/rfc8555#section-7.3.2 + * + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + updateAccount(data: object): Promise { + return this.apiRequest(this.getAccountUrl(), data, [200, 202]) + } + + /** + * Update account key + * + * https://tools.ietf.org/html/rfc8555#section-7.3.5 + * + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + updateAccountKey(data: object): Promise { + return this.apiResourceRequest('keyChange', data, [200]) + } + + /** + * Create new order + * + * https://tools.ietf.org/html/rfc8555#section-7.4 + * + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + createOrder(data: object): Promise { + return this.apiResourceRequest('newOrder', data, [201]) + } + + /** + * Get order + * + * https://tools.ietf.org/html/rfc8555#section-7.4 + * + * @param {string} url Order URL + * @returns {Promise} HTTP response + */ + getOrder(url: string): Promise { + return this.apiRequest(url, null, [200]) + } + + /** + * Finalize order + * + * https://tools.ietf.org/html/rfc8555#section-7.4 + * + * @param {string} url Finalization URL + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + finalizeOrder(url: string, data: object): Promise { + return this.apiRequest(url, data, [200]) + } + + /** + * Get identifier authorization + * + * https://tools.ietf.org/html/rfc8555#section-7.5 + * + * @param {string} url Authorization URL + * @returns {Promise} HTTP response + */ + getAuthorization(url: string): Promise { + return this.apiRequest(url, null, [200]) + } + + /** + * Update identifier authorization + * + * https://tools.ietf.org/html/rfc8555#section-7.5.2 + * + * @param {string} url Authorization URL + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + updateAuthorization( + url: string, + data: UpdateAuthorizationData + ): Promise { + return this.apiRequest(url, data, [200]) + } + + /** + * Completes a pending challenge with the ACME server by sending a response payload to the challenge URL. + * + * https://tools.ietf.org/html/rfc8555#section-7.5.1 + * + * @param {string} url Challenge URL + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + completeChallenge(url: string, data: object): Promise { + return this.apiRequest(url, data, [200]) + } + + /** + * Revoke certificate + * + * https://tools.ietf.org/html/rfc8555#section-7.6 + * + * + * @param {object} data - An object containing the data needed for revocation: + * @param {string} data.certificate - The certificate to be revoked. + * @param {number} data.reason - An optional reason for revocation (default: 1). + * See this https://datatracker.ietf.org/doc/html/rfc5280#section-5.3.1 + * @returns {Promise} HTTP response + */ + revokeCert(data: object): Promise { + return this.apiResourceRequest('revokeCert', data, [200]) + } + + /** + * Set the `verify` property to enable or disable verification of the HTTPS server certificate. + * + * @param {boolean} v - The value to set `verify` to. + */ + setVerify(v: boolean) { + this.verify = v + } + + /** + * Sets the debug mode for the HTTP client. + * + * @param {boolean} v - Whether to enable debug mode or not. + */ + setDebug(v: boolean): void { + this.debug = v + } } diff --git a/src/client.ts b/src/client.ts index 995ce68..daf6c78 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,9 +1,9 @@ -import { HttpClient } from "./api"; -import { formatResponseError, getPemBodyAsB64u, retry } from "./utils"; +import { HttpClient } from './api' +import { formatResponseError, getPemBodyAsB64u, retry } from './utils' export interface ClientExternalAccountBindingOptions { - kid: string; - hmacKey: string; + kid: string + hmacKey: string } /* rfc 8555 */ @@ -15,26 +15,29 @@ export interface ClientExternalAccountBindingOptions { * https://tools.ietf.org/html/rfc8555#section-7.3.2 */ export interface Account { - status: 'valid' | 'deactivated' | 'revoked'; - orders: string; - contact?: string[]; - termsOfServiceAgreed?: boolean; - externalAccountBinding?: object; + status: 'valid' | 'deactivated' | 'revoked' + orders: string + contact?: string[] + termsOfServiceAgreed?: boolean + externalAccountBinding?: ClientExternalAccountBindingOptions } export interface AccountCreateRequest { - contact?: string[]; - termsOfServiceAgreed?: boolean; - onlyReturnExisting?: boolean; - externalAccountBinding?: object; + contact?: string[] + termsOfServiceAgreed?: boolean + onlyReturnExisting?: boolean + externalAccountBinding?: ClientExternalAccountBindingOptions } export interface AccountUpdateRequest { - status?: string; - contact?: string[]; - termsOfServiceAgreed?: boolean; + status?: string + contact?: string[] + termsOfServiceAgreed?: boolean } +interface AuthorizationResponseData { + url?: string +} /** * Order @@ -43,43 +46,47 @@ export interface AccountUpdateRequest { * https://tools.ietf.org/html/rfc8555#section-7.4 */ export interface Order { - status: 'pending' | 'ready' | 'processing' | 'valid' | 'invalid'; - identifiers: Identifier[]; - authorizations: string[]; - finalize: string; - expires?: string; - notBefore?: string; - notAfter?: string; - error?: object; - certificate?: string; + status: 'pending' | 'ready' | 'processing' | 'valid' | 'invalid' + identifiers: Identifier[] + authorizations: string[] + finalize: string + expires?: string + notBefore?: string + notAfter?: string + error?: object + certificate?: string } export interface OrderCreateRequest { - identifiers: Identifier[]; - notBefore?: string; - notAfter?: string; + identifiers: Identifier[] + notBefore?: string + notAfter?: string } - /** * Authorization * * https://tools.ietf.org/html/rfc8555#section-7.1.4 */ export interface Authorization { - identifier: Identifier; - status: 'pending' | 'valid' | 'invalid' | 'deactivated' | 'expired' | 'revoked'; - challenges: Challenge[]; - expires?: string; - wildcard?: boolean; + identifier: Identifier + status: + | 'pending' + | 'valid' + | 'invalid' + | 'deactivated' + | 'expired' + | 'revoked' + challenges: Challenge[] + expires?: string + wildcard?: boolean } export interface Identifier { - type: string; - value: string; + type: string + value: string } - /** * Challenge * @@ -88,25 +95,24 @@ export interface Identifier { * https://tools.ietf.org/html/rfc8555#section-8.4 */ export interface ChallengeAbstract { - type: string; - url: string; - status: 'pending' | 'processing' | 'valid' | 'invalid'; - validated?: string; - error?: object; + type: string + url: string + status: 'pending' | 'processing' | 'valid' | 'invalid' + validated?: string + error?: object } export interface HttpChallenge extends ChallengeAbstract { - type: 'http-01'; - token: string; + type: 'http-01' + token: string } export interface DnsChallenge extends ChallengeAbstract { - type: 'dns-01'; - token: string; + type: 'dns-01' + token: string } -export type Challenge = HttpChallenge | DnsChallenge; - +export type Challenge = HttpChallenge | DnsChallenge /** * Certificate @@ -114,40 +120,48 @@ export type Challenge = HttpChallenge | DnsChallenge; * https://tools.ietf.org/html/rfc8555#section-7.6 */ export enum CertificateRevocationReason { - Unspecified = 0, - KeyCompromise = 1, - CACompromise = 2, - AffiliationChanged = 3, - Superseded = 4, - CessationOfOperation = 5, - CertificateHold = 6, - RemoveFromCRL = 8, - PrivilegeWithdrawn = 9, - AACompromise = 10, + Unspecified = 0, + KeyCompromise = 1, + CACompromise = 2, + AffiliationChanged = 3, + Superseded = 4, + CessationOfOperation = 5, + CertificateHold = 6, + RemoveFromCRL = 8, + PrivilegeWithdrawn = 9, + AACompromise = 10, } export interface CertificateRevocationRequest { - reason?: CertificateRevocationReason; + reason?: CertificateRevocationReason } export interface ClientOptions { - directoryUrl: string; - accountKey: CryptoKey; - accountUrl?: string; - externalAccountBinding?: ClientExternalAccountBindingOptions; - backoffAttempts?: number; - backoffMin?: number; - backoffMax?: number; + directoryUrl: string + accountKey: CryptoKey + accountUrl?: string + externalAccountBinding?: ClientExternalAccountBindingOptions + backoffAttempts?: number + backoffMin?: number + backoffMax?: number } export interface ClientAutoOptions { - csr: ArrayBuffer | Buffer | String; - challengeCreateFn: (authz: Authorization, challenge: Challenge, keyAuthorization: string) => Promise; - challengeRemoveFn: (authz: Authorization, challenge: Challenge, keyAuthorization: string) => Promise; - email?: string; - termsOfServiceAgreed?: boolean; - challengePriority?: string[]; - preferredChain?: string; + csr: ArrayBuffer | Buffer | string | null + challengeCreateFn: ( + authz: Authorization, + challenge: Challenge, + keyAuthorization: string + ) => Promise + challengeRemoveFn: ( + authz: Authorization, + challenge: Challenge, + keyAuthorization: string + ) => Promise + email?: string + termsOfServiceAgreed?: boolean + challengePriority?: string[] + preferredChain?: string } /** @@ -156,10 +170,9 @@ export interface ClientAutoOptions { * @private */ -const validStates = ['ready', 'valid']; -const pendingStates = ['pending', 'processing']; -const invalidStates = ['invalid']; - +const validStates = ['ready', 'valid'] +const pendingStates = ['pending', 'processing'] +const invalidStates = ['invalid'] /** * Default options @@ -167,14 +180,14 @@ const invalidStates = ['invalid']; * @private */ const defaultOpts = { - directoryUrl: undefined, - accountKey: undefined, - accountUrl: null, - externalAccountBinding: {}, - backoffAttempts: 10, - backoffMin: 3000, - backoffMax: 30000 -}; + directoryUrl: undefined, + accountKey: undefined, + accountUrl: null, + externalAccountBinding: {}, + backoffAttempts: 10, + backoffMin: 3000, + backoffMax: 30000, +} /** * AcmeClient @@ -224,622 +237,644 @@ const defaultOpts = { * ``` */ export class AcmeClient { - opts: ClientOptions; - backoffOpts: { attempts: number | undefined; min: number | undefined; max: number | undefined; }; - api: HttpClient; - - constructor(opts: ClientOptions) { - // if (!Buffer.isBuffer(opts.accountKey)) { - // opts.accountKey = Buffer.from(opts.accountKey); - // } - - this.opts = Object.assign({}, defaultOpts, opts); - - this.backoffOpts = { - attempts: this.opts.backoffAttempts, - min: this.opts.backoffMin, - max: this.opts.backoffMax - }; - - - // FIXME accountKey - is a CryptoKey object not a PEM/string/Object... - this.api = new HttpClient(this.opts.directoryUrl, this.opts.accountKey, this.opts.accountUrl); + opts: ClientOptions + backoffOpts: { + attempts: number | undefined + min: number | undefined + max: number | undefined + } + api: HttpClient + + constructor(opts: ClientOptions) { + // if (!Buffer.isBuffer(opts.accountKey)) { + // opts.accountKey = Buffer.from(opts.accountKey); + // } + + this.opts = Object.assign({}, defaultOpts, opts) + + this.backoffOpts = { + attempts: this.opts.backoffAttempts, + min: this.opts.backoffMin, + max: this.opts.backoffMax, } - - /** - * Get Terms of Service URL if available - * - * @returns {Promise} ToS URL - * - * @example Get Terms of Service URL - * ```js - * const termsOfService = client.getTermsOfServiceUrl(); - * - * if (!termsOfService) { - * // CA did not provide Terms of Service - * } - * ``` - */ - getTermsOfServiceUrl() { - return this.api.getTermsOfServiceUrl(); + // FIXME accountKey - is a CryptoKey object not a PEM/string/Object... + this.api = new HttpClient( + this.opts.directoryUrl, + this.opts.accountKey, + this.opts.accountUrl + ) + } + + /** + * Get Terms of Service URL if available + * + * @returns {Promise} ToS URL + * + * @example Get Terms of Service URL + * ```js + * const termsOfService = client.getTermsOfServiceUrl(); + * + * if (!termsOfService) { + * // CA did not provide Terms of Service + * } + * ``` + */ + getTermsOfServiceUrl() { + return this.api.getTermsOfServiceUrl() + } + + /** + * Get current account URL + * + * @returns {string} Account URL + * @throws {Error} No account URL found + * + * @example Get current account URL + * ```js + * try { + * const accountUrl = client.getAccountUrl(); + * } + * catch (e) { + * // No account URL exists, need to create account first + * } + * ``` + */ + getAccountUrl(): string { + return this.api.getAccountUrl() + } + + /** + * Create a new account + * + * https://tools.ietf.org/html/rfc8555#section-7.3 + * + * @param {object} [data] Request data + * @returns {Promise} Account + * + * @example Create a new account + * ```js + * const account = await client.createAccount({ + * termsOfServiceAgreed: true + * }); + * ``` + * + * @example Create a new account with contact info + * ```js + * const account = await client.createAccount({ + * termsOfServiceAgreed: true, + * contact: ['mailto:test@example.com'] + * }); + * ``` + */ + async createAccount( + data: AccountCreateRequest = { + termsOfServiceAgreed: false, } - - - /** - * Get current account URL - * - * @returns {string} Account URL - * @throws {Error} No account URL found - * - * @example Get current account URL - * ```js - * try { - * const accountUrl = client.getAccountUrl(); - * } - * catch (e) { - * // No account URL exists, need to create account first - * } - * ``` - */ - getAccountUrl(): string { - return this.api.getAccountUrl(); + ): Promise { + try { + this.getAccountUrl() + + /* Account URL exists */ + ngx.log(ngx.INFO, 'njs-acme: [client] Account URL exists, updating it...') + return await this.updateAccount(data) + } catch (e) { + const resp = await this.api.createAccount(data) + + /* HTTP 200: Account exists */ + if (resp.status === 200) { + ngx.log( + ngx.INFO, + 'njs-acme: [client] Account already exists (HTTP 200), updating it...' + ) + return await this.updateAccount(data) + } + return await resp.json() } - - - /** - * Create a new account - * - * https://tools.ietf.org/html/rfc8555#section-7.3 - * - * @param {object} [data] Request data - * @returns {Promise} Account - * - * @example Create a new account - * ```js - * const account = await client.createAccount({ - * termsOfServiceAgreed: true - * }); - * ``` - * - * @example Create a new account with contact info - * ```js - * const account = await client.createAccount({ - * termsOfServiceAgreed: true, - * contact: ['mailto:test@example.com'] - * }); - * ``` - */ - async createAccount(data: AccountCreateRequest = { - termsOfServiceAgreed: false - }): Promise { - try { - this.getAccountUrl(); - - /* Account URL exists */ - ngx.log(ngx.INFO, 'njs-acme: [client] Account URL exists, updating it...'); - return await this.updateAccount(data); - } - catch (e) { - const resp = await this.api.createAccount(data); - - /* HTTP 200: Account exists */ - if (resp.status === 200) { - ngx.log(ngx.INFO, 'njs-acme: [client] Account already exists (HTTP 200), updating it...'); - return await this.updateAccount(data); - } - return await resp.json(); - } + } + + /** + * Update existing account + * + * https://tools.ietf.org/html/rfc8555#section-7.3.2 + * + * @param {object} [data] Request data + * @returns {Promise} Account + * + * @example Update existing account + * ```js + * const account = await client.updateAccount({ + * contact: ['mailto:foo@example.com'] + * }); + * ``` + */ + async updateAccount(data: AccountUpdateRequest = {}) { + try { + this.api.getAccountUrl() + } catch (e) { + return this.createAccount(data) } - - /** - * Update existing account - * - * https://tools.ietf.org/html/rfc8555#section-7.3.2 - * - * @param {object} [data] Request data - * @returns {Promise} Account - * - * @example Update existing account - * ```js - * const account = await client.updateAccount({ - * contact: ['mailto:foo@example.com'] - * }); - * ``` - */ - async updateAccount(data: AccountUpdateRequest = {}) { - try { - this.api.getAccountUrl(); - } - catch (e) { - return this.createAccount(data); - } - - /* Remove data only applicable to createAccount() */ - if ('onlyReturnExisting' in data) { - delete data.onlyReturnExisting; - } - - /* POST-as-GET */ - if (Object.keys(data).length === 0) { - data = null; - } - - const resp = await this.api.updateAccount(data); - return await resp.json(); + /* Remove data only applicable to createAccount() */ + if ('onlyReturnExisting' in data) { + delete data.onlyReturnExisting } - - /** - * Update account private key - * - * https://tools.ietf.org/html/rfc8555#section-7.3.5 - * - * @param {CryptoKey|buffer|string} newAccountKey New account private key - * @param {object} [data] Additional request data - * @returns {Promise} Account - * - * @example Update account private key - * ```js - * const newAccountKey = 'New private key goes here'; - * const result = await client.updateAccountKey(newAccountKey); - * ``` - */ - async updateAccountKey(newAccountKey: CryptoKey | string | Buffer, data = {}) { - // FIXME: if string | Buffer then handle reading from PEM - - if (Buffer.isBuffer(newAccountKey) || (typeof newAccountKey === 'string')) { - newAccountKey = Buffer.from(newAccountKey); - } - - const accountUrl = this.api.getAccountUrl(); - - // FIXME - const newCryptoKey = null; - - /* Create new HTTP and API clients using new key */ - const newHttpClient = new HttpClient(this.opts.directoryUrl, newCryptoKey, accountUrl); - - /* Get old JWK */ - data.account = accountUrl; - data.oldKey = this.api.getJwk(); - - /* Get signed request body from new client */ - const url = await newHttpClient.getResourceUrl('keyChange'); - const body = newHttpClient.createSignedBody(url, data); - - /* Change key using old client */ - const resp = await this.api.updateAccountKey(body); - - /* Replace existing HTTP and API client */ - this.api = newHttpClient; - - // FIXME - return await resp.json(); + /* POST-as-GET */ + if (Object.keys(data).length === 0) { + data = null } - - /** - * Create a new order - * - * https://tools.ietf.org/html/rfc8555#section-7.4 - * - * @param {object} data Request data - * @returns {Promise} Order - * - * @example Create a new order - * ```js - * const order = await client.createOrder({ - * identifiers: [ - * { type: 'dns', value: 'example.com' }, - * { type: 'dns', value: 'test.example.com' } - * ] - * }); - * ``` - */ - async createOrder(data: OrderCreateRequest) { - const resp = await this.api.createOrder(data); - - if (!resp.headers.get("location")) { - throw new Error('Creating a new order did not return an order link'); - } - - // FIXME - /* Add URL to response */ - let respData = await resp.json() - respData.url = resp.headers.get("location"); - return respData; + const resp = await this.api.updateAccount(data) + return await resp.json() + } + + /** + * Update account private key + * + * https://tools.ietf.org/html/rfc8555#section-7.3.5 + * + * @param {CryptoKey|buffer|string} newAccountKey New account private key + * @param {object} [data] Additional request data + * @returns {Promise} Account + * + * @example Update account private key + * ```js + * const newAccountKey = 'New private key goes here'; + * const result = await client.updateAccountKey(newAccountKey); + * ``` + */ + async updateAccountKey( + newAccountKey: CryptoKey | string | Buffer, + data = {} + ) { + // FIXME: if string | Buffer then handle reading from PEM + + if (Buffer.isBuffer(newAccountKey) || typeof newAccountKey === 'string') { + newAccountKey = Buffer.from(newAccountKey) } - - /** - * Refresh order object from CA - * - * https://tools.ietf.org/html/rfc8555#section-7.4 - * - * @param {object} order Order object - * @returns {Promise} Order - * - * @example - * ```js - * const order = { ... }; // Previously created order object - * const result = await client.getOrder(order); - * ``` - */ - async getOrder(order: Order) { - if (!order.url) { - throw new Error('Unable to get order, URL not found'); - } - - const resp = await this.api.getOrder(order.url); - - /* Add URL to response */ - let respData = await resp.json(); - respData.url = order.url; - return respData; + const accountUrl = this.api.getAccountUrl() + + // FIXME + const newCryptoKey = null + + /* Create new HTTP and API clients using new key */ + const newHttpClient = new HttpClient( + this.opts.directoryUrl, + newCryptoKey, + accountUrl + ) + + /* Get old JWK */ + data.account = accountUrl + data.oldKey = this.api.getJwk() + + /* Get signed request body from new client */ + const url = await newHttpClient.getResourceUrl('keyChange') + const body = newHttpClient.createSignedBody(url, data) + + /* Change key using old client */ + const resp = await this.api.updateAccountKey(body) + + /* Replace existing HTTP and API client */ + this.api = newHttpClient + + // FIXME + return await resp.json() + } + + /** + * Create a new order + * + * https://tools.ietf.org/html/rfc8555#section-7.4 + * + * @param {object} data Request data + * @returns {Promise} Order + * + * @example Create a new order + * ```js + * const order = await client.createOrder({ + * identifiers: [ + * { type: 'dns', value: 'example.com' }, + * { type: 'dns', value: 'test.example.com' } + * ] + * }); + * ``` + */ + async createOrder(data: OrderCreateRequest) { + const resp = await this.api.createOrder(data) + + if (!resp.headers.get('location')) { + throw new Error('Creating a new order did not return an order link') } - /** - * Finalize order - * - * https://tools.ietf.org/html/rfc8555#section-7.4 - * - * @param {object} order Order object - * @param {buffer|string} csr PEM encoded Certificate Signing Request - * @returns {Promise} Order - * - * @example Finalize order - * ```js - * const order = { ... }; // Previously created order object - * const csr = { ... }; // Previously created Certificate Signing Request - * const result = await client.finalizeOrder(order, csr); - * ``` - */ - async finalizeOrder(order: Order, csr: Buffer | string) { - if (!order.finalize) { - throw new Error('Unable to finalize order, URL not found'); - } - - if (!Buffer.isBuffer(csr)) { - csr = Buffer.from(csr); - } - - // FIXME - const data = { csr: getPemBodyAsB64u(csr) }; - let resp; - try { - resp = await this.api.finalizeOrder(order.finalize, data); - } catch (e) { - ngx.log(ngx.WARN, `njs-acme: [client] finalize order failed: ${e}`); - throw e; - } - /* Add URL to response */ - const respData = await resp.json(); - respData.url = order.url; - return respData; + // FIXME + /* Add URL to response */ + const respData = await resp.json() + respData.url = resp.headers.get('location') + return respData + } + + /** + * Refresh order object from CA + * + * https://tools.ietf.org/html/rfc8555#section-7.4 + * + * @param {object} order Order object + * @returns {Promise} Order + * + * @example + * ```js + * const order = { ... }; // Previously created order object + * const result = await client.getOrder(order); + * ``` + */ + async getOrder(order: Order) { + if (!order.url) { + throw new Error('Unable to get order, URL not found') } - - /** - * Get identifier authorizations from order - * - * https://tools.ietf.org/html/rfc8555#section-7.5 - * - * @param {object} order Order - * @returns {Promise} Authorizations - * - * @example Get identifier authorizations - * ```js - * const order = { ... }; // Previously created order object - * const authorizations = await client.getAuthorizations(order); - * - * authorizations.forEach((authz) => { - * const { challenges } = authz; - * }); - * ``` - */ - async getAuthorizations(order) { - return Promise.all((order.authorizations || []).map(async (url) => { - const resp = await this.api.getAuthorization(url); - const respData = await resp.json(); - /* Add URL to response */ - respData.url = url; - return respData; - })); + const resp = await this.api.getOrder(order.url) + + /* Add URL to response */ + const respData = await resp.json() + respData.url = order.url + return respData + } + + /** + * Finalize order + * + * https://tools.ietf.org/html/rfc8555#section-7.4 + * + * @param {object} order Order object + * @param {buffer|string} csr PEM encoded Certificate Signing Request + * @returns {Promise} Order + * + * @example Finalize order + * ```js + * const order = { ... }; // Previously created order object + * const csr = { ... }; // Previously created Certificate Signing Request + * const result = await client.finalizeOrder(order, csr); + * ``` + */ + async finalizeOrder(order: Order, csr: Buffer | string) { + if (!order.finalize) { + throw new Error('Unable to finalize order, URL not found') } + if (!Buffer.isBuffer(csr)) { + csr = Buffer.from(csr) + } - /** - * Deactivate identifier authorization - * - * https://tools.ietf.org/html/rfc8555#section-7.5.2 - * - * @param {object} authz Identifier authorization - * @returns {Promise} Authorization - * - * @example Deactivate identifier authorization - * ```js - * const authz = { ... }; // Identifier authorization resolved from previously created order - * const result = await client.deactivateAuthorization(authz); - * ``` - */ - async deactivateAuthorization(authz) { - if (!authz.url) { - throw new Error('Unable to deactivate identifier authorization, URL not found'); - } - - const data = { - status: 'deactivated' - }; - - const resp = await this.api.updateAuthorization(authz.url, data); - + // FIXME + const data = { csr: getPemBodyAsB64u(csr) } + let resp + try { + resp = await this.api.finalizeOrder(order.finalize, data) + } catch (e) { + ngx.log(ngx.WARN, `njs-acme: [client] finalize order failed: ${e}`) + throw e + } + /* Add URL to response */ + const respData = await resp.json() + respData.url = order.url + return respData + } + + /** + * Get identifier authorizations from order + * + * https://tools.ietf.org/html/rfc8555#section-7.5 + * + * @param {object} order Order + * @returns {Promise} Authorizations + * + * @example Get identifier authorizations + * ```js + * const order = { ... }; // Previously created order object + * const authorizations = await client.getAuthorizations(order); + * + * authorizations.forEach((authz) => { + * const { challenges } = authz; + * }); + * ``` + */ + async getAuthorizations(order: Order): Promise { + return Promise.all( + (order.authorizations || []).map(async (url) => { + const resp = await this.api.getAuthorization(url) + const respData = (await resp.json()) as AuthorizationResponseData /* Add URL to response */ - const respData = await resp.json(); - respData.url = authz.url; - return respData; + respData.url = url + return respData + }) + ) + } + + /** + * Deactivate identifier authorization + * + * https://tools.ietf.org/html/rfc8555#section-7.5.2 + * + * @param {object} authz Identifier authorization + * @returns {Promise} Authorization + * + * @example Deactivate identifier authorization + * ```js + * const authz = { ... }; // Identifier authorization resolved from previously created order + * const result = await client.deactivateAuthorization(authz); + * ``` + */ + async deactivateAuthorization(authz) { + if (!authz.url) { + throw new Error( + 'Unable to deactivate identifier authorization, URL not found' + ) } - - /** - * Get key authorization for ACME challenge - * - * https://tools.ietf.org/html/rfc8555#section-8.1 - * - * @param {object} challenge Challenge object returned by API - * @returns {Promise} Key authorization - * - * @example Get challenge key authorization - * ```js - * const challenge = { ... }; // Challenge from previously resolved identifier authorization - * const key = await client.getChallengeKeyAuthorization(challenge); - * - * // Write key somewhere to satisfy challenge - * ``` - */ - async getChallengeKeyAuthorization(challenge) { - const jwk = await this.api.getJwk(); - - var keysum = require('crypto').createHash('sha256').update(JSON.stringify(jwk)); - const thumbprint = keysum.digest('base64url'); - const result = `${challenge.token}.${thumbprint}`; - - /** - * https://tools.ietf.org/html/rfc8555#section-8.3 - */ - if (challenge.type === 'http-01') { - return result; - } - - /** - * https://tools.ietf.org/html/rfc8555#section-8.4 - * https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01 - */ - if ((challenge.type === 'dns-01') || (challenge.type === 'tls-alpn-01')) { - throw new Error(`Unsupported challenge type: ${challenge.type}`); - } - - throw new Error(`Unable to produce key authorization, unknown challenge type: ${challenge.type}`); + const data = { + status: 'deactivated', } + const resp = await this.api.updateAuthorization(authz.url, data) + + /* Add URL to response */ + const respData = await resp.json() + respData.url = authz.url + return respData + } + + /** + * Get key authorization for ACME challenge + * + * https://tools.ietf.org/html/rfc8555#section-8.1 + * + * @param {object} challenge Challenge object returned by API + * @returns {Promise} Key authorization + * + * @example Get challenge key authorization + * ```js + * const challenge = { ... }; // Challenge from previously resolved identifier authorization + * const key = await client.getChallengeKeyAuthorization(challenge); + * + * // Write key somewhere to satisfy challenge + * ``` + */ + async getChallengeKeyAuthorization(challenge) { + const jwk = await this.api.getJwk() + + const keysum = require('crypto') + .createHash('sha256') + .update(JSON.stringify(jwk)) + const thumbprint = keysum.digest('base64url') + const result = `${challenge.token}.${thumbprint}` /** - * Notify CA that challenge has been completed - * - * https://tools.ietf.org/html/rfc8555#section-7.5.1 - * - * @param {object} challenge Challenge object returned by API - * @returns {Promise} Challenge - * - * @example Notify CA that challenge has been completed - * ```js - * const challenge = { ... }; // Satisfied challenge - * const result = await client.completeChallenge(challenge); - * ``` + * https://tools.ietf.org/html/rfc8555#section-8.3 */ - async completeChallenge(challenge) { - const resp = await this.api.completeChallenge(challenge.url, {}); - return await resp.json(); + if (challenge.type === 'http-01') { + return result } - /** - * Wait for ACME provider to verify status on a order, authorization or challenge - * - * https://tools.ietf.org/html/rfc8555#section-7.5.1 - * - * @param {object} item An order, authorization or challenge object - * @returns {Promise} Valid order, authorization or challenge - * - * @example Wait for valid challenge status - * ```js - * const challenge = { ... }; - * await client.waitForValidStatus(challenge); - * ``` - * - * @example Wait for valid authoriation status - * ```js - * const authz = { ... }; - * await client.waitForValidStatus(authz); - * ``` - * - * @example Wait for valid order status - * ```js - * const order = { ... }; - * await client.waitForValidStatus(order); - * ``` + * https://tools.ietf.org/html/rfc8555#section-8.4 + * https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01 */ - async waitForValidStatus(item) { - if (!item.url) { - throw new Error('Unable to verify status of item, URL not found'); - } - - const verifyFn = async (abort) => { - const resp = await this.api.apiRequest(item.url, null, [200]); - - /* Verify status */ - const respData = await resp.json(); - ngx.log(ngx.INFO, `njs-acme: [client] Item has status: ${respData.status}`); - - if (invalidStates.includes(respData.status)) { - abort(); - throw new Error(formatResponseError(respData)); - } - else if (pendingStates.includes(respData.status)) { - throw new Error('Operation is pending or processing'); - } - else if (validStates.includes(respData.status)) { - return respData; - } - - throw new Error(`Unexpected item status: ${respData.status}`); - }; - - ngx.log(ngx.INFO, `njs-acme: [client] Waiting for valid status from: ${item.url} ${this.backoffOpts}`); - return retry(verifyFn, this.backoffOpts); + if (challenge.type === 'dns-01' || challenge.type === 'tls-alpn-01') { + throw new Error(`Unsupported challenge type: ${challenge.type}`) } + throw new Error( + `Unable to produce key authorization, unknown challenge type: ${challenge.type}` + ) + } + + /** + * Notify CA that challenge has been completed + * + * https://tools.ietf.org/html/rfc8555#section-7.5.1 + * + * @param {object} challenge Challenge object returned by API + * @returns {Promise} Challenge + * + * @example Notify CA that challenge has been completed + * ```js + * const challenge = { ... }; // Satisfied challenge + * const result = await client.completeChallenge(challenge); + * ``` + */ + async completeChallenge(challenge) { + const resp = await this.api.completeChallenge(challenge.url, {}) + return await resp.json() + } + + /** + * Wait for ACME provider to verify status on a order, authorization or challenge + * + * https://tools.ietf.org/html/rfc8555#section-7.5.1 + * + * @param {object} item An order, authorization or challenge object + * @returns {Promise} Valid order, authorization or challenge + * + * @example Wait for valid challenge status + * ```js + * const challenge = { ... }; + * await client.waitForValidStatus(challenge); + * ``` + * + * @example Wait for valid authoriation status + * ```js + * const authz = { ... }; + * await client.waitForValidStatus(authz); + * ``` + * + * @example Wait for valid order status + * ```js + * const order = { ... }; + * await client.waitForValidStatus(order); + * ``` + */ + async waitForValidStatus(item) { + if (!item.url) { + throw new Error('Unable to verify status of item, URL not found') + } - /** - * Get certificate from ACME order - * - * https://tools.ietf.org/html/rfc8555#section-7.4.2 - * - * @param {object} order Order object - * @param {string} [preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null` - * @returns {Promise} Certificate - * - * @example Get certificate - * ```js - * const order = { ... }; // Previously created order - * const certificate = await client.getCertificate(order); - * ``` - * - * @example Get certificate with preferred chain - * ```js - * const order = { ... }; // Previously created order - * const certificate = await client.getCertificate(order, 'DST Root CA X3'); - * ``` - */ - async getCertificate(order, preferredChain = null): Promise{ - if (!validStates.includes(order.status)) { - order = await this.waitForValidStatus(order); - } - - if (!order.certificate) { - throw new Error('Unable to download certificate, URL not found'); - } - - const resp = await this.api.apiRequest(order.certificate, null, [200]); - - /* Handle alternate certificate chains */ - if (preferredChain && resp.headers.link) { - const alternateLinks = util.parseLinkHeader(resp.headers.link); - const alternates = await Promise.all(alternateLinks.map(async (link) => this.api.apiRequest(link, null, [200]))); - const certificates = [resp].concat(alternates).map((c) => c.data); - - return util.findCertificateChainForIssuer(certificates, preferredChain); - } - - /* Return default certificate chain */ - // FIXME: is it json() or text() - return await resp.text(); + const verifyFn = async (abort) => { + const resp = await this.api.apiRequest(item.url, null, [200]) + + /* Verify status */ + const respData = await resp.json() + ngx.log( + ngx.INFO, + `njs-acme: [client] Item has status: ${respData.status}` + ) + + if (invalidStates.includes(respData.status)) { + abort() + throw new Error(formatResponseError(respData)) + } else if (pendingStates.includes(respData.status)) { + throw new Error('Operation is pending or processing') + } else if (validStates.includes(respData.status)) { + return respData + } + + throw new Error(`Unexpected item status: ${respData.status}`) } + ngx.log( + ngx.INFO, + `njs-acme: [client] Waiting for valid status from: ${item.url} ${this.backoffOpts}` + ) + return retry(verifyFn, this.backoffOpts) + } + + /** + * Get certificate from ACME order + * + * https://tools.ietf.org/html/rfc8555#section-7.4.2 + * + * @param {object} order Order object + * @param {string} [preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null` + * @returns {Promise} Certificate + * + * @example Get certificate + * ```js + * const order = { ... }; // Previously created order + * const certificate = await client.getCertificate(order); + * ``` + * + * @example Get certificate with preferred chain + * ```js + * const order = { ... }; // Previously created order + * const certificate = await client.getCertificate(order, 'DST Root CA X3'); + * ``` + */ + async getCertificate(order, preferredChain = null): Promise { + if (!validStates.includes(order.status)) { + order = await this.waitForValidStatus(order) + } - /** - * Revoke certificate - * - * https://tools.ietf.org/html/rfc8555#section-7.6 - * - * @param {buffer|string} cert PEM encoded certificate - * @param {object} [data] Additional request data - * @returns {Promise} - * - * @example Revoke certificate - * ```js - * const certificate = { ... }; // Previously created certificate - * const result = await client.revokeCertificate(certificate); - * ``` - * - * @example Revoke certificate with reason - * ```js - * const certificate = { ... }; // Previously created certificate - * const result = await client.revokeCertificate(certificate, { - * reason: 4 - * }); - * ``` - */ - async revokeCertificate(cert, data = {}) { - data.certificate = getPemBodyAsB64u(cert); - const resp = await this.api.revokeCert(data); - return await resp.json(); + if (!order.certificate) { + throw new Error('Unable to download certificate, URL not found') } + const resp = await this.api.apiRequest(order.certificate, null, [200]) - /** - * Auto mode - * - * @param {object} opts - * @param {buffer|string} opts.csr Certificate Signing Request - * @param {function} opts.challengeCreateFn Function returning Promise triggered before completing ACME challenge - * @param {function} opts.challengeRemoveFn Function returning Promise triggered after completing ACME challenge - * @param {string} [opts.email] Account email address - * @param {boolean} [opts.termsOfServiceAgreed] Agree to Terms of Service, default: `false` - * @param {string[]} [opts.challengePriority] Array defining challenge type priority, default: `['http-01', 'dns-01']` - * @param {string} [opts.preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null` - * @returns {Promise} Certificate - * - * @example Order a certificate using auto mode - * ```js - * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({ - * commonName: 'test.example.com' - * }); - * - * const certificate = await client.auto({ - * csr: certificateRequest, - * email: 'test@example.com', - * termsOfServiceAgreed: true, - * challengeCreateFn: async (authz, challenge, keyAuthorization) => { - * // Satisfy challenge here - * }, - * challengeRemoveFn: async (authz, challenge, keyAuthorization) => { - * // Clean up challenge here - * } - * }); - * ``` - * - * @example Order a certificate using auto mode with preferred chain - * ```js - * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({ - * commonName: 'test.example.com' - * }); - * - * const certificate = await client.auto({ - * csr: certificateRequest, - * email: 'test@example.com', - * termsOfServiceAgreed: true, - * preferredChain: 'DST Root CA X3', - * challengeCreateFn: async () => {}, - * challengeRemoveFn: async () => {} - * }); - * ``` - */ - auto(opts: ClientAutoOptions) { - return auto(this, opts); + /* Handle alternate certificate chains */ + if (preferredChain && resp.headers.link) { + const alternateLinks = util.parseLinkHeader(resp.headers.link) + const alternates = await Promise.all( + alternateLinks.map(async (link) => + this.api.apiRequest(link, null, [200]) + ) + ) + const certificates = [resp].concat(alternates).map((c) => c.data) + + return util.findCertificateChainForIssuer(certificates, preferredChain) } + + /* Return default certificate chain */ + // FIXME: is it json() or text() + return await resp.text() + } + + /** + * Revoke certificate + * + * https://tools.ietf.org/html/rfc8555#section-7.6 + * + * @param {buffer|string} cert PEM encoded certificate + * @param {object} [data] Additional request data + * @returns {Promise} + * + * @example Revoke certificate + * ```js + * const certificate = { ... }; // Previously created certificate + * const result = await client.revokeCertificate(certificate); + * ``` + * + * @example Revoke certificate with reason + * ```js + * const certificate = { ... }; // Previously created certificate + * const result = await client.revokeCertificate(certificate, { + * reason: 4 + * }); + * ``` + */ + async revokeCertificate(cert: Buffer | string, data = {}) { + data.certificate = getPemBodyAsB64u(cert) + const resp = await this.api.revokeCert(data) + return await resp.json() + } + + /** + * Auto mode + * + * @param {object} opts + * @param {buffer|string} opts.csr Certificate Signing Request + * @param {function} opts.challengeCreateFn Function returning Promise triggered before completing ACME challenge + * @param {function} opts.challengeRemoveFn Function returning Promise triggered after completing ACME challenge + * @param {string} [opts.email] Account email address + * @param {boolean} [opts.termsOfServiceAgreed] Agree to Terms of Service, default: `false` + * @param {string[]} [opts.challengePriority] Array defining challenge type priority, default: `['http-01', 'dns-01']` + * @param {string} [opts.preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null` + * @returns {Promise} Certificate + * + * @example Order a certificate using auto mode + * ```js + * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({ + * commonName: 'test.example.com' + * }); + * + * const certificate = await client.auto({ + * csr: certificateRequest, + * email: 'test@example.com', + * termsOfServiceAgreed: true, + * challengeCreateFn: async (authz, challenge, keyAuthorization) => { + * // Satisfy challenge here + * }, + * challengeRemoveFn: async (authz, challenge, keyAuthorization) => { + * // Clean up challenge here + * } + * }); + * ``` + * + * @example Order a certificate using auto mode with preferred chain + * ```js + * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({ + * commonName: 'test.example.com' + * }); + * + * const certificate = await client.auto({ + * csr: certificateRequest, + * email: 'test@example.com', + * termsOfServiceAgreed: true, + * preferredChain: 'DST Root CA X3', + * challengeCreateFn: async () => {}, + * challengeRemoveFn: async () => {} + * }); + * ``` + */ + auto(opts: ClientAutoOptions) { + return auto(this, opts) + } } -const autoDefaultOpts = { - csr: null, - email: null, - preferredChain: null, - termsOfServiceAgreed: false, - challengePriority: ['http-01'], - challengeCreateFn: async () => { throw new Error('Missing challengeCreateFn()'); }, - challengeRemoveFn: async () => { throw new Error('Missing challengeRemoveFn()'); } -}; +const autoDefaultOpts: ClientAutoOptions = { + csr: null, + email: undefined, + preferredChain: undefined, + termsOfServiceAgreed: false, + challengePriority: ['http-01'], + challengeCreateFn: async () => { + throw new Error('Missing challengeCreateFn()') + }, + challengeRemoveFn: async () => { + throw new Error('Missing challengeRemoveFn()') + }, +} /** * ACME client auto mode @@ -848,137 +883,169 @@ const autoDefaultOpts = { * @param {ClientAutoOptions} userOpts Options * @returns {Promise} Certificate */ -async function auto(client: AcmeClient, userOpts: ClientAutoOptions): Promise { - const opts = Object.assign({}, autoDefaultOpts, userOpts); - const accountPayload = { termsOfServiceAgreed: opts.termsOfServiceAgreed }; - - if (!Buffer.isBuffer(opts.csr)) { - opts.csr = Buffer.from(opts.csr); - } - - if (opts.email) { - accountPayload.contact = [`mailto:${opts.email}`]; +async function auto( + client: AcmeClient, + userOpts: ClientAutoOptions +): Promise { + const opts = Object.assign({}, autoDefaultOpts, userOpts) + const accountPayload = { termsOfServiceAgreed: opts.termsOfServiceAgreed } + + if (!Buffer.isBuffer(opts.csr)) { + opts.csr = Buffer.from(opts.csr) + } + + if (opts.email) { + accountPayload.contact = [`mailto:${opts.email}`] + } + + /** + * Register account + */ + ngx.log(ngx.INFO, 'njs-acme: [auto] Checking account') + + try { + client.getAccountUrl() + ngx.log( + ngx.INFO, + 'njs-acme: [auto] Account URL already exists, skipping account registration' + ) + } catch (e) { + ngx.log(ngx.INFO, 'njs-acme: [auto] Registering account') + await client.createAccount(accountPayload) + } + + /** + * Parse domains from CSR + */ + // FIXME implement parsing CSR to get a list of domain... + ngx.log( + ngx.INFO, + 'njs-acme: [auto] Parsing domains from Certificate Signing Request' + ) + // const csrDomains = readCsrDomains(opts.csr); + // const domains = [csrDomains.commonName].concat(csrDomains.altNames); + // const uniqueDomains = Array.from(new Set(domains)); + + const uniqueDomains = ['proxy.nginx.com'] + ngx.log( + ngx.INFO, + `njs-acme: [auto] Resolved ${uniqueDomains.length} unique domains from parsing the Certificate Signing Request` + ) + + /** + * Place order + */ + const orderPayload = { + identifiers: uniqueDomains.map((d) => ({ type: 'dns', value: d })), + } + const order = await client.createOrder(orderPayload) + const authorizations = await client.getAuthorizations(order) + ngx.log(ngx.INFO, `njs-acme: [auto] Placed certificate order successfully`) + + /** + * Resolve and satisfy challenges + */ + ngx.log( + ngx.INFO, + 'njs-acme: [auto] Resolving and satisfying authorization challenges' + ) + + const challengePromises = authorizations.map(async (authz) => { + const d = authz.identifier.value + let challengeCompleted = false + + /* Skip authz that already has valid status */ + if (authz.status === 'valid') { + ngx.log( + ngx.INFO, + `njs-acme: [auto] [${d}] Authorization already has valid status, no need to complete challenges` + ) + return } - - /** - * Register account - */ - ngx.log(ngx.INFO, 'njs-acme: [auto] Checking account'); - try { - client.getAccountUrl(); - ngx.log(ngx.INFO, 'njs-acme: [auto] Account URL already exists, skipping account registration'); - } - catch (e) { - ngx.log(ngx.INFO, 'njs-acme: [auto] Registering account'); - await client.createAccount(accountPayload); - } - - - /** - * Parse domains from CSR - */ - // FIXME implement parsing CSR to get a list of domain... - ngx.log(ngx.INFO, 'njs-acme: [auto] Parsing domains from Certificate Signing Request'); - // const csrDomains = readCsrDomains(opts.csr); - // const domains = [csrDomains.commonName].concat(csrDomains.altNames); - // const uniqueDomains = Array.from(new Set(domains)); - - const uniqueDomains = ["proxy.nginx.com"]; - ngx.log(ngx.INFO, `njs-acme: [auto] Resolved ${uniqueDomains.length} unique domains from parsing the Certificate Signing Request`); - - - /** - * Place order - */ - const orderPayload = { identifiers: uniqueDomains.map((d) => ({ type: 'dns', value: d })) }; - const order = await client.createOrder(orderPayload); - const authorizations = await client.getAuthorizations(order); - ngx.log(ngx.INFO, `njs-acme: [auto] Placed certificate order successfully`); - - /** - * Resolve and satisfy challenges - */ - ngx.log(ngx.INFO, 'njs-acme: [auto] Resolving and satisfying authorization challenges'); - - const challengePromises = authorizations.map(async (authz) => { - const d = authz.identifier.value; - let challengeCompleted = false; - - /* Skip authz that already has valid status */ - if (authz.status === 'valid') { - ngx.log(ngx.INFO, `njs-acme: [auto] [${d}] Authorization already has valid status, no need to complete challenges`); - return; + /* Select challenge based on priority */ + const challenge = authz.challenges + .sort((a, b) => { + const aidx = opts.challengePriority!.indexOf(a.type) + const bidx = opts.challengePriority!.indexOf(b.type) + + if (aidx === -1) return 1 + if (bidx === -1) return -1 + return aidx - bidx + }) + .slice(0, 1)[0] + + if (!challenge) { + throw new Error( + `Unable to select challenge for ${d}, no challenge found` + ) + } + + ngx.log( + ngx.INFO, + `njs-acme: [auto] [${d}] Found ${authz.challenges.length} challenges, selected type: ${challenge.type}` + ) + + /* Trigger challengeCreateFn() */ + const keyAuthorization = await client.getChallengeKeyAuthorization( + challenge + ) + + try { + await opts.challengeCreateFn(authz, challenge, keyAuthorization) + + /* Complete challenge and wait for valid status */ + ngx.log( + ngx.INFO, + `njs-acme: [auto] [${d}] Completing challenge with ACME provider and waiting for valid status` + ) + await client.completeChallenge(challenge) + challengeCompleted = true + + await client.waitForValidStatus(challenge) + } finally { + /* Trigger challengeRemoveFn(), suppress errors */ + try { + await opts.challengeRemoveFn(authz, challenge, keyAuthorization) + } catch (e) { + ngx.log( + ngx.INFO, + `njs-acme: [auto] [${d}] challengeRemoveFn threw error: ${e.message}` + ) } + } + } catch (e) { + /* Deactivate pending authz when unable to complete challenge */ + if (!challengeCompleted) { + ngx.log( + ngx.INFO, + `njs-acme: [auto] [${d}] Unable to complete challenge: ${e.message}` + ) try { - /* Select challenge based on priority */ - const challenge = authz.challenges.sort((a, b) => { - const aidx = opts.challengePriority!.indexOf(a.type); - const bidx = opts.challengePriority!.indexOf(b.type); - - if (aidx === -1) return 1; - if (bidx === -1) return -1; - return aidx - bidx; - }).slice(0, 1)[0]; - - if (!challenge) { - throw new Error(`Unable to select challenge for ${d}, no challenge found`); - } - - ngx.log(ngx.INFO, `njs-acme: [auto] [${d}] Found ${authz.challenges.length} challenges, selected type: ${challenge.type}`); - - /* Trigger challengeCreateFn() */ - const keyAuthorization = await client.getChallengeKeyAuthorization(challenge); - - try { - await opts.challengeCreateFn(authz, challenge, keyAuthorization); - - /* Complete challenge and wait for valid status */ - ngx.log(ngx.INFO, `njs-acme: [auto] [${d}] Completing challenge with ACME provider and waiting for valid status`); - await client.completeChallenge(challenge); - challengeCompleted = true; - - await client.waitForValidStatus(challenge); - } - finally { - /* Trigger challengeRemoveFn(), suppress errors */ - try { - await opts.challengeRemoveFn(authz, challenge, keyAuthorization); - } - catch (e) { - ngx.log(ngx.INFO, `njs-acme: [auto] [${d}] challengeRemoveFn threw error: ${e.message}`); - } - } - } - catch (e) { - /* Deactivate pending authz when unable to complete challenge */ - if (!challengeCompleted) { - ngx.log(ngx.INFO, `njs-acme: [auto] [${d}] Unable to complete challenge: ${e.message}`); - - try { - await client.deactivateAuthorization(authz); - } - catch (f) { - /* Suppress deactivateAuthorization() errors */ - ngx.log(ngx.INFO, `njs-acme: [auto] [${d}] Authorization deactivation threw error: ${f.message}`); - } - } - - throw e; + await client.deactivateAuthorization(authz) + } catch (f) { + /* Suppress deactivateAuthorization() errors */ + ngx.log( + ngx.INFO, + `njs-acme: [auto] [${d}] Authorization deactivation threw error: ${f.message}` + ) } - }); - - ngx.log(ngx.INFO, 'njs-acme: [auto] Waiting for challenge valid status'); - await Promise.all(challengePromises); - + } - /** - * Finalize order and download certificate - */ - ngx.log(ngx.INFO, 'njs-acme: [auto] Finalize order and download certificate'); - const finalized = await client.finalizeOrder(order, opts.csr); - const certData = await client.getCertificate(finalized, opts.preferredChain); - return certData; -}; + throw e + } + }) + + ngx.log(ngx.INFO, 'njs-acme: [auto] Waiting for challenge valid status') + await Promise.all(challengePromises) + + /** + * Finalize order and download certificate + */ + ngx.log(ngx.INFO, 'njs-acme: [auto] Finalize order and download certificate') + const finalized = await client.finalizeOrder(order, opts.csr) + const certData = await client.getCertificate(finalized, opts.preferredChain) + return certData +} diff --git a/src/index.ts b/src/index.ts index 3394297..03ac28c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,37 +1,50 @@ -import { toPEM, readOrCreateAccountKey, generateKey, createCsr, readCertificateInfo, acmeServerNames, getVariable, joinPaths, acmeDir, acmeAccountPrivateJWKPath, acmeDirectoryURI, acmeVerifyProviderHTTPS } from './utils' +import { + toPEM, + readOrCreateAccountKey, + generateKey, + createCsr, + readCertificateInfo, + acmeServerNames, + getVariable, + joinPaths, + acmeDir, + acmeAccountPrivateJWKPath, + acmeDirectoryURI, + acmeVerifyProviderHTTPS, +} from './utils' import { HttpClient } from './api' import { AcmeClient } from './client' -import fs from 'fs'; +import fs from 'fs' -const KEY_SUFFIX = '.key'; -const CERTIFICATE_SUFFIX = '.crt'; +const KEY_SUFFIX = '.key' +const CERTIFICATE_SUFFIX = '.crt' /** * Using AcmeClient to create a new account. It creates an account key if it doesn't exists * @param {NginxHTTPRequest} r Incoming request * @returns void */ -async function clientNewAccount(r: NginxHTTPRequest) { - const accountKey = await readOrCreateAccountKey(acmeAccountPrivateJWKPath(r)); - // Create a new ACME account - let client = new AcmeClient({ - directoryUrl: acmeDirectoryURI(r), - accountKey: accountKey - }); - // display more logs - client.api.setDebug(true); - // do not validate ACME provider cert - client.api.setVerify(acmeVerifyProviderHTTPS(r)); - - try { - const account = await client.createAccount({ - termsOfServiceAgreed: true, - contact: ['mailto:test@example.com'] - }); - return r.return(200, JSON.stringify(account)); - } catch (e) { - ngx.log(ngx.ERR, `Error creating ACME account. Error=${e}`) - } +async function clientNewAccount(r: NginxHTTPRequest): Promise { + const accountKey = await readOrCreateAccountKey(acmeAccountPrivateJWKPath(r)) + // Create a new ACME account + const client = new AcmeClient({ + directoryUrl: acmeDirectoryURI(r), + accountKey: accountKey, + }) + // display more logs + client.api.setDebug(true) + // do not validate ACME provider cert + client.api.setVerify(acmeVerifyProviderHTTPS(r)) + + try { + const account = await client.createAccount({ + termsOfServiceAgreed: true, + contact: ['mailto:test@example.com'], + }) + return r.return(200, JSON.stringify(account)) + } catch (e) { + ngx.log(ngx.ERR, `Error creating ACME account. Error=${e}`) + } } /** @@ -40,157 +53,193 @@ async function clientNewAccount(r: NginxHTTPRequest) { * @param {NginxHTTPRequest} r Incoming request * @returns void */ -async function clientAutoMode(r: NginxHTTPRequest) { - const prefix = acmeDir(r); - const serverNames = acmeServerNames(r); - - const commonName = serverNames[0]; - const pkeyPath = joinPaths(prefix, commonName + KEY_SUFFIX); - const csrPath = joinPaths(prefix, commonName + '.csr'); - const certPath = joinPaths(prefix, commonName + CERTIFICATE_SUFFIX); - - let email - try { - email = getVariable(r, 'njs_acme_account_email'); - } catch { - return r.return(500, "Nginx variable 'njs_acme_account_email' or 'NJS_ACME_ACCOUNT_EMAIL' environment variable must be set"); +async function clientAutoMode(r: NginxHTTPRequest): Promise { + const prefix = acmeDir(r) + const serverNames = acmeServerNames(r) + + const commonName = serverNames[0] + const pkeyPath = joinPaths(prefix, commonName + KEY_SUFFIX) + const csrPath = joinPaths(prefix, commonName + '.csr') + const certPath = joinPaths(prefix, commonName + CERTIFICATE_SUFFIX) + + let email + try { + email = getVariable(r, 'njs_acme_account_email') + } catch { + return r.return( + 500, + "Nginx variable 'njs_acme_account_email' or 'NJS_ACME_ACCOUNT_EMAIL' environment variable must be set" + ) + } + + let certificatePem + let pkeyPem + let renewCertificate = false + let certInfo + try { + const certData = fs.readFileSync(certPath, 'utf8') + const privateKeyData = fs.readFileSync(pkeyPath, 'utf8') + + certInfo = await readCertificateInfo(certData) + // Calculate the date 30 days before the certificate expiration + const renewalThreshold = new Date(certInfo.notAfter) + renewalThreshold.setDate(renewalThreshold.getDate() - 30) + + const currentDate = new Date() + if (currentDate > renewalThreshold) { + renewCertificate = true + } else { + certificatePem = certData + pkeyPem = privateKeyData + } + } catch { + renewCertificate = true + } + + if (renewCertificate) { + const accountKey = await readOrCreateAccountKey( + acmeAccountPrivateJWKPath(r) + ) + // Create a new ACME client + const client = new AcmeClient({ + directoryUrl: acmeDirectoryURI(r), + accountKey: accountKey, + }) + // client.api.setDebug(true); + client.api.setVerify(acmeVerifyProviderHTTPS(r)) + + // Create a new CSR + const params = { + altNames: [commonName], + commonName: commonName, + // state: "WA", + // country: "US", + // organizationUnit: "NGINX", + emailAddress: email, } - let certificatePem; - let pkeyPem; - let renewCertificate = false; - let certInfo; + const result = await createCsr(params) + fs.writeFileSync(csrPath, toPEM(result.pkcs10Ber, 'CERTIFICATE REQUEST')) + + const privKey = (await crypto.subtle.exportKey( + 'pkcs8', + result.keys.privateKey + )) as ArrayBuffer + pkeyPem = toPEM(privKey, 'PRIVATE KEY') + fs.writeFileSync(pkeyPath, pkeyPem) + ngx.log(ngx.INFO, `njs-acme: [auto] Wrote Private key to ${pkeyPath}`) + + // default challengePath = acmeDir/challenge + const challengePath = getVariable( + r, + 'njs_acme_challenge_dir', + joinPaths(acmeDir(r), 'challenge') + ) + if (challengePath === undefined || challengePath.length === 0) { + return r.return( + 500, + "Nginx variable 'njs_acme_challenge_dir' must be set" + ) + } + ngx.log( + ngx.INFO, + `njs-acme: [auto] Issuing a new Certificate: ${JSON.stringify(params)}` + ) + const fullChallengePath = joinPaths( + challengePath, + '.well-known/acme-challenge' + ) try { - const certData = fs.readFileSync(certPath, 'utf8'); - const privateKeyData = fs.readFileSync(pkeyPath, 'utf8'); - - certInfo = await readCertificateInfo(certData); - // Calculate the date 30 days before the certificate expiration - const renewalThreshold = new Date(certInfo.notAfter); - renewalThreshold.setDate(renewalThreshold.getDate() - 30); - - const currentDate = new Date(); - if (currentDate > renewalThreshold) { - renewCertificate = true; - } else { - certificatePem = certData; - pkeyPem = privateKeyData; - } - } catch { - renewCertificate = true; + fs.mkdirSync(fullChallengePath, { recursive: true }) + } catch (e) { + ngx.log( + ngx.ERR, + `Error creating directory to store challenges at ${fullChallengePath}. Ensure the ${challengePath} directory is writable by the nginx user.` + ) + return r.return(500, 'Cannot create challenge directory') } - if (renewCertificate) { - const accountKey = await readOrCreateAccountKey(acmeAccountPrivateJWKPath(r)); - // Create a new ACME client - let client = new AcmeClient({ - directoryUrl: acmeDirectoryURI(r), - accountKey: accountKey - }); - // client.api.setDebug(true); - client.api.setVerify(acmeVerifyProviderHTTPS(r)); - - // Create a new CSR - const params = { - altNames: [commonName], - commonName: commonName, - // state: "WA", - // country: "US", - // organizationUnit: "NGINX", - emailAddress: email, - } - - const result = await createCsr(params); - fs.writeFileSync(csrPath, toPEM(result.pkcs10Ber, "CERTIFICATE REQUEST")); - - const privKey = await crypto.subtle.exportKey("pkcs8", result.keys.privateKey) as ArrayBuffer; - pkeyPem = toPEM(privKey, "PRIVATE KEY"); - fs.writeFileSync(pkeyPath, pkeyPem); - ngx.log(ngx.INFO, `njs-acme: [auto] Wrote Private key to ${pkeyPath}`); - - // default challengePath = acmeDir/challenge - const challengePath = getVariable(r, 'njs_acme_challenge_dir', joinPaths(acmeDir(r), 'challenge')); - if (challengePath === undefined || challengePath.length === 0) { - return r.return(500, "Nginx variable 'njs_acme_challenge_dir' must be set"); - } - ngx.log(ngx.INFO, `njs-acme: [auto] Issuing a new Certificate: ${JSON.stringify(params)}`); - const fullChallengePath = joinPaths(challengePath, '.well-known/acme-challenge'); + certificatePem = await client.auto({ + csr: result.pkcs10Ber, + email: email, + termsOfServiceAgreed: true, + challengeCreateFn: async (authz, challenge, keyAuthorization) => { + ngx.log( + ngx.INFO, + `njs-acme: [auto] Challenge Create (authz='${JSON.stringify( + authz + )}', challenge='${JSON.stringify( + challenge + )}', keyAuthorization='${keyAuthorization}')` + ) + ngx.log( + ngx.INFO, + `njs-acme: [auto] Writing challenge file so nginx can serve it via .well-known/acme-challenge/${challenge.token}` + ) + const path = joinPaths(fullChallengePath, challenge.token) + fs.writeFileSync(path, keyAuthorization) + }, + challengeRemoveFn: async (_authz, challenge, _keyAuthorization) => { + const path = joinPaths(fullChallengePath, challenge.token) try { - fs.mkdirSync(fullChallengePath, { recursive: true }); + fs.unlinkSync(path) + ngx.log(ngx.INFO, `njs-acme: [auto] removed challenge ${path}`) } catch (e) { - ngx.log(ngx.ERR, `Error creating directory to store challenges at ${fullChallengePath}. Ensure the ${challengePath} directory is writable by the nginx user.`) - return r.return(500, "Cannot create challenge directory"); + ngx.log( + ngx.ERR, + `njs-acme: [auto] failed to remove challenge ${path}` + ) } - - certificatePem = await client.auto({ - csr: result.pkcs10Ber, - email: email, - termsOfServiceAgreed: true, - challengeCreateFn: async (authz, challenge, keyAuthorization) => { - ngx.log(ngx.INFO, `njs-acme: [auto] Challenge Create (authz='${JSON.stringify(authz)}', challenge='${JSON.stringify(challenge)}', keyAuthorization='${keyAuthorization}')`); - ngx.log(ngx.INFO, `njs-acme: [auto] Writing challenge file so nginx can serve it via .well-known/acme-challenge/${challenge.token}`); - const path = joinPaths(fullChallengePath, challenge.token); - fs.writeFileSync(path, keyAuthorization); - }, - challengeRemoveFn: async (_authz, challenge, _keyAuthorization) => { - const path = joinPaths(fullChallengePath, challenge.token); - try { - fs.unlinkSync(path); - ngx.log(ngx.INFO, `njs-acme: [auto] removed challenge ${path}`); - } catch (e) { - ngx.log(ngx.ERR, `njs-acme: [auto] failed to remove challenge ${path}`); - } - } - }); - certInfo = await readCertificateInfo(certificatePem); - fs.writeFileSync(certPath, certificatePem); - r.log(`njs-acme: wrote certificate to ${certPath}`); - } - - const info = { - certificate: certInfo, - renewedCertificate: renewCertificate, - } - - return r.return(200, JSON.stringify(info)); + }, + }) + certInfo = await readCertificateInfo(certificatePem) + fs.writeFileSync(certPath, certificatePem) + r.log(`njs-acme: wrote certificate to ${certPath}`) + } + + const info = { + certificate: certInfo, + renewedCertificate: renewCertificate, + } + + return r.return(200, JSON.stringify(info)) } - /** * Demonstrates how to use generate RSA Keys and use HttpClient * @param r * @returns */ -async function acmeNewAccount(r: NginxHTTPRequest) { - ngx.log(ngx.ERR, `VERIFY_PROVIDER_HTTPS: ${acmeVerifyProviderHTTPS(r)}`); - - /* Generate a new RSA key pair for ACME account */ - const keys = (await generateKey()) as Required; - - // /* Create a new ACME account */ - let client = new HttpClient(acmeDirectoryURI(r), keys.privateKey); - - client.setDebug(true); - client.setVerify(acmeVerifyProviderHTTPS(r)); - - // Get Terms Of Service link from the ACME provider - let tos = await client.getMetaField("termsOfService"); - ngx.log(ngx.INFO, `termsOfService: ${tos}`); - // obtain a resource URL - const resourceUrl: string = await client.getResourceUrl('newAccount'); - const payload = { - termsOfServiceAgreed: true, - contact: ['mailto:test@example.com'] - }; - // sends a signed request - let sresp = await client.signedRequest(resourceUrl, payload); - - let respO = { - "headers": sresp.headers, - "data": await sresp.json(), - "status": sresp.status, - }; - return r.return(200, JSON.stringify(respO)); +async function acmeNewAccount(r: NginxHTTPRequest): Promise { + ngx.log(ngx.ERR, `VERIFY_PROVIDER_HTTPS: ${acmeVerifyProviderHTTPS(r)}`) + + /* Generate a new RSA key pair for ACME account */ + const keys = (await generateKey()) as Required + + // /* Create a new ACME account */ + const client = new HttpClient(acmeDirectoryURI(r), keys.privateKey) + + client.setDebug(true) + client.setVerify(acmeVerifyProviderHTTPS(r)) + + // Get Terms Of Service link from the ACME provider + const tos = await client.getMetaField('termsOfService') + ngx.log(ngx.INFO, `termsOfService: ${tos}`) + // obtain a resource URL + const resourceUrl: string = await client.getResourceUrl('newAccount') + const payload = { + termsOfServiceAgreed: true, + contact: ['mailto:test@example.com'], + } + // sends a signed request + const sresp = await client.signedRequest(resourceUrl, payload) + + const respO = { + headers: sresp.headers, + data: await sresp.json(), + status: sresp.status, + } + return r.return(200, JSON.stringify(respO)) } /** @@ -198,22 +247,28 @@ async function acmeNewAccount(r: NginxHTTPRequest) { * @param r * @returns */ -async function createCsrHandler(r: NginxHTTPRequest) { - const { pkcs10Ber, keys } = await createCsr({ - // EXAMPLE VALUES BELOW - altNames: ["proxy1.f5.com", "proxy2.f5.com"], - commonName: "proxy.f5.com", - state: "WA", - country: "US", - organizationUnit: "NGINX" - }); - const privkey = await crypto.subtle.exportKey("pkcs8", keys.privateKey) as ArrayBuffer; - const pubkey = await crypto.subtle.exportKey("spki", keys.publicKey) as ArrayBuffer; - const privkeyPem = toPEM(privkey, "PRIVATE KEY"); - const pubkeyPem = toPEM(pubkey, "PUBLIC KEY"); - const csrPem = toPEM(pkcs10Ber, "CERTIFICATE REQUEST"); - const result = `${privkeyPem}\n${pubkeyPem}\n${csrPem}` - return r.return(200, result); +async function createCsrHandler(r: NginxHTTPRequest): Promise { + const { pkcs10Ber, keys } = await createCsr({ + // EXAMPLE VALUES BELOW + altNames: ['proxy1.f5.com', 'proxy2.f5.com'], + commonName: 'proxy.f5.com', + state: 'WA', + country: 'US', + organizationUnit: 'NGINX', + }) + const privkey = (await crypto.subtle.exportKey( + 'pkcs8', + keys.privateKey + )) as ArrayBuffer + const pubkey = (await crypto.subtle.exportKey( + 'spki', + keys.publicKey + )) as ArrayBuffer + const privkeyPem = toPEM(privkey, 'PRIVATE KEY') + const pubkeyPem = toPEM(pubkey, 'PUBLIC KEY') + const csrPem = toPEM(pkcs10Ber, 'CERTIFICATE REQUEST') + const result = `${privkeyPem}\n${pubkeyPem}\n${csrPem}` + return r.return(200, result) } /** @@ -221,20 +276,26 @@ async function createCsrHandler(r: NginxHTTPRequest) { * @param {NginxHTTPRequest} r - The Nginx HTTP request object. * @returns {string, string} - The path and cert associated with the server name. */ -function js_cert(r: NginxHTTPRequest) { - const prefix = acmeDir(r); - let { path, data } = read_cert_or_key(prefix, r.variables.ssl_server_name?.toLowerCase() || '', CERTIFICATE_SUFFIX); - // ngx.log(ngx.INFO, `njs-acme: Loaded cert for ${r.variables.ssl_server_name} from path: ${path}`); - if (data.length == 0) { - r.log(`njs-acme: seems there is no cert for ${r.variables.ssl_server_name} from path: ${path}`); - /* - // FIXME: is there a way to send a subrequest so we kick in auto mode to issue a new one? - r.subrequest('http://localhost:8000/acme/auto', - {detached: true, method: 'GET', body: undefined}); - r.log(`njs-acme: notified /acme/auto`); - */ - } - return path; +function js_cert(r: NginxHTTPRequest): string { + const prefix = acmeDir(r) + const { path, data } = read_cert_or_key( + prefix, + r.variables.ssl_server_name?.toLowerCase() || '', + CERTIFICATE_SUFFIX + ) + // ngx.log(ngx.INFO, `njs-acme: Loaded cert for ${r.variables.ssl_server_name} from path: ${path}`); + if (data.length == 0) { + r.log( + `njs-acme: seems there is no cert for ${r.variables.ssl_server_name} from path: ${path}` + ) + /* + // FIXME: is there a way to send a subrequest so we kick in auto mode to issue a new one? + r.subrequest('http://localhost:8000/acme/auto', + {detached: true, method: 'GET', body: undefined}); + r.log(`njs-acme: notified /acme/auto`); + */ + } + return path } /** @@ -242,40 +303,47 @@ function js_cert(r: NginxHTTPRequest) { * @param {NginxHTTPRequest} r - The Nginx HTTP request object. * @returns {string} - The path and key associated with the server name. */ -function js_key(r: NginxHTTPRequest) { - const prefix = acmeDir(r); - const { path } = read_cert_or_key(prefix, r.variables.ssl_server_name?.toLowerCase() || '', KEY_SUFFIX); - // r.log(`njs-acme: loaded key for ${r.variables.ssl_server_name} from path: ${path}`); - return path +function js_key(r: NginxHTTPRequest): string { + const prefix = acmeDir(r) + const { path } = read_cert_or_key( + prefix, + r.variables.ssl_server_name?.toLowerCase() || '', + KEY_SUFFIX + ) + // r.log(`njs-acme: loaded key for ${r.variables.ssl_server_name} from path: ${path}`); + return path } function read_cert_or_key(prefix: string, domain: string, suffix: string) { - const none_wildcard_path = joinPaths(prefix, domain + suffix); - const wildcard_path = joinPaths(prefix, domain.replace(/.*?\./, '*.') + suffix); - - let data = ''; - var path = ''; - + const none_wildcard_path = joinPaths(prefix, domain + suffix) + const wildcard_path = joinPaths( + prefix, + domain.replace(/.*?\./, '*.') + suffix + ) + + let data = '' + let path = '' + + try { + data = fs.readFileSync(none_wildcard_path, 'utf8') + path = none_wildcard_path + } catch (e) { try { - data = fs.readFileSync(none_wildcard_path, 'utf8'); - path = none_wildcard_path; + data = fs.readFileSync(wildcard_path, 'utf8') + path = wildcard_path } catch (e) { - try { - data = fs.readFileSync(wildcard_path, 'utf8'); - path = wildcard_path; - } catch (e) { - data = ''; - } + data = '' } + } - return { path, data }; + return { path, data } } export default { - js_cert, - js_key, - acmeNewAccount, - clientNewAccount, - clientAutoMode, - createCsrHandler + js_cert, + js_key, + acmeNewAccount, + clientNewAccount, + clientAutoMode, + createCsrHandler, } diff --git a/src/utils.ts b/src/utils.ts index 70dc8cc..0f3798f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,129 +1,145 @@ import x509 from './x509.js' -import * as pkijs from 'pkijs'; -import * as asn1js from 'asn1js'; -import fs from 'fs'; -import querystring from 'querystring'; +import * as pkijs from 'pkijs' +import * as asn1js from 'asn1js' +import fs from 'fs' +import querystring from 'querystring' // workaround for PKI.JS to work -globalThis.unescape = querystring.unescape; +globalThis.unescape = querystring.unescape // make PKI.JS to work with webcrypto -pkijs.setEngine("webcrypto", new pkijs.CryptoEngine({ name: "webcrypto", crypto: crypto })); +pkijs.setEngine( + 'webcrypto', + new pkijs.CryptoEngine({ name: 'webcrypto', crypto: crypto }) +) // workaround for PKI.js toJSON/fromJson // from https://stackoverflow.com/questions/36810940/alternative-or-polyfill-for-array-from-on-the-internet-explorer if (!Array.from) { Array.from = (function () { - var toStr = Object.prototype.toString; - var isCallable = function (fn) { - return typeof fn === 'function' || toStr.call(fn) === '[object Function]'; - }; - var toInteger = function (value) { - var number = Number(value); - if (isNaN(number)) { return 0; } - if (number === 0 || !isFinite(number)) { return number; } - return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number)); - }; - var maxSafeInteger = Math.pow(2, 53) - 1; - var toLength = function (value) { - var len = toInteger(value); - return Math.min(Math.max(len, 0), maxSafeInteger); - }; + const toStr = Object.prototype.toString + const isCallable = function (fn) { + return typeof fn === 'function' || toStr.call(fn) === '[object Function]' + } + const toInteger = function (value) { + const number = Number(value) + if (isNaN(number)) { + return 0 + } + if (number === 0 || !isFinite(number)) { + return number + } + return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number)) + } + const maxSafeInteger = Math.pow(2, 53) - 1 + const toLength = function (value) { + const len = toInteger(value) + return Math.min(Math.max(len, 0), maxSafeInteger) + } // The length property of the from method is 1. - return function from(arrayLike/*, mapFn, thisArg */) { + return function from(arrayLike /*, mapFn, thisArg */) { // 1. Let C be the this value. - var C = this; + const C = this // 2. Let items be ToObject(arrayLike). - var items = Object(arrayLike); + const items = Object(arrayLike) // 3. ReturnIfAbrupt(items). if (arrayLike == null) { - throw new TypeError("Array.from requires an array-like object - not null or undefined"); + throw new TypeError( + 'Array.from requires an array-like object - not null or undefined' + ) } // 4. If mapfn is undefined, then let mapping be false. - var mapFn = arguments.length > 1 ? arguments[1] : void undefined; - var T; + const mapFn = arguments.length > 1 ? arguments[1] : void undefined + let T if (typeof mapFn !== 'undefined') { // 5. else // 5. a If IsCallable(mapfn) is false, throw a TypeError exception. if (!isCallable(mapFn)) { - throw new TypeError('Array.from: when provided, the second argument must be a function'); + throw new TypeError( + 'Array.from: when provided, the second argument must be a function' + ) } // 5. b. If thisArg was supplied, let T be thisArg; else let T be undefined. if (arguments.length > 2) { - T = arguments[2]; + T = arguments[2] } } // 10. Let lenValue be Get(items, "length"). // 11. Let len be ToLength(lenValue). - var len = toLength(items.length); + const len = toLength(items.length) // 13. If IsConstructor(C) is true, then // 13. a. Let A be the result of calling the [[Construct]] internal method of C with an argument list containing the single item len. // 14. a. Else, Let A be ArrayCreate(len). - var A = isCallable(C) ? Object(new C(len)) : new Array(len); + const A = isCallable(C) ? Object(new C(len)) : new Array(len) // 16. Let k be 0. - var k = 0; + let k = 0 // 17. Repeat, while k < len… (also steps a - h) - var kValue; + let kValue while (k < len) { - kValue = items[k]; + kValue = items[k] if (mapFn) { - A[k] = typeof T === 'undefined' ? mapFn(kValue, k) : mapFn.call(T, kValue, k); + A[k] = + typeof T === 'undefined' + ? mapFn(kValue, k) + : mapFn.call(T, kValue, k) } else { - A[k] = kValue; + A[k] = kValue } - k += 1; + k += 1 } // 18. Let putStatus be Put(A, "length", len, true). - A.length = len; + A.length = len // 20. Return A. - return A; - }; - }()); + return A + } + })() } export interface RsaPublicJwk { - e: string; - kty: string; - n: string; + e: string + kty: string + n: string } export interface EcdsaPublicJwk { - crv: string; - kty: string; - x: string; - y: string; + crv: string + kty: string + x: string + y: string } const ACCOUNT_KEY_ALG_GENERATE: RsaHashedKeyGenParams = { - name: "RSASSA-PKCS1-v1_5", - hash: "SHA-256", + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', publicExponent: new Uint8Array([1, 0, 1]), modulusLength: 2048, } const ACCOUNT_KEY_ALG_IMPORT: RsaHashedImportParams = { - name: "RSASSA-PKCS1-v1_5", - hash: "SHA-256" + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', } /** * Generates RSA private and public key pair * @returns {CryptoKeyPair} a private and public key pair */ -export async function generateKey() { - const keys = (await crypto.subtle.generateKey(ACCOUNT_KEY_ALG_GENERATE, true, ["sign", "verify"])); +export async function generateKey(): Promise { + const keys = await crypto.subtle.generateKey(ACCOUNT_KEY_ALG_GENERATE, true, [ + 'sign', + 'verify', + ]) return keys } - /** * Reads the account key from the specified file path, or creates a new one if it does not exist. * @param {string} [path] - The path to the account key file. If not specified, the default location will be used. @@ -132,51 +148,73 @@ export async function generateKey() { */ export async function readOrCreateAccountKey(path: string): Promise { try { - const accountKeyJWK = fs.readFileSync(path, 'utf8'); - ngx.log(ngx.INFO, `acme-njs: [utils] Using account key from ${path}`); - return await crypto.subtle.importKey('jwk', JSON.parse(accountKeyJWK), ACCOUNT_KEY_ALG_IMPORT, true, ["sign"]); + const accountKeyJWK = fs.readFileSync(path, 'utf8') + ngx.log(ngx.INFO, `acme-njs: [utils] Using account key from ${path}`) + return await crypto.subtle.importKey( + 'jwk', + JSON.parse(accountKeyJWK), + ACCOUNT_KEY_ALG_IMPORT, + true, + ['sign'] + ) } catch (e) { // TODO: separate file not found, issues with importKey - ngx.log(ngx.WARN, `acme-njs: [utils] error ${e} while reading a private key from ${path}`); + ngx.log( + ngx.WARN, + `acme-njs: [utils] error ${e} while reading a private key from ${path}` + ) /* Generate a new RSA key pair for ACME account */ - const keys = (await generateKey()) as Required; - const jwkFormated = await crypto.subtle.exportKey("jwk", keys.privateKey) - fs.writeFileSync(path, JSON.stringify(jwkFormated)); - ngx.log(ngx.INFO, `acme-njs: [utils] Generated a new account key and saved it to ${path}`); - return keys.privateKey; + const keys = (await generateKey()) as Required + const jwkFormated = await crypto.subtle.exportKey('jwk', keys.privateKey) + fs.writeFileSync(path, JSON.stringify(jwkFormated)) + ngx.log( + ngx.INFO, + `acme-njs: [utils] Generated a new account key and saved it to ${path}` + ) + return keys.privateKey } } - +interface JWK { + crv: unknown + x: unknown + e: unknown + y: unknown + kty: unknown + n: unknown +} /** * Gets the public JWK from a given private key. * @param {CryptoKey} privateKey - The private key to extract the public JWK from. * @returns {Promise} The public JWK. * @throws {Error} Throws an error if the privateKey parameter is not provided or invalid. */ -export async function getPublicJwk(privateKey: CryptoKey): Promise { +export async function getPublicJwk( + privateKey: CryptoKey +): Promise { if (!privateKey) { - const errMsg = 'Invalid or missing private key'; - ngx.log(ngx.ERR, errMsg); - throw new Error(errMsg); + const errMsg = 'Invalid or missing private key' + ngx.log(ngx.ERR, errMsg) + throw new Error(errMsg) } - const jwk: any = await crypto.subtle.exportKey("jwk", privateKey); + // eslint-disable-next-line @typescript-eslint/ban-types + const jwk = (await crypto.subtle.exportKey('jwk', privateKey)) as JWK - if (jwk.crv && (jwk.kty === 'EC')) { - const { crv, x, y } = jwk; + if (jwk.crv && jwk.kty === 'EC') { + const { crv, x, y, kty } = jwk return { crv, - kty: jwk.kty, + kty, x, - y - } as EcdsaPublicJwk; + y, + } as EcdsaPublicJwk } else { return { e: jwk.e, kty: jwk.kty, n: jwk.n, - } as RsaPublicJwk; + } as RsaPublicJwk } } @@ -185,11 +223,15 @@ export async function getPublicJwk(privateKey: CryptoKey): Promise o.toSchema()) - })); + outputArray.push( + new asn1js.Constructed({ + idBlock: { + tagClass: 3, // CONTEXT-SPECIFIC + tagNumber: 0, // [0] + }, + value: Array.from(pkcs10.attributes, (o) => o.toSchema()), + }) + ) } return new asn1js.Sequence({ - value: outputArray - }); + value: outputArray, + }) } interface AlgoCryptoKey extends CryptoKey { @@ -253,93 +296,107 @@ interface AlgoCryptoKey extends CryptoKey { * @param hashAlgorithm {string} The hash algorithm used for the signature. Default is "SHA-1". * @returns {{signatureAlgorithm: pkijs.AlgorithmIdentifier; parameters: pkijs.CryptoEngineAlgorithmParams;}} An object containing signature algorithm and parameters */ -function getSignatureParameters(privateKey: AlgoCryptoKey, hashAlgorithm = "SHA-1"): { - signatureAlgorithm: pkijs.AlgorithmIdentifier; parameters: pkijs.CryptoEngineAlgorithmParams; +function getSignatureParameters( + privateKey: AlgoCryptoKey, + hashAlgorithm = 'SHA-1' +): { + signatureAlgorithm: pkijs.AlgorithmIdentifier + parameters: pkijs.CryptoEngineAlgorithmParams } { // Check hashing algorithm - pkijs.getOIDByAlgorithm({ name: hashAlgorithm }, true, "hashAlgorithm"); + pkijs.getOIDByAlgorithm({ name: hashAlgorithm }, true, 'hashAlgorithm') // Initial variables - const signatureAlgorithm = new pkijs.AlgorithmIdentifier(); - + const signatureAlgorithm = new pkijs.AlgorithmIdentifier() privateKey.algorithm = { - name: "RSASSA-PKCS1-V1_5" + name: 'RSASSA-PKCS1-V1_5', } //#region Get a "default parameters" for current algorithm - const parameters = pkijs.getAlgorithmParameters(privateKey.algorithm.name, "sign"); + const parameters = pkijs.getAlgorithmParameters( + privateKey.algorithm.name, + 'sign' + ) if (!Object.keys(parameters.algorithm).length) { - const errMsg = 'Parameter `algorithm` is empty'; - ngx.log(ngx.ERR, errMsg); - throw new Error(errMsg); + const errMsg = 'Parameter `algorithm` is empty' + ngx.log(ngx.ERR, errMsg) + throw new Error(errMsg) } - const algorithm = parameters.algorithm as any; // TODO remove `as any` - algorithm.hash.name = hashAlgorithm; + const algorithm = parameters.algorithm + algorithm.hash.name = hashAlgorithm //#endregion //#region Fill internal structures base on "privateKey" and "hashAlgorithm" switch (privateKey.algorithm.name.toUpperCase()) { - case "RSASSA-PKCS1-V1_5": - case "ECDSA": - signatureAlgorithm.algorithmId = pkijs.getOIDByAlgorithm(algorithm, true); - break; - case "RSA-PSS": + case 'RSASSA-PKCS1-V1_5': + case 'ECDSA': + signatureAlgorithm.algorithmId = pkijs.getOIDByAlgorithm(algorithm, true) + break + case 'RSA-PSS': { //#region Set "saltLength" as a length (in octets) of hash function result switch (hashAlgorithm.toUpperCase()) { - case "SHA-256": - algorithm.saltLength = 32; - break; - case "SHA-384": - algorithm.saltLength = 48; - break; - case "SHA-512": - algorithm.saltLength = 64; - break; + case 'SHA-256': + algorithm.saltLength = 32 + break + case 'SHA-384': + algorithm.saltLength = 48 + break + case 'SHA-512': + algorithm.saltLength = 64 + break default: } //#endregion //#region Fill "RSASSA_PSS_params" object - const paramsObject: Partial = {}; + const paramsObject: Partial = {} - if (hashAlgorithm.toUpperCase() !== "SHA-1") { - const hashAlgorithmOID = pkijs.getOIDByAlgorithm({ name: hashAlgorithm }, true, "hashAlgorithm"); + if (hashAlgorithm.toUpperCase() !== 'SHA-1') { + const hashAlgorithmOID = pkijs.getOIDByAlgorithm( + { name: hashAlgorithm }, + true, + 'hashAlgorithm' + ) paramsObject.hashAlgorithm = new pkijs.AlgorithmIdentifier({ algorithmId: hashAlgorithmOID, - algorithmParams: new asn1js.Null() - }); + algorithmParams: new asn1js.Null(), + }) paramsObject.maskGenAlgorithm = new pkijs.AlgorithmIdentifier({ - algorithmId: "1.2.840.113549.1.1.8", // MGF1 - algorithmParams: paramsObject.hashAlgorithm.toSchema() - }); + algorithmId: '1.2.840.113549.1.1.8', // MGF1 + algorithmParams: paramsObject.hashAlgorithm.toSchema(), + }) } if (algorithm.saltLength !== 20) - paramsObject.saltLength = algorithm.saltLength; + paramsObject.saltLength = algorithm.saltLength - const pssParameters = new pkijs.RSASSAPSSParams(paramsObject); + const pssParameters = new pkijs.RSASSAPSSParams(paramsObject) //#endregion //#region Automatically set signature algorithm - signatureAlgorithm.algorithmId = "1.2.840.113549.1.1.10"; - signatureAlgorithm.algorithmParams = pssParameters.toSchema(); + signatureAlgorithm.algorithmId = '1.2.840.113549.1.1.10' + signatureAlgorithm.algorithmParams = pssParameters.toSchema() //#endregion } - break; + break default: - const errMsg = `Unsupported signature algorithm: ${privateKey.algorithm.name}`; - ngx.log(ngx.ERR, errMsg) - throw new Error(errMsg); + ngx.log( + ngx.ERR, + `Unsupported signature algorithm: ${privateKey.algorithm.name}` + ) + throw new Error( + `Unsupported signature algorithm: ${privateKey.algorithm.name}` + ) } //#endregion return { signatureAlgorithm, - parameters - }; + parameters, + } } /** @@ -359,72 +416,76 @@ function getSignatureParameters(privateKey: AlgoCryptoKey, hashAlgorithm = "SHA- * the PKCS10 BER representation and generated keys */ export async function createCsr(params: { - keySize?: number; - commonName?: string; - altNames?: string[]; - country?: string; - state?: string; - locality?: string; - organization?: string; - organizationUnit?: string; - emailAddress?: string; + keySize?: number + commonName?: string + altNames?: string[] + country?: string + state?: string + locality?: string + organization?: string + organizationUnit?: string + emailAddress?: string }): Promise<{ pkcs10Ber: ArrayBuffer; keys: Required }> { // TODO: allow to provide keys in addition to always generating one - const { privateKey, publicKey } = (await generateKey()) as Required; - const algoPrivateKey = privateKey as AlgoCryptoKey; + const { privateKey, publicKey } = + (await generateKey()) as Required + const algoPrivateKey = privateKey as AlgoCryptoKey - const pkcs10 = new pkijs.CertificationRequest(); - pkcs10.version = 0; + const pkcs10 = new pkijs.CertificationRequest() + pkcs10.version = 0 - addSubjectAttributes(pkcs10.subject.typesAndValues, params); - await addExtensions(pkcs10, params, publicKey); + addSubjectAttributes(pkcs10.subject.typesAndValues, params) + await addExtensions(pkcs10, params, publicKey) // FIXME: workaround for PKIS.js - algoPrivateKey.algorithm = pkijs.getAlgorithmParameters("RSASSA-PKCS1-v1_5", "sign") - await signCsr(pkcs10, privateKey); + algoPrivateKey.algorithm = pkijs.getAlgorithmParameters( + 'RSASSA-PKCS1-v1_5', + 'sign' + ) + await signCsr(pkcs10, privateKey) - const pkcs10Ber = getPkcs10Ber(pkcs10); + const pkcs10Ber = getPkcs10Ber(pkcs10) return { pkcs10Ber, keys: { privateKey, publicKey }, - }; + } } function addSubjectAttributes( subjectTypesAndValues: pkijs.AttributeTypeAndValue[], params: { - country?: string; - state?: string; - organization?: string; - organizationUnit?: string; - commonName?: string; + country?: string + state?: string + organization?: string + organizationUnit?: string + commonName?: string } ): void { if (params.country) { subjectTypesAndValues.push( - createAttributeTypeAndValue("2.5.4.6", params.country) - ); + createAttributeTypeAndValue('2.5.4.6', params.country) + ) } if (params.state) { subjectTypesAndValues.push( - createAttributeTypeAndValue("2.5.4.8", params.state) - ); + createAttributeTypeAndValue('2.5.4.8', params.state) + ) } if (params.organization) { subjectTypesAndValues.push( - createAttributeTypeAndValue("2.5.4.10", params.organization) - ); + createAttributeTypeAndValue('2.5.4.10', params.organization) + ) } if (params.organizationUnit) { subjectTypesAndValues.push( - createAttributeTypeAndValue("2.5.4.11", params.organizationUnit) - ); + createAttributeTypeAndValue('2.5.4.11', params.organizationUnit) + ) } if (params.commonName) { subjectTypesAndValues.push( - createAttributeTypeAndValue("2.5.4.3", params.commonName) - ); + createAttributeTypeAndValue('2.5.4.3', params.commonName) + ) } } @@ -435,61 +496,61 @@ function createAttributeTypeAndValue( return new pkijs.AttributeTypeAndValue({ type, value: new asn1js.Utf8String({ value }), - }); + }) } function getAltNames(params: { - commonName?: string; - altNames?: string[]; + commonName?: string + altNames?: string[] }): pkijs.GeneralName[] { - const altNames: pkijs.GeneralName[] = []; + const altNames: pkijs.GeneralName[] = [] if (params.altNames) { altNames.push( - ...params.altNames.map((altName) => - createGeneralName(2, altName) - ) - ); + ...params.altNames.map((altName) => createGeneralName(2, altName)) + ) } - if (params.commonName && !altNames.some((name) => name.toString() === params.commonName) + if ( + params.commonName && + !altNames.some((name) => name.toString() === params.commonName) ) { - altNames.push(createGeneralName(2, params.commonName)); + altNames.push(createGeneralName(2, params.commonName)) } - return altNames; + return altNames } function createGeneralName( type: 0 | 2 | 1 | 6 | 3 | 4 | 7 | 8 | undefined, value: string ): pkijs.GeneralName { - return new pkijs.GeneralName({ type, value }); + return new pkijs.GeneralName({ type, value }) } async function addExtensions( pkcs10: pkijs.CertificationRequest, - params: { commonName?: string; altNames?: string[]; }, + params: { commonName?: string; altNames?: string[] }, publicKey: CryptoKey ) { + const altNames = getAltNames(params) - const altNames = getAltNames(params); - - await pkcs10.subjectPublicKeyInfo.importKey(publicKey, pkijs.getCrypto(true)); - const subjectKeyIdentifier = await getSubjectKeyIdentifier(pkcs10); - const altNamesGNs = new pkijs.GeneralNames({ names: altNames }); + await pkcs10.subjectPublicKeyInfo.importKey(publicKey, pkijs.getCrypto(true)) + const subjectKeyIdentifier = await getSubjectKeyIdentifier(pkcs10) + const altNamesGNs = new pkijs.GeneralNames({ names: altNames }) const extensions = new pkijs.Extensions({ extensions: [ - createExtension("2.5.29.14", subjectKeyIdentifier), // SubjectKeyIdentifier - createExtension("2.5.29.17", altNamesGNs.toSchema()), // SubjectAltName + createExtension('2.5.29.14', subjectKeyIdentifier), // SubjectKeyIdentifier + createExtension('2.5.29.17', altNamesGNs.toSchema()), // SubjectAltName ], - }); - pkcs10.attributes = []; - pkcs10.attributes.push(new pkijs.Attribute({ - type: "1.2.840.113549.1.9.14", // pkcs-9-at-extensionRequest - values: [extensions.toSchema()], }) - ); + pkcs10.attributes = [] + pkcs10.attributes.push( + new pkijs.Attribute({ + type: '1.2.840.113549.1.9.14', // pkcs-9-at-extensionRequest + values: [extensions.toSchema()], + }) + ) } function createExtension( @@ -500,62 +561,72 @@ function createExtension( extnID, critical: false, extnValue: extnValue.toBER(false), - }); + }) } -async function getSubjectKeyIdentifier(pkcs10: pkijs.CertificationRequest): Promise { - const subjectPublicKeyValue = pkcs10.subjectPublicKeyInfo.subjectPublicKey.valueBlock.valueHex - const subjectKeyIdentifier = await crypto.subtle.digest("SHA-256", subjectPublicKeyValue); - return new asn1js.OctetString({ valueHex: subjectKeyIdentifier }); +async function getSubjectKeyIdentifier( + pkcs10: pkijs.CertificationRequest +): Promise { + const subjectPublicKeyValue = + pkcs10.subjectPublicKeyInfo.subjectPublicKey.valueBlock.valueHex + const subjectKeyIdentifier = await crypto.subtle.digest( + 'SHA-256', + subjectPublicKeyValue + ) + return new asn1js.OctetString({ valueHex: subjectKeyIdentifier }) } -async function signCsr(pkcs10: pkijs.CertificationRequest, privateKey: AlgoCryptoKey): Promise { +async function signCsr( + pkcs10: pkijs.CertificationRequest, + privateKey: AlgoCryptoKey +): Promise { /* Set signatureValue */ - pkcs10.tbsView = new Uint8Array(encodeTBS(pkcs10).toBER()); - const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, pkcs10.tbsView); - pkcs10.signatureValue = new asn1js.BitString({ valueHex: signature }); + pkcs10.tbsView = new Uint8Array(encodeTBS(pkcs10).toBER()) + const signature = await crypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + privateKey, + pkcs10.tbsView + ) + pkcs10.signatureValue = new asn1js.BitString({ valueHex: signature }) /* Set signatureAlgorithm */ - const signatureParams = getSignatureParameters(privateKey, "SHA-256"); - pkcs10.signatureAlgorithm = signatureParams.signatureAlgorithm; + const signatureParams = getSignatureParameters(privateKey, 'SHA-256') + pkcs10.signatureAlgorithm = signatureParams.signatureAlgorithm } function getPkcs10Ber(pkcs10: pkijs.CertificationRequest): ArrayBuffer { - return pkcs10.toSchema(true).toBER(false); + return pkcs10.toSchema(true).toBER(false) } - /** * Returns the Base64url encoded representation of the input data. * * @param {string} data - The data to be encoded. * @returns {string} - The Base64url encoded representation of the input data. */ -export function getPemBodyAsB64u(data: string) { +export function getPemBodyAsB64u(data: string): string { return Buffer.from(data).toString('base64url') } - /** * Find and format error in response object * * @param {object} resp HTTP response * @returns {string} Error message */ -export function formatResponseError(data: any) { - let result; +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any +export function formatResponseError(data: any): string { + let result // const data = await resp.json(); if ('error' in data) { - result = data.error.detail || data.error; - } - else { - result = data.detail || JSON.stringify(data); + result = data.error.detail || data.error + } else { + result = data.detail || JSON.stringify(data) } - return result.replace(/\n/g, ''); + return result.replace(/\n/g, '') } - /** * Exponential backoff * @@ -572,12 +643,11 @@ class Backoff { attempts: number constructor({ min = 100, max = 10000 } = {}) { - this.min = min; - this.max = max; - this.attempts = 0; + this.min = min + this.max = max + this.attempts = 0 } - /** * Get backoff duration * @@ -585,13 +655,12 @@ class Backoff { */ duration() { - const ms = this.min * (2 ** this.attempts); - this.attempts += 1; - return Math.min(ms, this.max); + const ms = this.min * 2 ** this.attempts + this.attempts += 1 + return Math.min(ms, this.max) } } - /** * Retry promise * @@ -600,27 +669,36 @@ class Backoff { * @param {Backoff} backoff Backoff instance * @returns {Promise} */ -async function retryPromise(fn: Function, attempts: number, backoff: Backoff): Promise { - let aborted = false; +async function retryPromise( + fn: (arg0: () => unknown) => unknown, + attempts: number, + backoff: Backoff +): Promise { + let aborted = false try { - const data = await fn(() => { aborted = true; }); - return data; - } - catch (e) { - if (aborted || ((backoff.attempts + 1) >= attempts)) { - throw e; + const data = await fn(() => { + aborted = true + }) + return data + } catch (e) { + if (aborted || backoff.attempts + 1 >= attempts) { + throw e } - const duration = backoff.duration(); - ngx.log(ngx.INFO, `acme-js: [utils] Promise rejected attempt #${backoff.attempts}, retrying in ${duration}ms: ${e}`); + const duration = backoff.duration() + ngx.log( + ngx.INFO, + `acme-js: [utils] Promise rejected attempt #${backoff.attempts}, retrying in ${duration}ms: ${e}` + ) - await new Promise((resolve) => { setTimeout(resolve, duration, {}); }); - return retryPromise(fn, attempts, backoff); + await new Promise((resolve) => { + setTimeout(resolve, duration, {}) + }) + return retryPromise(fn, attempts, backoff) } } - /** * Retry promise * @@ -631,9 +709,12 @@ async function retryPromise(fn: Function, attempts: number, backoff: Backoff): P * @param {number} [backoffOpts.max] Maximum attempt delay in milliseconds, default: `30000` * @returns {Promise} */ -export function retry(fn: Function, { attempts = 5, min = 5000, max = 30000 } = {}) { - const backoff = new Backoff({ min, max }); - return retryPromise(fn, attempts, backoff); +export function retry( + fn: (arg0: () => unknown) => unknown, + { attempts = 5, min = 5000, max = 30000 } = {} +): Promise { + const backoff = new Backoff({ min, max }) + return retryPromise(fn, attempts, backoff) } /** @@ -645,11 +726,11 @@ export function retry(fn: Function, { attempts = 5, min = 5000, max = 30000 } = */ export async function importPemPrivateKey(pem: string): Promise { // Decode PEM string to Uint8Array - const pemData = pemToBuffer(pem, 'PRIVATE KEY'); + const pemData = pemToBuffer(pem, 'PRIVATE KEY') // Parse PEM data to ASN.1 structure using pkijs - const asn1 = asn1js.fromBER(pemData.buffer); - const privateKeyInfo = new pkijs.PrivateKeyInfo({ schema: asn1.result }); + const asn1 = asn1js.fromBER(pemData.buffer) + const privateKeyInfo = new pkijs.PrivateKeyInfo({ schema: asn1.result }) // Use crypto.subtle.importKey to import private key as CryptoKey const privateKey = await crypto.subtle.importKey( @@ -657,10 +738,10 @@ export async function importPemPrivateKey(pem: string): Promise { privateKeyInfo.toSchema().toBER(false), { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }, true, - ['sign'], - ); + ['sign'] + ) - return privateKey; + return privateKey } /** @@ -670,7 +751,13 @@ export async function importPemPrivateKey(pem: string): Promise { * @returns Buffer */ export function pemToBuffer(pem: string, tag: PemTag = 'PRIVATE KEY') { - return Buffer.from(pem.replace(new RegExp(`(-----BEGIN ${tag}-----|-----END ${tag}-----|\n)`, 'g'), ''), 'base64'); + return Buffer.from( + pem.replace( + new RegExp(`(-----BEGIN ${tag}-----|-----END ${tag}-----|\n)`, 'g'), + '' + ), + 'base64' + ) } /** @@ -681,23 +768,22 @@ export function pemToBuffer(pem: string, tag: PemTag = 'PRIVATE KEY') { * @returns {object} Certificate info */ export async function readCertificateInfo(certPem: string) { - const domains = readCsrDomainNames(certPem); - const certBuffer = pemToBuffer(certPem, "CERTIFICATE"); - const cert = pkijs.Certificate.fromBER(certBuffer); + const domains = readCsrDomainNames(certPem) + const certBuffer = pemToBuffer(certPem, 'CERTIFICATE') + const cert = pkijs.Certificate.fromBER(certBuffer) const issuer = cert.issuer.typesAndValues.map((typeAndValue) => ({ [typeAndValue.type]: typeAndValue.value.valueBlock.value, - })); - const notBefore = cert.notBefore.value; - const notAfter = cert.notAfter.value; + })) + const notBefore = cert.notBefore.value + const notAfter = cert.notAfter.value return { issuer: issuer, domains: domains, notBefore: notBefore, - notAfter: notAfter - }; -}; - + notAfter: notAfter, + } +} /** * Split chain of PEM encoded objects from string into array @@ -707,37 +793,44 @@ export async function readCertificateInfo(certPem: string) { */ export function splitPemChain(chainPem: Buffer | string) { if (Buffer.isBuffer(chainPem)) { - chainPem = chainPem.toString(); + chainPem = chainPem.toString() } - return chainPem - /* Split chain into chunks, starting at every header */ - .split(/\s*(?=-----BEGIN [A-Z0-9- ]+-----\r?\n?)/g) - /* Match header, PEM body and footer */ - .map((pem) => pem.match(/\s*-----BEGIN ([A-Z0-9- ]+)-----\r?\n?([\S\s]+)\r?\n?-----END \1-----/)) - /* Filter out non-matches or empty bodies */ - .filter((pem) => pem && pem[2] && pem[2].replace(/[\r\n]+/g, '').trim()) - .map((arr) => arr && arr[0]); + return ( + chainPem + /* Split chain into chunks, starting at every header */ + .split(/\s*(?=-----BEGIN [A-Z0-9- ]+-----\r?\n?)/g) + /* Match header, PEM body and footer */ + .map((pem) => + pem.match( + /\s*-----BEGIN ([A-Z0-9- ]+)-----\r?\n?([\S\s]+)\r?\n?-----END \1-----/ + ) + ) + /* Filter out non-matches or empty bodies */ + .filter((pem) => pem && pem[2] && pem[2].replace(/[\r\n]+/g, '').trim()) + .map((arr) => arr && arr[0]) + ) } - /** * Reads the common name and alternative names from a CSR (Certificate Signing Request). * @param csrPem The PEM-encoded CSR string or a Buffer containing the CSR. * @returns An object with the commonName and altNames extracted from the CSR. * If the CSR does not have alternative names, altNames will be false. */ -export function readCsrDomainNames(csrPem: string | Buffer): { commonName: string, altNames: string[] | false } { +export function readCsrDomainNames(csrPem: string | Buffer): { + commonName: string + altNames: string[] | false +} { if (Buffer.isBuffer(csrPem)) { - csrPem = csrPem.toString(); + csrPem = csrPem.toString() } - var csr = x509.parse_pem_cert(csrPem); + const csr = x509.parse_pem_cert(csrPem) return { - commonName: x509.get_oid_value(csr, "2.5.4.3"), - altNames: x509.get_oid_value(csr, "2.5.29.17") - }; + commonName: x509.get_oid_value(csr, '2.5.4.3'), + altNames: x509.get_oid_value(csr, '2.5.29.17'), + } } - /** * Convenience method to return the value of a given environment variable or * nginx variable. Will return the environment variable if that is found first. @@ -747,17 +840,21 @@ export function readCsrDomainNames(csrPem: string | Buffer): { commonName: strin * @param varname Name of the variable * @returns value of the variable */ -export function getVariable(r: NginxHTTPRequest, varname: string, defaultVal?: string) { - const retval = process.env[varname.toUpperCase()] || r.variables[varname] || defaultVal +export function getVariable( + r: NginxHTTPRequest, + varname: string, + defaultVal?: string +) { + const retval = + process.env[varname.toUpperCase()] || r.variables[varname] || defaultVal if (retval === undefined) { - const errMsg = `Variable ${varname} not found and no default value given.`; - ngx.log(ngx.ERR, errMsg); - throw new Error(errMsg); + const errMsg = `Variable ${varname} not found and no default value given.` + ngx.log(ngx.ERR, errMsg) + throw new Error(errMsg) } return retval } - /** * Return an array of hostnames specified in the njs_acme_server_names variable * @param r request @@ -769,49 +866,54 @@ export function acmeServerNames(r: NginxHTTPRequest) { return nameStr.split(/[,\s]+/).map((n) => n.toLocaleLowerCase()) } - /** * Return the path where ACME magic happens * @param r request * @returns configured path or default */ export function acmeDir(r: NginxHTTPRequest) { - return getVariable(r, 'njs_acme_dir', '/etc/acme'); + return getVariable(r, 'njs_acme_dir', '/etc/acme') } - /** * Returns the path for the account private JWK * @param r {NginxHTTPRequest} */ export function acmeAccountPrivateJWKPath(r: NginxHTTPRequest) { - return getVariable(r, 'njs_acme_account_private_jwk', + return getVariable( + r, + 'njs_acme_account_private_jwk', joinPaths(acmeDir(r), 'account_private_key.json') - ); + ) } - /** * Returns the ACME directory URI * @param r {NginxHTTPRequest} */ export function acmeDirectoryURI(r: NginxHTTPRequest) { - return getVariable(r, 'njs_acme_directory_uri', 'https://acme-staging-v02.api.letsencrypt.org/directory'); + return getVariable( + r, + 'njs_acme_directory_uri', + 'https://acme-staging-v02.api.letsencrypt.org/directory' + ) } - /** * Returns whether to verify the ACME provider HTTPS certificate and chain * @param r {NginxHTTPRequest} * @returns boolean */ export function acmeVerifyProviderHTTPS(r: NginxHTTPRequest) { - return ['true', 'yes', '1'].indexOf( - getVariable(r, 'njs_acme_verify_provider_https', 'true').toLowerCase().trim() - ) > -1; + return ( + ['true', 'yes', '1'].indexOf( + getVariable(r, 'njs_acme_verify_provider_https', 'true') + .toLowerCase() + .trim() + ) > -1 + ) } - /** * Joins args with slashes and removes duplicate slashes * @param args path fragments to join From e58454f4d5bfb327c7cc87791bff62fb9bff005d Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Wed, 28 Jun 2023 14:01:15 -0700 Subject: [PATCH 07/13] make TypeScript gods slightly less unhappy with us --- docker-compose.yml | 7 +- src/api.ts | 178 +++++++++++++++++++++++--------------- src/client.ts | 209 ++++++++++++++++++++++++++------------------- src/index.ts | 6 +- src/tsconfig.json | 2 + src/utils.ts | 58 ++++++++----- 6 files changed, 274 insertions(+), 186 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7a837fd..be73ead 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,12 +20,7 @@ services: node: build: context: . - dockerfile_inline: | - FROM node:18 - WORKDIR /app - COPY package*.json . - RUN npm ci - CMD npm run watch + dockerfile: Dockerfile.node volumes: - .:/app - /app/node_modules diff --git a/src/api.ts b/src/api.ts index de4b3d4..d427162 100644 --- a/src/api.ts +++ b/src/api.ts @@ -5,7 +5,13 @@ import { EcdsaPublicJwk, } from './utils' import { version } from '../package.json' -import { ClientExternalAccountBindingOptions } from './client' +import { + AccountCreateRequest, + AccountUpdateRequest, + ClientExternalAccountBindingOptions, + OrderCreateRequest, +} from './client' +import crypto from 'crypto' export type AcmeMethod = 'GET' | 'HEAD' | 'POST' | 'POST-as-GET' export type AcmeResource = @@ -18,14 +24,23 @@ export type AcmeResource = | 'renewalInfo' export type AcmeSignAlgo = 'RS256' | 'ES256' | 'ES512' | 'ES384' -/* */ - export interface SignedPayload { payload: string protected: string signature?: string + externalAccountBinding?: ClientExternalAccountBindingOptions } +type ApiPayload = + | Record + | AccountCreateRequest + | OrderCreateRequest + | SignedPayload + | RsaPublicJwk + | EcdsaPublicJwk + | null + | undefined + export type UpdateAuthorizationData = { status: string } @@ -234,13 +249,13 @@ export class HttpClient { * @param {string} url HTTP URL * @param {string} method HTTP method * @param {object} [body] Request options - * @returns {Promise} HTTP response + * @returns {Promise} HTTP response */ async request( url: NjsStringLike, method: AcmeMethod, body: NjsStringLike = '' - ) { + ): Promise { const options: NgxFetchOptions = { headers: { 'user-agent': `njs-acme-v${version}`, @@ -264,7 +279,8 @@ export class HttpClient { if (this.debug) { ngx.log( ngx.INFO, - `njs-acme: [http] Got a response: ${resp.status + `njs-acme: [http] Got a response: ${ + resp.status } ${method} ${url} ${JSON.stringify(resp.headers)}` ) } @@ -287,12 +303,16 @@ export class HttpClient { */ async signedRequest( url: string, - payload: object, - { kid = null, nonce = null, includeExternalAccountBinding = false } = {}, + payload: ApiPayload, + { + kid = null as string | null, + nonce = null as NjsByteString | null, + includeExternalAccountBinding = false, + } = {}, attempts = 0 ): Promise { if (!nonce) { - nonce = await this.getNonce() + nonce = (await this.getNonce()) as NjsByteString } if (!this.jwk) { await this.getJwk() @@ -319,13 +339,16 @@ export class HttpClient { const jwk = this.jwk const eabKid = this.externalAccountBinding.kid const eabHmacKey = this.externalAccountBinding.hmacKey - // FIXME - ; (payload as any).externalAccountBinding = this.createSignedHmacBody( + + // FIXME + if (payload) { + payload.externalAccountBinding = this.createSignedHmacBody( eabHmacKey, url, jwk, { kid: eabKid } ) + } } } @@ -342,7 +365,7 @@ export class HttpClient { if (resp.status === 400) { // FIXME: potential issue here as we reading the response body // TODO: refactor maybe - const respData = await resp.json() + const respData = (await resp.json()) as Record /* Retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 */ if ( respData?.type === 'urn:ietf:params:acme:error:badNonce' && @@ -382,10 +405,10 @@ export class HttpClient { */ async apiRequest( url: string, - payload: any = null, + payload: ApiPayload = null, validStatusCodes: number[] = [], { includeJwsKid = true, includeExternalAccountBinding = false } = {} - ) { + ): Promise { const kid = includeJwsKid ? this.getAccountUrl() : null if (this.debug) { ngx.log( @@ -407,7 +430,8 @@ export class HttpClient { const b = await resp.json() ngx.log( ngx.WARN, - `njs-acme: [http] Received unexpected status code ${resp.status + `njs-acme: [http] Received unexpected status code ${ + resp.status } for API request ${url}. Expected status codes: ${validStatusCodes.join( ', ' )}. Body response: ${JSON.stringify(b)}` @@ -432,10 +456,10 @@ export class HttpClient { */ async apiResourceRequest( resource: AcmeResource, - payload: any = null, + payload: ApiPayload, validStatusCodes: number[] = [], { includeJwsKid = true, includeExternalAccountBinding = false } = {} - ) { + ): Promise { const resourceUrl = await this.getResourceUrl(resource) return this.apiRequest(resourceUrl, payload, validStatusCodes, { includeJwsKid, @@ -447,9 +471,9 @@ export class HttpClient { * Retrieves the ACME directory from the directory URL specified in the constructor. * * @throws {Error} Throws an error if the response status code is not 200 OK or the response body is invalid. - * @returns {Promise} Returns a Promise that resolves to an object representing the ACME directory. + * @returns {Promise} Updates internal `this.directory` and returns a promise */ - async getDirectory() { + async getDirectory(): Promise { if (!this.directory) { const resp = await this.request(this.directoryUrl, 'GET') @@ -458,11 +482,11 @@ export class HttpClient { `Attempting to read ACME directory returned error ${resp.status}: ${this.directoryUrl}` ) } - const data = await resp.json() + const data = (await resp.json()) as AcmeDirectory if (!data) { throw new Error('Attempting to read ACME directory returned no data') } - this.directory = data + this.directory = data if (this.debug) { ngx.log( ngx.INFO, @@ -482,7 +506,7 @@ export class HttpClient { * @returns {Promise} The public key associated with the account key, or null if not found * @throws {Error} If the account key is not set or is not valid */ - async getJwk() { + async getJwk(): Promise { // singleton if (!this.jwk) { if (this.debug) { @@ -511,7 +535,7 @@ export class HttpClient { * * @returns {Promise} nonce */ - async getNonce() { + async getNonce(): Promise { const url = await this.getResourceUrl('newNonce') const resp = await this.request(url, 'HEAD') if (!resp.headers.get('replay-nonce')) { @@ -542,25 +566,31 @@ export class HttpClient { `Unable to locate API resource URL in ACME directory: "${resource}"` ) } - return this.directory![resource] as string + if (!this.directory) { + throw new Error('this.directory is null') + } + return this.directory[resource] as string } /** * Get directory meta field * * @param {string} field Meta field name - * @returns {Promise} Meta field value - */ - async getMetaField(field: string): Promise { + * @returns {Promise} Meta field value + */ + async getMetaField( + field: + | 'termsOfService' + | 'website' + | 'caaIdentities' + | 'externalAccountRequired' + | 'endpoints' + ): Promise { await this.getDirectory() - if ( - this.directory && - 'meta' in this.directory && - field in this.directory.meta - ) { - return this.directory.meta[field] + if (!this.directory) { + throw new Error('this.directory is null') } - return + return this.directory?.meta?.[field] } /** @@ -577,11 +607,11 @@ export class HttpClient { prepareSignedBody( alg: AcmeSignAlgo | string, url: NjsStringLike, - payload = null, + payload: ApiPayload = null, jwk: RsaPublicJwk | EcdsaPublicJwk | null | undefined, - { nonce = null, kid = null } = {} + { nonce = null as string | null, kid = null as string | null } = {} ): SignedPayload { - const header: any = { alg, url } + const header: Record = { alg, url } /* Nonce */ if (nonce) { @@ -620,17 +650,17 @@ export class HttpClient { async createSignedHmacBody( hmacKey: string, url: string, - payload = null, - { nonce = null, kid = null } = {} - ) { + payload: ApiPayload = null, + { nonce = null as string | null, kid = null as string | null } = {} + ): Promise { if (!hmacKey) { throw new Error('HMAC key is required.') } - const result = this.prepareSignedBody('HS256', url, payload, { nonce, kid }) - const h = require('crypto').createHmac( - 'sha256', - Buffer.from(hmacKey, 'base64') - ) + const result = this.prepareSignedBody('HS256', url, payload, null, { + nonce, + kid, + }) + const h = crypto.createHmac('sha256', Buffer.from(hmacKey, 'base64')) h.update(`${result.protected}.${result.payload}`) result.signature = h.digest('base64url') return result @@ -650,22 +680,25 @@ export class HttpClient { */ async createSignedBody( url: NjsStringLike, - payload: any = null, - { nonce = null, kid = null } = {} + payload: ApiPayload = null, + { nonce = null as string | null, kid = null as string | null } = {} ): Promise { - const jwk = this.jwk! + const jwk = this.jwk let headerAlg: AcmeSignAlgo = 'RS256' - let signerAlg = 'SHA256' + let signerAlg = 'SHA-256' + if (!jwk) { + throw new Error('jwk is undefined') + } /* https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 */ if ('crv' in jwk && jwk.crv && jwk.kty === 'EC') { headerAlg = 'ES256' if (jwk.crv === 'P-384') { headerAlg = 'ES384' - signerAlg = 'SHA384' + signerAlg = 'SHA-384' } else if (jwk.crv === 'P-521') { headerAlg = 'ES512' - signerAlg = 'SHA512' + signerAlg = 'SHA-512' } } @@ -682,22 +715,27 @@ export class HttpClient { ) } + // njs-types/crypto.d.ts does not contain a `subtle` propery on the crypto object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const subtle = crypto.subtle as SubtleCrypto + let sign if (jwk.kty === 'EC') { - const hash = await crypto.subtle.digest( - { name: signerAlg }, + const hash = await subtle.digest( + signerAlg as HashVariants, `${result.protected}.${result.payload}` ) - sign = await crypto.subtle.sign( + sign = await subtle.sign( { name: 'ECDSA', - hash: hash, + hash: hash.toString() as HashVariants, }, this.accountKey, hash ) } else { - sign = await crypto.subtle.sign( + sign = await subtle.sign( { name: 'RSASSA-PKCS1-v1_5' }, this.accountKey, `${result.protected}.${result.payload}` @@ -730,7 +768,7 @@ export class HttpClient { * @returns {Promise} ToS URL */ async getTermsOfServiceUrl(): Promise { - return this.getMetaField('termsOfService') + return this.getMetaField('termsOfService') as Promise } /** @@ -744,7 +782,7 @@ export class HttpClient { * @param {boolean} data.onlyReturnExisting Whether the server should only return an existing account, or create a new one if it does not exist. * @returns {Promise} HTTP response. */ - async createAccount(data: object): Promise { + async createAccount(data: AccountCreateRequest): Promise { const resp = await this.apiResourceRequest('newAccount', data, [200, 201], { includeJwsKid: false, includeExternalAccountBinding: data.onlyReturnExisting !== true, @@ -766,7 +804,7 @@ export class HttpClient { * @param {object} data Request payload * @returns {Promise} HTTP response */ - updateAccount(data: object): Promise { + updateAccount(data: AccountUpdateRequest): Promise { return this.apiRequest(this.getAccountUrl(), data, [200, 202]) } @@ -775,10 +813,10 @@ export class HttpClient { * * https://tools.ietf.org/html/rfc8555#section-7.3.5 * - * @param {object} data Request payload + * @param {ApiPayload} data Request payload * @returns {Promise} HTTP response */ - updateAccountKey(data: object): Promise { + updateAccountKey(data: ApiPayload): Promise { return this.apiResourceRequest('keyChange', data, [200]) } @@ -787,10 +825,10 @@ export class HttpClient { * * https://tools.ietf.org/html/rfc8555#section-7.4 * - * @param {object} data Request payload + * @param {ApiPayload} data Request payload * @returns {Promise} HTTP response */ - createOrder(data: object): Promise { + createOrder(data: ApiPayload): Promise { return this.apiResourceRequest('newOrder', data, [201]) } @@ -812,10 +850,10 @@ export class HttpClient { * https://tools.ietf.org/html/rfc8555#section-7.4 * * @param {string} url Finalization URL - * @param {object} data Request payload + * @param {ApiPayload} data Request payload * @returns {Promise} HTTP response */ - finalizeOrder(url: string, data: object): Promise { + finalizeOrder(url: string, data: ApiPayload): Promise { return this.apiRequest(url, data, [200]) } @@ -853,10 +891,10 @@ export class HttpClient { * https://tools.ietf.org/html/rfc8555#section-7.5.1 * * @param {string} url Challenge URL - * @param {object} data Request payload + * @param {ApiPayload} data Request payload * @returns {Promise} HTTP response */ - completeChallenge(url: string, data: object): Promise { + completeChallenge(url: string, data: ApiPayload): Promise { return this.apiRequest(url, data, [200]) } @@ -866,13 +904,13 @@ export class HttpClient { * https://tools.ietf.org/html/rfc8555#section-7.6 * * - * @param {object} data - An object containing the data needed for revocation: + * @param {ApiPayload} data - An object containing the data needed for revocation: * @param {string} data.certificate - The certificate to be revoked. * @param {number} data.reason - An optional reason for revocation (default: 1). * See this https://datatracker.ietf.org/doc/html/rfc5280#section-5.3.1 * @returns {Promise} HTTP response */ - revokeCert(data: object): Promise { + revokeCert(data: ApiPayload): Promise { return this.apiResourceRequest('revokeCert', data, [200]) } @@ -881,7 +919,7 @@ export class HttpClient { * * @param {boolean} v - The value to set `verify` to. */ - setVerify(v: boolean) { + setVerify(v: boolean): void { this.verify = v } diff --git a/src/client.ts b/src/client.ts index daf6c78..327120b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,6 @@ import { HttpClient } from './api' import { formatResponseError, getPemBodyAsB64u, retry } from './utils' +import crypto from 'crypto' export interface ClientExternalAccountBindingOptions { kid: string @@ -29,15 +30,12 @@ export interface AccountCreateRequest { externalAccountBinding?: ClientExternalAccountBindingOptions } -export interface AccountUpdateRequest { +export type AccountUpdateRequest = { status?: string contact?: string[] termsOfServiceAgreed?: boolean -} - -interface AuthorizationResponseData { - url?: string -} + externalAccountBinding?: ClientExternalAccountBindingOptions +} | null /** * Order @@ -53,14 +51,16 @@ export interface Order { expires?: string notBefore?: string notAfter?: string - error?: object + error?: Record certificate?: string + url?: string } export interface OrderCreateRequest { identifiers: Identifier[] notBefore?: string notAfter?: string + externalAccountBinding?: ClientExternalAccountBindingOptions } /** @@ -71,15 +71,16 @@ export interface OrderCreateRequest { export interface Authorization { identifier: Identifier status: - | 'pending' - | 'valid' - | 'invalid' - | 'deactivated' - | 'expired' - | 'revoked' + | 'pending' + | 'valid' + | 'invalid' + | 'deactivated' + | 'expired' + | 'revoked' challenges: Challenge[] expires?: string wildcard?: boolean + url?: string } export interface Identifier { @@ -99,7 +100,7 @@ export interface ChallengeAbstract { url: string status: 'pending' | 'processing' | 'valid' | 'invalid' validated?: string - error?: object + error?: Record } export interface HttpChallenge extends ChallengeAbstract { @@ -112,7 +113,12 @@ export interface DnsChallenge extends ChallengeAbstract { token: string } -export type Challenge = HttpChallenge | DnsChallenge +export interface TlsAlpnChallenge extends ChallengeAbstract { + type: 'tls-alpn-01' + token: string +} + +export type Challenge = HttpChallenge | DnsChallenge | TlsAlpnChallenge /** * Certificate @@ -152,12 +158,12 @@ export interface ClientAutoOptions { authz: Authorization, challenge: Challenge, keyAuthorization: string - ) => Promise + ) => Promise challengeRemoveFn: ( authz: Authorization, challenge: Challenge, keyAuthorization: string - ) => Promise + ) => Promise email?: string termsOfServiceAgreed?: boolean challengePriority?: string[] @@ -280,7 +286,7 @@ export class AcmeClient { * } * ``` */ - getTermsOfServiceUrl() { + async getTermsOfServiceUrl(): Promise { return this.api.getTermsOfServiceUrl() } @@ -331,7 +337,7 @@ export class AcmeClient { data: AccountCreateRequest = { termsOfServiceAgreed: false, } - ): Promise { + ): Promise> { try { this.getAccountUrl() @@ -349,7 +355,7 @@ export class AcmeClient { ) return await this.updateAccount(data) } - return await resp.json() + return (await resp.json()) as Promise> } } @@ -368,25 +374,27 @@ export class AcmeClient { * }); * ``` */ - async updateAccount(data: AccountUpdateRequest = {}) { + async updateAccount( + data: AccountUpdateRequest = {} + ): Promise> { try { this.api.getAccountUrl() } catch (e) { - return this.createAccount(data) + return this.createAccount(data || undefined) } /* Remove data only applicable to createAccount() */ - if ('onlyReturnExisting' in data) { + if (data && 'onlyReturnExisting' in data) { delete data.onlyReturnExisting } /* POST-as-GET */ - if (Object.keys(data).length === 0) { + if (data && Object.keys(data).length === 0) { data = null } const resp = await this.api.updateAccount(data) - return await resp.json() + return (await resp.json()) as Promise> } /** @@ -406,8 +414,8 @@ export class AcmeClient { */ async updateAccountKey( newAccountKey: CryptoKey | string | Buffer, - data = {} - ) { + data: Record = {} + ): Promise> { // FIXME: if string | Buffer then handle reading from PEM if (Buffer.isBuffer(newAccountKey) || typeof newAccountKey === 'string') { @@ -416,8 +424,7 @@ export class AcmeClient { const accountUrl = this.api.getAccountUrl() - // FIXME - const newCryptoKey = null + const newCryptoKey = '' // TODO FIX THIS /* Create new HTTP and API clients using new key */ const newHttpClient = new HttpClient( @@ -432,7 +439,7 @@ export class AcmeClient { /* Get signed request body from new client */ const url = await newHttpClient.getResourceUrl('keyChange') - const body = newHttpClient.createSignedBody(url, data) + const body = await newHttpClient.createSignedBody(url, data) /* Change key using old client */ const resp = await this.api.updateAccountKey(body) @@ -441,7 +448,7 @@ export class AcmeClient { this.api = newHttpClient // FIXME - return await resp.json() + return (await resp.json()) as Record } /** @@ -462,7 +469,7 @@ export class AcmeClient { * }); * ``` */ - async createOrder(data: OrderCreateRequest) { + async createOrder(data: OrderCreateRequest): Promise { const resp = await this.api.createOrder(data) if (!resp.headers.get('location')) { @@ -471,7 +478,7 @@ export class AcmeClient { // FIXME /* Add URL to response */ - const respData = await resp.json() + const respData = (await resp.json()) as Order respData.url = resp.headers.get('location') return respData } @@ -490,7 +497,7 @@ export class AcmeClient { * const result = await client.getOrder(order); * ``` */ - async getOrder(order: Order) { + async getOrder(order: Order): Promise> { if (!order.url) { throw new Error('Unable to get order, URL not found') } @@ -498,7 +505,7 @@ export class AcmeClient { const resp = await this.api.getOrder(order.url) /* Add URL to response */ - const respData = await resp.json() + const respData = (await resp.json()) as Record respData.url = order.url return respData } @@ -519,7 +526,10 @@ export class AcmeClient { * const result = await client.finalizeOrder(order, csr); * ``` */ - async finalizeOrder(order: Order, csr: Buffer | string) { + async finalizeOrder( + order: Order, + csr: Buffer | string + ): Promise> { if (!order.finalize) { throw new Error('Unable to finalize order, URL not found') } @@ -529,7 +539,7 @@ export class AcmeClient { } // FIXME - const data = { csr: getPemBodyAsB64u(csr) } + const data = { csr: getPemBodyAsB64u(csr.toString()) } let resp try { resp = await this.api.finalizeOrder(order.finalize, data) @@ -538,7 +548,7 @@ export class AcmeClient { throw e } /* Add URL to response */ - const respData = await resp.json() + const respData = (await resp.json()) as Record respData.url = order.url return respData } @@ -561,11 +571,11 @@ export class AcmeClient { * }); * ``` */ - async getAuthorizations(order: Order): Promise { + async getAuthorizations(order: Order): Promise { return Promise.all( (order.authorizations || []).map(async (url) => { const resp = await this.api.getAuthorization(url) - const respData = (await resp.json()) as AuthorizationResponseData + const respData = (await resp.json()) as Authorization /* Add URL to response */ respData.url = url return respData @@ -587,7 +597,9 @@ export class AcmeClient { * const result = await client.deactivateAuthorization(authz); * ``` */ - async deactivateAuthorization(authz) { + async deactivateAuthorization( + authz: Authorization + ): Promise> { if (!authz.url) { throw new Error( 'Unable to deactivate identifier authorization, URL not found' @@ -598,10 +610,10 @@ export class AcmeClient { status: 'deactivated', } - const resp = await this.api.updateAuthorization(authz.url, data) + const resp = await this.api.updateAuthorization(authz.url as string, data) /* Add URL to response */ - const respData = await resp.json() + const respData = (await resp.json()) as Record respData.url = authz.url return respData } @@ -622,12 +634,10 @@ export class AcmeClient { * // Write key somewhere to satisfy challenge * ``` */ - async getChallengeKeyAuthorization(challenge) { + async getChallengeKeyAuthorization(challenge: Challenge): Promise { const jwk = await this.api.getJwk() - const keysum = require('crypto') - .createHash('sha256') - .update(JSON.stringify(jwk)) + const keysum = crypto.createHash('sha256').update(JSON.stringify(jwk)) const thumbprint = keysum.digest('base64url') const result = `${challenge.token}.${thumbprint}` @@ -647,7 +657,7 @@ export class AcmeClient { } throw new Error( - `Unable to produce key authorization, unknown challenge type: ${challenge.type}` + `Unable to produce key authorization, unknown challenge type: ${challenge}` ) } @@ -665,9 +675,11 @@ export class AcmeClient { * const result = await client.completeChallenge(challenge); * ``` */ - async completeChallenge(challenge) { - const resp = await this.api.completeChallenge(challenge.url, {}) - return await resp.json() + async completeChallenge( + challenge: Challenge + ): Promise> { + const resp = await this.api.completeChallenge(challenge.url as string, {}) + return (await resp.json()) as Record } /** @@ -696,16 +708,18 @@ export class AcmeClient { * await client.waitForValidStatus(order); * ``` */ - async waitForValidStatus(item) { + async waitForValidStatus( + item: Record | Challenge + ): Promise> { if (!item.url) { throw new Error('Unable to verify status of item, URL not found') } - const verifyFn = async (abort) => { - const resp = await this.api.apiRequest(item.url, null, [200]) + const verifyFn = async (abort: () => void) => { + const resp = await this.api.apiRequest(item.url as string, null, [200]) /* Verify status */ - const respData = await resp.json() + const respData = (await resp.json()) as Record ngx.log( ngx.INFO, `njs-acme: [client] Item has status: ${respData.status}` @@ -727,7 +741,7 @@ export class AcmeClient { ngx.INFO, `njs-acme: [client] Waiting for valid status from: ${item.url} ${this.backoffOpts}` ) - return retry(verifyFn, this.backoffOpts) + return retry(verifyFn, this.backoffOpts) as Promise> } /** @@ -751,8 +765,11 @@ export class AcmeClient { * const certificate = await client.getCertificate(order, 'DST Root CA X3'); * ``` */ - async getCertificate(order, preferredChain = null): Promise { - if (!validStates.includes(order.status)) { + async getCertificate( + order: Record, + _preferredChain: string | null = null // TODO delete? + ): Promise { + if (!validStates.includes(order.status as string)) { order = await this.waitForValidStatus(order) } @@ -760,20 +777,23 @@ export class AcmeClient { throw new Error('Unable to download certificate, URL not found') } - const resp = await this.api.apiRequest(order.certificate, null, [200]) + const resp = await this.api.apiRequest(order.certificate as string, null, [ + 200, + ]) /* Handle alternate certificate chains */ - if (preferredChain && resp.headers.link) { - const alternateLinks = util.parseLinkHeader(resp.headers.link) - const alternates = await Promise.all( - alternateLinks.map(async (link) => - this.api.apiRequest(link, null, [200]) - ) - ) - const certificates = [resp].concat(alternates).map((c) => c.data) - - return util.findCertificateChainForIssuer(certificates, preferredChain) - } + // TODO -- SHOULD WE DELETE THIS? OR IMPLEMENT utils.* + //if (preferredChain && resp.headers.link) { + // const alternateLinks = util.parseLinkHeader(resp.headers.link) + // const alternates = await Promise.all( + // alternateLinks.map(async (link: string) => + // this.api.apiRequest(link, null, [200]) + // ) + // ) + // const certificates = [resp].concat(alternates).map((c) => c.data) + + // return util.findCertificateChainForIssuer(certificates, preferredChain) + //} /* Return default certificate chain */ // FIXME: is it json() or text() @@ -803,10 +823,13 @@ export class AcmeClient { * }); * ``` */ - async revokeCertificate(cert: Buffer | string, data = {}) { - data.certificate = getPemBodyAsB64u(cert) + async revokeCertificate( + cert: Buffer | string, + data: Record = {} + ): Promise> { + data.certificate = getPemBodyAsB64u(cert.toString()) const resp = await this.api.revokeCert(data) - return await resp.json() + return (await resp.json()) as Record } /** @@ -857,7 +880,7 @@ export class AcmeClient { * }); * ``` */ - auto(opts: ClientAutoOptions) { + auto(opts: ClientAutoOptions): Promise { return auto(this, opts) } } @@ -888,9 +911,11 @@ async function auto( userOpts: ClientAutoOptions ): Promise { const opts = Object.assign({}, autoDefaultOpts, userOpts) - const accountPayload = { termsOfServiceAgreed: opts.termsOfServiceAgreed } + const accountPayload: Record = { + termsOfServiceAgreed: opts.termsOfServiceAgreed, + } - if (!Buffer.isBuffer(opts.csr)) { + if (!Buffer.isBuffer(opts.csr) && opts.csr) { opts.csr = Buffer.from(opts.csr) } @@ -950,8 +975,8 @@ async function auto( 'njs-acme: [auto] Resolving and satisfying authorization challenges' ) - const challengePromises = authorizations.map(async (authz) => { - const d = authz.identifier.value + const challengePromises = authorizations.map(async (authz: Authorization) => { + const d = authz.identifier?.value let challengeCompleted = false /* Skip authz that already has valid status */ @@ -966,9 +991,9 @@ async function auto( try { /* Select challenge based on priority */ const challenge = authz.challenges - .sort((a, b) => { - const aidx = opts.challengePriority!.indexOf(a.type) - const bidx = opts.challengePriority!.indexOf(b.type) + ?.sort((a: Challenge, b: Challenge) => { + const aidx = opts.challengePriority?.indexOf(a.type as string) || -1 + const bidx = opts.challengePriority?.indexOf(b.type as string) || -1 if (aidx === -1) return 1 if (bidx === -1) return -1 @@ -1011,25 +1036,31 @@ async function auto( } catch (e) { ngx.log( ngx.INFO, - `njs-acme: [auto] [${d}] challengeRemoveFn threw error: ${e.message}` + `njs-acme: [auto] [${d}] challengeRemoveFn threw error: ${ + (e as Error).message + }` ) } } - } catch (e) { + } catch (e: unknown) { /* Deactivate pending authz when unable to complete challenge */ if (!challengeCompleted) { ngx.log( ngx.INFO, - `njs-acme: [auto] [${d}] Unable to complete challenge: ${e.message}` + `njs-acme: [auto] [${d}] Unable to complete challenge: ${ + (e as Error).message + }` ) try { await client.deactivateAuthorization(authz) - } catch (f) { + } catch (f: unknown) { /* Suppress deactivateAuthorization() errors */ ngx.log( ngx.INFO, - `njs-acme: [auto] [${d}] Authorization deactivation threw error: ${f.message}` + `njs-acme: [auto] [${d}] Authorization deactivation threw error: ${ + (f as Error).message + }` ) } } @@ -1041,11 +1072,15 @@ async function auto( ngx.log(ngx.INFO, 'njs-acme: [auto] Waiting for challenge valid status') await Promise.all(challengePromises) + if (!opts.csr) { + throw new Error('options is missing required csr') + } + /** * Finalize order and download certificate */ ngx.log(ngx.INFO, 'njs-acme: [auto] Finalize order and download certificate') - const finalized = await client.finalizeOrder(order, opts.csr) + const finalized = await client.finalizeOrder(order, opts.csr.toString()) const certData = await client.getCertificate(finalized, opts.preferredChain) return certData } diff --git a/src/index.ts b/src/index.ts index 03ac28c..8af1c31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,7 +43,9 @@ async function clientNewAccount(r: NginxHTTPRequest): Promise { }) return r.return(200, JSON.stringify(account)) } catch (e) { - ngx.log(ngx.ERR, `Error creating ACME account. Error=${e}`) + const errMsg = `Error creating ACME account. Error=${e}` + ngx.log(ngx.ERR, errMsg) + return r.return(500, errMsg) } } @@ -82,7 +84,7 @@ async function clientAutoMode(r: NginxHTTPRequest): Promise { certInfo = await readCertificateInfo(certData) // Calculate the date 30 days before the certificate expiration - const renewalThreshold = new Date(certInfo.notAfter) + const renewalThreshold = new Date(certInfo.notAfter as string) renewalThreshold.setDate(renewalThreshold.getDate() - 30) const currentDate = new Date() diff --git a/src/tsconfig.json b/src/tsconfig.json index 44526c9..020c71b 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -27,6 +27,7 @@ // "alwaysStrict": true, "isolatedModules": true, "resolveJsonModule" : true, + "rootDir": "../", "typeRoots": [], "types": [ "njs-types", @@ -34,6 +35,7 @@ }, "include": [ ".", + "../package.json" ], "exclude": [ "../node_modules" diff --git a/src/utils.ts b/src/utils.ts index 0f3798f..ef811b3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,8 @@ import * as pkijs from 'pkijs' import * as asn1js from 'asn1js' import fs from 'fs' import querystring from 'querystring' +import { SignedPayload } from './api.js' +import { ClientExternalAccountBindingOptions } from './client.js' // workaround for PKI.JS to work globalThis.unescape = querystring.unescape @@ -18,10 +20,12 @@ pkijs.setEngine( if (!Array.from) { Array.from = (function () { const toStr = Object.prototype.toString - const isCallable = function (fn) { + const isCallable = function ( + fn: ((arg0: unknown, arg1?: unknown) => unknown) | Array + ) { return typeof fn === 'function' || toStr.call(fn) === '[object Function]' } - const toInteger = function (value) { + const toInteger = function (value: unknown) { const number = Number(value) if (isNaN(number)) { return 0 @@ -32,28 +36,35 @@ if (!Array.from) { return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number)) } const maxSafeInteger = Math.pow(2, 53) - 1 - const toLength = function (value) { + const toLength = function (value: unknown) { const len = toInteger(value) return Math.min(Math.max(len, 0), maxSafeInteger) } // The length property of the from method is 1. - return function from(arrayLike /*, mapFn, thisArg */) { + return function from( + this: Array | ((arg1: unknown, arg2?: unknown) => unknown), + ...args: unknown[] /*, mapFn, thisArg */ + ) { // 1. Let C be the this value. + // eslint-disable-next-line @typescript-eslint/no-this-alias const C = this // 2. Let items be ToObject(arrayLike). - const items = Object(arrayLike) + const items = Object(args) // 3. ReturnIfAbrupt(items). - if (arrayLike == null) { + if (args == null) { throw new TypeError( 'Array.from requires an array-like object - not null or undefined' ) } // 4. If mapfn is undefined, then let mapping be false. - const mapFn = arguments.length > 1 ? arguments[1] : void undefined + const mapFn = + arguments.length > 1 + ? (args[1] as (arg0: unknown, arg1: unknown) => unknown) + : void undefined let T if (typeof mapFn !== 'undefined') { // 5. else @@ -66,7 +77,7 @@ if (!Array.from) { // 5. b. If thisArg was supplied, let T be thisArg; else let T be undefined. if (arguments.length > 2) { - T = arguments[2] + T = args[2] } } @@ -77,12 +88,13 @@ if (!Array.from) { // 13. If IsConstructor(C) is true, then // 13. a. Let A be the result of calling the [[Construct]] internal method of C with an argument list containing the single item len. // 14. a. Else, Let A be ArrayCreate(len). - const A = isCallable(C) ? Object(new C(len)) : new Array(len) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const A = isCallable(C) ? Object(new (C as any)(len)) : new Array(len) // 16. Let k be 0. let k = 0 // 17. Repeat, while k < len… (also steps a - h) - let kValue + let kValue: unknown while (k < len) { kValue = items[k] if (mapFn) { @@ -107,6 +119,7 @@ export interface RsaPublicJwk { e: string kty: string n: string + externalAccountBinding?: ClientExternalAccountBindingOptions } export interface EcdsaPublicJwk { @@ -114,6 +127,7 @@ export interface EcdsaPublicJwk { kty: string x: string y: string + externalAccountBinding?: ClientExternalAccountBindingOptions } const ACCOUNT_KEY_ALG_GENERATE: RsaHashedKeyGenParams = { @@ -736,7 +750,7 @@ export async function importPemPrivateKey(pem: string): Promise { const privateKey = await crypto.subtle.importKey( 'pkcs8', privateKeyInfo.toSchema().toBER(false), - { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, true, ['sign'] ) @@ -750,7 +764,7 @@ export async function importPemPrivateKey(pem: string): Promise { * @param {string} tag The tag name used to identify the PEM block. * @returns Buffer */ -export function pemToBuffer(pem: string, tag: PemTag = 'PRIVATE KEY') { +export function pemToBuffer(pem: string, tag: PemTag = 'PRIVATE KEY'): Buffer { return Buffer.from( pem.replace( new RegExp(`(-----BEGIN ${tag}-----|-----END ${tag}-----|\n)`, 'g'), @@ -767,7 +781,9 @@ export function pemToBuffer(pem: string, tag: PemTag = 'PRIVATE KEY') { * @param {buffer|string} certPem PEM encoded certificate or chain * @returns {object} Certificate info */ -export async function readCertificateInfo(certPem: string) { +export async function readCertificateInfo( + certPem: string +): Promise> { const domains = readCsrDomainNames(certPem) const certBuffer = pemToBuffer(certPem, 'CERTIFICATE') const cert = pkijs.Certificate.fromBER(certBuffer) @@ -791,7 +807,7 @@ export async function readCertificateInfo(certPem: string) { * @param {buffer|string} chainPem PEM encoded object chain * @returns {array} Array of PEM objects including headers */ -export function splitPemChain(chainPem: Buffer | string) { +export function splitPemChain(chainPem: Buffer | string): (string | null)[] { if (Buffer.isBuffer(chainPem)) { chainPem = chainPem.toString() } @@ -844,7 +860,7 @@ export function getVariable( r: NginxHTTPRequest, varname: string, defaultVal?: string -) { +): string { const retval = process.env[varname.toUpperCase()] || r.variables[varname] || defaultVal if (retval === undefined) { @@ -860,7 +876,7 @@ export function getVariable( * @param r request * @returns array of hostnames */ -export function acmeServerNames(r: NginxHTTPRequest) { +export function acmeServerNames(r: NginxHTTPRequest): string[] { const nameStr = getVariable(r, 'njs_acme_server_names') // no default == mandatory // split string value on comma and/or whitespace and lowercase each element return nameStr.split(/[,\s]+/).map((n) => n.toLocaleLowerCase()) @@ -871,7 +887,7 @@ export function acmeServerNames(r: NginxHTTPRequest) { * @param r request * @returns configured path or default */ -export function acmeDir(r: NginxHTTPRequest) { +export function acmeDir(r: NginxHTTPRequest): string { return getVariable(r, 'njs_acme_dir', '/etc/acme') } @@ -879,7 +895,7 @@ export function acmeDir(r: NginxHTTPRequest) { * Returns the path for the account private JWK * @param r {NginxHTTPRequest} */ -export function acmeAccountPrivateJWKPath(r: NginxHTTPRequest) { +export function acmeAccountPrivateJWKPath(r: NginxHTTPRequest): string { return getVariable( r, 'njs_acme_account_private_jwk', @@ -891,7 +907,7 @@ export function acmeAccountPrivateJWKPath(r: NginxHTTPRequest) { * Returns the ACME directory URI * @param r {NginxHTTPRequest} */ -export function acmeDirectoryURI(r: NginxHTTPRequest) { +export function acmeDirectoryURI(r: NginxHTTPRequest): string { return getVariable( r, 'njs_acme_directory_uri', @@ -904,7 +920,7 @@ export function acmeDirectoryURI(r: NginxHTTPRequest) { * @param r {NginxHTTPRequest} * @returns boolean */ -export function acmeVerifyProviderHTTPS(r: NginxHTTPRequest) { +export function acmeVerifyProviderHTTPS(r: NginxHTTPRequest): boolean { return ( ['true', 'yes', '1'].indexOf( getVariable(r, 'njs_acme_verify_provider_https', 'true') @@ -919,7 +935,7 @@ export function acmeVerifyProviderHTTPS(r: NginxHTTPRequest) { * @param args path fragments to join * @returns joined path string */ -export function joinPaths(...args: string[]) { +export function joinPaths(...args: string[]): string { // join args with a slash remove duplicate slashes return args.join('/').replace(/\/+/g, '/') } From 8e3b027dd65e9494495cdd13cb1a122bd4f13228 Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Wed, 28 Jun 2023 14:36:44 -0700 Subject: [PATCH 08/13] remove unused import --- src/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index ef811b3..a6486ae 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,7 +3,6 @@ import * as pkijs from 'pkijs' import * as asn1js from 'asn1js' import fs from 'fs' import querystring from 'querystring' -import { SignedPayload } from './api.js' import { ClientExternalAccountBindingOptions } from './client.js' // workaround for PKI.JS to work From 9b1fe361ba66f8029eeda8494648da51d769fef3 Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Thu, 29 Jun 2023 16:02:53 -0700 Subject: [PATCH 09/13] enhancements for dev mode; return to multistage build; clean up Makefile; Add lots of content to README.md --- .devcontainer/devcontainer.json | 18 ++ .dockerignore | 1 + Dockerfile | 21 +- Makefile | 46 ++--- README.md | 212 ++++++++++++++++++--- dev/Dockerfile.nginx | 13 ++ dev/Dockerfile.node | 5 + nginx_wait_for_js => dev/nginx_wait_for_js | 0 docker-compose.yml | 9 +- examples/nginx.conf | 16 +- src/index.ts | 9 +- src/utils.ts | 8 +- 12 files changed, 283 insertions(+), 75 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 dev/Dockerfile.nginx create mode 100644 dev/Dockerfile.node rename nginx_wait_for_js => dev/nginx_wait_for_js (100%) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..ad4dea2 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,18 @@ +// For format details, see https://aka.ms/devcontainer.json. +{ + "name": "njsacme", + "dockerComposeFile": ["../docker-compose.yml"], + "service": "node", + "workspaceFolder": "/app", + "shutdownAction": "stopCompose", + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "ms-vsliveshare.vsliveshare", + "ms-azuretools.vscode-docker", + "esbenp.prettier-vscode" + ] + } + } +} diff --git a/.dockerignore b/.dockerignore index 3c3629e..968d9e7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,2 @@ node_modules +Makefile diff --git a/Dockerfile b/Dockerfile index 46d37b9..86c3451 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,9 @@ -FROM nginx:1.24.0 +FROM node:18 AS builder +WORKDIR /app +COPY . . +RUN npm ci +RUN npm run build -# following installation steps from http://nginx.org/en/linux_packages.html#Debian -RUN --mount=type=cache,target=/var/cache/apt < /dev/null || command -v grep 2> /dev/null) AWK ?= $(shell command -v gawk 2> /dev/null || command -v awk 2> /dev/null) DOCKER ?= docker -PROJECT_NAME ?= njs-acme-experemental +PROJECT_NAME ?= njs-acme GITHUB_REPOSITORY ?= nginxinc/$(PROJECT_NAME) SRC_REPO := https://github.com/$(GITHUB_REPOSITORY) CURRENT_DIR = $(shell pwd) @@ -17,46 +17,46 @@ help: $(AWK) 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-28s\033[0m %s\n", $$1, $$2}' | sort -.PHONY: -format: ## Run rustfmt - $Q echo "$(M) building in release mode for the current platform" - - .PHONY: build: ## Run npm run build $Q echo "$(M) building in release mode for the current platform" $Q npm run build -.PHONY: -build: ## Run npm run build - $Q echo "$(M) building in release mode for the current platform" - $Q npm run build .PHONY: build-docker build-docker: ## Build docker image - $(DOCKER) buildx build $(DOCKER_BUILD_FLAGS) -t $(PROJECT_NAME) . + $(DOCKER) build $(DOCKER_BUILD_FLAGS) -t $(PROJECT_NAME) . + + +.PHONY: copy-docker +copy-docker: CONTAINER_ID=$(shell $(DOCKER) create $(PROJECT_NAME)) +copy-docker: build-docker ## Copy the acme.js file out of the container and save in dist/ + echo ${CONTAINER_ID} + $(DOCKER) cp ${CONTAINER_ID}:/usr/lib/nginx/njs_modules/acme.js dist/acme.js + $(DOCKER) rm -v ${CONTAINER_ID} .PHONY: start-docker -start-docker: build # build-docker ## Start docker container +start-docker: build-docker ## Start nginx container $(DOCKER) run --rm -it -p 8000:8000 \ - -e "NJS_ACME_DIR=/etc/nginx/examples" \ - -v $(CURRENT_DIR)/examples:/etc/nginx/examples/ \ - -v $(CURRENT_DIR)/dist:/etc/nginx/dist/ njs-acme-experemental nginx -c examples/nginx.conf + -e "NJS_ACME_DIR=/etc/nginx/examples" \ + -v $(CURRENT_DIR)/examples:/etc/nginx/examples/ \ + -v $(CURRENT_DIR)/dist:/usr/lib/nginx/njs_modules/ $(PROJECT_NAME) nginx -c examples/nginx.conf -.PHONY: start-docker -start-njs: build # build-docker ## Start docker container +.PHONY: start-njs +start-njs: build-docker ## Start nginx container and run `njs` $(DOCKER) run --rm -it -p 8000:8000 \ - -e "NJS_ACME_DIR=/etc/nginx/examples" \ - -v $(CURRENT_DIR)/examples:/etc/nginx/examples/ \ - -v $(CURRENT_DIR)/dist:/etc/nginx/dist/ njs-acme-experemental njs + -e "NJS_ACME_DIR=/etc/nginx/examples" \ + -v $(CURRENT_DIR)/examples:/etc/nginx/examples/ \ + -v $(CURRENT_DIR)/dist:/usr/lib/nginx/njs_modules/ $(PROJECT_NAME) njs + .PHONY: start-all start-all: build ## Start all docker compose services - docker compose up -d + $(DOCKER) compose up -d + .PHONY: reload-nginx reload-nginx: build start-all ## Reload nginx - docker compose stop nginx && docker compose start nginx && docker compose logs -f nginx - + $(DOCKER) compose up -d --force-recreate nginx && $(DOCKER) compose logs -f nginx diff --git a/README.md b/README.md index f091bb5..cd9536f 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,192 @@ # njs-acme -This repository provides JavaScript library to work with [ACME](https://datatracker.ietf.org/doc/html/rfc8555) providers(such as Let's Encrypt) for **NJS**. The source code is compatible with `ngx_http_js_module` runtime. This may allow the automatic issue of certificates for NGINX. +This repository provides a JavaScript library to work with [ACME](https://datatracker.ietf.org/doc/html/rfc8555) providers such as Let's Encrypt for **NJS**. The source code is compatible with the `ngx_http_js_module` runtime. This allows for the automatic issue of TLS/SSL certificates for NGINX. + +Some ACME providers have strict rate limits. Please consult with your provider. For Let's Encrypt refer to [this](https://letsencrypt.org/docs/rate-limits/) rate-limits documentation. + +This project uses Babel and Rollup to compile TypeScript sources into a single JavaScript file for `njs`. It uses Mocha with nginx-testing for running integration tests against the NGINX server. This project uses [njs-typescript-starter](https://github.com/jirutka/njs-typescript-starter/tree/master) to write NJS modules and integration tests in TypeScript. + +The ACME RESTful client is implemented using [ngx.fetch](http://nginx.org/en/docs/njs/reference.html#ngx_fetch), [crypto API](http://nginx.org/en/docs/njs/reference.html#builtin_crypto), [PKI.js](https://pkijs.org/) APIs in NJS runtime. + + +## Configuration Variables + +You can use environment variables or NGINX configuration variables to control the behavior of the NJS ACME client. In the case where both are defined, environment variables are preferred. Environment variables are in `ALL_CAPS`, whereas the nginx config variable is the same name, just `lower_case`. + +### Required Variables + + - `NJS_ACME_ACCOUNT_EMAIL`\ + Your email address to send to the ACME provider.\ + value: Any valid email address\ + default: none (you must specify this!) + + - `NJS_ACME_SERVER_NAMES`\ + The hostname or list of hostnames to request the certificate for.\ + value: Space-separated list of hostnames, e.g. `www1.mydomain.com www2.mydomain.com`\ + default: none (you must specify this!) + +### NGINX Variables Only (not allowed as environment variable) + - `njs_acme_challenge_dir`\ + NGINX variable with the path to where store HTTP-01 challenges.\ + value: Any valid system path writable by the `nginx` user.\ + default: none (you must specify this!) + +### Optional Variables + - `NJS_ACME_VERIFY_PROVIDER_HTTPS`\ + Verifies the ACME provider SSL certificate when connecting.\ + value: `false` | `true`\ + default: `true` + + - `NJS_ACME_DIRECTORY_URI`\ + ACME directory URL.\ + value: Any valid URL\ + default: `https://acme-staging-v02.api.letsencrypt.org/directory` + + - `NJS_ACME_DIR`\ + Path to store ACME-related files such as keys, certificate requests, certificates, etc.\ + value: Any valid system path writable by the `nginx` user. \ + default: `/etc/acme/` + + - `NJS_ACME_ACCOUNT_PRIVATE_JWK`\ + Path to fetch/store the account private JWK.\ + value: Path to the private JWK\ + default: `${NJS_ACME_DIR}/account_private_key.json` + + +## NGINX Configuration + +There are a few pieces that are required to be present in your `nginx.conf` file. The file at `examples/nginx.conf` shows them all. + +### Config Root +* Ensures the NJS module is loaded. + ```nginx + load_module modules/ngx_http_js_module.so; + ``` + +### `http` Section +* Adds our module directory to the search path. + ```nginx + js_path "/usr/lib/nginx/njs_modules/"; + ``` +* Ensures the Let's Encrypt root certificate is loaded. + ```nginx + js_fetch_trusted_certificate /etc/ssl/certs/ISRG_Root_X1.pem; + ``` +* Load `acme.js` into the `acme` namespace. + ```nginx + js_import acme from acme.js; + ``` +* Configure a DNS resolver for NJS to use. + ```nginx + resolver 127.0.0.11 ipv6=off; # docker-compose + ``` + +### `server` Section +* Set the hostname or hostnames (space-separated) to generate the certificate. + ```nginx + set $njs_acme_server_names proxy.nginx.com; + ``` +* Set your email address to use to configure your ACME account. + ```nginx + set $njs_acme_account_email test@example.com; + ``` +* Set the directory to store challenges. This is also used in a `location{}` block below. + ```nginx + set $njs_acme_challenge_dir /etc/acme/challenge; + ``` +* Set and use variables to hold the certificate and key paths using Javascript. + ```nginx + js_set $dynamic_ssl_cert acme.js_cert; + js_set $dynamic_ssl_key acme.js_key; + + ssl_certificate $dynamic_ssl_cert; + ssl_certificate_key $dynamic_ssl_key; + ``` +### `location` Blocks +* Location to handle ACME challenge requests. `$njs_acme_challenge_dir` is used here. + ```nginx + location ^~ /.well-known/acme-challenge/ { + default_type "text/plain"; + root $njs_acme_challenge_dir; + } + ``` +* Location, that when requested, inspects the stored certificate (if present) and will request a new certificate if necessary. The included `docker-compose.yml` shows how to use a `healthcheck:` configuration for the NGINX service to periodically request this endpoint. + ```nginx + location = /acme/auto { + js_content acme.clientAutoMode; + } + ``` + +## Automatic Certificate Renewal + +NGINX and NJS do not yet have a mechanism for running code on a time interval, which presents a challenge for certificate renewal. One workaround to this is to set something up to periodically request `/acme/auto` from the NGINX server. This can be done via `cron`, or if you are running in a `docker compose` context, you can use Docker's `healthcheck:` functionality to do this. Here is an example: + +```docker +service: + nginx: + ... + healthcheck: + test: ["CMD", "curl", "-f", "http://proxy.nginx.com:8000/acme/auto"] + interval: 1m30s + timeout: 90s + retries: 3 + start_period: 10s +``` + +This configuration will request `/acme/auto` every 90 seconds. If the certificate is nearing expiry, it will be automatically renewed. + +## Development + +### With Docker + +There is a `docker-compose.yml` file in the project root directory that brings up an ACME server, a challenge server, a Node.js container for rebuilding the `acme.js` file when source files change, and an NGINX container. The built `acme.js` file is shared between the Node.js and NGINX containers. The NGINX container will reload when the `acme.js` file changes. + +To start up the development environment with docker compose, run the following: + + make start-all + +If you use VSCode or another devcontainer-compatible editor, then run the following: + + code . + +Choose to "Reopen in container" and the services specified in the `docker-compose.yml` file will start. Editing and saving source files will trigger a rebuild of the `acme.js` file, and NGINX will reload its configuration. + +### Without Docker + +To follow these steps, you will need to have Node.js version 14.15 or greater installed on your system. + +1. Install dependencies: + + npm ci + +2. Start the watcher: + + npm run watch + +3. Edit the source files. When you save a change, the watcher will rebuild `./dist/acme.js` or display errors. -Some ACME providers, such as Let's Encrypt have strict rate limits. Please consult with your provider. For Let's Encrypt refer to [this](https://letsencrypt.org/docs/rate-limits/) rate-limits documentation. +## Building the `acme.js` File -## Getting Started +### With Docker -It uses Babel and Rollup to compile TypeScript sources into a single JavaScript file for `njs` and Mocha with nginx-testing for running integration tests against the NGINX server. This project uses [njs-typescript-starter](https://github.com/jirutka/njs-typescript-starter/tree/master) to write NJS modules and integration tests in TypeScript. +Run this command to build an NGINX container that has the `acme.js` file and an example config loaded: -To build a JavaScript code From TypeScript: + make build-docker + +You can then copy the created `acme.js` file out of the container with this command: + + make copy-docker +The `acme.js` file will then be copied into the `dist/` directory. + + +### Without Docker + +To build `acme.js` from the TypeScript source, first ensure that you have Node.js (at least version 14.15) installed on your machine, then: 1. Install dependencies - npm install + npm ci 1. Build it: @@ -22,7 +195,9 @@ To build a JavaScript code From TypeScript: 1. `./dist/acme.js` would contain the JavaScript code -Here are some steps to test it via Docker: +## Testing + +### With Docker 1. Start a test environment in Docker: @@ -54,28 +229,19 @@ Here are some steps to test it via Docker: [Docker-compose](./docker-compose.yml) file uses volumes to persist artifacts (account keys, certificate, keys). Additionally, [letsencrypt/pebble](https://github.com/letsencrypt/pebble) is used for testing in Docker, so you don't need to open up port 80 for challenge validation. -## Project Structure - -| Path | Description | -| ---- | ------------| -| [src](src) | Contains your source code that will be compiled to the `dist/` directory. | -| [integration-tests](integration-tests) | Contains your source code of tests. | -## How to Use +## Build Your Own Flows -This library implements ACME RESTful client using [ngx.fetch](http://nginx.org/en/docs/njs/reference.html#ngx_fetch), [crypto API](http://nginx.org/en/docs/njs/reference.html#builtin_crypto), [PKI.js](https://pkijs.org/) APIs in NJS runtime. This allows using this ACME Client as a library to implement your flows. Such as within a handler for `js_content`: +If the reference impelementation does not meet your needs, then you can build your own flows using this project as a library of convenience functions. The `clientAutoMode` exported function is a reference implementation of the `js_content` handler. -This implementation uses the following env variables: - - - `NJS_ACME_VERIFY_PROVIDER_HTTPS` sets verify ACME provider SSL certificate when connecting to it, default value `true`; - - `NJS_ACME_DIRECTORY_URI` ACME directory URL, default value `https://acme-staging-v02.api.letsencrypt.org/directory` - - `NJS_ACME_DIR` (or `njs_acme_dir` nginx variable) default value `/etc/nginx` - - `NJS_ACME_ACCOUNT_EMAIL` (`njs_acme_account_email`) - email address for ACME provider - - `njs_acme_challenge_dir` - nginx variable with the path to where store HTTP-01 challenges - - `server_name` or `njs_acme_server_name` - nginx variable with the value of Subject Name for a certificate to issue +## Project Structure +| Path | Description | +| ---- | ------------| +| [src](src) | Contains your source code that will be compiled to the `dist/` directory. | +| [integration-tests](integration-tests) | Contains your source code of tests. | ```TypeScript /** diff --git a/dev/Dockerfile.nginx b/dev/Dockerfile.nginx new file mode 100644 index 0000000..3c9613f --- /dev/null +++ b/dev/Dockerfile.nginx @@ -0,0 +1,13 @@ +FROM nginx:1.24.0 + +# following installation steps from http://nginx.org/en/linux_packages.html#Debian +RUN --mount=type=cache,target=/var/cache/apt < { fs.writeFileSync(pkeyPath, pkeyPem) ngx.log(ngx.INFO, `njs-acme: [auto] Wrote Private key to ${pkeyPath}`) - // default challengePath = acmeDir/challenge - const challengePath = getVariable( - r, - 'njs_acme_challenge_dir', - joinPaths(acmeDir(r), 'challenge') - ) + // this is the only variable that has to be set in nginx.conf + const challengePath = r.variables.njs_acme_challenge_dir + if (challengePath === undefined || challengePath.length === 0) { return r.return( 500, diff --git a/src/utils.ts b/src/utils.ts index a6486ae..472aa89 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -857,7 +857,13 @@ export function readCsrDomainNames(csrPem: string | Buffer): { */ export function getVariable( r: NginxHTTPRequest, - varname: string, + varname: + | 'njs_acme_account_email' + | 'njs_acme_server_names' + | 'njs_acme_dir' + | 'njs_acme_account_private_jwk' + | 'njs_acme_directory_uri' + | 'njs_acme_verify_provider_https', defaultVal?: string ): string { const retval = From c1beb89c4ca0541eb536a058c13cb347e8db3608 Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Thu, 29 Jun 2023 16:24:51 -0700 Subject: [PATCH 10/13] move eslint-config-prettier to devDependencies; some small cleanup from testing --- Dockerfile | 1 + Makefile | 36 +++++++++++++++++------------------- dev/Dockerfile.nginx | 1 + examples/nginx.conf | 6 +++--- package.json | 2 +- src/utils.ts | 2 +- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Dockerfile b/Dockerfile index 86c3451..cb10574 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,3 +7,4 @@ RUN npm run build FROM nginx:1.24.0 COPY --from=builder /app/dist/acme.js /usr/lib/nginx/njs_modules/acme.js COPY ./examples/nginx.conf /etc/nginx/nginx.conf +RUN mkdir /etc/acme diff --git a/Makefile b/Makefile index db781e8..ff6a710 100644 --- a/Makefile +++ b/Makefile @@ -23,40 +23,38 @@ build: ## Run npm run build $Q npm run build -.PHONY: build-docker -build-docker: ## Build docker image +.PHONY: docker-build +docker-build: ## Build docker image $(DOCKER) build $(DOCKER_BUILD_FLAGS) -t $(PROJECT_NAME) . -.PHONY: copy-docker -copy-docker: CONTAINER_ID=$(shell $(DOCKER) create $(PROJECT_NAME)) -copy-docker: build-docker ## Copy the acme.js file out of the container and save in dist/ +.PHONY: docker-copy +docker-copy: CONTAINER_ID=$(shell $(DOCKER) create $(PROJECT_NAME)) +docker-copy: docker-build ## Copy the acme.js file out of the container and save in dist/ echo ${CONTAINER_ID} $(DOCKER) cp ${CONTAINER_ID}:/usr/lib/nginx/njs_modules/acme.js dist/acme.js $(DOCKER) rm -v ${CONTAINER_ID} -.PHONY: start-docker -start-docker: build-docker ## Start nginx container +.PHONY: docker-nginx +docker-nginx: docker-build ## Start nginx container $(DOCKER) run --rm -it -p 8000:8000 \ - -e "NJS_ACME_DIR=/etc/nginx/examples" \ - -v $(CURRENT_DIR)/examples:/etc/nginx/examples/ \ - -v $(CURRENT_DIR)/dist:/usr/lib/nginx/njs_modules/ $(PROJECT_NAME) nginx -c examples/nginx.conf + -e "NJS_ACME_DIR=/etc/acme" \ + $(PROJECT_NAME) -.PHONY: start-njs -start-njs: build-docker ## Start nginx container and run `njs` +.PHONY: docker-njs +docker-njs: docker-build ## Start nginx container and run `njs` $(DOCKER) run --rm -it -p 8000:8000 \ - -e "NJS_ACME_DIR=/etc/nginx/examples" \ - -v $(CURRENT_DIR)/examples:/etc/nginx/examples/ \ - -v $(CURRENT_DIR)/dist:/usr/lib/nginx/njs_modules/ $(PROJECT_NAME) njs + -e "NJS_ACME_DIR=/etc/acme" \ + $(PROJECT_NAME) njs -.PHONY: start-all -start-all: build ## Start all docker compose services +.PHONY: docker-devup +docker-devup: docker-build ## Start all docker compose services $(DOCKER) compose up -d -.PHONY: reload-nginx -reload-nginx: build start-all ## Reload nginx +.PHONY: docker-reload-nginx +docker-reload-nginx: ## Reload nginx $(DOCKER) compose up -d --force-recreate nginx && $(DOCKER) compose logs -f nginx diff --git a/dev/Dockerfile.nginx b/dev/Dockerfile.nginx index 3c9613f..04d8706 100644 --- a/dev/Dockerfile.nginx +++ b/dev/Dockerfile.nginx @@ -11,3 +11,4 @@ RUN --mount=type=cache,target=/var/cache/apt < n.toLocaleLowerCase()) + return nameStr.split(/[,\s]+/).map((n) => n.toLowerCase()) } /** From ec92c6f9d1972ef42408552f51228976a21b47ae Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Sun, 2 Jul 2023 17:26:49 -0700 Subject: [PATCH 11/13] Fix bugs I introduced; Make default acme dir /etc/nginx/njs-acme; move Array.from polyfill to its own .js file since .ts was not happy with that code; put versions back in Dockerfiles --- Dockerfile | 11 +++-- Makefile | 4 +- README.md | 4 +- dev/Dockerfile.nginx | 6 ++- docker-compose.yml | 9 ++-- examples/nginx.conf | 15 +++--- src/api.ts | 28 ++++------- src/arrayFrom.js | 87 ++++++++++++++++++++++++++++++++++ src/client.ts | 13 +++--- src/index.ts | 2 +- src/utils.ts | 109 ++++--------------------------------------- 11 files changed, 140 insertions(+), 148 deletions(-) create mode 100644 src/arrayFrom.js diff --git a/Dockerfile b/Dockerfile index cb10574..c1f0386 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,15 @@ FROM node:18 AS builder WORKDIR /app +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/app/.npm \ + npm set cache /app/.npm && \ + npm ci COPY . . -RUN npm ci RUN npm run build -FROM nginx:1.24.0 +ARG NGINX_VERSION=1.24.0 +FROM nginx:${NGINX_VERSION} COPY --from=builder /app/dist/acme.js /usr/lib/nginx/njs_modules/acme.js COPY ./examples/nginx.conf /etc/nginx/nginx.conf -RUN mkdir /etc/acme +RUN mkdir /etc/nginx/njs-acme +RUN chown nginx: /etc/nginx/njs-acme diff --git a/Makefile b/Makefile index ff6a710..fbf88ec 100644 --- a/Makefile +++ b/Makefile @@ -39,14 +39,14 @@ docker-copy: docker-build ## Copy the acme.js file out of the container and save .PHONY: docker-nginx docker-nginx: docker-build ## Start nginx container $(DOCKER) run --rm -it -p 8000:8000 \ - -e "NJS_ACME_DIR=/etc/acme" \ + -e "NJS_ACME_DIR=/etc/nginx/njs-acme" \ $(PROJECT_NAME) .PHONY: docker-njs docker-njs: docker-build ## Start nginx container and run `njs` $(DOCKER) run --rm -it -p 8000:8000 \ - -e "NJS_ACME_DIR=/etc/acme" \ + -e "NJS_ACME_DIR=/etc/nginx/njs-acme" \ $(PROJECT_NAME) njs diff --git a/README.md b/README.md index cd9536f..16b3e6f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ You can use environment variables or NGINX configuration variables to control th - `NJS_ACME_DIR`\ Path to store ACME-related files such as keys, certificate requests, certificates, etc.\ value: Any valid system path writable by the `nginx` user. \ - default: `/etc/acme/` + default: `/etc/nginx/njs-acme/` - `NJS_ACME_ACCOUNT_PRIVATE_JWK`\ Path to fetch/store the account private JWK.\ @@ -92,7 +92,7 @@ There are a few pieces that are required to be present in your `nginx.conf` file ``` * Set the directory to store challenges. This is also used in a `location{}` block below. ```nginx - set $njs_acme_challenge_dir /etc/acme/challenge; + set $njs_acme_challenge_dir /etc/nginx/njs-acme/challenge; ``` * Set and use variables to hold the certificate and key paths using Javascript. ```nginx diff --git a/dev/Dockerfile.nginx b/dev/Dockerfile.nginx index 04d8706..2744810 100644 --- a/dev/Dockerfile.nginx +++ b/dev/Dockerfile.nginx @@ -1,4 +1,5 @@ -FROM nginx:1.24.0 +ARG NGINX_VERSION=1.24.0 +FROM nginx:${NGINX_VERSION} # following installation steps from http://nginx.org/en/linux_packages.html#Debian RUN --mount=type=cache,target=/var/cache/apt < - /* Retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 */ - if ( - respData?.type === 'urn:ietf:params:acme:error:badNonce' && - attempts < this.maxBadNonceRetries - ) { + // Retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 + // or bad response code. + if (attempts < this.maxBadNonceRetries) { nonce = resp.headers.get('replay-nonce') || null attempts += 1 ngx.log( ngx.WARN, - `njs-acme: [http] Invalid nonce error, retrying (${attempts}/${this.maxBadNonceRetries}) signed request to: ${url}` + `njs-acme: [http] Error response from server, retrying (${attempts}/${this.maxBadNonceRetries}) signed request to: ${url}` ) return this.signedRequest( url, @@ -660,7 +655,7 @@ export class HttpClient { nonce, kid, }) - const h = crypto.createHmac('sha256', Buffer.from(hmacKey, 'base64')) + const h = OGCrypto.createHmac('sha256', Buffer.from(hmacKey, 'base64')) h.update(`${result.protected}.${result.payload}`) result.signature = h.digest('base64url') return result @@ -715,18 +710,13 @@ export class HttpClient { ) } - // njs-types/crypto.d.ts does not contain a `subtle` propery on the crypto object - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const subtle = crypto.subtle as SubtleCrypto - let sign if (jwk.kty === 'EC') { - const hash = await subtle.digest( + const hash = await crypto.subtle.digest( signerAlg as HashVariants, `${result.protected}.${result.payload}` ) - sign = await subtle.sign( + sign = await crypto.subtle.sign( { name: 'ECDSA', hash: hash.toString() as HashVariants, @@ -735,7 +725,7 @@ export class HttpClient { hash ) } else { - sign = await subtle.sign( + sign = await crypto.subtle.sign( { name: 'RSASSA-PKCS1-v1_5' }, this.accountKey, `${result.protected}.${result.payload}` diff --git a/src/arrayFrom.js b/src/arrayFrom.js new file mode 100644 index 0000000..f2dca1d --- /dev/null +++ b/src/arrayFrom.js @@ -0,0 +1,87 @@ +// workaround for PKI.js toJSON/fromJson +// from https://stackoverflow.com/questions/36810940/alternative-or-polyfill-for-array-from-on-the-internet-explorer +const polyfill = (function () { + const toStr = Object.prototype.toString + const isCallable = function (fn) { + return typeof fn === 'function' || toStr.call(fn) === '[object Function]' + } + const toInteger = function (value) { + const number = Number(value) + if (isNaN(number)) { + return 0 + } + if (number === 0 || !isFinite(number)) { + return number + } + return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number)) + } + const maxSafeInteger = Math.pow(2, 53) - 1 + const toLength = function (value) { + const len = toInteger(value) + return Math.min(Math.max(len, 0), maxSafeInteger) + } + + // The length property of the from method is 1. + return function from(arrayLike /*, mapFn, thisArg */) { + // 1. Let C be the this value. + // eslint-disable-next-line @typescript-eslint/no-this-alias + const C = this + + // 2. Let items be ToObject(arrayLike). + const items = Object(arrayLike) + + // 3. ReturnIfAbrupt(items). + if (arrayLike == null) { + throw new TypeError( + 'Array.from requires an array-like object - not null or undefined' + ) + } + + // 4. If mapfn is undefined, then let mapping be false. + const mapFn = arguments.length > 1 ? arguments[1] : void undefined + let T + if (typeof mapFn !== 'undefined') { + // 5. else + // 5. a If IsCallable(mapfn) is false, throw a TypeError exception. + if (!isCallable(mapFn)) { + throw new TypeError( + 'Array.from: when provided, the second argument must be a function' + ) + } + + // 5. b. If thisArg was supplied, let T be thisArg; else let T be undefined. + if (arguments.length > 2) { + T = arguments[2] + } + } + // 10. Let lenValue be Get(items, "length"). + // 11. Let len be ToLength(lenValue). + const len = toLength(items.length) + + // 13. If IsConstructor(C) is true, then + // 13. a. Let A be the result of calling the [[Construct]] internal method of C with an argument list containing the single item len. + // 14. a. Else, Let A be ArrayCreate(len). + const A = isCallable(C) ? Object(new C(len)) : new Array(len) + + // 16. Let k be 0. + let k = 0 + // 17. Repeat, while k < len… (also steps a - h) + let kValue + while (k < len) { + kValue = items[k] + if (mapFn) { + A[k] = + typeof T === 'undefined' ? mapFn(kValue, k) : mapFn.call(T, kValue, k) + } else { + A[k] = kValue + } + k += 1 + } + // 18. Let putStatus be Put(A, "length", len, true). + A.length = len + // 20. Return A. + return A + } +})() + +export default polyfill diff --git a/src/client.ts b/src/client.ts index 327120b..0408039 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,6 @@ import { HttpClient } from './api' import { formatResponseError, getPemBodyAsB64u, retry } from './utils' -import crypto from 'crypto' +import OGCrypto from 'crypto' export interface ClientExternalAccountBindingOptions { kid: string @@ -153,7 +153,7 @@ export interface ClientOptions { } export interface ClientAutoOptions { - csr: ArrayBuffer | Buffer | string | null + csr: Buffer | string | null challengeCreateFn: ( authz: Authorization, challenge: Challenge, @@ -538,8 +538,7 @@ export class AcmeClient { csr = Buffer.from(csr) } - // FIXME - const data = { csr: getPemBodyAsB64u(csr.toString()) } + const data = { csr: getPemBodyAsB64u(csr) } let resp try { resp = await this.api.finalizeOrder(order.finalize, data) @@ -637,7 +636,7 @@ export class AcmeClient { async getChallengeKeyAuthorization(challenge: Challenge): Promise { const jwk = await this.api.getJwk() - const keysum = crypto.createHash('sha256').update(JSON.stringify(jwk)) + const keysum = OGCrypto.createHash('sha256').update(JSON.stringify(jwk)) const thumbprint = keysum.digest('base64url') const result = `${challenge.token}.${thumbprint}` @@ -827,7 +826,7 @@ export class AcmeClient { cert: Buffer | string, data: Record = {} ): Promise> { - data.certificate = getPemBodyAsB64u(cert.toString()) + data.certificate = getPemBodyAsB64u(cert) const resp = await this.api.revokeCert(data) return (await resp.json()) as Record } @@ -1080,7 +1079,7 @@ async function auto( * Finalize order and download certificate */ ngx.log(ngx.INFO, 'njs-acme: [auto] Finalize order and download certificate') - const finalized = await client.finalizeOrder(order, opts.csr.toString()) + const finalized = await client.finalizeOrder(order, opts.csr) const certData = await client.getCertificate(finalized, opts.preferredChain) return certData } diff --git a/src/index.ts b/src/index.ts index d1ab03d..89f0ab2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -159,7 +159,7 @@ async function clientAutoMode(r: NginxHTTPRequest): Promise { } certificatePem = await client.auto({ - csr: result.pkcs10Ber, + csr: Buffer.from(result.pkcs10Ber), email: email, termsOfServiceAgreed: true, challengeCreateFn: async (authz, challenge, keyAuthorization) => { diff --git a/src/utils.ts b/src/utils.ts index 69d3500..9622349 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,7 @@ import * as pkijs from 'pkijs' import * as asn1js from 'asn1js' import fs from 'fs' import querystring from 'querystring' +import arrayFromPolyfill from './arrayFrom.js' import { ClientExternalAccountBindingOptions } from './client.js' // workaround for PKI.JS to work @@ -14,104 +15,8 @@ pkijs.setEngine( new pkijs.CryptoEngine({ name: 'webcrypto', crypto: crypto }) ) -// workaround for PKI.js toJSON/fromJson -// from https://stackoverflow.com/questions/36810940/alternative-or-polyfill-for-array-from-on-the-internet-explorer if (!Array.from) { - Array.from = (function () { - const toStr = Object.prototype.toString - const isCallable = function ( - fn: ((arg0: unknown, arg1?: unknown) => unknown) | Array - ) { - return typeof fn === 'function' || toStr.call(fn) === '[object Function]' - } - const toInteger = function (value: unknown) { - const number = Number(value) - if (isNaN(number)) { - return 0 - } - if (number === 0 || !isFinite(number)) { - return number - } - return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number)) - } - const maxSafeInteger = Math.pow(2, 53) - 1 - const toLength = function (value: unknown) { - const len = toInteger(value) - return Math.min(Math.max(len, 0), maxSafeInteger) - } - - // The length property of the from method is 1. - return function from( - this: Array | ((arg1: unknown, arg2?: unknown) => unknown), - ...args: unknown[] /*, mapFn, thisArg */ - ) { - // 1. Let C be the this value. - // eslint-disable-next-line @typescript-eslint/no-this-alias - const C = this - - // 2. Let items be ToObject(arrayLike). - const items = Object(args) - - // 3. ReturnIfAbrupt(items). - if (args == null) { - throw new TypeError( - 'Array.from requires an array-like object - not null or undefined' - ) - } - - // 4. If mapfn is undefined, then let mapping be false. - const mapFn = - arguments.length > 1 - ? (args[1] as (arg0: unknown, arg1: unknown) => unknown) - : void undefined - let T - if (typeof mapFn !== 'undefined') { - // 5. else - // 5. a If IsCallable(mapfn) is false, throw a TypeError exception. - if (!isCallable(mapFn)) { - throw new TypeError( - 'Array.from: when provided, the second argument must be a function' - ) - } - - // 5. b. If thisArg was supplied, let T be thisArg; else let T be undefined. - if (arguments.length > 2) { - T = args[2] - } - } - - // 10. Let lenValue be Get(items, "length"). - // 11. Let len be ToLength(lenValue). - const len = toLength(items.length) - - // 13. If IsConstructor(C) is true, then - // 13. a. Let A be the result of calling the [[Construct]] internal method of C with an argument list containing the single item len. - // 14. a. Else, Let A be ArrayCreate(len). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const A = isCallable(C) ? Object(new (C as any)(len)) : new Array(len) - - // 16. Let k be 0. - let k = 0 - // 17. Repeat, while k < len… (also steps a - h) - let kValue: unknown - while (k < len) { - kValue = items[k] - if (mapFn) { - A[k] = - typeof T === 'undefined' - ? mapFn(kValue, k) - : mapFn.call(T, kValue, k) - } else { - A[k] = kValue - } - k += 1 - } - // 18. Let putStatus be Put(A, "length", len, true). - A.length = len - // 20. Return A. - return A - } - })() + Array.from = arrayFromPolyfill } export interface RsaPublicJwk { @@ -617,8 +522,12 @@ function getPkcs10Ber(pkcs10: pkijs.CertificationRequest): ArrayBuffer { * @param {string} data - The data to be encoded. * @returns {string} - The Base64url encoded representation of the input data. */ -export function getPemBodyAsB64u(data: string): string { - return Buffer.from(data).toString('base64url') +export function getPemBodyAsB64u(data: string | Buffer): string { + let buf = data + if (typeof data === 'string') { + buf = Buffer.from(data) + } + return buf.toString('base64url') } /** @@ -893,7 +802,7 @@ export function acmeServerNames(r: NginxHTTPRequest): string[] { * @returns configured path or default */ export function acmeDir(r: NginxHTTPRequest): string { - return getVariable(r, 'njs_acme_dir', '/etc/acme') + return getVariable(r, 'njs_acme_dir', '/etc/nginx/njs-acme') } /** From 69c4d8f6036a89b8d61c9516fb005cd06f654147 Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Sun, 2 Jul 2023 18:13:59 -0700 Subject: [PATCH 12/13] remove Makefile --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 968d9e7..3c3629e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1 @@ node_modules -Makefile From ffc5d80202524031cd227cde00f9b318fdeeeb1c Mon Sep 17 00:00:00 2001 From: Zack Steinkamp Date: Mon, 3 Jul 2023 10:32:18 -0700 Subject: [PATCH 13/13] add `buildx` back; move ARG to the top of the file --- Dockerfile | 3 ++- Makefile | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index c1f0386..75a098e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ +ARG NGINX_VERSION=1.24.0 + FROM node:18 AS builder WORKDIR /app COPY package.json package-lock.json ./ @@ -7,7 +9,6 @@ RUN --mount=type=cache,target=/app/.npm \ COPY . . RUN npm run build -ARG NGINX_VERSION=1.24.0 FROM nginx:${NGINX_VERSION} COPY --from=builder /app/dist/acme.js /usr/lib/nginx/njs_modules/acme.js COPY ./examples/nginx.conf /etc/nginx/nginx.conf diff --git a/Makefile b/Makefile index fbf88ec..1a0b075 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ build: ## Run npm run build .PHONY: docker-build docker-build: ## Build docker image - $(DOCKER) build $(DOCKER_BUILD_FLAGS) -t $(PROJECT_NAME) . + $(DOCKER) buildx build $(DOCKER_BUILD_FLAGS) -t $(PROJECT_NAME) . .PHONY: docker-copy