lint and fix and use domains.generateKeypair

This commit is contained in:
AJ ONeal 2018-11-09 21:05:53 -07:00
parent d63d8e1aed
commit 2cc5a41268
2 changed files with 215 additions and 221 deletions

View File

@ -444,7 +444,7 @@
}; };
} }
return BACME.accounts.generateKeypair(opts).then(function (serverJwk) { return BACME.domains.generateKeypair(opts).then(function (serverJwk) {
localStorage.setItem('server:' + key, JSON.stringify(serverJwk)); localStorage.setItem('server:' + key, JSON.stringify(serverJwk));
return serverJwk; return serverJwk;
}); });

View File

@ -4,6 +4,8 @@
var BACME = exports.BACME = {}; var BACME = exports.BACME = {};
var webFetch = exports.fetch; var webFetch = exports.fetch;
var webCrypto = exports.crypto; var webCrypto = exports.crypto;
var Promise = window.Promise;
var CSR = window.CSR;
var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory'; var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory';
var directory; var directory;
@ -15,7 +17,6 @@ var accountKeypair;
var accountJwk; var accountJwk;
var accountUrl; var accountUrl;
var signedAccount;
BACME.challengePrefixes = { BACME.challengePrefixes = {
'http-01': '/.well-known/acme-challenge' 'http-01': '/.well-known/acme-challenge'
@ -23,38 +24,38 @@ BACME.challengePrefixes = {
}; };
BACME._logHeaders = function (resp) { BACME._logHeaders = function (resp) {
console.log('Headers:'); console.log('Headers:');
Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); }); Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); });
}; };
BACME._logBody = function (body) { BACME._logBody = function (body) {
console.log('Body:'); console.log('Body:');
console.log(JSON.stringify(body, null, 2)); console.log(JSON.stringify(body, null, 2));
console.log(''); console.log('');
}; };
BACME.directory = function (opts) { BACME.directory = function (opts) {
return webFetch(opts.directoryUrl || directoryUrl, { mode: 'cors' }).then(function (resp) { return webFetch(opts.directoryUrl || directoryUrl, { mode: 'cors' }).then(function (resp) {
BACME._logHeaders(resp); BACME._logHeaders(resp);
return resp.json().then(function (body) { return resp.json().then(function (body) {
directory = body; directory = body;
nonceUrl = directory.newNonce || 'https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce'; nonceUrl = directory.newNonce || 'https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce';
accountUrl = directory.newAccount || 'https://acme-staging-v02.api.letsencrypt.org/acme/new-account'; accountUrl = directory.newAccount || 'https://acme-staging-v02.api.letsencrypt.org/acme/new-account';
orderUrl = directory.newOrder || "https://acme-staging-v02.api.letsencrypt.org/acme/new-order"; orderUrl = directory.newOrder || "https://acme-staging-v02.api.letsencrypt.org/acme/new-order";
BACME._logBody(body); BACME._logBody(body);
return body; return body;
}); });
}); });
}; };
BACME.nonce = function () { BACME.nonce = function () {
return webFetch(nonceUrl, { mode: 'cors' }).then(function (resp) { return webFetch(nonceUrl, { mode: 'cors' }).then(function (resp) {
BACME._logHeaders(resp); BACME._logHeaders(resp);
nonce = resp.headers.get('replay-nonce'); nonce = resp.headers.get('replay-nonce');
console.log('Nonce:', nonce); console.log('Nonce:', nonce);
// resp.body is empty // resp.body is empty
return resp.headers.get('replay-nonce'); return resp.headers.get('replay-nonce');
}); });
}; };
BACME.accounts = {}; BACME.accounts = {};
@ -62,66 +63,38 @@ BACME.accounts = {};
// type = ECDSA // type = ECDSA
// bitlength = 256 // bitlength = 256
BACME.accounts.generateKeypair = function (opts) { BACME.accounts.generateKeypair = function (opts) {
var wcOpts = {}; return BACME.generateKeypair(opts).then(function (result) {
accountKeypair = result;
// ECDSA has only the P curves and an associated bitlength return webCrypto.subtle.exportKey(
if (/^EC/i.test(opts.type)) { "jwk"
wcOpts.name = 'ECDSA'; , result.privateKey
if (/256/.test(opts.bitlength)) { ).then(function (privJwk) {
wcOpts.namedCurve = 'P-256';
}
}
// RSA-PSS is another option, but I don't think it's used for Let's Encrypt accountJwk = privJwk;
// I think the hash is only necessary for signing, not generation or import console.log('private jwk:');
if (/^RS/i.test(opts.type)) { console.log(JSON.stringify(privJwk, null, 2));
wcOpts.name = 'RSASSA-PKCS1-v1_5';
wcOpts.modulusLength = opts.bitlength;
if (opts.bitlength < 2048) {
wcOpts.modulusLength = opts.bitlength * 8;
}
wcOpts.publicExponent = new Uint8Array([0x01, 0x00, 0x01]);
wcOpts.hash = { name: "SHA-256" };
}
// https://github.com/diafygi/webcrypto-examples#ecdsa---generatekey
var extractable = true;
return webCrypto.subtle.generateKey(
wcOpts
, extractable
, [ 'sign', 'verify' ]
).then(function (result) {
accountKeypair = result;
return webCrypto.subtle.exportKey(
"jwk"
, result.privateKey
).then(function (privJwk) {
accountJwk = privJwk;
console.log('private jwk:');
console.log(JSON.stringify(privJwk, null, 2));
return privJwk; return privJwk;
/* /*
return webCrypto.subtle.exportKey( return webCrypto.subtle.exportKey(
"pkcs8" "pkcs8"
, result.privateKey , result.privateKey
).then(function (keydata) { ).then(function (keydata) {
console.log('pkcs8:'); console.log('pkcs8:');
console.log(Array.from(new Uint8Array(keydata))); console.log(Array.from(new Uint8Array(keydata)));
return privJwk; return privJwk;
//return accountKeypair; //return accountKeypair;
}); });
*/ */
}) });
}); });
}; };
// json to url-safe base64 // json to url-safe base64
BACME._jsto64 = function (json) { BACME._jsto64 = function (json) {
return btoa(JSON.stringify(json)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); return btoa(JSON.stringify(json)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}; };
var textEncoder = new TextEncoder(); var textEncoder = new TextEncoder();
@ -158,7 +131,7 @@ BACME._importKey = function (jwk) {
e: priv.e e: priv.e
, kty: priv.kty , kty: priv.kty
, n: priv.n , n: priv.n
} };
if (!priv.p) { if (!priv.p) {
priv = null; priv = null;
} }
@ -167,7 +140,7 @@ BACME._importKey = function (jwk) {
return window.crypto.subtle.importKey( return window.crypto.subtle.importKey(
"jwk" "jwk"
, pub , pub
, wcOpts , wcOpts
, extractable , extractable
, [ "verify" ] , [ "verify" ]
).then(function (publicKey) { ).then(function (publicKey) {
@ -271,8 +244,8 @@ BACME.accounts.sign = function (opts) {
protectedJson protectedJson
); );
// Note: this function hashes before signing so send data, not the hash // Note: this function hashes before signing so send data, not the hash
return BACME._sign({ return BACME._sign({
abstractKey: abstractKey abstractKey: abstractKey
, payload64: payload64 , payload64: payload64
, protected64: protected64 , protected64: protected64
@ -280,30 +253,29 @@ BACME.accounts.sign = function (opts) {
}); });
}; };
var account;
var accountId; var accountId;
BACME.accounts.set = function (opts) { BACME.accounts.set = function (opts) {
nonce = null; nonce = null;
return window.fetch(accountUrl, { return window.fetch(accountUrl, {
mode: 'cors' mode: 'cors'
, method: 'POST' , method: 'POST'
, headers: { 'Content-Type': 'application/jose+json' } , headers: { 'Content-Type': 'application/jose+json' }
, body: JSON.stringify(opts.signedAccount) , body: JSON.stringify(opts.signedAccount)
}).then(function (resp) { }).then(function (resp) {
BACME._logHeaders(resp); BACME._logHeaders(resp);
nonce = resp.headers.get('replay-nonce'); nonce = resp.headers.get('replay-nonce');
accountId = resp.headers.get('location'); accountId = resp.headers.get('location');
console.log('Next nonce:', nonce); console.log('Next nonce:', nonce);
console.log('Location/kid:', accountId); console.log('Location/kid:', accountId);
if (!resp.headers.get('content-type')) { if (!resp.headers.get('content-type')) {
console.log('Body: <none>'); console.log('Body: <none>');
return { kid: accountId }; return { kid: accountId };
} }
return resp.json().then(function (result) { return resp.json().then(function (result) {
if (/^Error/i.test(result.detail)) { if (/^Error/i.test(result.detail)) {
return Promise.reject(new Error(result.detail)); return Promise.reject(new Error(result.detail));
} }
@ -311,21 +283,20 @@ BACME.accounts.set = function (opts) {
BACME._logBody(result); BACME._logBody(result);
return result; return result;
}); });
}); });
}; };
var orderUrl; var orderUrl;
var signedOrder;
BACME.orders = {}; BACME.orders = {};
// identifiers = [ { type: 'dns', value: 'example.com' }, { type: 'dns', value: '*.example.com' } ] // identifiers = [ { type: 'dns', value: 'example.com' }, { type: 'dns', value: '*.example.com' } ]
// signedAccount // signedAccount
BACME.orders.sign = function (opts) { BACME.orders.sign = function (opts) {
var payload64 = BACME._jsto64({ identifiers: opts.identifiers }); var payload64 = BACME._jsto64({ identifiers: opts.identifiers });
return BACME._importKey(opts.jwk).then(function (abstractKey) { return BACME._importKey(opts.jwk).then(function (abstractKey) {
var protected64 = BACME._jsto64( var protected64 = BACME._jsto64(
{ nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: orderUrl, kid: opts.kid } { nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: orderUrl, kid: opts.kid }
); );
@ -345,36 +316,35 @@ BACME.orders.sign = function (opts) {
}); });
}; };
var order;
var currentOrderUrl; var currentOrderUrl;
var authorizationUrls; var authorizationUrls;
var finalizeUrl; var finalizeUrl;
BACME.orders.create = function (opts) { BACME.orders.create = function (opts) {
nonce = null; nonce = null;
return window.fetch(orderUrl, { return window.fetch(orderUrl, {
mode: 'cors' mode: 'cors'
, method: 'POST' , method: 'POST'
, headers: { 'Content-Type': 'application/jose+json' } , headers: { 'Content-Type': 'application/jose+json' }
, body: JSON.stringify(opts.signedOrder) , body: JSON.stringify(opts.signedOrder)
}).then(function (resp) { }).then(function (resp) {
BACME._logHeaders(resp); BACME._logHeaders(resp);
currentOrderUrl = resp.headers.get('location'); currentOrderUrl = resp.headers.get('location');
nonce = resp.headers.get('replay-nonce'); nonce = resp.headers.get('replay-nonce');
console.log('Next nonce:', nonce); console.log('Next nonce:', nonce);
return resp.json().then(function (result) { return resp.json().then(function (result) {
if (/^Error/i.test(result.detail)) { if (/^Error/i.test(result.detail)) {
return Promise.reject(new Error(result.detail)); return Promise.reject(new Error(result.detail));
} }
authorizationUrls = result.authorizations; authorizationUrls = result.authorizations;
finalizeUrl = result.finalize; finalizeUrl = result.finalize;
BACME._logBody(result); BACME._logBody(result);
result.url = currentOrderUrl; result.url = currentOrderUrl;
return result; return result;
}); });
}); });
}; };
BACME.challenges = {}; BACME.challenges = {};
@ -395,22 +365,22 @@ BACME.challenges.all = function () {
return next(); return next();
}; };
BACME.challenges.view = function () { BACME.challenges.view = function () {
var authzUrl = authorizationUrls.pop(); var authzUrl = authorizationUrls.pop();
var token; var token;
var challengeDomain; var challengeDomain;
var challengeUrl; var challengeUrl;
return window.fetch(authzUrl, { return window.fetch(authzUrl, {
mode: 'cors' mode: 'cors'
}).then(function (resp) { }).then(function (resp) {
BACME._logHeaders(resp); BACME._logHeaders(resp);
return resp.json().then(function (result) { return resp.json().then(function (result) {
// Note: select the challenge you wish to use // Note: select the challenge you wish to use
var challenge = result.challenges.slice(0).pop(); var challenge = result.challenges.slice(0).pop();
token = challenge.token; token = challenge.token;
challengeUrl = challenge.url; challengeUrl = challenge.url;
challengeDomain = result.identifier.value; challengeDomain = result.identifier.value;
BACME._logBody(result); BACME._logBody(result);
@ -424,8 +394,8 @@ BACME.challenges.view = function () {
//, url: challenge.url //, url: challenge.url
//, domain: result.identifier.value, //, domain: result.identifier.value,
}; };
}); });
}); });
}; };
var thumbprint; var thumbprint;
@ -435,7 +405,7 @@ var dnsAuth;
var dnsRecord; var dnsRecord;
BACME.thumbprint = function (opts) { BACME.thumbprint = function (opts) {
// https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk // https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk
var accountJwk = opts.jwk; var accountJwk = opts.jwk;
var keys; var keys;
@ -446,34 +416,34 @@ BACME.thumbprint = function (opts) {
keys = [ 'e', 'kty', 'n' ]; keys = [ 'e', 'kty', 'n' ];
} }
var accountPublicStr = '{' + keys.map(function (key) { var accountPublicStr = '{' + keys.map(function (key) {
return '"' + key + '":"' + accountJwk[key] + '"'; return '"' + key + '":"' + accountJwk[key] + '"';
}).join(',') + '}'; }).join(',') + '}';
return window.crypto.subtle.digest( return window.crypto.subtle.digest(
{ name: "SHA-256" } // SHA-256 is spec'd, non-optional { name: "SHA-256" } // SHA-256 is spec'd, non-optional
, textEncoder.encode(accountPublicStr) , textEncoder.encode(accountPublicStr)
).then(function (hash) { ).then(function (hash) {
thumbprint = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) { thumbprint = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) {
return String.fromCharCode(ch); return String.fromCharCode(ch);
}).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
console.log('Thumbprint:'); console.log('Thumbprint:');
console.log(opts); console.log(opts);
console.log(accountPublicStr); console.log(accountPublicStr);
console.log(thumbprint); console.log(thumbprint);
return thumbprint; return thumbprint;
}); });
}; };
// { token, thumbprint, challengeDomain } // { token, thumbprint, challengeDomain }
BACME.challenges['http-01'] = function (opts) { BACME.challenges['http-01'] = function (opts) {
// The contents of the key authorization file // The contents of the key authorization file
keyAuth = opts.token + '.' + opts.thumbprint; keyAuth = opts.token + '.' + opts.thumbprint;
// Where the key authorization file goes // Where the key authorization file goes
httpPath = 'http://' + opts.challengeDomain + '/.well-known/acme-challenge/' + opts.token; httpPath = 'http://' + opts.challengeDomain + '/.well-known/acme-challenge/' + opts.token;
console.log("echo '" + keyAuth + "' > '" + httpPath + "'"); console.log("echo '" + keyAuth + "' > '" + httpPath + "'");
@ -487,28 +457,28 @@ BACME.challenges['http-01'] = function (opts) {
BACME.challenges['dns-01'] = function (opts) { BACME.challenges['dns-01'] = function (opts) {
console.log('opts.keyAuth for DNS:'); console.log('opts.keyAuth for DNS:');
console.log(opts.keyAuth); console.log(opts.keyAuth);
return window.crypto.subtle.digest( return window.crypto.subtle.digest(
{ name: "SHA-256", } { name: "SHA-256", }
, textEncoder.encode(opts.keyAuth) , textEncoder.encode(opts.keyAuth)
).then(function (hash) { ).then(function (hash) {
dnsAuth = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) { dnsAuth = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) {
return String.fromCharCode(ch); return String.fromCharCode(ch);
}).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
dnsRecord = '_acme-challenge.' + opts.challengeDomain; dnsRecord = '_acme-challenge.' + opts.challengeDomain;
console.log('DNS TXT Auth:'); console.log('DNS TXT Auth:');
// The name of the record // The name of the record
console.log(dnsRecord); console.log(dnsRecord);
// The TXT record value // The TXT record value
console.log(dnsAuth); console.log(dnsAuth);
return { return {
type: 'TXT' type: 'TXT'
, host: dnsRecord , host: dnsRecord
, answer: dnsAuth , answer: dnsAuth
}; };
}); });
}; };
var challengePollUrl; var challengePollUrl;
@ -516,84 +486,108 @@ var challengePollUrl;
// { jwk, challengeUrl, accountId (kid) } // { jwk, challengeUrl, accountId (kid) }
BACME.challenges.accept = function (opts) { BACME.challenges.accept = function (opts) {
var payload64 = BACME._jsto64( var payload64 = BACME._jsto64(
{} {}
); );
return BACME._importKey(opts.jwk).then(function (abstractKey) { return BACME._importKey(opts.jwk).then(function (abstractKey) {
var protected64 = BACME._jsto64( var protected64 = BACME._jsto64(
{ nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: opts.challengeUrl, kid: opts.accountId } { nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: opts.challengeUrl, kid: opts.accountId }
); );
return BACME._sign({ return BACME._sign({
abstractKey: abstractKey abstractKey: abstractKey
, payload64: payload64 , payload64: payload64
, protected64: protected64 , protected64: protected64
}); });
}).then(function (signedAccept) { }).then(function (signedAccept) {
nonce = null; nonce = null;
return window.fetch( return window.fetch(
opts.challengeUrl opts.challengeUrl
, { mode: 'cors' , { mode: 'cors'
, method: 'POST' , method: 'POST'
, headers: { 'Content-Type': 'application/jose+json' } , headers: { 'Content-Type': 'application/jose+json' }
, body: JSON.stringify(signedAccept) , body: JSON.stringify(signedAccept)
} }
).then(function (resp) { ).then(function (resp) {
BACME._logHeaders(resp); BACME._logHeaders(resp);
nonce = resp.headers.get('replay-nonce'); nonce = resp.headers.get('replay-nonce');
console.log("ACCEPT NONCE:", nonce); console.log("ACCEPT NONCE:", nonce);
return resp.json().then(function (reply) { return resp.json().then(function (reply) {
challengePollUrl = reply.url; challengePollUrl = reply.url;
console.log('Challenge ACK:'); console.log('Challenge ACK:');
console.log(JSON.stringify(reply)); console.log(JSON.stringify(reply));
return reply; return reply;
}); });
}); });
}); });
}; };
BACME.challenges.check = function (opts) { BACME.challenges.check = function (opts) {
return window.fetch(opts.challengePollUrl, { mode: 'cors' }).then(function (resp) { return window.fetch(opts.challengePollUrl, { mode: 'cors' }).then(function (resp) {
BACME._logHeaders(resp); BACME._logHeaders(resp);
return resp.json().then(function (reply) { return resp.json().then(function (reply) {
challengePollUrl = reply.url; challengePollUrl = reply.url;
BACME._logBody(reply); BACME._logBody(reply);
return reply; return reply;
}); });
}); });
}; };
var domainKeypair; var domainKeypair;
var domainJwk; var domainJwk;
BACME.generateKeypair = function (opts) {
var wcOpts = {};
// ECDSA has only the P curves and an associated bitlength
if (/^EC/i.test(opts.type)) {
wcOpts.name = 'ECDSA';
if (/256/.test(opts.bitlength)) {
wcOpts.namedCurve = 'P-256';
}
}
// RSA-PSS is another option, but I don't think it's used for Let's Encrypt
// I think the hash is only necessary for signing, not generation or import
if (/^RS/i.test(opts.type)) {
wcOpts.name = 'RSASSA-PKCS1-v1_5';
wcOpts.modulusLength = opts.bitlength;
if (opts.bitlength < 2048) {
wcOpts.modulusLength = opts.bitlength * 8;
}
wcOpts.publicExponent = new Uint8Array([0x01, 0x00, 0x01]);
wcOpts.hash = { name: "SHA-256" };
}
var extractable = true;
return window.crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" }
, extractable
, [ 'sign', 'verify' ]
);
};
BACME.domains = {}; BACME.domains = {};
// TODO factor out from BACME.accounts.generateKeypair // TODO factor out from BACME.accounts.generateKeypair even more
BACME.domains.generateKeypair = function () { BACME.domains.generateKeypair = function (opts) {
var extractable = true; return BACME.generateKeypair(opts).then(function (result) {
return window.crypto.subtle.generateKey( domainKeypair = result;
{ name: "ECDSA", namedCurve: "P-256" }
, extractable
, [ 'sign', 'verify' ]
).then(function (result) {
domainKeypair = result;
return window.crypto.subtle.exportKey( return window.crypto.subtle.exportKey(
"jwk" "jwk"
, result.privateKey , result.privateKey
).then(function (jwk) { ).then(function (privJwk) {
domainJwk = jwk; domainJwk = privJwk;
console.log('private jwk:'); console.log('private jwk:');
console.log(JSON.stringify(jwk, null, 2)); console.log(JSON.stringify(privJwk, null, 2));
return domainKeypair; return privJwk;
}) });
}); });
}; };
// { serverJwk, domains } // { serverJwk, domains }
@ -607,41 +601,41 @@ var certificateUrl;
// { csr, jwk, finalizeUrl, accountId } // { csr, jwk, finalizeUrl, accountId }
BACME.orders.finalize = function (opts) { BACME.orders.finalize = function (opts) {
var payload64 = BACME._jsto64( var payload64 = BACME._jsto64(
{ csr: opts.csr } { csr: opts.csr }
); );
return BACME._importKey(opts.jwk).then(function (abstractKey) { return BACME._importKey(opts.jwk).then(function (abstractKey) {
var protected64 = BACME._jsto64( var protected64 = BACME._jsto64(
{ nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: opts.finalizeUrl, kid: opts.accountId } { nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: opts.finalizeUrl, kid: opts.accountId }
); );
return BACME._sign({ return BACME._sign({
abstractKey: abstractKey abstractKey: abstractKey
, payload64: payload64 , payload64: payload64
, protected64: protected64 , protected64: protected64
}); });
}).then(function (signedFinal) { }).then(function (signedFinal) {
nonce = null; nonce = null;
return window.fetch( return window.fetch(
opts.finalizeUrl opts.finalizeUrl
, { mode: 'cors' , { mode: 'cors'
, method: 'POST' , method: 'POST'
, headers: { 'Content-Type': 'application/jose+json' } , headers: { 'Content-Type': 'application/jose+json' }
, body: JSON.stringify(signedFinal) , body: JSON.stringify(signedFinal)
} }
).then(function (resp) { ).then(function (resp) {
BACME._logHeaders(resp); BACME._logHeaders(resp);
nonce = resp.headers.get('replay-nonce'); nonce = resp.headers.get('replay-nonce');
return resp.json().then(function (reply) { return resp.json().then(function (reply) {
certificateUrl = reply.certificate; certificateUrl = reply.certificate;
BACME._logBody(reply); BACME._logBody(reply);
return reply; return reply;
}); });
}); });
}); });
}; };
BACME.orders.receive = function (opts) { BACME.orders.receive = function (opts) {