/**
 * Native Node.js crypto interface
 *
 * @namespace crypto
 */
import  net from 'net';
import { promisify } from 'util';
import crypto from 'crypto';
import asn1js from 'asn1js';
import x509 from '@peculiar/x509';


const randomInt = promisify(crypto.randomInt);
const generateKeyPair = promisify(crypto.generateKeyPair);

/* Use Node.js Web Crypto API */
x509.cryptoProvider.set(crypto.webcrypto);

/* id-ce-subjectAltName - https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 */
const subjectAltNameOID = '2.5.29.17';

/* id-pe-acmeIdentifier - https://datatracker.ietf.org/doc/html/rfc8737#section-6.1 */
const alpnAcmeIdentifierOID = '1.3.6.1.5.5.7.1.31';

/**
 * Determine key type and info by attempting to derive public key
 *
 * @private
 * @param {buffer|string} keyPem PEM encoded private or public key
 * @returns {object}
 */

function getKeyInfo(keyPem) {
    const result = {
        isRSA: false,
        isECDSA: false,
        publicKey: crypto.createPublicKey(keyPem),
    };

    if (result.publicKey.asymmetricKeyType === 'rsa') {
        result.isRSA = true;
    }
    else if (result.publicKey.asymmetricKeyType === 'ec') {
        result.isECDSA = true;
    }
    else {
        throw new Error('Unable to parse key information, unknown format');
    }

    return result;
}

/**
 * Generate a private RSA key
 *
 * @param {number} [modulusLength] Size of the keys modulus in bits, default: `2048`
 * @returns {Promise<buffer>} PEM encoded private RSA key
 *
 * @example Generate private RSA key
 * ```js
 * const privateKey = await acme.crypto.createPrivateRsaKey();
 * ```
 *
 * @example Private RSA key with modulus size 4096
 * ```js
 * const privateKey = await acme.crypto.createPrivateRsaKey(4096);
 * ```
 */

export async function createPrivateRsaKey(modulusLength = 2048, encodingType = 'pkcs8') {
    const pair = await generateKeyPair('rsa', {
        modulusLength,
        privateKeyEncoding: {
            type: encodingType,
            format: 'pem',
        },
    });

    return Buffer.from(pair.privateKey);
}


/**
 * Alias of `createPrivateRsaKey()`
 *
 * @function
 */

export const createPrivateKey = createPrivateRsaKey;

/**
 * Generate a private ECDSA key
 *
 * @param {string} [namedCurve] ECDSA curve name (P-256, P-384 or P-521), default `P-256`
 * @returns {Promise<buffer>} PEM encoded private ECDSA key
 *
 * @example Generate private ECDSA key
 * ```js
 * const privateKey = await acme.crypto.createPrivateEcdsaKey();
 * ```
 *
 * @example Private ECDSA key using P-384 curve
 * ```js
 * const privateKey = await acme.crypto.createPrivateEcdsaKey('P-384');
 * ```
 */

export const createPrivateEcdsaKey = async (namedCurve = 'P-256', encodingType = 'pkcs8') => {
    const pair = await generateKeyPair('ec', {
        namedCurve,
        privateKeyEncoding: {
            type: encodingType,
            format: 'pem',
        },
    });

    return Buffer.from(pair.privateKey);
};

/**
 * Get a public key derived from a RSA or ECDSA key
 *
 * @param {buffer|string} keyPem PEM encoded private or public key
 * @returns {buffer} PEM encoded public key
 *
 * @example Get public key
 * ```js
 * const publicKey = acme.crypto.getPublicKey(privateKey);
 * ```
 */

export const getPublicKey = (keyPem) => {
    const info = getKeyInfo(keyPem);

    const publicKey = info.publicKey.export({
        type: info.isECDSA ? 'spki' : 'pkcs1',
        format: 'pem',
    });

    return Buffer.from(publicKey);
};

/**
 * Get a JSON Web Key derived from a RSA or ECDSA key
 *
 * https://datatracker.ietf.org/doc/html/rfc7517
 *
 * @param {buffer|string} keyPem PEM encoded private or public key
 * @returns {object} JSON Web Key
 *
 * @example Get JWK
 * ```js
 * const jwk = acme.crypto.getJwk(privateKey);
 * ```
 */

export function getJwk(keyPem) {
    const jwk = crypto.createPublicKey(keyPem).export({
        format: 'jwk',
    });

    /* Sort keys */
    return Object.keys(jwk).sort().reduce((result, k) => {
        result[k] = jwk[k];
        return result;
    }, {});
}


/**
 * Produce CryptoKeyPair and signing algorithm from a PEM encoded private key
 *
 * @private
 * @param {buffer|string} keyPem PEM encoded private key
 * @returns {Promise<array>} [keyPair, signingAlgorithm]
 */

async function getWebCryptoKeyPair(keyPem) {
    const info = getKeyInfo(keyPem);
    const jwk = getJwk(keyPem);

    /* Signing algorithm */
    const sigalg = {
        name: 'RSASSA-PKCS1-v1_5',
        hash: { name: 'SHA-256' },
    };

    if (info.isECDSA) {
        sigalg.name = 'ECDSA';
        sigalg.namedCurve = jwk.crv;

        if (jwk.crv === 'P-384') {
            sigalg.hash.name = 'SHA-384';
        }

        if (jwk.crv === 'P-521') {
            sigalg.hash.name = 'SHA-512';
        }
    }

    /* Decode PEM and import into CryptoKeyPair */
    const privateKeyDec = x509.PemConverter.decodeFirst(keyPem.toString());
    const privateKey = await crypto.webcrypto.subtle.importKey('pkcs8', privateKeyDec, sigalg, true, ['sign']);
    const publicKey = await crypto.webcrypto.subtle.importKey('jwk', jwk, sigalg, true, ['verify']);

    return [{ privateKey, publicKey }, sigalg];
}

/**
 * Split chain of PEM encoded objects from string into array
 *
 * @param {buffer|string} chainPem PEM encoded object chain
 * @returns {string[]} Array of PEM objects including headers
 */

export function splitPemChain(chainPem) {
    if (Buffer.isBuffer(chainPem)) {
        chainPem = chainPem.toString();
    }

    /* Decode into array and re-encode */
    return x509.PemConverter.decodeWithHeaders(chainPem)
        .map((params) => x509.PemConverter.encode([params]));
}


/**
 * Parse body of PEM encoded object and return a Base64URL string
 * If multiple objects are chained, the first body will be returned
 *
 * @param {buffer|string} pem PEM encoded chain or object
 * @returns {string} Base64URL-encoded body
 */

export const getPemBodyAsB64u = (pem) => {
    const chain = splitPemChain(pem);

    if (!chain.length) {
        throw new Error('Unable to parse PEM body from string');
    }

    /* Select first object, extract body and convert to b64u */
    const dec = x509.PemConverter.decodeFirst(chain[0]);
    return Buffer.from(dec).toString('base64url');
};

/**
 * Parse domains from a certificate or CSR
 *
 * @private
 * @param {object} input x509.Certificate or x509.Pkcs10CertificateRequest
 * @returns {object} {commonName, altNames}
 */

function parseDomains(input) {
    const commonName = input.subjectName.getField('CN').pop() || null;
    const altNamesRaw = input.getExtension(subjectAltNameOID);
    let altNames = [];

    if (altNamesRaw) {
        const altNamesExt = new x509.SubjectAlternativeNameExtension(altNamesRaw.rawData);
        altNames = altNames.concat(altNamesExt.names.items.map((i) => i.value));
    }

    return {
        commonName,
        altNames,
    };
}

/**
 * Read domains from a Certificate Signing Request
 *
 * @param {buffer|string} csrPem PEM encoded Certificate Signing Request
 * @returns {object} {commonName, altNames}
 *
 * @example Read Certificate Signing Request domains
 * ```js
 * const { commonName, altNames } = acme.crypto.readCsrDomains(certificateRequest);
 *
 * console.log(`Common name: ${commonName}`);
 * console.log(`Alt names: ${altNames.join(', ')}`);
 * ```
 */

export const readCsrDomains = (csrPem) => {
    if (Buffer.isBuffer(csrPem)) {
        csrPem = csrPem.toString();
    }
    const dec = x509.PemConverter.decodeFirst(csrPem);
    const csr = new x509.Pkcs10CertificateRequest(dec);
    return parseDomains(csr);
};

/**
 * Read information from a certificate
 * If multiple certificates are chained, the first will be read
 *
 * @param {buffer|string} certPem PEM encoded certificate or chain
 * @returns {object} Certificate info
 *
 * @example Read certificate information
 * ```js
 * const info = acme.crypto.readCertificateInfo(certificate);
 * const { commonName, altNames } = info.domains;
 *
 * console.log(`Not after: ${info.notAfter}`);
 * console.log(`Not before: ${info.notBefore}`);
 *
 * console.log(`Common name: ${commonName}`);
 * console.log(`Alt names: ${altNames.join(', ')}`);
 * ```
 */

export const readCertificateInfo = (certPem) => {
    if (Buffer.isBuffer(certPem)) {
        certPem = certPem.toString();
    }

    const dec = x509.PemConverter.decodeFirst(certPem);
    const cert = new x509.X509Certificate(dec);

    return {
        issuer: {
            commonName: cert.issuerName.getField('CN').pop() || null,
        },
        domains: parseDomains(cert),
        notBefore: cert.notBefore,
        notAfter: cert.notAfter,
    };
};

/**
 * Determine ASN.1 character string type for CSR subject field name
 *
 * https://datatracker.ietf.org/doc/html/rfc5280
 * https://github.com/PeculiarVentures/x509/blob/ecf78224fd594abbc2fa83c41565d79874f88e00/src/name.ts#L65-L71
 *
 * @private
 * @param {string} field CSR subject field name
 * @returns {string} ASN.1 character string type
 */

function getCsrAsn1CharStringType(field) {
    switch (field) {
        case 'C':
            return 'printableString';
        case 'E':
            return 'ia5String';
        default:
            return 'utf8String';
    }
}

/**
 * Create array of subject fields for a Certificate Signing Request
 *
 * https://github.com/PeculiarVentures/x509/blob/ecf78224fd594abbc2fa83c41565d79874f88e00/src/name.ts#L65-L71
 *
 * @private
 * @param {object} input Key-value of subject fields
 * @returns {object[]} Certificate Signing Request subject array
 */

function createCsrSubject(input) {
    return Object.entries(input).reduce((result, [type, value]) => {
        if (value) {
            const ds = getCsrAsn1CharStringType(type);
            result.push({ [type]: [{ [ds]: value }] });
        }

        return result;
    }, []);
}

/**
 * Create x509 subject alternate name extension
 *
 * https://github.com/PeculiarVentures/x509/blob/ecf78224fd594abbc2fa83c41565d79874f88e00/src/extensions/subject_alt_name.ts
 *
 * @private
 * @param {string[]} altNames Array of alt names
 * @returns {x509.SubjectAlternativeNameExtension} Subject alternate name extension
 */

function createSubjectAltNameExtension(altNames) {
    return new x509.SubjectAlternativeNameExtension(altNames.map((value) => {
        const type = net.isIP(value) ? 'ip' : 'dns';
        return { type, value };
    }));
}

/**
 * Create a Certificate Signing Request
 *
 * @param {object} data
 * @param {number} [data.keySize] Size of newly created RSA private key modulus in bits, default: `2048`
 * @param {string} [data.commonName] FQDN of your server
 * @param {string[]} [data.altNames] SAN (Subject Alternative Names), default: `[]`
 * @param {string} [data.country] 2 letter country code
 * @param {string} [data.state] State or province
 * @param {string} [data.locality] City
 * @param {string} [data.organization] Organization name
 * @param {string} [data.organizationUnit] Organizational unit name
 * @param {string} [data.emailAddress] Email address
 * @param {buffer|string} [keyPem] PEM encoded CSR private key
 * @returns {Promise<buffer[]>} [privateKey, certificateSigningRequest]
 *
 * @example Create a Certificate Signing Request
 * ```js
 * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
 *     altNames: ['test.example.com'],
 * });
 * ```
 *
 * @example Certificate Signing Request with both common and alternative names
 * > *Warning*: Certificate subject common name has been [deprecated](https://letsencrypt.org/docs/glossary/#def-CN) and its use is [discouraged](https://cabforum.org/uploads/BRv1.2.3.pdf).
 * ```js
 * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
 *     keySize: 4096,
 *     commonName: 'test.example.com',
 *     altNames: ['foo.example.com', 'bar.example.com'],
 * });
 * ```
 *
 * @example Certificate Signing Request with additional information
 * ```js
 * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
 *     altNames: ['test.example.com'],
 *     country: 'US',
 *     state: 'California',
 *     locality: 'Los Angeles',
 *     organization: 'The Company Inc.',
 *     organizationUnit: 'IT Department',
 *     emailAddress: 'contact@example.com',
 * });
 * ```
 *
 * @example Certificate Signing Request with ECDSA private key
 * ```js
 * const certificateKey = await acme.crypto.createPrivateEcdsaKey();
 *
 * const [, certificateRequest] = await acme.crypto.createCsr({
 *     altNames: ['test.example.com'],
 * }, certificateKey);
 * ```
 */

export const createCsr = async (data, keyPem = null) => {
    if (!keyPem) {
        keyPem = await createPrivateRsaKey(data.keySize);
    }
    else if (!Buffer.isBuffer(keyPem)) {
        keyPem = Buffer.from(keyPem);
    }

    if (typeof data.altNames === 'undefined') {
        data.altNames = [];
    }

    /* Ensure subject common name is present in SAN - https://cabforum.org/wp-content/uploads/BRv1.2.3.pdf */
    if (data.commonName && !data.altNames.includes(data.commonName)) {
        data.altNames.unshift(data.commonName);
    }

    /* CryptoKeyPair and signing algorithm from private key */
    const [keys, signingAlgorithm] = await getWebCryptoKeyPair(keyPem);

    const extensions = [
        /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3 */
        new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment), // eslint-disable-line no-bitwise

        /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 */
        createSubjectAltNameExtension(data.altNames),
    ];

    /* Create CSR */
    const csr = await x509.Pkcs10CertificateRequestGenerator.create({
        keys,
        extensions,
        signingAlgorithm,
        name: createCsrSubject({
            CN: data.commonName,
            C: data.country,
            ST: data.state,
            L: data.locality,
            O: data.organization,
            OU: data.organizationUnit,
            E: data.emailAddress,
        }),
    });

    /* Done */
    const pem = csr.toString('pem');
    return [keyPem, Buffer.from(pem)];
};

/**
 * Create a self-signed ALPN certificate for TLS-ALPN-01 challenges
 *
 * https://datatracker.ietf.org/doc/html/rfc8737
 *
 * @param {object} authz Identifier authorization
 * @param {string} keyAuthorization Challenge key authorization
 * @param {buffer|string} [keyPem] PEM encoded CSR private key
 * @returns {Promise<buffer[]>} [privateKey, certificate]
 *
 * @example Create a ALPN certificate
 * ```js
 * const [alpnKey, alpnCertificate] = await acme.crypto.createAlpnCertificate(authz, keyAuthorization);
 * ```
 *
 * @example Create a ALPN certificate with ECDSA private key
 * ```js
 * const alpnKey = await acme.crypto.createPrivateEcdsaKey();
 * const [, alpnCertificate] = await acme.crypto.createAlpnCertificate(authz, keyAuthorization, alpnKey);
 * ```
 */

export const createAlpnCertificate = async (authz, keyAuthorization, keyPem = null) => {
    if (!keyPem) {
        keyPem = await createPrivateRsaKey();
    }
    else if (!Buffer.isBuffer(keyPem)) {
        keyPem = Buffer.from(keyPem);
    }

    const now = new Date();
    const commonName = authz.identifier.value;

    /* Pseudo-random serial - max 20 bytes, 11 for epoch (year 5138), 9 random */
    const random = await randomInt(1, 999999999);
    const serialNumber = `${Math.floor(now.getTime() / 1000)}${random}`;

    /* CryptoKeyPair and signing algorithm from private key */
    const [keys, signingAlgorithm] = await getWebCryptoKeyPair(keyPem);

    const extensions = [
        /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3 */
        new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), // eslint-disable-line no-bitwise

        /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.9 */
        new x509.BasicConstraintsExtension(true, 2, true),

        /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.2 */
        await x509.SubjectKeyIdentifierExtension.create(keys.publicKey),

        /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 */
        createSubjectAltNameExtension([commonName]),
    ];

    /* ALPN extension */
    const payload = crypto.createHash('sha256').update(keyAuthorization).digest('hex');
    const octstr = new asn1js.OctetString({ valueHex: Buffer.from(payload, 'hex') });
    extensions.push(new x509.Extension(alpnAcmeIdentifierOID, true, octstr.toBER()));

    /* Self-signed ALPN certificate */
    const cert = await x509.X509CertificateGenerator.createSelfSigned({
        keys,
        signingAlgorithm,
        extensions,
        serialNumber,
        notBefore: now,
        notAfter: now,
        name: createCsrSubject({
            CN: commonName,
        }),
    });

    /* Done */
    const pem = cert.toString('pem');
    return [keyPem, Buffer.from(pem)];
};

/**
 * Validate that a ALPN certificate contains the expected key authorization
 *
 * @param {buffer|string} certPem PEM encoded certificate
 * @param {string} keyAuthorization Expected challenge key authorization
 * @returns {boolean} True when valid
 */

export const isAlpnCertificateAuthorizationValid = (certPem, keyAuthorization) => {
    const expected = crypto.createHash('sha256').update(keyAuthorization).digest('hex');

    /* Attempt to locate ALPN extension */
    const cert = new x509.X509Certificate(certPem);
    const ext = cert.getExtension(alpnAcmeIdentifierOID);

    if (!ext) {
        throw new Error('Unable to locate ALPN extension within parsed certificate');
    }

    /* Decode extension value */
    const parsed = asn1js.fromBER(ext.value);
    const result = Buffer.from(parsed.result.valueBlock.valueHexView).toString('hex');

    /* Return true if match */
    return (result === expected);
};
