2019-06-06 18:15:40 +00:00
'use strict' ;
2019-04-07 21:55:48 +00:00
/*global Promise*/
2019-06-15 17:10:45 +00:00
if ( process . version . match ( /^v(\d+)/ ) [ 1 ] > 6 ) {
console . warn ( ) ;
console . warn ( '#########################' ) ;
console . warn ( '# Node v6 Compatibility #' ) ;
console . warn ( '#########################' ) ;
console . warn ( ) ;
console . warn ( "You're using node " + process . version ) ;
console . warn (
'Please write node-v6 compatible JavaScript (not Babel/ECMAScript) and test with node v6.'
) ;
console . warn ( ) ;
console . warn (
'(ACME.js and Greenlock.js are widely deployed in enterprise node v6 environments. The few node v6 bugs in Buffer and Promise are hotfixed by ACME.js in just a few lines of code)'
) ;
console . warn ( ) ;
}
require ( './node-v6-compat.js' ) ;
// Load _after_ node v6 compat
2019-06-06 18:15:40 +00:00
var crypto = require ( 'crypto' ) ;
2019-06-15 17:10:45 +00:00
var promisify = require ( 'util' ) . promisify ;
var request = require ( '@root/request' ) ;
request = promisify ( request ) ;
2019-04-07 21:55:48 +00:00
2019-06-03 04:08:29 +00:00
module . exports . create = function ( ) {
2019-06-13 07:36:25 +00:00
throw new Error (
'acme-challenge-test is a test fixture for acme-challenge-* plugins, not a plugin itself'
) ;
2019-04-07 21:55:48 +00:00
} ;
// ignore all of this, it's just to normalize Promise vs node-style callback thunk vs synchronous
function promiseCheckAndCatch ( obj , name ) {
2019-06-13 07:36:25 +00:00
// don't loose this-ness, just in case that's important
var fn = obj [ name ] . bind ( obj ) ;
var promiser ;
2019-04-07 21:55:48 +00:00
2019-06-13 07:36:25 +00:00
// function signature must match, or an error will be thrown
2019-06-15 17:10:45 +00:00
if ( fn . length <= 1 ) {
2019-06-13 07:36:25 +00:00
// wrap so that synchronous errors are caught (alsa handles synchronous results)
promiser = function ( opts ) {
return Promise . resolve ( ) . then ( function ( ) {
return fn ( opts ) ;
} ) ;
} ;
} else if ( 2 === fn . length ) {
// wrap as a promise
promiser = promisify ( fn ) ;
} else {
2019-06-15 17:10:45 +00:00
throw new Error (
"'challenge." +
name +
"' should accept either one argument, the options," +
' and return a Promise or accept two arguments, the options and a node-style callback thunk'
2019-06-13 07:36:25 +00:00
) ;
}
2019-04-07 21:55:48 +00:00
2019-06-13 07:36:25 +00:00
function shouldntBeUndefined ( result ) {
if ( 'undefined' === typeof result ) {
throw new Error (
"'challenge.'" +
name +
2019-06-15 17:10:45 +00:00
"' should never return `undefined`. Please explicitly `return null`" +
2019-06-13 07:36:25 +00:00
" (or fix the place where a value should have been returned but wasn't)."
) ;
}
return result ;
}
2019-04-07 21:55:48 +00:00
2019-06-13 07:36:25 +00:00
return function ( opts ) {
return promiser ( opts ) . then ( shouldntBeUndefined ) ;
} ;
2019-04-07 21:55:48 +00:00
}
2019-06-07 06:14:10 +00:00
function mapAsync ( els , doer ) {
2019-06-13 07:36:25 +00:00
els = els . slice ( 0 ) ;
var results = [ ] ;
function next ( ) {
var el = els . shift ( ) ;
if ( ! el ) {
return results ;
}
return doer ( el ) . then ( function ( result ) {
results . push ( result ) ;
return next ( ) ;
} ) ;
}
return next ( ) ;
2019-06-07 06:14:10 +00:00
}
2019-04-07 21:55:48 +00:00
2019-06-13 07:32:52 +00:00
function newZoneRegExp ( zonename ) {
2019-06-13 07:36:25 +00:00
// (^|\.)example\.com$
// which matches:
// foo.example.com
// example.com
// but not:
// fooexample.com
return new RegExp ( '(^|\\.)' + zonename . replace ( /\./g , '\\.' ) + '$' ) ;
2019-06-13 07:32:52 +00:00
}
2019-06-15 17:10:45 +00:00
2019-06-13 07:32:52 +00:00
function pluckZone ( zones , dnsHost ) {
2019-06-13 07:36:25 +00:00
return zones
. filter ( function ( zonename ) {
// the only character that needs to be escaped for regex
// and is allowed in a domain name is '.'
return newZoneRegExp ( zonename ) . test ( dnsHost ) ;
} )
. sort ( function ( a , b ) {
// longest match first
return b . length - a . length ;
} ) [ 0 ] ;
2019-06-13 07:32:52 +00:00
}
2019-06-07 06:14:10 +00:00
// Here's the meat, where the tests are happening:
function testEach ( type , domains , challenger ) {
2019-06-13 07:36:25 +00:00
var chr = wrapChallenger ( type , challenger ) ;
2019-06-15 17:10:45 +00:00
// We want the same rnd for all domains so that we catch errors like removing
// the apex (bare) domain TXT record to when creating the wildcard record
2019-06-13 07:36:25 +00:00
var rnd = crypto . randomBytes ( 2 ) . toString ( 'hex' ) ;
2019-04-07 21:55:48 +00:00
2019-06-13 09:16:09 +00:00
console . info ( "Testing each of '%s'" , domains . join ( ', ' ) ) ;
2019-06-13 07:32:52 +00:00
2019-06-15 17:10:45 +00:00
//
// Zones
//
// Get ALL zones for all records on the certificate
//
return chr
. init ( { request : request } )
. then ( function ( ) {
return chr . zones ( { request : request , dnsHosts : domains } ) ;
2019-06-13 07:36:25 +00:00
} )
2019-06-15 17:10:45 +00:00
. then ( function ( zones ) {
var all = domains . map ( function ( domain ) {
var zone = pluckZone ( zones , domain ) ;
return {
domain : domain ,
challenge : fakeChallenge ( type , zone , domain , rnd ) ,
request : request
} ;
} ) ;
// resolving for the sake of same indentation / scope
return Promise . resolve ( )
. then ( function ( ) {
return mapAsync ( all , function ( opts ) {
return set ( chr , opts ) ;
2019-06-13 07:36:25 +00:00
} ) ;
2019-06-15 17:10:45 +00:00
} )
. then ( function ( ) {
return mapAsync ( all , function ( opts ) {
return check ( chr , opts ) ;
} ) ;
} )
. then ( function ( ) {
return mapAsync ( all , function ( opts ) {
return remove ( chr , opts ) . then ( function ( ) {
2019-06-13 07:36:25 +00:00
console . info ( "PASS '%s'" , opts . domain ) ;
} ) ;
} ) ;
2019-06-15 17:10:45 +00:00
} )
. then ( function ( ) {
console . info ( ) ;
console . info ( 'It looks like the soft tests all passed.' ) ;
console . log ( 'It is highly likely that your plugin is correct.' ) ;
console . log (
'Now go test with Greenlock.js and/or ACME.js to be sure.'
) ;
console . info ( ) ;
2019-06-13 07:36:25 +00:00
} ) ;
2019-06-15 17:10:45 +00:00
} ) ;
}
function set ( chr , opts ) {
var ch = opts . challenge ;
if ( 'http-01' === ch . type && ch . wildname ) {
throw new Error ( 'http-01 cannot be used for wildcard domains' ) ;
}
//
// Set
//
// Add (not replace) a TXT for the domain
//
return chr . set ( opts ) . then ( function ( ) {
// _test is used by the manual cli reference implementations
var query = { type : ch . type , /*debug*/ status : ch . status , _test : true } ;
if ( 'http-01' === ch . type ) {
query . identifier = ch . identifier ;
query . token = ch . token ;
// For testing only
query . url = ch . challengeUrl ;
} else if ( 'dns-01' === ch . type ) {
query . identifier = { type : 'dns' , value : ch . dnsHost } ;
// For testing only
query . altname = ch . altname ;
// there should only be two possible TXT records per challenge domain:
// one for the bare domain, and the other if and only if there's a wildcard
query . wildcard = ch . wildcard ;
query . dnsAuthorization = ch . dnsAuthorization ;
query . dnsZone = ch . dnsZone ;
query . dnsPrefix = ch . dnsPrefix ;
} else {
query = JSON . parse ( JSON . stringify ( ch ) ) ;
query . comment = 'unknown challenge type, supplying everything' ;
}
opts . query = query ;
return opts ;
} ) ;
}
function check ( chr , opts ) {
var ch = opts . challenge ;
//
// Get
//
// Check that ONE of the relevant TXT records matches
//
return chr
. get ( { request : request , challenge : opts . query } )
. then ( function ( secret ) {
if ( ! secret ) {
throw new Error (
'`secret` should be an object containing `keyAuthorization` or `dnsAuthorization`'
2019-06-13 07:36:25 +00:00
) ;
2019-06-15 17:10:45 +00:00
}
if ( 'string' === typeof secret ) {
console . info (
'secret was passed as a string, which works historically, but should be an object instead:'
2019-06-13 07:36:25 +00:00
) ;
2019-06-15 17:10:45 +00:00
console . info ( '{ "keyAuthorization": "' + secret + '" }' ) ;
console . info ( 'or' ) ;
// TODO this should be "keyAuthorizationDigest"
console . info ( '{ "dnsAuthorization": "' + secret + '" }' ) ;
console . info (
'This is to help keep acme / greenlock (and associated plugins) future-proof for new challenge types'
) ;
}
// historically 'secret' has been a string, but I'd like it to transition to be an object.
// to make it backwards compatible in v2.7 to change it,
// so I'm not sure that we really need to.
if ( 'http-01' === ch . type ) {
secret = secret . keyAuthorization || secret ;
if ( ch . keyAuthorization !== secret ) {
throw new Error (
"http-01 challenge.get() returned '" +
secret +
"', which does not match the keyAuthorization" +
" saved with challenge.set(), which was '" +
ch . keyAuthorization +
"'"
) ;
}
} else if ( 'dns-01' === ch . type ) {
secret = secret . dnsAuthorization || secret ;
if ( ch . dnsAuthorization !== secret ) {
throw new Error (
"dns-01 challenge.get() returned '" +
secret +
"', which does not match the dnsAuthorization" +
" (keyAuthDigest) saved with challenge.set(), which was '" +
ch . dnsAuthorization +
"'"
) ;
}
} else {
if ( 'tls-alpn-01' === ch . type ) {
console . warn (
"'tls-alpn-01' support is in development" +
" (or developed and we haven't update this yet). Please contact us."
) ;
} else {
console . warn (
"We don't know how to test '" +
ch . type +
"'... are you sure that's a thing?"
) ;
}
secret = secret . keyAuthorization || secret ;
if ( ch . keyAuthorization !== secret ) {
console . warn (
"The returned value doesn't match keyAuthorization" ,
ch . keyAuthorization ,
secret
) ;
}
}
} ) ;
2019-04-07 21:55:48 +00:00
}
2019-06-15 17:10:45 +00:00
function remove ( chr , opts ) {
//
// Remove
//
// Delete ONLY the SINGLE relevant TXT record
//
return chr . remove ( opts ) . then ( function ( ) {
return chr . get ( opts ) . then ( function ( result ) {
if ( result ) {
throw new Error (
'challenge.remove() should have made it not possible for challenge.get() to return a value'
) ;
}
if ( null !== result ) {
throw new Error (
'challenge.get() should return null when the value is not set'
) ;
}
} ) ;
} ) ;
2019-06-07 06:14:10 +00:00
}
2019-06-07 04:48:34 +00:00
2019-06-13 07:32:52 +00:00
function wrapChallenger ( type , challenger ) {
2019-06-13 07:36:25 +00:00
var zones ;
if ( 'dns-01' === type ) {
if ( 'function' !== typeof challenger . zones ) {
console . error (
'You must implement `zones` to return an array of strings.' +
" If you're testing a special type of service that doesn't support" +
' domain zone listing (as opposed to domain record listing),' +
' such as DuckDNS, return an empty array.'
) ;
process . exit ( 28 ) ;
return ;
}
zones = promiseCheckAndCatch ( challenger , 'zones' ) ;
} else {
zones = function ( ) {
return Promise . resolve ( [ ] ) ;
} ;
}
2019-06-13 07:32:52 +00:00
2019-06-13 07:36:25 +00:00
if ( 'function' !== typeof challenger . get ) {
console . error (
"'challenge.get' should be implemented for the sake of testing." +
' It should be implemented as the internal method for fetching the challenge' +
' (i.e. reading from a database, file system or API, not return internal),' +
' not the external check (the http call, dns query, etc),' +
' which will already be done as part of this test.'
) ;
process . exit ( 29 ) ;
return ;
}
2019-06-07 04:48:34 +00:00
2019-06-15 17:10:45 +00:00
var init = challenger . init ;
if ( 'function' !== typeof init ) {
init = function ( opts ) {
return null ;
} ;
}
2019-06-13 07:36:25 +00:00
return {
2019-06-15 17:10:45 +00:00
init : promiseCheckAndCatch ( challenger , 'init' ) ,
2019-06-13 07:36:25 +00:00
zones : zones ,
set : promiseCheckAndCatch ( challenger , 'set' ) ,
get : promiseCheckAndCatch ( challenger , 'get' ) ,
remove : promiseCheckAndCatch ( challenger , 'remove' )
} ;
2019-06-07 06:14:10 +00:00
}
2019-06-07 04:48:34 +00:00
2019-06-13 07:32:52 +00:00
function fakeChallenge ( type , zone , altname , rnd ) {
2019-06-13 07:36:25 +00:00
var expires = new Date ( Date . now ( ) + 10 * 60 * 1000 ) . toISOString ( ) ;
var token = crypto . randomBytes ( 8 ) . toString ( 'hex' ) ;
var thumb = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ;
var keyAuth = token + '.' + crypto . randomBytes ( 16 ) . toString ( 'hex' ) ;
var dnsAuth = crypto
. createHash ( 'sha256' )
. update ( keyAuth )
. digest ( 'base64' )
. replace ( /\+/g , '-' )
. replace ( /\//g , '_' )
. replace ( /=/g , '' ) ;
2019-04-07 21:55:48 +00:00
2019-06-13 07:36:25 +00:00
var challenge = {
type : type ,
identifier : { type : 'dns' , value : null } , // completed below
wildcard : false , // completed below
status : 'pending' ,
expires : expires ,
token : token ,
thumbprint : thumb ,
keyAuthorization : keyAuth ,
url : null , // completed below
2019-06-15 17:10:45 +00:00
// we create a random record to prevent self cache-poisoning
2019-06-13 07:36:25 +00:00
dnsHost : '_' + rnd . slice ( 0 , 2 ) + '-acme-challenge-' + rnd . slice ( 2 ) + '.' , // completed below
dnsAuthorization : dnsAuth ,
altname : altname ,
_test : true // used by CLI referenced implementations
} ;
if ( '*.' === altname . slice ( 0 , 2 ) ) {
challenge . wildcard = true ;
altname = altname . slice ( 2 ) ;
}
challenge . identifier . value = altname ;
challenge . url =
'http://' + altname + '/.well-known/acme-challenge/' + challenge . token ;
challenge . dnsHost += altname ;
2019-06-13 09:16:09 +00:00
if ( zone ) {
challenge . dnsZone = zone ;
challenge . dnsPrefix = challenge . dnsHost
. replace ( newZoneRegExp ( zone ) , '' )
. replace ( /\.$/ , '' ) ;
}
2019-04-07 21:55:48 +00:00
2019-06-13 07:36:25 +00:00
return challenge ;
2019-06-07 06:14:10 +00:00
}
2019-06-15 17:10:45 +00:00
function testZone ( type , zone , challenger ) {
var domains = [ zone , 'foo.' + zone ] ;
if ( 'dns-01' === type ) {
domains . push ( '*.foo.' + zone ) ;
}
return testEach ( type , domains , challenger ) ;
}
2019-06-07 06:14:10 +00:00
function testRecord ( type , altname , challenger ) {
2019-06-13 07:36:25 +00:00
return testEach ( type , [ altname ] , challenger ) ;
2019-06-07 04:48:34 +00:00
}
2019-06-07 05:04:51 +00:00
module . exports . testRecord = testRecord ;
module . exports . testZone = testZone ;
module . exports . test = testZone ;