254 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			254 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict';
 | |
| /*global Promise*/
 | |
| var crypto = require('crypto');
 | |
| 
 | |
| module.exports.create = function() {
 | |
| 	throw new Error(
 | |
| 		'acme-challenge-test is a test fixture for acme-challenge-* plugins, not a plugin itself'
 | |
| 	);
 | |
| };
 | |
| 
 | |
| // ignore all of this, it's just to normalize Promise vs node-style callback thunk vs synchronous
 | |
| function promiseCheckAndCatch(obj, name) {
 | |
| 	var promisify = require('util').promisify;
 | |
| 	// don't loose this-ness, just in case that's important
 | |
| 	var fn = obj[name].bind(obj);
 | |
| 	var promiser;
 | |
| 
 | |
| 	// function signature must match, or an error will be thrown
 | |
| 	if (1 === fn.length) {
 | |
| 		// 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 {
 | |
| 		return Promise.reject(
 | |
| 			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'
 | |
| 			)
 | |
| 		);
 | |
| 	}
 | |
| 
 | |
| 	function shouldntBeNull(result) {
 | |
| 		if ('undefined' === typeof result) {
 | |
| 			throw new Error(
 | |
| 				"'challenge.'" +
 | |
| 					name +
 | |
| 					"' should never return `undefined`. Please explicitly return null" +
 | |
| 					" (or fix the place where a value should have been returned but wasn't)."
 | |
| 			);
 | |
| 		}
 | |
| 		return result;
 | |
| 	}
 | |
| 
 | |
| 	return function(opts) {
 | |
| 		return promiser(opts).then(shouldntBeNull);
 | |
| 	};
 | |
| }
 | |
| 
 | |
| // Here's the meat, where the tests are happening:
 | |
| function run(challenger, opts) {
 | |
| 	var ch = opts.challenge;
 | |
| 	if ('http-01' === ch.type && ch.wildname) {
 | |
| 		throw new Error('http-01 cannot be used for wildcard domains');
 | |
| 	}
 | |
| 
 | |
| 	var set = promiseCheckAndCatch(challenger, 'set');
 | |
| 	if ('function' !== typeof challenger.get) {
 | |
| 		throw new 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.'
 | |
| 		);
 | |
| 	}
 | |
| 	var get = promiseCheckAndCatch(challenger, 'get');
 | |
| 	var remove = promiseCheckAndCatch(challenger, 'remove');
 | |
| 
 | |
| 	// The first time we just check it against itself
 | |
| 	// this will cause the prompt to appear
 | |
| 	return set(opts).then(function() {
 | |
| 		// this will cause the final completion message to appear
 | |
| 		// _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;
 | |
| 		} else {
 | |
| 			query = JSON.parse(JSON.stringify(ch));
 | |
| 			query.comment = 'unknown challenge type, supplying everything';
 | |
| 		}
 | |
| 		return get({ challenge: query })
 | |
| 			.then(function(secret) {
 | |
| 				if ('string' === typeof secret) {
 | |
| 					console.info(
 | |
| 						'secret was passed as a string, which works historically, but should be an object instead:'
 | |
| 					);
 | |
| 					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
 | |
| 						);
 | |
| 					}
 | |
| 				}
 | |
| 			})
 | |
| 			.then(function() {
 | |
| 				return remove(opts).then(function() {
 | |
| 					return 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'
 | |
| 							);
 | |
| 						}
 | |
| 					});
 | |
| 				});
 | |
| 			});
 | |
| 	});
 | |
| }
 | |
| 
 | |
| function testZone(type, zone, challenger) {
 | |
| 	var domains = [zone, 'foo.' + zone];
 | |
| 	if ('dns-01' === type) {
 | |
| 		domains.push('*.foo.' + zone);
 | |
| 	}
 | |
| 
 | |
| 	function next() {
 | |
| 		var domain = domains.shift();
 | |
| 		if (!domain) {
 | |
| 			return;
 | |
| 		}
 | |
| 		console.info("TEST '%s'", domain);
 | |
| 		return testRecord(type, domain, challenger).then(function() {
 | |
| 			console.info("PASS '%s'", domain);
 | |
| 			return next();
 | |
| 		});
 | |
| 	}
 | |
| 
 | |
| 	return next().then(function() {
 | |
| 		console.info('All soft tests: PASS');
 | |
| 		console.warn(
 | |
| 			'Hard tests (actually checking http URLs and dns records) is implemented in acme-v2.'
 | |
| 		);
 | |
| 		console.warn(
 | |
| 			"We'll copy them over here as well, but that's a TODO for next week."
 | |
| 		);
 | |
| 	});
 | |
| };
 | |
| 
 | |
| function testRecord(type, altname, challenger) {
 | |
| 	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, '');
 | |
| 
 | |
| 	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
 | |
| 		dnsHost: '_acme-challenge-' + token.slice(0, 4) + '.', // 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;
 | |
| 
 | |
| 	return run(challenger, { challenge: challenge });
 | |
| }
 | |
| 
 | |
| module.exports.testRecord = testRecord;
 | |
| module.exports.testZone = testZone;
 | |
| module.exports.test = testZone;
 |