UNPKG

@softvisio/core

Version:
777 lines (599 loc) • 21.9 kB
import "#lib/result"; import crypto from "node:crypto"; import dns from "node:dns"; import { createCsr } from "#lib/certificates"; import fetch from "#lib/fetch"; import Hostname from "#lib/hostname"; import subnets from "#lib/ip/subnets"; import Counter from "#lib/threads/counter"; import Mutex from "#lib/threads/mutex"; import { sleep } from "#lib/utils"; 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", }, }; const STATUSES = { "invalid": new Set( [ "invalid" ] ), "pending": new Set( [ "pending", "processing" ] ), "ready": new Set( [ "ready", "valid" ] ), }; export default class Acme { #directory; #email; #accountKey; #accountUrl; #directories; #jwk; #mutex = new Mutex(); constructor ( { provider, test, email, accountKey, accountUrl } = {} ) { provider ||= "letsencrypt"; this.#email = email; if ( accountKey ) { // from DER if ( Buffer.isBuffer( accountKey ) ) { accountKey = crypto.createPrivateKey( { "key": accountKey, "type": "pkcs8", "format": "der", } ); } // to PEM if ( typeof accountKey === "object" ) { accountKey = accountKey.export( { "type": "pkcs8", "format": "pem", } ); } this.#accountKey = accountKey; } this.#accountUrl = accountUrl; this.#directory = DIRECTORIES[ provider ][ test ? "staging" : "production" ]; } // static static canGetCertificate ( domains ) { if ( !Array.isArray( domains ) ) domains = [ domains ]; if ( !domains.length ) return false; if ( domains.length > 100 ) return false; for ( const domain of domains ) { try { // wildcard domain if ( domain.startsWith( "*." ) ) { const hostname = new Hostname( domain.slice( 2 ) ); if ( !hostname.isDomain || !hostname.isValid || hostname.isTld || !hostname.tldIsValid ) { return false; } } // regular domain else { const hostname = new Hostname( domain ); if ( !hostname.isDomain || !hostname.isValid || hostname.isTld || !hostname.tldIsValid || hostname.isPublicSuffix ) { return false; } } } catch { return false; } } return true; } // properties get accountKey () { return this.#accountKey; } get accountUrl () { return this.#accountUrl; } // public async getCertificate ( { domains, checkDomain, createChallenge, deleteChallenge, ...attributes } = {} ) { var res; if ( !Array.isArray( domains ) ) domains = [ domains ]; // pre-check domains if ( !this.canGetCertificate( domains ) ) return result( [ 400, "Domains are not valid" ] ); // init if ( !this.#accountUrl ) { res = await this.createAccount(); if ( !res.ok ) return res; } // prepare domains const index = {}, counter = new Counter(); for ( const name of domains ) { const isWildcard = name.startsWith( "*." ), domain = isWildcard ? name.slice( 2 ) : name, dnsTxtRecordName = `_acme-challenge.${ domain }`; if ( index[ domain ] ) continue; index[ domain ] = true; counter.value++; dns.lookup( domain, ( e, address ) => { if ( address && ( subnets.get( "local" ).includes( address ) || subnets.get( "private" ).includes( address ) ) ) { address = null; } index[ domain ] = { domain, dnsTxtRecordName, "resolved": address, }; counter.value--; } ); } await counter.wait(); // pre-check domains if ( checkDomain ) { let checked = true; const counter = new Counter(); for ( const record of Object.values( index ) ) { const canGetCertificate = checkDomain( { ...record } ); if ( canGetCertificate instanceof Promise ) { counter.value++; canGetCertificate.then( canGetCertificate => { if ( !canGetCertificate ) checked = false; counter.value--; } ); } else { if ( !canGetCertificate ) checked = false; } } await counter.wait(); if ( !checked ) return result( [ 400, "Domains check failed" ] ); } // create order res = await this.#createOrder( { "identifiers": domains.map( domain => { return { "type": "dns", "value": domain }; } ), } ); if ( !res.ok ) return res; const order = res.data; res = await this.#getAuthorizations( order ); if ( !res.ok ) return res; const authorizations = res.data; for ( const authorization of authorizations ) { const record = index[ authorization.identifier.value ]; let authorizationDone; for ( const challenge of authorization.challenges ) { const type = challenge.type, httpLocation = `/.well-known/acme-challenge/${ challenge.token }`, token = challenge.token, content = this.#getChallengeKeyAuthorization( challenge ); if ( authorization.wildcard && type !== "dns-01" ) continue; if ( type === "http-01" && !record.resolved ) continue; try { // create challenge res = await createChallenge( { ...record, type, httpLocation, token, content, } ); if ( !res.ok ) continue; const dnsTtl = res.data?.dnsTtl; // verify challenge res = await this.#verifyChallenge( { ...record, type, httpLocation, token, content, dnsTtl, } ); if ( !res.ok ) throw res; // complete challenge res = await this.#completeChallenge( challenge ); if ( !res.ok ) throw res; // wait for challenge verified res = await this.#waitForValidStatus( challenge.url ); if ( !res.ok ) throw res; authorizationDone = true; } catch {} // delete challenge await deleteChallenge( { ...record, type, httpLocation, token, } ); if ( authorizationDone ) break; } // authorization failed if ( !authorizationDone ) { // deactivate authorization await this.#deactivateAuthorization( authorization ); return result( [ 500, `ACME failed authorize domain: "${ ( authorization.wildcard ? "*." : "" ) + record.domain }"` ] ); } } // create csr const { csr, privateKey } = await createCsr( domains, attributes ); // finalize orfer res = await this.#finalizeOrder( order, csr ); if ( !res.ok ) return res; res = await this.#getCertificate( res.data ); if ( !res.ok ) return res; const x509Certificate = new crypto.X509Certificate( res.data ); return result( 200, { "certificate": res.data.toString(), privateKey, "fingerprint": x509Certificate.fingerprint512, "expires": new Date( x509Certificate.validTo ), } ); } async createAccount () { var res; if ( !this.#accountUrl ) { // generate account key if ( this.#mutex.tryLock() ) { if ( !this.#accountKey ) { const keyPair = await new Promise( ( resolve, reject ) => { crypto.generateKeyPair( "ec", { "namedCurve": "P-384", }, ( e, publicKey, privateKey ) => { if ( e ) { reject( e ); } else { resolve( { publicKey, privateKey } ); } } ); } ); this.#accountKey = keyPair.privateKey.export( { "type": "pkcs8", "format": "pem", } ); } // create account res = await this.#createAccount(); this.#mutex.unlock( res ); } else { res = await this.#mutex.wait(); } if ( !res.is2xx ) return res; } return result( 200 ); } canGetCertificate ( domains ) { return this.constructor.canGetCertificate( domains ); } // private async #createAccount () { const res = await this.#apiResourceRequest( "newAccount", { "termsOfServiceAgreed": true, "contact": [ "mailto:" + this.#email ], }, { "includeJwsKid": false, } ); // account created if ( res.status === 200 || res.status === 201 ) { this.#accountUrl = res.meta.location; } return res; } async #createOrder ( data ) { const res = await this.#apiResourceRequest( "newOrder", data ); if ( res.status !== 201 ) { return res; } else if ( !res.meta.location ) { return result( [ 500, "ACME account url not reeturned" ] ); } else { res.data.url = res.meta.location; return result( 200, res.data ); } } async #getAuthorizations ( order ) { const data = []; for ( const url of order.authorizations || [] ) { const res = await this.#apiRequest( url ); if ( !res.ok ) return res; res.data.url = url; data.push( res.data ); } return result( 200, data ); } #getChallengeKeyAuthorization ( challenge ) { const jwk = this.#getJwk(), keysum = crypto.createHash( "SHA256" ).update( JSON.stringify( jwk ) ), thumbprint = keysum.digest( "base64url" ), res = `${ challenge.token }.${ thumbprint }`; if ( challenge.type === "http-01" ) { return res; } else if ( challenge.type === "dns-01" || challenge.type === "tls-alpn-01" ) { const shasum = crypto.createHash( "SHA256" ).update( res ); return shasum.digest( "base64url" ); } } async #verifyChallenge ( { type, domain, dnsTxtRecordName, httpLocation, content, dnsTtl } ) { var attempt, interval = 3; // seconds if ( type === "http-01" ) { attempt = 10; } else if ( type === "dns-01" ) { dnsTtl ||= 60; // seconds attempt = 2; interval = dnsTtl + 1; } else { attempt = 10; } TEST: while ( true ) { // http if ( type === "http-01" ) { const res = await fetch( `http://${ domain }${ httpLocation }` ); if ( res.ok ) { const text = await res.text(); if ( text === content ) { return result( 200 ); } else { break TEST; } } } // dns else if ( type === "dns-01" ) { const resolver = new dns.Resolver(); resolver.setServers( [ "1.1.1.1" ] ); const res = await new Promise( resolve => { resolver.resolveTxt( dnsTxtRecordName, ( error, records ) => { if ( error ) return resolve( result.catch( error, { "log": false } ) ); if ( records ) { for ( const record of records ) { for ( const value of record ) { if ( value === content ) return resolve( result( 200 ) ); } } } resolve( result( 500 ) ); } ); } ); if ( res.ok ) return result( 200 ); } // not supported else { return result( 200 ); } attempt--; if ( attempt <= 0 ) break; await sleep( interval * 1000 ); } return result( [ 500, "Challenge verification error" ] ); } async #completeChallenge ( challenge ) { return this.#apiRequest( challenge.url, {} ); } async #waitForValidStatus ( url ) { while ( true ) { const res = await this.#apiRequest( url ); // request error if ( !res.ok ) return res; // complete if ( STATUSES.ready.has( res.data.status ) ) { return res; } // inlid else if ( STATUSES.invalid.has( res.data.status ) ) { return result( [ 500, "Challenge is not valid" ] ); } // pending else if ( STATUSES.pending.has( res.data.status ) ) { await sleep( 3000 ); } } } async #deactivateAuthorization ( authorization ) { const data = { "status": "deactivated", }; const res = await this.#apiRequest( authorization.url, data ); if ( !res.ok ) return res; res.data.url = authorization.url; return res; } async #finalizeOrder ( order, csr ) { const res = await this.#apiRequest( order.finalize, { csr, } ); if ( !res.ok ) return res; res.data.url = order.url; return res; } async #getCertificate ( order ) { var res; if ( !STATUSES.ready.has( order.status ) ) { res = await this.#waitForValidStatus( order.url ); if ( !res.ok ) return res; order = res.data; } if ( !order.certificate ) { return result( [ 500, "Unable to download certificate, URL not found" ] ); } res = await this.#apiRequest( order.certificate ); return res; } async #getResourceUrl ( resource ) { if ( !this.#directories ) { const res = await this.#getDirectories(); if ( !res.ok ) return res; } const url = this.#directories[ resource ]; if ( !url ) return result( [ 400, "Resource url not found" ] ); return result( 200, url ); } async #getDirectories () { if ( !this.#directories ) { const res = await fetch( this.#directory ); if ( !res.ok ) return res; this.#directories = await res.json(); } return result( 200, this.#directories ); } async #apiRequest ( url, data, { includeJwsKid = true } = {} ) { const kid = includeJwsKid ? this.#accountUrl : null; const res = await this.#signedRequest( url, data, { kid, } ); return res; } async #apiResourceRequest ( resource, data, { includeJwsKid = true } = {} ) { var res; res = await this.#getResourceUrl( resource ); if ( !res.ok ) return res; const url = res.data; return this.#apiRequest( url, data, { includeJwsKid, } ); } async #signedRequest ( url, payload, { kid = null, nonce = null } = {}, attempts = 0 ) { if ( !nonce ) { const res = await this.#getNonce(); if ( !res.ok ) return res; nonce = res.data; } // sign body and send request const data = this.#createSignedBody( url, payload, { nonce, kid } ); const res = await fetch( url, { "method": "post", "headers": { "content-type": "application/jose+json", }, "body": JSON.stringify( data ), } ); var body; if ( res.ok && res.headers.contentType.type === "application/pem-certificate-chain" ) { body = await res.buffer(); } else { body = await res.json().catch( e => null ); // retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 if ( res.status === 400 && body?.type === "urn:ietf:params:acme:error:badNonce" && attempts < this.maxBadNonceRetries ) { nonce = res.headers.get( "replay-nonce" ) || null; attempts += 1; return this.#signedRequest( url, payload, { kid, nonce, }, attempts ); } } /* Return response */ return result( [ res.status, body?.detail ], body, { "location": res.headers.get( "location" ), "link": res.headers.get( "link" ), } ); } async #getNonce () { var res; res = await this.#getResourceUrl( "newNonce" ); if ( !res.ok ) return res; res = await fetch( res.data, { "method": "head", } ); const nonce = res.headers.get( "replay-nonce" ); if ( !nonce ) { return result( [ 500, "Get nonce failed" ] ); } else { return result( 200, nonce ); } } #createSignedBody ( url, payload = null, { nonce = null, kid = null } = {} ) { const jwk = this.#getJwk(); let headerAlg = "RS256", signerAlg = "SHA256"; // https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 if ( 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 signer const res = this.#prepareSignedBody( headerAlg, url, payload, { nonce, kid } ); const signer = crypto.createSign( signerAlg ).update( `${ res.protected }.${ res.payload }`, "utf8" ); // signature - https://stackoverflow.com/questions/39554165 res.signature = signer.sign( { "key": this.#accountKey, "padding": crypto.RSA_PKCS1_PADDING, "dsaEncoding": "ieee-p1363", }, "base64url" ); return res; } #getJwk () { if ( !this.#jwk ) { const jwk = crypto.createPublicKey( this.#accountKey ).export( { "format": "jwk", } ); /* Sort keys */ this.#jwk = Object.keys( jwk ) .sort() .reduce( ( result, key ) => { result[ key ] = jwk[ key ]; return result; }, {} ); } return this.#jwk; } #prepareSignedBody ( alg, url, payload = null, { nonce = null, kid = null } = {} ) { const header = { alg, url }; // nonce if ( nonce ) { header.nonce = nonce; } // kID or jwk if ( kid ) { header.kid = kid; } else { header.jwk = this.#getJwk(); } return { "payload": payload ? Buffer.from( JSON.stringify( payload ) ).toString( "base64url" ) : "", "protected": Buffer.from( JSON.stringify( header ) ).toString( "base64url" ), }; } }