2026-02-14 09:53:14 +01:00
import assert from 'node:assert' ;
import blobs from './blobs.js' ;
import BoxError from './boxerror.js' ;
import crypto from 'node:crypto' ;
2026-03-12 22:55:28 +05:30
import logger from './logger.js' ;
2026-02-14 15:43:24 +01:00
import dns from './dns.js' ;
import openssl from './openssl.js' ;
2026-02-14 09:53:14 +01:00
import path from 'node:path' ;
import paths from './paths.js' ;
2026-03-27 11:39:38 +01:00
import retry from './retry.js' ;
2026-04-01 09:40:28 +02:00
import safe from '@cloudron/safetydance' ;
2026-02-14 09:53:14 +01:00
import superagent from '@cloudron/superagent' ;
2026-02-14 15:43:24 +01:00
import users from './users.js' ;
2018-09-10 15:19:10 -07:00
2026-03-12 23:23:23 +05:30
const { log } = logger ( 'cert/acme2' ) ;
2026-02-14 09:53:14 +01:00
2018-09-10 15:19:10 -07:00
const CA _PROD _DIRECTORY _URL = 'https://acme-v02.api.letsencrypt.org/directory' ,
CA _STAGING _DIRECTORY _URL = 'https://acme-staging-v02.api.letsencrypt.org/directory' ;
// http://jose.readthedocs.org/en/latest/
// https://www.ietf.org/proceedings/92/slides/slides-92-acme-1.pdf
// https://community.letsencrypt.org/t/list-of-client-implementations/2103
2026-01-17 13:38:17 +01:00
function Acme2 ( fqdn , domainObject , email , key , options ) {
2022-11-17 08:58:20 +01:00
assert . strictEqual ( typeof fqdn , 'string' ) ;
assert . strictEqual ( typeof domainObject , 'object' ) ;
assert . strictEqual ( typeof email , 'string' ) ;
2026-01-17 13:38:17 +01:00
assert . strictEqual ( typeof key , 'string' ) ;
2026-01-17 09:48:45 +01:00
assert . strictEqual ( typeof options , 'object' ) ; // { profile }
2018-09-10 15:19:10 -07:00
2022-11-17 08:58:20 +01:00
this . fqdn = fqdn ;
2022-11-29 13:57:58 +01:00
this . accountKey = null ;
2022-11-17 08:58:20 +01:00
this . email = email ;
2026-01-17 13:38:17 +01:00
this . key = key ;
2026-01-17 10:26:04 +01:00
this . accountKeyId = null ;
2022-11-17 08:58:20 +01:00
const prod = domainObject . tlsConfig . provider . match ( /.*-prod/ ) !== null ; // matches 'le-prod' or 'letsencrypt-prod'
this . caDirectory = prod ? CA _PROD _DIRECTORY _URL : CA _STAGING _DIRECTORY _URL ;
2018-09-10 15:19:10 -07:00
this . directory = { } ;
2023-05-02 23:01:14 +02:00
this . forceHttpAuthorization = domainObject . provider . match ( /noop|manual|wildcard/ ) !== null ;
2022-11-17 08:58:20 +01:00
this . wildcard = ! ! domainObject . tlsConfig . wildcard ;
this . domain = domainObject . domain ;
2023-02-25 02:47:40 +01:00
if ( fqdn !== this . domain && this . wildcard ) { // bare domain is not part of wildcard SAN
this . cn = dns . makeWildcard ( fqdn ) ;
this . altNames = [ this . cn ] ;
if ( fqdn . startsWith ( '*.' ) ) this . altNames . push ( fqdn . replace ( /^\*\./ , '' ) ) ; // add bare domain to cert for wildcard certs
} else {
this . cn = fqdn ;
this . altNames = [ this . cn ] ;
}
2022-11-17 08:58:20 +01:00
this . certName = this . cn . replace ( '*.' , '_.' ) ;
2026-01-17 09:48:45 +01:00
this . profile = options . profile || '' ; // https://letsencrypt.org/docs/profiles/ . is validated against the directory
2026-03-12 22:55:28 +05:30
log ( ` Acme2: will get cert for fqdn: ${ this . fqdn } cn: ${ this . cn } certName: ${ this . certName } wildcard: ${ this . wildcard } http: ${ this . forceHttpAuthorization } ` ) ;
2018-09-10 15:19:10 -07:00
}
// urlsafe base64 encoding (jose)
2026-01-17 22:31:36 +01:00
function urlBase64Encode ( base64String ) {
return base64String . replace ( /\+/g , '-' ) . replace ( /\//g , '_' ) . replace ( /=/g , '' ) ;
2018-09-10 15:19:10 -07:00
}
function b64 ( str ) {
2022-04-14 17:41:41 -05:00
const buf = Buffer . isBuffer ( str ) ? str : Buffer . from ( str ) ;
2018-09-10 15:19:10 -07:00
return urlBase64Encode ( buf . toString ( 'base64' ) ) ;
}
2026-01-17 22:31:36 +01:00
// https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients#step-3-constructing-the-ari-certid
// https://www.rfc-editor.org/rfc/rfc9773.txt
async function getAriCertId ( certPem ) {
assert . strictEqual ( typeof certPem , 'string' ) ;
const aki = await openssl . getAuthorityKeyId ( certPem ) ;
const serial = await openssl . getSerial ( certPem ) ;
return b64 ( aki ) + '.' + b64 ( serial ) ;
} ;
// ARI - https://www.rfc-editor.org/rfc/rfc9773.txt . https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients#step-3-constructing-the-ari-certid
async function getRenewalInfo ( certPem , renewalUrl ) {
assert . strictEqual ( typeof certPem , 'string' ) ;
assert . strictEqual ( typeof renewalUrl , 'string' ) ;
const now = new Date ( ) ;
const ariCertId = await getAriCertId ( certPem ) ;
const response = await superagent . get ( ` ${ renewalUrl } / ${ ariCertId } ` ) . timeout ( 30000 ) . ok ( ( ) => true ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . ACME _ERROR , ` Invalid response code when renewal info : ${ response . status } ${ response . text } ` ) ;
const body = response . body ;
if ( typeof body . suggestedWindow ? . start !== 'string' ) throw new BoxError ( BoxError . ACME _ERROR , ` Invalid response suggestedWindow.start : ${ response . text } ` ) ;
if ( typeof body . suggestedWindow ? . end !== 'string' ) throw new BoxError ( BoxError . ACME _ERROR , ` Invalid response suggestedWindow.end : ${ response . text } ` ) ;
const start = new Date ( body . suggestedWindow . start ) , end = new Date ( body . suggestedWindow . end ) ;
const retryAfter = Number . parseInt ( response . headers [ 'Retry-After' . toLocaleLowerCase ( ) ] , 10 ) ; // seconds
if ( ! Number . isFinite ( retryAfter ) ) throw new BoxError ( BoxError . ACME _ERROR , 'Missing or invalid retry-after in response header' ) ;
const valid = new Date ( now . getTime ( ) + retryAfter * 1000 ) ;
const rt = new Date ( start . getTime ( ) + Math . random ( ) * ( end . getTime ( ) - start . getTime ( ) ) ) ; // a uniform random time in the window
return {
start : start . toUTCString ( ) ,
end : end . toUTCString ( ) ,
rt : rt . toUTCString ( ) ,
valid : valid . toUTCString ( ) ,
url : renewalUrl ,
ts : now . toUTCString ( )
} ;
} ;
2021-05-06 22:29:34 -07:00
Acme2 . prototype . sendSignedRequest = async function ( url , payload ) {
2018-09-10 15:19:10 -07:00
assert . strictEqual ( typeof url , 'string' ) ;
assert . strictEqual ( typeof payload , 'string' ) ;
2022-11-29 13:57:58 +01:00
assert . strictEqual ( typeof this . accountKey , 'string' ) ;
2018-09-10 15:19:10 -07:00
const that = this ;
2023-02-01 15:43:59 +01:00
const header = {
2018-09-10 15:19:10 -07:00
url : url ,
alg : 'RS256'
} ;
// keyId is null when registering account
2026-01-17 10:26:04 +01:00
if ( this . accountKeyId ) {
header . kid = this . accountKeyId ;
2018-09-10 15:19:10 -07:00
} else {
header . jwk = {
e : b64 ( Buffer . from ( [ 0x01 , 0x00 , 0x01 ] ) ) , // exponent - 65537
kty : 'RSA' ,
2026-01-17 13:38:17 +01:00
n : b64 ( await openssl . getModulus ( this . accountKey ) )
2018-09-10 15:19:10 -07:00
} ;
}
2021-05-06 22:29:34 -07:00
const payload64 = b64 ( payload ) ;
2019-12-08 18:37:25 -08:00
2021-05-07 15:56:43 -07:00
let [ error , response ] = await safe ( superagent . get ( this . directory . newNonce ) . timeout ( 30000 ) . ok ( ( ) => true ) ) ;
if ( error ) throw new BoxError ( BoxError . NETWORK _ERROR , ` Network error sending signed request: ${ error . message } ` ) ;
2021-11-17 10:54:26 -08:00
if ( response . status !== 204 ) throw new BoxError ( BoxError . ACME _ERROR , ` Invalid response code when fetching nonce : ${ response . status } ` ) ;
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const nonce = response . headers [ 'Replay-Nonce' . toLowerCase ( ) ] ;
2021-11-17 10:54:26 -08:00
if ( ! nonce ) throw new BoxError ( BoxError . ACME _ERROR , 'No nonce in response' ) ;
2018-09-10 15:19:10 -07:00
2026-03-12 22:55:28 +05:30
log ( ` sendSignedRequest: using nonce ${ nonce } for url ${ url } ` ) ;
2018-09-10 15:19:10 -07:00
2023-05-25 11:27:23 +02:00
const protected64 = b64 ( JSON . stringify ( Object . assign ( { } , header , { nonce : nonce } ) ) ) ;
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const signer = crypto . createSign ( 'RSA-SHA256' ) ;
signer . update ( protected64 + '.' + payload64 , 'utf8' ) ;
2022-11-29 13:57:58 +01:00
const signature64 = urlBase64Encode ( signer . sign ( that . accountKey , 'base64' ) ) ;
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const data = {
protected : protected64 ,
payload : payload64 ,
signature : signature64
} ;
2018-09-10 15:19:10 -07:00
2021-05-07 15:56:43 -07:00
[ error , response ] = await safe ( superagent . post ( url ) . send ( data ) . set ( 'Content-Type' , 'application/jose+json' ) . set ( 'User-Agent' , 'acme-cloudron' ) . timeout ( 30000 ) . ok ( ( ) => true ) ) ;
if ( error ) throw new BoxError ( BoxError . NETWORK _ERROR , ` Network error sending signed request: ${ error . message } ` ) ;
2019-12-08 18:37:25 -08:00
2021-05-07 15:56:43 -07:00
return response ;
2018-09-10 15:19:10 -07:00
} ;
2019-12-08 18:37:25 -08:00
// https://tools.ietf.org/html/rfc8555#section-6.3
2021-05-06 22:29:34 -07:00
Acme2 . prototype . postAsGet = async function ( url ) {
return await this . sendSignedRequest ( url , '' ) ;
2019-12-08 18:37:25 -08:00
} ;
2021-05-06 22:29:34 -07:00
Acme2 . prototype . updateContact = async function ( registrationUri ) {
2018-09-10 15:19:10 -07:00
assert . strictEqual ( typeof registrationUri , 'string' ) ;
2026-03-12 22:55:28 +05:30
log ( ` updateContact: registrationUri: ${ registrationUri } email: ${ this . email } ` ) ;
2018-09-10 15:19:10 -07:00
// https://github.com/ietf-wg-acme/acme/issues/30
const payload = {
contact : [ 'mailto:' + this . email ]
} ;
2021-05-06 22:29:34 -07:00
const result = await this . sendSignedRequest ( registrationUri , JSON . stringify ( payload ) ) ;
2025-02-14 17:26:54 +01:00
if ( result . status !== 200 ) throw new BoxError ( BoxError . ACME _ERROR , ` Failed to update contact. Expecting 200, got ${ result . status } ${ result . text } ` ) ;
2018-09-10 15:19:10 -07:00
2026-03-12 22:55:28 +05:30
log ( ` updateContact: contact of user updated to ${ this . email } ` ) ;
2018-09-10 15:19:10 -07:00
} ;
2021-11-16 22:56:35 -08:00
Acme2 . prototype . ensureAccount = async function ( ) {
2021-05-06 22:29:34 -07:00
const payload = {
2018-09-10 15:19:10 -07:00
termsOfServiceAgreed : true
} ;
2026-03-12 22:55:28 +05:30
log ( 'ensureAccount: registering user' ) ;
2021-11-16 22:56:35 -08:00
2022-11-29 13:57:58 +01:00
this . accountKey = await blobs . getString ( blobs . ACME _ACCOUNT _KEY ) ;
if ( ! this . accountKey ) {
2026-03-12 22:55:28 +05:30
log ( 'ensureAccount: generating new account keys' ) ;
2026-01-17 13:38:17 +01:00
this . accountKey = await openssl . generateKey ( 'rsa4096' ) ;
2022-11-29 13:57:58 +01:00
await blobs . setString ( blobs . ACME _ACCOUNT _KEY , this . accountKey ) ;
2021-11-16 22:56:35 -08:00
}
let result = await this . sendSignedRequest ( this . directory . newAccount , JSON . stringify ( payload ) ) ;
if ( result . status === 403 && result . body . type === 'urn:ietf:params:acme:error:unauthorized' ) {
2026-03-12 22:55:28 +05:30
log ( ` ensureAccount: key was revoked. ${ result . status } ${ result . text } . generating new account key ` ) ;
2026-01-17 13:38:17 +01:00
this . accountKey = await openssl . generateKey ( 'rsa4096' ) ;
2022-11-29 13:57:58 +01:00
await blobs . setString ( blobs . ACME _ACCOUNT _KEY , this . accountKey ) ;
2021-11-16 22:56:35 -08:00
result = await this . sendSignedRequest ( this . directory . newAccount , JSON . stringify ( payload ) ) ;
}
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
// 200 if already exists. 201 for new accounts
2025-02-14 17:26:54 +01:00
if ( result . status !== 200 && result . status !== 201 ) throw new BoxError ( BoxError . ACME _ERROR , ` Failed to register new account. Expecting 200 or 201, got ${ result . status } ${ result . text } ` ) ;
2018-09-10 15:19:10 -07:00
2026-03-12 22:55:28 +05:30
log ( ` ensureAccount: user registered keyid: ${ result . headers . location } ` ) ;
2018-09-10 15:19:10 -07:00
2026-01-17 10:26:04 +01:00
this . accountKeyId = result . headers . location ;
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
await this . updateContact ( result . headers . location ) ;
2018-09-10 15:19:10 -07:00
} ;
2022-11-17 08:58:20 +01:00
Acme2 . prototype . newOrder = async function ( ) {
2023-02-25 02:47:40 +01:00
const payload = { identifiers : [ ] } ;
2026-01-17 09:48:45 +01:00
if ( this . profile ) payload . profile = this . profile ;
2023-02-25 02:47:40 +01:00
this . altNames . forEach ( an => {
payload . identifiers . push ( {
2018-09-10 15:19:10 -07:00
type : 'dns' ,
2023-02-25 02:47:40 +01:00
value : an
} ) ;
} ) ;
2018-09-10 15:19:10 -07:00
2026-03-12 22:55:28 +05:30
log ( ` newOrder: ${ JSON . stringify ( this . altNames ) } ` ) ;
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const result = await this . sendSignedRequest ( this . directory . newOrder , JSON . stringify ( payload ) ) ;
if ( result . status === 403 ) throw new BoxError ( BoxError . ACCESS _DENIED , ` Forbidden sending new order: ${ result . body . detail } ` ) ;
2025-02-14 17:26:54 +01:00
if ( result . status !== 201 ) throw new BoxError ( BoxError . ACME _ERROR , ` Failed to send new order. Expecting 201, got ${ result . status } ${ result . text } ` ) ;
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const order = result . body , orderUrl = result . headers . location ;
2026-03-12 22:55:28 +05:30
log ( ` newOrder: created order ${ this . cn } order: ${ result . text } orderUrl: ${ orderUrl } ` ) ;
2018-09-10 15:19:10 -07:00
2021-11-17 10:54:26 -08:00
if ( ! Array . isArray ( order . authorizations ) ) throw new BoxError ( BoxError . ACME _ERROR , 'invalid authorizations in order' ) ;
if ( typeof order . finalize !== 'string' ) throw new BoxError ( BoxError . ACME _ERROR , 'invalid finalize in order' ) ;
if ( typeof orderUrl !== 'string' ) throw new BoxError ( BoxError . ACME _ERROR , 'invalid order location in order header' ) ;
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
return { order , orderUrl } ;
2018-09-10 15:19:10 -07:00
} ;
2021-05-06 22:29:34 -07:00
Acme2 . prototype . waitForOrder = async function ( orderUrl ) {
2018-09-10 15:19:10 -07:00
assert . strictEqual ( typeof orderUrl , 'string' ) ;
2026-03-12 22:55:28 +05:30
log ( ` waitForOrder: ${ orderUrl } ` ) ;
2018-09-10 15:19:10 -07:00
2026-03-27 11:39:38 +01:00
return await retry ( { times : 15 , interval : 20000 , log } , async ( ) => {
2026-03-12 22:55:28 +05:30
log ( 'waitForOrder: getting status' ) ;
2018-09-10 15:19:10 -07:00
2021-05-07 15:56:43 -07:00
const result = await this . postAsGet ( orderUrl ) ;
2021-05-06 22:29:34 -07:00
if ( result . status !== 200 ) {
2026-03-12 22:55:28 +05:30
log ( ` waitForOrder: invalid response code getting uri ${ result . status } ` ) ;
2021-11-17 10:54:26 -08:00
throw new BoxError ( BoxError . ACME _ERROR , ` Bad response when waiting for order. code: ${ result . status } ` ) ;
2021-05-06 22:29:34 -07:00
}
2026-03-12 22:55:28 +05:30
log ( 'waitForOrder: status is "%s %j' , result . body . status , result . body ) ;
2021-05-06 22:29:34 -07:00
2021-11-17 10:54:26 -08:00
if ( result . body . status === 'pending' || result . body . status === 'processing' ) throw new BoxError ( BoxError . ACME _ERROR , ` Request is in ${ result . body . status } state ` ) ;
2021-05-06 22:29:34 -07:00
else if ( result . body . status === 'valid' && result . body . certificate ) return result . body . certificate ;
2025-02-14 17:26:54 +01:00
else throw new BoxError ( BoxError . ACME _ERROR , ` Unexpected status or invalid response when waiting for order: ${ result . text } ` ) ;
2021-05-06 22:29:34 -07:00
} ) ;
2018-09-10 15:19:10 -07:00
} ;
2024-02-20 23:09:49 +01:00
Acme2 . prototype . getKeyAuthorization = async function ( token ) {
2022-11-29 13:57:58 +01:00
assert ( typeof this . accountKey , 'string' ) ;
2018-09-10 15:19:10 -07:00
2023-02-01 15:43:59 +01:00
const jwk = {
2018-09-10 15:19:10 -07:00
e : b64 ( Buffer . from ( [ 0x01 , 0x00 , 0x01 ] ) ) , // Exponent - 65537
kty : 'RSA' ,
2026-01-17 13:38:17 +01:00
n : b64 ( await openssl . getModulus ( this . accountKey ) )
2018-09-10 15:19:10 -07:00
} ;
2023-02-01 15:43:59 +01:00
const shasum = crypto . createHash ( 'sha256' ) ;
2018-09-10 15:19:10 -07:00
shasum . update ( JSON . stringify ( jwk ) ) ;
2023-02-01 15:43:59 +01:00
const thumbprint = urlBase64Encode ( shasum . digest ( 'base64' ) ) ;
2018-09-10 20:50:36 -07:00
return token + '.' + thumbprint ;
2018-09-10 15:19:10 -07:00
} ;
2021-05-06 22:29:34 -07:00
Acme2 . prototype . notifyChallengeReady = async function ( challenge ) {
2018-09-10 15:19:10 -07:00
assert . strictEqual ( typeof challenge , 'object' ) ; // { type, status, url, token }
2026-03-12 22:55:28 +05:30
log ( ` notifyChallengeReady: ${ challenge . url } was met ` ) ;
2018-09-10 15:19:10 -07:00
2024-02-20 23:09:49 +01:00
const keyAuthorization = await this . getKeyAuthorization ( challenge . token ) ;
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const payload = {
2018-09-10 15:19:10 -07:00
resource : 'challenge' ,
keyAuthorization : keyAuthorization
} ;
2021-05-06 22:29:34 -07:00
const result = await this . sendSignedRequest ( challenge . url , JSON . stringify ( payload ) ) ;
2025-02-14 17:26:54 +01:00
if ( result . status !== 200 ) throw new BoxError ( BoxError . ACME _ERROR , ` Failed to notify challenge. Expecting 200, got ${ result . status } ${ result . text } ` ) ;
2018-09-10 15:19:10 -07:00
} ;
2021-05-06 22:29:34 -07:00
Acme2 . prototype . waitForChallenge = async function ( challenge ) {
2018-09-10 15:19:10 -07:00
assert . strictEqual ( typeof challenge , 'object' ) ;
2026-03-12 22:55:28 +05:30
log ( ` waitingForChallenge: ${ JSON . stringify ( challenge ) } ` ) ;
2018-09-10 15:19:10 -07:00
2026-03-27 11:39:38 +01:00
await retry ( { times : 15 , interval : 20000 , log } , async ( ) => {
2026-03-12 22:55:28 +05:30
log ( 'waitingForChallenge: getting status' ) ;
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const result = await this . postAsGet ( challenge . url ) ;
if ( result . status !== 200 ) {
2026-03-12 22:55:28 +05:30
log ( ` waitForChallenge: invalid response code getting uri ${ result . status } ` ) ;
2021-11-17 10:54:26 -08:00
throw new BoxError ( BoxError . ACME _ERROR , ` Bad response code when waiting for challenge : ${ result . status } ` ) ;
2021-05-06 22:29:34 -07:00
}
2026-03-12 22:55:28 +05:30
log ( ` waitForChallenge: status is " ${ result . body . status } " " ${ result . text } " ` ) ;
2021-05-06 22:29:34 -07:00
2021-11-17 10:54:26 -08:00
if ( result . body . status === 'pending' ) throw new BoxError ( BoxError . ACME _ERROR , 'Challenge is in pending state' ) ;
2021-05-06 22:29:34 -07:00
else if ( result . body . status === 'valid' ) return ;
2021-11-17 10:54:26 -08:00
else throw new BoxError ( BoxError . ACME _ERROR , ` Unexpected status when waiting for challenge: ${ result . body . status } ` ) ;
2018-09-10 15:19:10 -07:00
} ) ;
} ;
// https://community.letsencrypt.org/t/public-beta-rate-limits/4772 for rate limits
2022-11-29 13:57:58 +01:00
Acme2 . prototype . signCertificate = async function ( finalizationUrl , csrPem ) {
2018-09-10 15:19:10 -07:00
assert . strictEqual ( typeof finalizationUrl , 'string' ) ;
2022-11-29 13:57:58 +01:00
assert . strictEqual ( typeof csrPem , 'string' ) ;
2026-01-17 13:38:17 +01:00
const csrDer = await openssl . pemToDer ( csrPem ) ;
2018-09-10 15:19:10 -07:00
const payload = {
csr : b64 ( csrDer )
} ;
2026-03-12 22:55:28 +05:30
log ( ` signCertificate: sending sign request to ${ finalizationUrl } ` ) ;
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const result = await this . sendSignedRequest ( finalizationUrl , JSON . stringify ( payload ) ) ;
// 429 means we reached the cert limit for this domain
2025-02-14 17:26:54 +01:00
if ( result . status !== 200 ) throw new BoxError ( BoxError . ACME _ERROR , ` Failed to sign certificate. Expecting 200, got ${ result . status } ${ result . text } ` ) ;
2018-09-10 15:19:10 -07:00
} ;
2022-11-17 08:58:20 +01:00
Acme2 . prototype . downloadCertificate = async function ( certUrl ) {
2018-09-10 15:19:10 -07:00
assert . strictEqual ( typeof certUrl , 'string' ) ;
2026-03-27 11:39:38 +01:00
return await retry ( { times : 5 , interval : 20000 , log } , async ( ) => {
2026-03-12 22:55:28 +05:30
log ( ` downloadCertificate: downloading certificate of ${ this . cn } ` ) ;
2018-09-10 15:19:10 -07:00
2021-05-07 15:56:43 -07:00
const result = await this . postAsGet ( certUrl ) ;
2025-02-14 17:26:54 +01:00
if ( result . status === 202 ) throw new BoxError ( BoxError . ACME _ERROR , 'Retry downloading certificate' ) ;
if ( result . status !== 200 ) throw new BoxError ( BoxError . ACME _ERROR , ` Failed to get cert. Expecting 200, got ${ result . status } ${ result . text } ` ) ;
2018-09-10 15:19:10 -07:00
2022-11-29 13:57:58 +01:00
const fullChainPem = result . body . toString ( 'utf8' ) ; // buffer
2022-11-17 08:58:20 +01:00
return fullChainPem ;
2021-05-06 22:29:34 -07:00
} ) ;
2018-09-10 15:19:10 -07:00
} ;
2023-05-02 23:01:14 +02:00
Acme2 . prototype . prepareHttpChallenge = async function ( challenge ) {
assert . strictEqual ( typeof challenge , 'object' ) ;
2018-09-10 20:50:36 -07:00
2026-03-12 22:55:28 +05:30
log ( ` prepareHttpChallenge: preparing for challenge ${ JSON . stringify ( challenge ) } ` ) ;
2018-09-10 20:50:36 -07:00
2024-02-20 23:09:49 +01:00
const keyAuthorization = await this . getKeyAuthorization ( challenge . token ) ;
2018-09-10 20:50:36 -07:00
2023-02-01 15:43:59 +01:00
const challengeFilePath = path . join ( paths . ACME _CHALLENGES _DIR , challenge . token ) ;
2026-03-12 22:55:28 +05:30
log ( ` prepareHttpChallenge: writing ${ keyAuthorization } to ${ challengeFilePath } ` ) ;
2018-09-10 20:50:36 -07:00
2023-02-01 15:43:59 +01:00
if ( ! safe . fs . writeFileSync ( challengeFilePath , keyAuthorization ) ) throw new BoxError ( BoxError . FS _ERROR , ` Error writing challenge: ${ safe . error . message } ` ) ;
2018-09-10 20:50:36 -07:00
} ;
2022-11-17 08:58:20 +01:00
Acme2 . prototype . cleanupHttpChallenge = async function ( challenge ) {
2018-09-10 20:50:36 -07:00
assert . strictEqual ( typeof challenge , 'object' ) ;
2023-02-01 15:43:59 +01:00
const challengeFilePath = path . join ( paths . ACME _CHALLENGES _DIR , challenge . token ) ;
2026-03-12 22:55:28 +05:30
log ( ` cleanupHttpChallenge: unlinking ${ challengeFilePath } ` ) ;
2018-09-28 17:05:53 -07:00
2023-02-01 15:43:59 +01:00
if ( ! safe . fs . unlinkSync ( challengeFilePath ) ) throw new BoxError ( BoxError . FS _ERROR , ` Error unlinking challenge: ${ safe . error . message } ` ) ;
2018-09-10 20:50:36 -07:00
} ;
2022-11-17 08:58:20 +01:00
function getChallengeSubdomain ( cn , domain ) {
2018-09-28 12:13:12 -07:00
let challengeSubdomain ;
2022-11-17 08:58:20 +01:00
if ( cn === domain ) {
2018-09-28 12:13:12 -07:00
challengeSubdomain = '_acme-challenge' ;
2022-11-17 08:58:20 +01:00
} else if ( cn . includes ( '*' ) ) { // wildcard
2024-10-14 19:10:31 +02:00
const subdomain = cn . slice ( 0 , - domain . length - 1 ) ;
2018-10-31 15:41:02 -07:00
challengeSubdomain = subdomain ? subdomain . replace ( '*' , '_acme-challenge' ) : '_acme-challenge' ;
2018-09-28 12:13:12 -07:00
} else {
2022-11-17 08:58:20 +01:00
challengeSubdomain = '_acme-challenge.' + cn . slice ( 0 , - domain . length - 1 ) ;
2018-09-28 12:13:12 -07:00
}
2026-03-12 22:55:28 +05:30
log ( ` getChallengeSubdomain: challenge subdomain for cn ${ cn } at domain ${ domain } is ${ challengeSubdomain } ` ) ;
2018-10-31 15:41:02 -07:00
2018-09-28 12:13:12 -07:00
return challengeSubdomain ;
}
2023-05-02 23:01:14 +02:00
Acme2 . prototype . prepareDnsChallenge = async function ( cn , challenge ) {
assert . strictEqual ( typeof challenge , 'object' ) ;
2018-09-10 20:50:36 -07:00
2026-03-12 22:55:28 +05:30
log ( ` prepareDnsChallenge: preparing for challenge: ${ JSON . stringify ( challenge ) } ` ) ;
2018-09-10 20:50:36 -07:00
2024-02-20 23:09:49 +01:00
const keyAuthorization = await this . getKeyAuthorization ( challenge . token ) ;
2021-05-06 22:29:34 -07:00
const shasum = crypto . createHash ( 'sha256' ) ;
2018-09-10 20:50:36 -07:00
shasum . update ( keyAuthorization ) ;
const txtValue = urlBase64Encode ( shasum . digest ( 'base64' ) ) ;
2023-02-25 02:47:40 +01:00
const challengeSubdomain = getChallengeSubdomain ( cn , this . domain ) ;
2018-09-10 20:50:36 -07:00
2026-03-12 22:55:28 +05:30
log ( ` prepareDnsChallenge: update ${ challengeSubdomain } with ${ txtValue } ` ) ;
2018-09-10 20:50:36 -07:00
2022-11-17 08:58:20 +01:00
await dns . upsertDnsRecords ( challengeSubdomain , this . domain , 'TXT' , [ ` " ${ txtValue } " ` ] ) ;
2018-09-10 20:50:36 -07:00
2022-11-17 08:58:20 +01:00
await dns . waitForDnsRecord ( challengeSubdomain , this . domain , 'TXT' , txtValue , { times : 200 } ) ;
2018-09-10 20:50:36 -07:00
} ;
2023-02-25 02:47:40 +01:00
Acme2 . prototype . cleanupDnsChallenge = async function ( cn , challenge ) {
assert . strictEqual ( typeof cn , 'string' ) ;
2018-09-10 20:50:36 -07:00
assert . strictEqual ( typeof challenge , 'object' ) ;
2024-02-20 23:09:49 +01:00
const keyAuthorization = await this . getKeyAuthorization ( challenge . token ) ;
2022-11-17 08:58:20 +01:00
const shasum = crypto . createHash ( 'sha256' ) ;
2018-09-10 20:50:36 -07:00
shasum . update ( keyAuthorization ) ;
const txtValue = urlBase64Encode ( shasum . digest ( 'base64' ) ) ;
2023-02-25 02:47:40 +01:00
const challengeSubdomain = getChallengeSubdomain ( cn , this . domain ) ;
2018-09-10 20:50:36 -07:00
2026-03-12 22:55:28 +05:30
log ( ` cleanupDnsChallenge: remove ${ challengeSubdomain } with ${ txtValue } ` ) ;
2018-09-10 20:50:36 -07:00
2022-11-17 08:58:20 +01:00
await dns . removeDnsRecords ( challengeSubdomain , this . domain , 'TXT' , [ ` " ${ txtValue } " ` ] ) ;
2018-09-10 20:50:36 -07:00
} ;
2023-02-25 02:47:40 +01:00
Acme2 . prototype . prepareChallenge = async function ( cn , authorization ) {
assert . strictEqual ( typeof cn , 'string' ) ;
assert . strictEqual ( typeof authorization , 'object' ) ;
2018-09-10 15:19:10 -07:00
2026-03-12 22:55:28 +05:30
log ( ` prepareChallenge: http: ${ this . forceHttpAuthorization } cn: ${ cn } authorization: ${ JSON . stringify ( authorization ) } ` ) ;
2018-09-10 20:50:36 -07:00
2023-05-02 23:01:14 +02:00
// validation is cached by LE for 60 days or so. if a user switches from non-wildcard DNS (http challenge) to programmatic DNS (dns challenge), then
// LE remembers the challenge type and won't give us a dns challenge for 60 days!
// https://letsencrypt.org/docs/faq/#i-successfully-renewed-a-certificate-but-validation-didn-t-happen-this-time-how-is-that-possible
const dnsChallenges = authorization . challenges . filter ( function ( x ) { return x . type === 'dns-01' ; } ) ;
const httpChallenges = authorization . challenges . filter ( function ( x ) { return x . type === 'http-01' ; } ) ;
if ( this . forceHttpAuthorization || dnsChallenges . length === 0 ) {
if ( httpChallenges . length === 0 ) throw new BoxError ( BoxError . ACME _ERROR , 'no http challenges' ) ;
await this . prepareHttpChallenge ( httpChallenges [ 0 ] ) ;
return httpChallenges [ 0 ] ;
2021-05-06 22:29:34 -07:00
}
2023-05-02 23:01:14 +02:00
await this . prepareDnsChallenge ( cn , dnsChallenges [ 0 ] ) ;
return dnsChallenges [ 0 ] ;
2018-09-10 15:19:10 -07:00
} ;
2023-02-25 02:47:40 +01:00
Acme2 . prototype . cleanupChallenge = async function ( cn , challenge ) {
assert . strictEqual ( typeof cn , 'string' ) ;
2018-09-10 20:50:36 -07:00
assert . strictEqual ( typeof challenge , 'object' ) ;
2026-03-12 22:55:28 +05:30
log ( ` cleanupChallenge: http: ${ this . forceHttpAuthorization } ` ) ;
2019-10-03 10:36:57 -07:00
2023-05-02 23:01:14 +02:00
if ( this . forceHttpAuthorization ) {
2022-11-17 08:58:20 +01:00
await this . cleanupHttpChallenge ( challenge ) ;
2018-09-10 20:50:36 -07:00
} else {
2023-02-25 02:47:40 +01:00
await this . cleanupDnsChallenge ( cn , challenge ) ;
2018-09-10 20:50:36 -07:00
}
} ;
2022-11-17 08:58:20 +01:00
Acme2 . prototype . acmeFlow = async function ( ) {
2021-11-16 22:56:35 -08:00
await this . ensureAccount ( ) ;
2022-11-17 08:58:20 +01:00
const { order , orderUrl } = await this . newOrder ( ) ;
2023-02-25 02:47:40 +01:00
for ( const authorizationUrl of order . authorizations ) {
2026-03-12 22:55:28 +05:30
log ( ` acmeFlow: authorizing ${ authorizationUrl } ` ) ;
2021-05-06 22:29:34 -07:00
2023-02-25 02:47:40 +01:00
const response = await this . postAsGet ( authorizationUrl ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . ACME _ERROR , ` Invalid response code getting authorization : ${ response . status } ` ) ;
const authorization = response . body ; // { identifier, status, expires, challenges, wildcard }
const cn = authorization . wildcard ? ` *. ${ authorization . identifier . value } ` : authorization . identifier . value ;
const challenge = await this . prepareChallenge ( cn , authorization ) ;
2021-05-06 22:29:34 -07:00
await this . notifyChallengeReady ( challenge ) ;
await this . waitForChallenge ( challenge ) ;
2026-03-18 14:26:35 +05:30
await safe ( this . cleanupChallenge ( cn , challenge ) , { debug : log } ) ;
2021-05-06 22:29:34 -07:00
}
2022-11-17 08:58:20 +01:00
2026-01-17 13:38:17 +01:00
const csr = await openssl . createCsr ( this . key , this . cn , this . altNames ) ;
2023-02-25 02:47:40 +01:00
await this . signCertificate ( order . finalize , csr ) ;
const certUrl = await this . waitForOrder ( orderUrl ) ;
const cert = await this . downloadCertificate ( certUrl ) ;
2026-01-17 22:31:36 +01:00
const renewalInfo = typeof this . directory . renewalInfo === 'string' ? await getRenewalInfo ( cert , this . directory . renewalInfo ) : null ;
return { cert , csr , renewalInfo } ;
2018-09-10 15:19:10 -07:00
} ;
2021-05-06 22:29:34 -07:00
Acme2 . prototype . loadDirectory = async function ( ) {
2026-03-27 11:39:38 +01:00
await retry ( { times : 3 , interval : 20000 , log } , async ( ) => {
2021-05-07 15:56:43 -07:00
const response = await superagent . get ( this . caDirectory ) . timeout ( 30000 ) . ok ( ( ) => true ) ;
2018-09-10 15:19:10 -07:00
2021-11-17 10:54:26 -08:00
if ( response . status !== 200 ) throw new BoxError ( BoxError . ACME _ERROR , ` Invalid response code when fetching directory : ${ response . status } ` ) ;
2018-09-10 15:19:10 -07:00
2026-01-17 09:48:45 +01:00
const body = response . body ;
if ( typeof body . newNonce !== 'string' || typeof body . newOrder !== 'string' || typeof body . newAccount !== 'string' ) throw new BoxError ( BoxError . ACME _ERROR , ` Invalid response body : ${ response . text } ` ) ;
const availableProfiles = Object . keys ( body . meta ? . profiles || { } ) ; // https://www.ietf.org/archive/id/draft-aaron-acme-profiles-01.txt
if ( this . profile && ! availableProfiles . includes ( this . profile ) ) throw new BoxError ( BoxError . BAD _FIELD , ` No such profile " ${ this . profile } " : ${ response . text } ` ) ;
2018-09-10 15:19:10 -07:00
2026-01-17 09:48:45 +01:00
this . directory = body ; // has meta.profiles
2021-05-06 22:29:34 -07:00
} ) ;
2018-09-10 15:19:10 -07:00
} ;
2022-11-17 08:58:20 +01:00
Acme2 . prototype . getCertificate = async function ( ) {
2026-03-12 22:55:28 +05:30
log ( ` getCertificate: start acme flow for ${ this . cn } from ${ this . caDirectory } ` ) ;
2018-09-10 15:19:10 -07:00
2022-11-17 08:58:20 +01:00
await this . loadDirectory ( ) ;
2026-01-17 22:31:36 +01:00
const result = await this . acmeFlow ( ) ; // { key, cert, csr, renewalInfo }
2026-03-12 22:55:28 +05:30
log ( ` getCertificate: acme flow completed for ${ this . cn } . renewalInfo: ${ JSON . stringify ( result . renewalInfo ) } ` ) ;
2023-02-25 02:47:40 +01:00
return result ;
2018-09-10 15:19:10 -07:00
} ;
2026-01-17 13:38:17 +01:00
async function getCertificate ( fqdn , domainObject , key ) {
2022-07-13 09:26:27 +05:30
assert . strictEqual ( typeof fqdn , 'string' ) ; // this can also be a wildcard domain (for alias domains)
2022-11-17 08:58:20 +01:00
assert . strictEqual ( typeof domainObject , 'object' ) ;
2026-01-17 13:38:17 +01:00
assert . strictEqual ( typeof key , 'string' ) ;
2022-11-17 08:58:20 +01:00
// registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197)
// we cannot use admin@fqdn because the user might not have set it up.
// we simply update the account with the latest email we have each time when getting letsencrypt certs
// https://github.com/ietf-wg-acme/acme/issues/30
const owner = await users . getOwner ( ) ;
const email = owner ? . email || 'webmaster@cloudron.io' ; // can error if not activated yet
2018-09-10 15:19:10 -07:00
2026-03-27 11:39:38 +01:00
return await retry ( { times : 3 , interval : 0 , log } , async function ( ) {
2026-03-12 22:55:28 +05:30
log ( ` getCertificate: for fqdn ${ fqdn } and domain ${ domainObject . domain } ` ) ;
2019-10-03 14:47:18 -07:00
2026-01-17 13:38:17 +01:00
const acme = new Acme2 ( fqdn , domainObject , email , key , { /* profile: 'shortlived' */ } ) ;
2022-11-17 08:58:20 +01:00
return await acme . getCertificate ( ) ;
2021-09-07 09:34:23 -07:00
} ) ;
2018-09-10 15:19:10 -07:00
}
2026-02-14 15:43:24 +01:00
2026-02-14 16:34:34 +01:00
const _name = 'acme' ;
2026-02-14 15:43:24 +01:00
export default {
getCertificate ,
getRenewalInfo ,
_name ,
2026-02-14 16:34:34 +01:00
_getChallengeSubdomain : getChallengeSubdomain ,
2026-02-14 15:43:24 +01:00
} ;