Fixes KriptoKey extraction

This commit is contained in:
Pierre Lannoy 2026-04-25 18:15:54 +02:00
commit 4f4f4f5fcb
Signed by: Pierre Lannoy
GPG key ID: D27231EF87D53F31
3 changed files with 155 additions and 127 deletions

View file

@ -1,17 +1,7 @@
import fs from "fs"; import rsacsr from "rsa-csr";
import path from "path"; import { exportJWK, generateKeyPair, calculateJwkThumbprint, SignJWT, CompactSign } from "jose";
import {promisify} from "util";
import {exportJWK} from "jose";
import {generateKeyPair} from "jose";
import {calculateJwkThumbprint} from "jose";
import {SignJWT} from "jose";
import {CompactSign} from "jose";
import pem from "pem";
import * as common from "./common.js"; import * as common from "./common.js";
import request from "./request.js"; import request from "./request.js";
const createCsr = promisify(pem.createCSR);
/** /**
* Represents a Let's Encrypt account and * Represents a Let's Encrypt account and
* sends requests to get valid TLS certificates. * sends requests to get valid TLS certificates.
@ -24,7 +14,7 @@ class Client {
this.accountPrivateJwk = null; this.accountPrivateJwk = null;
/** @type {import('crypto').KeyObject|null} */ /** @type {import('crypto').KeyObject|null} */
this.accountPrivateKey = null; this.accountPrivateKey = null;
/** @type {import("jose/types.js").JWK | undefined} */ /** @type {import("jose").JWK | undefined} */
this.accountPublicJwk = undefined; this.accountPublicJwk = undefined;
/** @type {import('crypto').KeyObject|null} */ /** @type {import('crypto').KeyObject|null} */
this.accountPublicKey = null; this.accountPublicKey = null;
@ -38,88 +28,81 @@ class Client {
this.replayNonce = ''; this.replayNonce = '';
this.thumbprint = ''; this.thumbprint = '';
} }
/** /**
* Export account public and private keys to a directory. * Export account public and private keys to a directory.
* *
* @param {String} dirname - name of directory to write key files to
* @param {String} [passphrase] - optional passphrase to encrypt private key with * @param {String} [passphrase] - optional passphrase to encrypt private key with
* *
* @return {Promise} * @return {{ privateKey: string, publicKey: string }}
*/ */
exportAccountKeyPair(dirname, passphrase) { exportAccountKeyPair(passphrase) {
if (this.accountPrivateKey == null || this.accountPublicKey == null) { if (this.accountPrivateKey == null || this.accountPublicKey == null) {
return Promise.reject(new Error('Account key pair not generated')); throw new Error('Account key pair not generated');
}
return {
// @ts-ignore
accountPrivateKey: common.exportPrivateKey(this.accountPrivateKey, passphrase),
// @ts-ignore
accountPublicKey: common.exportPublicKey(this.accountPublicKey),
} }
const privateKeyFile = path.join(dirname, 'privateKey.pem');
const publicKeyFile = path.join(dirname, 'publicKey.pem');
return Promise.all([
common.writeKeyToFile(privateKeyFile, this.accountPrivateKey, passphrase),
common.writeKeyToFile(publicKeyFile, this.accountPublicKey)
]);
} }
/** /**
* Generate new account public and private keys. * Generate new account public and private keys.
* *
* @return {Promise} * @return {Promise<void>}
*/ */
async generateAccountKeyPair() { async generateAccountKeyPair() {
const {privateKey, publicKey} = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM); const { privateKey, publicKey } = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM);
// @ts-ignore // @ts-ignore
this.accountPrivateKey = privateKey; this.accountPrivateKey = privateKey;
// @ts-ignore // @ts-ignore
this.accountPublicKey = publicKey; this.accountPublicKey = publicKey;
await this.initAccountJwks(); await this.initAccountJwks();
} }
/** /**
* Generate a certificate from Let's Encrypt for your domain. * Generate a certificate from Let's Encrypt for your domain.
* *
* @param {String} domain - the domain you want a certificate for * @param {String} domain - the domain you want a certificate for
* *
* @return {Promise} * @return {Promise<{cert: string, key: string}>}
*/ */
async generateCertificate(domain) { async generateCertificate(domain) {
await this.directory(); await this.directory();
await this.newNonce(); await this.newNonce();
if (!this.myAccountUrl) if (!this.myAccountUrl)
await this.newAccount(); await this.newAccount();
const {authzUrls, finalizeUrl} = await this.newOrder(domain); const { authzUrls, finalizeUrl } = await this.newOrder(domain);
const {challenge} = await this.authz(authzUrls[0]); const { challenge } = await this.authz(authzUrls[0]);
await this.completeChallenge(challenge, domain); await this.completeChallenge(challenge, domain);
await this.pollAuthz(authzUrls[0]); await this.pollAuthz(authzUrls[0]);
const {certificate, privateKeyData} = await this.finalizeOrder(finalizeUrl, domain); const { certificate, privateKeyData } = await this.finalizeOrder(finalizeUrl, domain);
return { return {
certificate, cert: certificate,
privateKeyData key: privateKeyData
}; };
} }
/** /**
* Import account public and private keys from a directory. * Import account from PEM public and private keys.
* * @param {string} privateKey
* @param {String} dirname - name of directory to read key files from * @param {string} publicKey
* @param {String} [passphrase] - optional passphrase to decrypt private key with * @param {string | undefined} [passphrase]
* * @return {Promise<void>}
* @return {Promise}
*/ */
async importAccountKeyPair(dirname, passphrase) { async importAccountKeyPair(privateKey, publicKey, passphrase) {
const [privateKeyData, publicKeyData] = await Promise.all([ this.accountPrivateKey = common.importPrivateKey(privateKey, passphrase);
fs.promises.readFile(path.join(dirname, 'privateKey.pem'), 'utf8'), this.accountPublicKey = common.importPublicKey(publicKey);
fs.promises.readFile(path.join(dirname, 'publicKey.pem'), 'utf8')
]);
this.accountPrivateKey = common.importPrivateKey(privateKeyData, passphrase);
this.accountPublicKey = common.importPublicKey(publicKeyData);
await this.initAccountJwks(); await this.initAccountJwks();
} }
/**
* @param {string | URL} authzUrl
*/
async authz(authzUrl) { async authz(authzUrl) {
const data = await this.sign({ const data = await this.sign({
kid: this.myAccountUrl, kid: this.myAccountUrl,
nonce: this.replayNonce, nonce: this.replayNonce,
url: authzUrl url: authzUrl
}); });
/** @type {import("./request.js").Response<{challenges: {type: string}[], identifier: {value: string}, status: string}>} */
const res = await request(authzUrl, { const res = await request(authzUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -131,20 +114,22 @@ class Client {
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
throw new Error(`authz() Status Code: ${res.statusCode} Data: ${res.data}`); throw new Error(`authz() Status Code: ${res.statusCode} Data: ${res.data}`);
} }
const {challenges, identifier, ...rest} = res.data; const { challenges, identifier, ...rest } = res.data;
const challenge = challenges.find(({type}) => type === 'http-01'); const challenge = challenges.find(({ type }) => type === 'http-01');
return { return {
challenge, challenge,
domain: identifier.value, domain: identifier.value,
...rest ...rest
}; };
} }
/**
* @param {any} challenge
* @param {string} domain
*/
async completeChallenge(challenge, domain) { async completeChallenge(challenge, domain) {
await this.readyChallenge(challenge); await this.readyChallenge(challenge);
await this.receiveServerRequest(challenge, domain); await this.receiveServerRequest(challenge, domain);
} }
async directory() { async directory() {
if (this.hasDirectory) if (this.hasDirectory)
return false; return false;
@ -158,7 +143,9 @@ class Client {
this.newOrderUrl = res.data.newOrder; this.newOrderUrl = res.data.newOrder;
return true; return true;
} }
/**
* @param {string | URL} certificateUrl
*/
async fetchCertificate(certificateUrl) { async fetchCertificate(certificateUrl) {
const data = await this.sign({ const data = await this.sign({
kid: this.myAccountUrl, kid: this.myAccountUrl,
@ -180,17 +167,20 @@ class Client {
return res.data; return res.data;
} }
/**
* @param {any} finalizeUrl
* @param {string} domain
*/
async finalizeOrder(finalizeUrl, domain) { async finalizeOrder(finalizeUrl, domain) {
const {privateKey} = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM); const { privateKey } = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM);
// @ts-ignore // @ts-ignore
const clientKey = common.exportPrivateKey(privateKey); const privateKeyData = common.exportPrivateKey(privateKey);
let {
csr let csr = await rsacsr({
// @ts-ignore key: await exportJWK(privateKey),
} = await createCsr({ domains: [domain],
clientKey,
commonName: domain,
}); });
// "The CSR is sent in the base64url-encoded version of the DER format. // "The CSR is sent in the base64url-encoded version of the DER format.
// (Note: Because this field uses base64url, and does not include headers, // (Note: Because this field uses base64url, and does not include headers,
// it is different from PEM.)" // it is different from PEM.)"
@ -202,13 +192,14 @@ class Client {
.replace(/\+/g, '-') .replace(/\+/g, '-')
.replace(/\//g, '_') .replace(/\//g, '_')
.replace(/=/g, ''); .replace(/=/g, '');
const sendFinalizeRequest = async (/** @type {string | URL} */ finalizeUrl, /** @type {import("jose").JWTPayload | undefined} */ payload) => {
const data = await this.sign({ const data = await this.sign({
kid: this.myAccountUrl, kid: this.myAccountUrl,
nonce: this.replayNonce, nonce: this.replayNonce,
url: finalizeUrl url: finalizeUrl
}, { }, payload);
csr
});
const res = await request(finalizeUrl, { const res = await request(finalizeUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -217,16 +208,32 @@ class Client {
data data
}); });
this.setReplayNonce(res); this.setReplayNonce(res);
return res;
}
let res = await sendFinalizeRequest(finalizeUrl, { csr });
// Let's encrypt actually want this to work!
// https://community.letsencrypt.org/t/enabling-asynchronous-order-finalization/193522
while (res.data.status === 'processing') {
let retryUrl = res.headers.location || '';
let retryTime = parseInt(res.headers["retry-after"] || '1') * 1000
// sleep, retry
await new Promise(resolve => setTimeout(resolve, retryTime))
res = await sendFinalizeRequest(retryUrl, undefined);
if (res.data.status == "ready") {
res = await sendFinalizeRequest(res.data.finalize, undefined);
break
}
}
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
throw new Error(`finalizeOrder() Status Code: ${res.statusCode} Data: ${res.data}`); throw new Error(`finalizeOrder() Status Code: ${res.statusCode} Data: ${res.data}`);
} }
const certificate = await this.fetchCertificate(res.data.certificate); const certificate = await this.fetchCertificate(res.data.certificate);
return { return {
certificate, certificate,
privateKeyData: clientKey privateKeyData
}; };
}
}
async initAccountJwks() { async initAccountJwks() {
if (this.accountPrivateKey == null || this.accountPublicKey == null) { if (this.accountPrivateKey == null || this.accountPublicKey == null) {
return Promise.reject(new Error('Account key pair not generated')); return Promise.reject(new Error('Account key pair not generated'));
@ -239,7 +246,9 @@ class Client {
this.accountPrivateJwk = accountPrivateJwk; this.accountPrivateJwk = accountPrivateJwk;
this.thumbprint = await calculateJwkThumbprint(publicJwk); this.thumbprint = await calculateJwkThumbprint(publicJwk);
} }
/**
* @param {undefined[]} emails
*/
async newAccount(...emails) { async newAccount(...emails) {
const data = await this.sign({ const data = await this.sign({
jwk: this.accountPublicJwk, jwk: this.accountPublicJwk,
@ -259,10 +268,9 @@ class Client {
if (![200, 201].includes(res.statusCode)) { if (![200, 201].includes(res.statusCode)) {
throw new Error(`newAccount() Status Code: ${res.statusCode} Data: ${res.data}`); throw new Error(`newAccount() Status Code: ${res.statusCode} Data: ${res.data}`);
} }
this.myAccountUrl = res.headers.location; this.myAccountUrl = res.headers.location || "";
return res.statusCode === 201; return res.statusCode === 201;
} }
async newNonce() { async newNonce() {
if (this.replayNonce) if (this.replayNonce)
return false; return false;
@ -275,7 +283,9 @@ class Client {
this.setReplayNonce(res); this.setReplayNonce(res);
return true; return true;
} }
/**
* @param {string[]} domains
*/
async newOrder(...domains) { async newOrder(...domains) {
const identifiers = domains.map(domain => ({ const identifiers = domains.map(domain => ({
type: 'dns', type: 'dns',
@ -300,7 +310,7 @@ class Client {
throw new Error(`newOrder() Status Code: ${res.statusCode} Data: ${res.data}`); throw new Error(`newOrder() Status Code: ${res.statusCode} Data: ${res.data}`);
} }
const orderUrl = res.headers.location; const orderUrl = res.headers.location;
const {authorizations: authzUrls, finalize: finalizeUrl} = res.data; const { authorizations: authzUrls, finalize: finalizeUrl } = res.data;
return { return {
authzUrls, authzUrls,
domains, domains,
@ -308,12 +318,14 @@ class Client {
orderUrl orderUrl
}; };
} }
/**
* @param {any} authzUrl
*/
async pollAuthz(authzUrl) { async pollAuthz(authzUrl) {
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const result = await this.authz(authzUrl); const result = await this.authz(authzUrl);
if (result.status === 'pending') { if (result.status === 'pending') {
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 1e3));
continue; continue;
} }
if (result.status === 'invalid') { if (result.status === 'invalid') {
@ -323,7 +335,9 @@ class Client {
} }
throw new Error('pollAuthz() timed out'); throw new Error('pollAuthz() timed out');
} }
/**
* @param {{ url: string | URL; }} challenge
*/
async readyChallenge(challenge) { async readyChallenge(challenge) {
const data = await this.sign({ const data = await this.sign({
kid: this.myAccountUrl, kid: this.myAccountUrl,
@ -342,16 +356,19 @@ class Client {
throw new Error(`readyChallenge() Status Code: ${res.statusCode} Data: ${res.data}`); throw new Error(`readyChallenge() Status Code: ${res.statusCode} Data: ${res.data}`);
} }
} }
/**
* @param {{ token: string; }} challenge
* @param {any} domain
*/
receiveServerRequest(challenge, domain) { receiveServerRequest(challenge, domain) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const time = setTimeout(() => { const time = setTimeout(() => {
reject(new Error('Timed out waiting for server request')); reject(new Error('Timed out waiting for server request'));
}, 5000); }, 10e3);
let hasResolved = false; let hasResolved = false;
this.challengeCallbacks = () => { this.challengeCallbacks = () => {
if (!hasResolved) if (!hasResolved)
setTimeout(resolve, 5000); setTimeout(resolve, 100);
else else
return challenge.token + '.' + this.thumbprint; return challenge.token + '.' + this.thumbprint;
hasResolved = true; hasResolved = true;
@ -362,7 +379,9 @@ class Client {
}; };
}); });
} }
/**
* @param {{ data?: any; headers: any; statusCode?: number; }} res
*/
setReplayNonce(res) { setReplayNonce(res) {
const replayNonce = (res.headers['replay-nonce'] || '').trim(); const replayNonce = (res.headers['replay-nonce'] || '').trim();
if (!replayNonce) { if (!replayNonce) {
@ -370,10 +389,9 @@ class Client {
} }
this.replayNonce = replayNonce; this.replayNonce = replayNonce;
} }
/** /**
* @param {import("jose/types.js").JWSHeaderParameters} header * @param {import("jose").JWSHeaderParameters} header
* @param {import("jose/types.js").JWTPayload | undefined} [payload] * @param {import("jose").JWTPayload | undefined} [payload]
*/ */
async sign(header, payload) { async sign(header, payload) {
if (this.accountPrivateKey == null) { if (this.accountPrivateKey == null) {
@ -382,12 +400,14 @@ class Client {
let data; let data;
if (payload) { if (payload) {
data = await new SignJWT(payload) data = await new SignJWT(payload)
// @ts-ignore
.setProtectedHeader({ .setProtectedHeader({
alg: common.ACCOUNT_KEY_ALGORITHM, alg: common.ACCOUNT_KEY_ALGORITHM,
...header ...header
}) })
.sign(this.accountPrivateKey); .sign(this.accountPrivateKey);
} else { }
else {
// SignJWT constructor only accepts object but RFC8555 requires empty payload // SignJWT constructor only accepts object but RFC8555 requires empty payload
// Workaround: manually pass empty Uint8Array to CompactSign constructor // Workaround: manually pass empty Uint8Array to CompactSign constructor
const sig = new CompactSign(new Uint8Array()); const sig = new CompactSign(new Uint8Array());
@ -405,5 +425,4 @@ class Client {
}); });
} }
} }
export default Client; export default Client;

View file

@ -1,12 +1,12 @@
import crypto from "crypto"; import crypto from "node:crypto";
import fs from "fs"; import fs from "node:fs";
export const ACCOUNT_KEY_ALGORITHM = 'ES256'; export const ACCOUNT_KEY_ALGORITHM = 'ES256';
export const CERTIFICATE_KEY_ALGORITHM = 'RS256'; export const CERTIFICATE_KEY_ALGORITHM = 'RS256';
const env = (process.env.NODE_ENV || '').trim().toLowerCase(); export const DIRECTORY_URL = {
export const DIRECTORY_URL = ['development', 'test'].includes(env) 'production': 'https://acme-v02.api.letsencrypt.org/directory',
? 'https://acme-staging-v02.api.letsencrypt.org/directory' 'development': 'https://acme-staging-v02.api.letsencrypt.org/directory',
: 'https://acme-v02.api.letsencrypt.org/directory'; 'test': 'https://localhost:14000/dir',
}[(process.env.NODE_ENV || 'development').trim().toLowerCase()] || ""
export const PRIVATE_KEY_CIPHER = 'aes-256-cbc'; export const PRIVATE_KEY_CIPHER = 'aes-256-cbc';
export const PRIVATE_KEY_FORMAT = 'pem'; export const PRIVATE_KEY_FORMAT = 'pem';
export const PRIVATE_KEY_PERMISSIONS = 0o600; export const PRIVATE_KEY_PERMISSIONS = 0o600;
@ -14,11 +14,10 @@ export const PRIVATE_KEY_TYPE = 'pkcs8';
export const PUBLIC_KEY_FORMAT = 'pem'; export const PUBLIC_KEY_FORMAT = 'pem';
export const PUBLIC_KEY_PERMISSIONS = 0o666; export const PUBLIC_KEY_PERMISSIONS = 0o666;
export const PUBLIC_KEY_TYPE = 'spki'; export const PUBLIC_KEY_TYPE = 'spki';
/** /**
* @param {crypto.KeyObject} privateKey * @param {crypto.KeyObject} privateKey
* @param {String} [passphrase] * @param {String} [passphrase]
* * @returns {string}
*/ */
export const exportPrivateKey = (privateKey, passphrase) => { export const exportPrivateKey = (privateKey, passphrase) => {
/** @type {crypto.KeyExportOptions<'pem'>} */ /** @type {crypto.KeyExportOptions<'pem'>} */
@ -30,20 +29,21 @@ export const exportPrivateKey = (privateKey, passphrase) => {
privateKeyOpts.cipher = PRIVATE_KEY_CIPHER; privateKeyOpts.cipher = PRIVATE_KEY_CIPHER;
privateKeyOpts.passphrase = passphrase; privateKeyOpts.passphrase = passphrase;
} }
// @ts-ignore
return privateKey.export(privateKeyOpts); return privateKey.export(privateKeyOpts);
}; };
/** /**
* @param {crypto.KeyObject} publicKey * @param {crypto.KeyObject} publicKey
* @returns {string}
*/ */
export const exportPublicKey = publicKey => { export const exportPublicKey = publicKey => {
/** @type {crypto.KeyExportOptions<'pem'>} */ /** @type {crypto.KeyExportOptions<'pem'>} */
// @ts-ignore
return publicKey.export({ return publicKey.export({
type: PUBLIC_KEY_TYPE, type: PUBLIC_KEY_TYPE,
format: PUBLIC_KEY_FORMAT format: PUBLIC_KEY_FORMAT
}); });
}; };
/** /**
* @param {String} privateKeyData * @param {String} privateKeyData
* @param {String} [passphrase] * @param {String} [passphrase]
@ -62,11 +62,11 @@ export const importPrivateKey = (privateKeyData, passphrase) => {
} }
try { try {
return crypto.createPrivateKey(privateKeyOpts); return crypto.createPrivateKey(privateKeyOpts);
} catch { }
catch {
throw new Error('Failed to import private key'); throw new Error('Failed to import private key');
} }
}; };
/** /**
* @param {String} publicKeyData * @param {String} publicKeyData
* *
@ -79,24 +79,25 @@ export const importPublicKey = publicKeyData => {
format: PUBLIC_KEY_FORMAT, format: PUBLIC_KEY_FORMAT,
type: PUBLIC_KEY_TYPE type: PUBLIC_KEY_TYPE
}); });
} catch { }
catch {
throw new Error('Failed to import public key'); throw new Error('Failed to import public key');
} }
}; };
/** /**
* @param {String} filename * @param {String} filename
* @param {crypto.KeyObject|string} key * @param {crypto.KeyObject|string} key
* @param {String} [passphrase] * @param {String} [passphrase]
* *
* @return {Promise} * @return {Promise<void>}
*/ */
export const writeKeyToFile = async (filename, key, passphrase) => { export const writeKeyToFile = async (filename, key, passphrase) => {
if (typeof key === 'string') { if (typeof key === 'string') {
key = key.includes('PRIVATE KEY') key = key.includes('PRIVATE KEY')
? importPrivateKey(key, passphrase) ? importPrivateKey(key, passphrase)
: importPublicKey(key); : importPublicKey(key);
} else if (!(key instanceof crypto.KeyObject)) { }
else if (!(key instanceof crypto.KeyObject)) {
throw new Error('Expected "key" to be crypto.KeyObject or string'); throw new Error('Expected "key" to be crypto.KeyObject or string');
} }
const isPrivateKey = key.type === 'private'; const isPrivateKey = key.type === 'private';
@ -104,6 +105,5 @@ export const writeKeyToFile = async (filename, key, passphrase) => {
? exportPrivateKey(key, passphrase) ? exportPrivateKey(key, passphrase)
: exportPublicKey(key); : exportPublicKey(key);
const mode = isPrivateKey ? PRIVATE_KEY_PERMISSIONS : PUBLIC_KEY_PERMISSIONS; const mode = isPrivateKey ? PRIVATE_KEY_PERMISSIONS : PUBLIC_KEY_PERMISSIONS;
await fs.promises.writeFile(filename, keyData, {mode}); await fs.promises.writeFile(filename, keyData, { mode });
}; };

View file

@ -1,20 +1,29 @@
import https from "https"; import http from "node:http";
import https from "node:https";
/**
* @template T
* @typedef {{data: T, headers: import('http').IncomingHttpHeaders, statusCode: number}} Response
*/
/** /**
* @template T
* @param {string | URL} url * @param {string | URL} url
* @param {import('https').RequestOptions & {data?: string}} [options] * @param {import('https').RequestOptions & {data?: string}} [options]
* @param {() => void} [cb] * @return {Promise<Response<T>>}
* @return {Promise<{data: any, headers: import('http').IncomingHttpHeaders, statusCode: number}>}
*/ */
const request = (url, {data = '', ...options} = {}, cb) => { const request = (url, { data = '', ...options } = {}) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
url = new URL(url); url = new URL(url);
} catch (err) { }
catch (err) {
return reject(err); return reject(err);
} }
https.request(url, options, res => { (url.protocol == 'https:' ? https : http).request(url, options, res => {
const {statusCode, headers} = res; const { statusCode, headers } = res;
/**
* @type {any}
*/
let data = ''; let data = '';
res res
.on('data', chunk => { .on('data', chunk => {
@ -24,12 +33,13 @@ const request = (url, {data = '', ...options} = {}, cb) => {
if (headers['content-type']?.includes('application/json')) { if (headers['content-type']?.includes('application/json')) {
try { try {
data = JSON.parse(data); data = JSON.parse(data);
} catch (err) { }
catch (err) {
reject(err); reject(err);
return; return;
} }
} }
resolve({data, headers, statusCode: statusCode || 0}); resolve({ data, headers, statusCode: statusCode || 0 });
}) })
.once('error', reject); .once('error', reject);
}) })
@ -39,7 +49,6 @@ const request = (url, {data = '', ...options} = {}, cb) => {
const method = options.method || 'GET'; const method = options.method || 'GET';
reject(new Error(`${method} request to "${url}" timed out`)); reject(new Error(`${method} request to "${url}" timed out`));
}, 10e3); }, 10e3);
cb && cb();
}); });
}; };
export default request; export default request;