470 lines
14 KiB
JavaScript
470 lines
14 KiB
JavaScript
/**
|
|
* Email-credentials-storage service
|
|
*
|
|
* Allows users to store encrypted data on the server, useful to store the user's credentials.
|
|
*
|
|
* Triggers an email to the user's provided email account. Note that the service may decide to
|
|
* remove information associated with unconfirmed email addresses!
|
|
*
|
|
* Steps for the user would be:
|
|
*
|
|
* 1. Select an email to use
|
|
* 2. Choose a password
|
|
* 3. Create a strong key for encryption using PBKDF2 or scrypt with the email and password
|
|
* 4. Use that key to AES-CRT encrypt the private key
|
|
* 5. Take the double SHA256 hash of "salt"+"email"+"password" and use that as a secret
|
|
* 6. Send a POST request to resource /email/register with the params:
|
|
* [email protected]
|
|
* secret=2413fb3709b05939f04cf2e92f7d0897fc2596f9ad0b8a9ea855c7bfebaae892
|
|
* record=YjU1MTI2YTM5ZjliMTE3MGEzMmU2ZjYxZTRhNjk0YzQ1MjM1ZTVhYzExYzA1ZWNkNmZm
|
|
* NjM5NWRlNmExMTE4NzIzYzYyYWMwODU1MTdkNWMyNjRiZTVmNmJjYTMxMGQyYmFiNjc4YzdiODV
|
|
* lZjg5YWIxYzQ4YjJmY2VkYWJjMDQ2NDYzODhkODFiYTU1NjZmMzgwYzhiODdiMzlmYjQ5ZTc1Nz
|
|
* FjYzQzYjk1YTEyYWU1OGMxYmQ3OGFhOTZmNGMz
|
|
*
|
|
* To verify an email:
|
|
*
|
|
* 1. Check the email sent by the insight server
|
|
* 2. Click on the link provided, or take the verification secret to make a request
|
|
* 3. The request done can be a POST or GET request to /email/validate with the params:
|
|
* [email protected]
|
|
* verification_code=M5NWRlNmExMTE4NzIzYzYyYWMwODU1MT
|
|
*
|
|
* To retrieve data:
|
|
*
|
|
* 1. Recover the secret from the double sha256 of the salt, email, and password
|
|
* 2. Send a GET request to resource /email/retrieve?secret=......
|
|
* 3. Decrypt the data received
|
|
*/
|
|
(function () {
|
|
|
|
'use strict';
|
|
|
|
var logger = require('../lib/logger').logger;
|
|
var levelup = require('levelup');
|
|
var async = require('async');
|
|
var crypto = require('crypto');
|
|
var querystring = require('querystring');
|
|
var nodemailer = require('nodemailer');
|
|
var globalConfig = require('../config/config');
|
|
var _ = require('lodash');
|
|
var fs = require('fs');
|
|
|
|
var emailPlugin = {};
|
|
|
|
/**
|
|
* Constant enum with the errors that the application may return
|
|
*/
|
|
emailPlugin.errors = {
|
|
MISSING_PARAMETER: {
|
|
code: 400,
|
|
message: 'Missing required parameter'
|
|
},
|
|
INVALID_REQUEST: {
|
|
code: 400,
|
|
message: 'Invalid request parameter'
|
|
},
|
|
NOT_FOUND: {
|
|
code: 404,
|
|
message: 'Credentials were not found'
|
|
},
|
|
INTERNAL_ERROR: {
|
|
code: 500,
|
|
message: 'Unable to save to database'
|
|
},
|
|
EMAIL_TAKEN: {
|
|
code: 409,
|
|
message: 'That email is already registered'
|
|
},
|
|
INVALID_CODE: {
|
|
code: 400,
|
|
message: 'The provided code is invalid'
|
|
}
|
|
};
|
|
|
|
var NAMESPACE = 'credentials-store-';
|
|
var SEPARATOR = '#';
|
|
var VALIDATION_NAMESPACE = 'validation-code-';
|
|
var MAP_EMAIL_TO_SECRET = 'map-email-';
|
|
var EMAIL_NAMESPACE = 'validated-email-';
|
|
var MAX_ALLOWED_STORAGE = 1024 * 100 /* no more than 100 kb */;
|
|
|
|
var makeKey = function(email, key) {
|
|
return NAMESPACE + email + SEPARATOR + key;
|
|
}
|
|
|
|
/**
|
|
* Initializes the plugin
|
|
*
|
|
* @param {Object} config
|
|
*/
|
|
emailPlugin.init = function (config) {
|
|
logger.info('Using emailstore plugin');
|
|
|
|
var path = globalConfig.leveldb + '/emailstore' + (globalConfig.name ? ('-' + globalConfig.name) : '');
|
|
emailPlugin.db = config.db || globalConfig.db || levelup(path);
|
|
|
|
emailPlugin.email = config.emailTransport || nodemailer.createTransport(config.email);
|
|
|
|
emailPlugin.textTemplate = config.textTemplate || 'copay.plain';
|
|
emailPlugin.htmlTemplate = config.htmlTemplate || 'copay.html';
|
|
emailPlugin.confirmUrl = (
|
|
(config.confirmEmailHost || 'https://insight.bitpay.com')
|
|
+ globalConfig.apiPrefix
|
|
+ '/email/validate'
|
|
);
|
|
emailPlugin.redirectUrl = (
|
|
config.redirectUrl || 'https://copay.io/in/app?confirmed=true'
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Helper function that ends a requests showing the user an error. The response body will be a JSON
|
|
* encoded object with only one property with key "error" and value <tt>error.message</tt>, one of
|
|
* the parameters of the function
|
|
*
|
|
* @param {Object} error - The error that caused the request to be terminated
|
|
* @param {number} error.code - the HTTP code to return
|
|
* @param {string} error.message - the message to send in the body
|
|
* @param {Express.Response} response - the express.js response. the methods status, json, and end
|
|
* will be called, terminating the request.
|
|
*/
|
|
emailPlugin.returnError = function (error, response) {
|
|
response.status(error.code).json({error: error.message}).end();
|
|
};
|
|
|
|
/**
|
|
* Helper that sends a verification email.
|
|
*
|
|
* @param {string} email - the user's email
|
|
* @param {string} secret - the verification secret
|
|
*/
|
|
emailPlugin.sendVerificationEmail = function (email, secret) {
|
|
|
|
async.series([
|
|
function(callback) {
|
|
emailPlugin.makeEmailBody({
|
|
email: email,
|
|
confirm_url: emailPlugin.makeConfirmUrl(secret)
|
|
}, callback);
|
|
},
|
|
function(callback) {
|
|
emailPlugin.makeEmailHTMLBody({
|
|
email: email,
|
|
confirm_url: emailPlugin.makeConfirmUrl(secret),
|
|
title: 'Your wallet backup needs confirmation'
|
|
}, callback);
|
|
}
|
|
], function(err, results) {
|
|
var emailBody = results[0];
|
|
var emailBodyHTML = results[1];
|
|
var mailOptions = {
|
|
from: 'Insight Services <[email protected]>',
|
|
to: email,
|
|
subject: '[Copay] Your wallet backup needs confirmation',
|
|
text: emailBody,
|
|
html: emailBodyHTML
|
|
};
|
|
|
|
// send mail with defined transport object
|
|
emailPlugin.email.sendMail(mailOptions, function (err, info) {
|
|
if (err) {
|
|
logger.error('An error occurred when trying to send email to ' + email, err);
|
|
} else {
|
|
logger.error('Message sent: ' + info.response);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
emailPlugin.makeConfirmUrl = function(secret) {
|
|
return emailPlugin.confirmUrl + '?secret='+secret;
|
|
};
|
|
|
|
/**
|
|
* Returns a function that reads an underscore template and uses the `opts` param
|
|
* to build an email body
|
|
*/
|
|
var applyTemplate = function(templateFilename) {
|
|
return function(opts, callback) {
|
|
fs.readFile(__dirname + '/emailTemplates/' + emailPlugin[templateFilename],
|
|
function(err, template) {
|
|
return callback(err, _.template(template, opts));
|
|
}
|
|
);
|
|
};
|
|
};
|
|
|
|
emailPlugin.makeEmailBody = applyTemplate('textTemplate');
|
|
emailPlugin.makeEmailHTMLBody = applyTemplate('htmlTemplate');
|
|
|
|
/**
|
|
* @param {string} email
|
|
* @param {Function(err, boolean)} callback
|
|
*/
|
|
emailPlugin.exists = function(email, callback) {
|
|
emailPlugin.db.get(EMAIL_NAMESPACE + email, function(err, value) {
|
|
if (err && err.notFound) {
|
|
return callback(null, false);
|
|
} else if (err) {
|
|
return callback(err);
|
|
}
|
|
return callback(null, true);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @param {string} email
|
|
* @param {string} passphrase
|
|
* @param {Function(err, boolean)} callback
|
|
*/
|
|
emailPlugin.checkPassphrase = function(email, passphrase, callback) {
|
|
emailPlugin.db.get(MAP_EMAIL_TO_SECRET + email, function(err, retrievedPassphrase) {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
return callback(err, passphrase === retrievedPassphrase);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @param {string} email
|
|
* @param {string} passphrase
|
|
* @param {Function(err)} callback
|
|
*/
|
|
emailPlugin.savePassphrase = function(email, passphrase, callback) {
|
|
emailPlugin.db.put(MAP_EMAIL_TO_SECRET + email, passphrase, callback);
|
|
};
|
|
|
|
/**
|
|
* @param {string} email
|
|
* @param {string} key
|
|
* @param {string} record
|
|
* @param {Function(err)} callback
|
|
*/
|
|
emailPlugin.saveEncryptedData = function(email, key, record, callback) {
|
|
emailPlugin.db.put(makeKey(email, key), record, callback);
|
|
};
|
|
|
|
emailPlugin.createVerificationSecretAndSendEmail = function (email, callback) {
|
|
emailPlugin.createVerificationSecret(email, function(err, secret) {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
if (secret) {
|
|
emailPlugin.sendVerificationEmail(email, secret);
|
|
}
|
|
callback();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Store a record in the database. The underlying database is merely a levelup instance (a key
|
|
* value store) that uses the email concatenated with the secret as a key to store the record.
|
|
* The request is expected to contain the parameters:
|
|
* * email
|
|
* * secret
|
|
* * record
|
|
*
|
|
* @param {Express.Request} request
|
|
* @param {Express.Response} response
|
|
*/
|
|
emailPlugin.post = function (request, response) {
|
|
|
|
var queryData = '';
|
|
|
|
request.on('data', function (data) {
|
|
queryData += data;
|
|
if (queryData.length > MAX_ALLOWED_STORAGE) {
|
|
queryData = '';
|
|
response.writeHead(413, {'Content-Type': 'text/plain'}).end();
|
|
request.connection.destroy();
|
|
}
|
|
}).on('end', function () {
|
|
var params = querystring.parse(queryData);
|
|
var email = params.email;
|
|
var key = params.key;
|
|
var secret = params.secret;
|
|
var record = params.record;
|
|
if (!email || !secret || !record || !key) {
|
|
return emailPlugin.returnError(emailPlugin.errors.MISSING_PARAMETER, response);
|
|
}
|
|
|
|
emailPlugin.processPost(request, response, email, key, secret, record);
|
|
});
|
|
};
|
|
|
|
emailPlugin.processPost = function(request, response, email, key, secret, record) {
|
|
async.series([
|
|
/**
|
|
* Try to fetch this user's email. If it exists, check the secret is the same.
|
|
*/
|
|
function (callback) {
|
|
emailPlugin.exists(email, function(err, exists) {
|
|
if (err) {
|
|
return callback({code: 500, message: err});
|
|
} else if (exists) {
|
|
emailPlugin.checkPassphrase(email, secret, function(err, match) {
|
|
if (err) {
|
|
return callback({code: 500, message: err});
|
|
}
|
|
if (match) {
|
|
return callback();
|
|
} else {
|
|
return callback(emailPlugin.errors.EMAIL_TAKEN);
|
|
}
|
|
});
|
|
} else {
|
|
emailPlugin.savePassphrase(email, secret, function(err) {
|
|
if (err) {
|
|
return callback({code: 500, message: err});
|
|
}
|
|
return callback();
|
|
});
|
|
}
|
|
});
|
|
},
|
|
/**
|
|
* Save the encrypted private key in the storage.
|
|
*/
|
|
function (callback) {
|
|
emailPlugin.saveEncryptedData(email, key, record, function(err) {
|
|
if (err) {
|
|
return callback({code: 500, message: err});
|
|
}
|
|
return callback();
|
|
});
|
|
},
|
|
/**
|
|
* Create and store the verification secret. If successful, send a verification email.
|
|
*/
|
|
function (callback) {
|
|
emailPlugin.createVerificationSecretAndSendEmail(email, function (err) {
|
|
if (err) {
|
|
callback({code: 500, message: err});
|
|
} else {
|
|
callback();
|
|
}
|
|
});
|
|
}
|
|
], function (err) {
|
|
if (err) {
|
|
emailPlugin.returnError(err, response);
|
|
} else {
|
|
response.json({success: true}).end();
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Creates and stores a verification secret in the database.
|
|
*
|
|
* @param {string} email - the user's email
|
|
* @param {Function} callback - will be called with params (err, secret)
|
|
*/
|
|
emailPlugin.createVerificationSecret = function (email, callback) {
|
|
emailPlugin.db.get(VALIDATION_NAMESPACE + email, function(err, value) {
|
|
if (err && err.notFound) {
|
|
var secret = crypto.randomBytes(16).toString('hex');
|
|
emailPlugin.db.put(VALIDATION_NAMESPACE + email, secret, function (err, value) {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
callback(err, secret);
|
|
});
|
|
} else {
|
|
callback(err, null);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @param {string} email
|
|
* @param {Function(err)} callback
|
|
*/
|
|
emailPlugin.retrieveByEmailAndKey = function(email, key, callback) {
|
|
emailPlugin.db.get(makeKey(email, key), callback);
|
|
};
|
|
|
|
emailPlugin.retrieveDataByEmailAndPassphrase = function(email, key, passphrase, callback) {
|
|
emailPlugin.checkPassphrase(email, passphrase, function(err, matches) {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
if (matches) {
|
|
return emailPlugin.retrieveByEmailAndKey(email, key, callback);
|
|
} else {
|
|
return callback(emailPlugin.errors.INVALID_CODE);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Retrieve a record from the database.
|
|
*
|
|
* The request is expected to contain the parameters:
|
|
* * secret
|
|
*
|
|
* @param {Express.Request} request
|
|
* @param {Express.Response} response
|
|
*/
|
|
emailPlugin.get = function (request, response) {
|
|
var email = request.param('email');
|
|
var key = request.param('key');
|
|
var secret = request.param('secret');
|
|
if (!secret) {
|
|
return emailPlugin.returnError(emailPlugin.errors.MISSING_PARAMETER, response);
|
|
}
|
|
|
|
emailPlugin.retrieveDataByEmailAndPassphrase(email, key, secret, function (err, value) {
|
|
if (err) {
|
|
if (err.notFound) {
|
|
return emailPlugin.returnError(emailPlugin.errors.NOT_FOUND, response);
|
|
}
|
|
return emailPlugin.returnError({code: 500, message: err}, response);
|
|
}
|
|
response.send(value).end();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Marks an email as validated
|
|
*
|
|
* The two expected params are:
|
|
* * email
|
|
* * verification_code
|
|
*
|
|
* @param {Express.Request} request
|
|
* @param {Express.Response} response
|
|
*/
|
|
emailPlugin.validate = function (request, response) {
|
|
var email = request.param('email');
|
|
var secret = request.param('verification_code');
|
|
if (!email || !secret) {
|
|
return emailPlugin.returnError(emailPlugin.errors.MISSING_PARAMETER, response);
|
|
}
|
|
|
|
emailPlugin.db.get(VALIDATION_NAMESPACE + email, function (err, value) {
|
|
if (err) {
|
|
if (err.notFound) {
|
|
return emailPlugin.returnError(emailPlugin.errors.NOT_FOUND, response);
|
|
}
|
|
return emailPlugin.returnError({code: 500, message: err}, response);
|
|
} else if (value !== secret) {
|
|
return emailPlugin.returnError(emailPlugin.errors.INVALID_CODE, response);
|
|
} else {
|
|
emailPlugin.db.put(EMAIL_NAMESPACE + email, true, function (err, value) {
|
|
if (err) {
|
|
return emailPlugin.returnError({code: 500, message: err}, response);
|
|
} else {
|
|
response.redirect(emailPlugin.redirectUrl).end();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
module.exports = emailPlugin;
|
|
|
|
})();
|