diff --git a/src/js/ripple/authinfo.js b/src/js/ripple/authinfo.js index 423eeb06..785663ff 100644 --- a/src/js/ripple/authinfo.js +++ b/src/js/ripple/authinfo.js @@ -22,13 +22,12 @@ AuthInfo.prototype.get = function (domain, username, fn) { function processTxt(txt) { - if (!txt.authinfo_url) return fn(new Error("Authentication is not supported on "+domain)); var url = Array.isArray(txt.authinfo_url) ? txt.authinfo_url[0] : txt.authinfo_url; url += "?domain="+domain+"&username="+username; request.get(url, function(err, resp){ - if (err) return fn(new Error("Authentication info server unreachable")); + if (err || resp.error) return fn(new Error("Authentication info server unreachable")); fn(null, resp.body); }); } diff --git a/src/js/ripple/blob.js b/src/js/ripple/blob.js new file mode 100644 index 00000000..a4cc7b1c --- /dev/null +++ b/src/js/ripple/blob.js @@ -0,0 +1,560 @@ +var crypt = require('./crypt'), + message = require('./message'), + request = require('superagent'), + extend = require("extend"); + +//Blob object class +var BlobObj = function (url, id, key) { + this.url = url; + this.id = id; + this.key = key; + this.data = {}; +}; + +// Blob operations +// Do NOT change the mapping of existing ops +BlobObj.ops = { + // Special + "noop" : 0, + + // Simple ops + "set" : 16, + "unset" : 17, + "extend" : 18, + + // Meta ops + "push" : 32, + "pop" : 33, + "shift" : 34, + "unshift" : 35, + "filter" : 36 +}; + + +BlobObj.opsReverseMap = []; +for (var name in BlobObj.ops) { + BlobObj.opsReverseMap[BlobObj.ops[name]] = name; +} + + +/* + * Init - + * initialize a new blob object + * + */ +BlobObj.prototype.init = function (fn) { + var self = this, url; + if (self.url.indexOf("://") === -1) self.url = "http://" + url; + + url = self.url + '/v1/blob/' + self.id; + + request.get(url, function(err, resp){ + + if (err || !resp.body || resp.body.result !== 'success') + return fn(new Error("Could not retrieve blob")); + + self.revision = resp.body.revision; + self.encrypted_secret = resp.body.encrypted_secret; + + if (!self.decrypt(resp.body.blob)) { + return fn(new Error("Error while decrypting blob")); + } + + //Apply patches + if (resp.body.patches && resp.body.patches.length) { + var successful = true; + resp.body.patches.forEach(function (patch) { + successful = successful && self.applyEncryptedPatch(patch); + }); + + if (successful) self.consolidate(); + } + + fn(null, self);//return with newly decrypted blob + + }).timeout(8000); +} + + +/* + * Consolidate - + * Consolidate patches as a new revision + * + */ +BlobObj.prototype.consolidate = function (fn) { + + // Callback is optional + if ("function" !== typeof fn) fn = function(){}; + + console.log("client: blob: consolidation at revision", this.revision); + var encrypted = this.encrypt(); + + var config = { + method : 'POST', + url : this.url + '/v1/blob/consolidate', + dataType : 'json', + data : { + blob_id : this.id, + data : encrypted, + revision : this.revision + }, + }; + + var signed = crypt.signRequestHmac(config, this.data.auth_secret, this.id); + + request.post(signed.url) + .send(signed.data) + .end(function(err, resp) { + + // XXX Add better error information to exception + if (err) return fn(new Error("Failed to consolidate blob - XHR error")); + else if (resp.body && resp.body.result === 'success') return fn(null, resp.body); + else return fn(new Error("Failed to consolidate blob")); + }); +}; + + +/* + * ApplyEncryptedPatch - + * save changes from a downloaded patch to the blob + * + */ +BlobObj.prototype.applyEncryptedPatch = function (patch) +{ + try { + var params = JSON.parse(crypt.decrypt(this.key, patch)); + var op = params.shift(); + var path = params.shift(); + + this.applyUpdate(op, path, params); + this.revision++; + + return true; + + } catch (err) { + console.log("client: blob: failed to apply patch:", err.toString()); + console.log(err.stack); + return false; + } +} + + +//decrypt secret with unlock key +BlobObj.prototype.decryptSecret = function (secretUnlockKey) { + return crypt.decrypt(secretUnlockKey, this.data.encrypted_secret); +}; + + +//encrypt secret with unlock key +BlobObj.prototype.encryptSecret = function (secretUnlockKey, secret) { + return crypt.encrypt(secretUnlockKey, secret); +}; + + +//decrypt blob with crypt key +BlobObj.prototype.decrypt = function (data) { + + try { + this.data = JSON.parse(crypt.decrypt(this.key, data)); + return this; + } catch (e) { + console.log("client: blob: decryption failed", e.toString()); + console.log(e.stack); + return false; + } +}; + + +//encrypt blob data with crypt key +BlobObj.prototype.encrypt = function() +{ + +// Filter Angular metadata before encryption +// if ('object' === typeof this.data && +// 'object' === typeof this.data.contacts) +// this.data.contacts = angular.fromJson(angular.toJson(this.data.contacts)); + + return crypt.encrypt(this.key, JSON.stringify(this.data)); +}; + +BlobObj.prototype.decryptBlobCrypt = function (secret) { + var recoveryEncryptionKey = crypt.deriveRecoveryEncryptionKeyFromSecret(secret); + return crypt.decrypt(recoveryEncryptionKey, this.encrypted_blobdecrypt_key); +}; + +BlobObj.prototype.encryptBlobCrypt = function (secret, blobDecryptKey) { + var recoveryEncryptionKey = crypt.deriveRecoveryEncryptionKeyFromSecret(secret); + return crypt.encrypt(recoveryEncryptionKey, blobDecryptKey); +}; + + + +/**** Blob updating ****/ + + +//set blob element +BlobObj.prototype.set = function (pointer, value, fn) { + this.applyUpdate('set', pointer, [value]); + this.postUpdate('set', pointer, [value], fn); +}; + + +//get remove blob element +BlobObj.prototype.unset = function (pointer, fn) { + this.applyUpdate('unset', pointer, []); + this.postUpdate('unset', pointer, [], fn); +}; + + +//extend blob element +BlobObj.prototype.extend = function (pointer, value, fn) { + this.applyUpdate('extend', pointer, [value]); + this.postUpdate('extend', pointer, [value], fn); +}; + + +//Prepend an entry to an array. +BlobObj.prototype.unshift = function (pointer, value, fn) { + this.applyUpdate('unshift', pointer, [value]); + this.postUpdate('unshift', pointer, [value], fn); +}; + + +/** + * Filter the row(s) from an array. + * + * This method will find any entries from the array stored under `pointer` and + * apply the `subcommands` to each of them. + * + * The subcommands can be any commands with the pointer parameter left out. + */ +BlobObj.prototype.filter = function (pointer, field, value, subcommands, callback) { + var params = Array.prototype.slice.apply(arguments); + if ("function" === typeof params[params.length-1]) { + callback = params.pop(); + } + params.shift(); + + // Normalize subcommands to minimize the patch size + params = params.slice(0, 2).concat(normalizeSubcommands(params.slice(2), true)); + + this.applyUpdate('filter', pointer, params); + this.postUpdate('filter', pointer, params, callback); +}; + + +//apply new update to the blob data +BlobObj.prototype.applyUpdate = function (op, path, params) { + + // Exchange from numeric op code to string + if ("number" === typeof op) { + op = BlobObj.opsReverseMap[op]; + } + if ("string" !== typeof op) { + throw new Error("Blob update op code must be a number or a valid op id string"); + } + + // Separate each step in the "pointer" + var pointer = path.split("/"); + + var first = pointer.shift(); + if (first !== "") { + throw new Error("Invalid JSON pointer: "+path); + } + + this._traverse(this.data, pointer, path, op, params); +}; + + +//for applyUpdate function +BlobObj.prototype._traverse = function (context, pointer, + originalPointer, op, params) { + var _this = this; + var part = _this.unescapeToken(pointer.shift()); + + if (Array.isArray(context)) { + if (part === '-') { + part = context.length; + } else if (part % 1 !== 0 && part >= 0) { + throw new Error("Invalid pointer, array element segments must be " + + "a positive integer, zero or '-'"); + } + } else if ("object" !== typeof context) { + return null; + } else if (!context.hasOwnProperty(part)) { + // Some opcodes create the path as they're going along + if (op === "set") { + context[part] = {}; + } else if (op === "unshift") { + context[part] = []; + } else { + return null; + } + } + + if (pointer.length !== 0) { + return this._traverse(context[part], pointer, + originalPointer, op, params); + } + + switch (op) { + case "set": + context[part] = params[0]; + break; + case "unset": + if (Array.isArray(context)) { + context.splice(part, 1); + } else { + delete context[part]; + } + break; + case "extend": + if ("object" !== typeof context[part]) { + throw new Error("Tried to extend a non-object"); + } + extend(true, context[part], params[0]); + break; + case "unshift": + if ("undefined" === typeof context[part]) { + context[part] = []; + } else if (!Array.isArray(context[part])) { + throw new Error("Operator 'unshift' must be applied to an array."); + } + context[part].unshift(params[0]); + break; + case "filter": + if (Array.isArray(context[part])) { + context[part].forEach(function (element, i) { + if ("object" === typeof element && + element.hasOwnProperty(params[0]) && + element[params[0]] === params[1]) { + var subpointer = originalPointer+"/"+i; + var subcommands = normalizeSubcommands(params.slice(2)); + + subcommands.forEach(function (subcommand) { + var op = subcommand[0]; + var pointer = subpointer+subcommand[1]; + _this.applyUpdate(op, pointer, subcommand.slice(2)); + }); + } + }); + } + break; + default: + throw new Error("Unsupported op "+op); + } +}; + + +BlobObj.prototype.escapeToken = function (token) { + return token.replace(/[~\/]/g, function (key) { return key === "~" ? "~0" : "~1"; }); +}; + + +BlobObj.prototype.unescapeToken = function(str) { + return str.replace(/~./g, function(m) { + switch (m) { + case "~0": + return "~"; + case "~1": + return "/"; + } + throw("Invalid tilde escape: " + m); + }); +}; + + +//sumbit update to blob vault +BlobObj.prototype.postUpdate = function (op, pointer, params, fn) { + // Callback is optional + if ("function" !== typeof fn) fn = function(){}; + + if ("string" === typeof op) { + op = BlobObj.ops[op]; + } + if ("number" !== typeof op) { + throw new Error("Blob update op code must be a number or a valid op id string"); + } + if (op < 0 || op > 255) { + throw new Error("Blob update op code out of bounds"); + } + + console.log("client: blob: submitting update", BlobObj.opsReverseMap[op], pointer, params); + + params.unshift(pointer); + params.unshift(op); + + var config = { + method : 'POST', + url : this.url + '/v1/blob/patch', + dataType : 'json', + data : { + blob_id : this.id, + patch : crypt.encrypt(this.key, JSON.stringify(params)) + } + }; + + + var signed = crypt.signRequestHmac(config, this.data.auth_secret, this.id); + + request.post(signed.url) + .send(signed.data) + .end(function(err, resp) { + if (err) + return fn(new Error("Patch could not be saved - XHR error")); + else if (!resp.body || resp.body.result !== 'success') + return fn(new Error("Patch could not be saved - bad result")); + + return fn(null, resp.body); + }); +}; + + + +/***** helper functions *****/ + + +function normalizeSubcommands(subcommands, compress) { + // Normalize parameter structure + if ("number" === typeof subcommands[0] || + "string" === typeof subcommands[0]) { + // Case 1: Single subcommand inline + subcommands = [subcommands]; + } else if (subcommands.length === 1 && + Array.isArray(subcommands[0]) && + ("number" === typeof subcommands[0][0] || + "string" === typeof subcommands[0][0])) { + // Case 2: Single subcommand as array + // (nothing to do) + } else if (Array.isArray(subcommands[0])) { + // Case 3: Multiple subcommands as array of arrays + subcommands = subcommands[0]; + } + + // Normalize op name and convert strings to numeric codes + subcommands = subcommands.map(function (subcommand) { + if ("string" === typeof subcommand[0]) { + subcommand[0] = BlobObj.ops[subcommand[0]]; + } + if ("number" !== typeof subcommand[0]) { + throw new Error("Invalid op in subcommand"); + } + if ("string" !== typeof subcommand[1]) { + throw new Error("Invalid path in subcommand"); + } + return subcommand; + }); + + if (compress) { + // Convert to the minimal possible format + if (subcommands.length === 1) { + return subcommands[0]; + } else { + return [subcommands]; + } + } else { + return subcommands; + } +} + + +/***** blob client methods ****/ + + +//blob object class +module.exports.Blob = BlobObj + +//get ripple name for a given address +module.exports.getRippleName = function (url, address, fn) { + + if (!crypt.isValidAddress(address)) return fn (new Error("Invalid ripple address")); + request.get(url + '/v1/user/' + address, function(err, resp){ + if (err) return fn(new Error("Unable to access vault sever")); + else if (resp.body && resp.body.username) return fn(null, resp.body.username); + else if (resp.body && resp.body.exists === false) return fn (new Error("No ripple name for this address")); + else return fn(new Error("Unable to determine if ripple name exists")); + }); +} + +//retrive a blob with url, id and key +module.exports.get = function (url, id, crypt, fn) { + + var blob = new BlobObj(url, id, crypt); + blob.init(fn); +} + + +//verify email address +module.exports.verify = function (url, username, token, fn) { + url += '/v1/user/' + username + '/verify/' + token; + request.get(url, function(err, resp){ + if (err) return fn(err); + else if (resp.body && resp.body.result === 'success') return fn(null, data); + else return fn(new Error("Failed to verify the account")); + }); +} + + +/** + * Create a blob object + * + * @param {object} options + * @param {string} options.url + * @param {string} options.id + * @param {string} options.crypt + * @param {string} options.unlock + * @param {string} options.username + * @param {string} options.masterkey + * @param {object} options.oldUserBlob + * @param {function} fn + */ +module.exports.create = function (options, fn) +{ + + var blob = new BlobObj(options.url, options.id, options.crypt); + + blob.revision = 0; + blob.data = { + auth_secret : crypt.createSecret(8), + account_id : crypt.getAddress(options.masterkey), + email : options.email, + contacts : [], + created : (new Date()).toJSON() + }; + + blob.encrypted_secret = blob.encryptSecret(options.unlock, options.masterkey); + + // Migration + if (options.oldUserBlob) { + blob.data.contacts = options.oldUserBlob.data.contacts; + } + + //post to the blob vault to create + var config = { + method : "POST", + url : options.url + '/v1/user', + data : { + blob_id : options.id, + username : options.username, + address : blob.data.account_id, + auth_secret : blob.data.auth_secret, + data : blob.encrypt(), + email : options.email, + hostlink : options.activateLink, + encrypted_blobdecrypt_key : blob.encryptBlobCrypt(options.masterkey, options.crypt), + encrypted_secret : blob.encrypted_secret + } + }; + + + var signed = crypt.signRequestAsymmetric(config, options.masterkey, blob.data.account_id, options.id); + + + request.post(signed) + .send(signed.data) + .end(function(err, resp) { + if (err) return fn(err); + else if (resp.body && resp.body.result === 'success') return fn(null, blob,resp.body); + else return fn(new Error("Could not create blob")); + }); +} \ No newline at end of file diff --git a/src/js/ripple/crypt.js b/src/js/ripple/crypt.js new file mode 100644 index 00000000..e747b822 --- /dev/null +++ b/src/js/ripple/crypt.js @@ -0,0 +1,356 @@ +var sjcl = require('./utils').sjcl, + request = require('superagent'), + extend = require("extend"), + parser = require("url"); + +var cryptConfig = { + cipher : "aes", + mode : "ccm", + ts : 64, // tag length + ks : 256, // key size + iter : 1000 // iterations (key derivation) +}; + +var Crypt = {}; + +// Full domain hash based on SHA512 +function fdh(data, bytelen) +{ + var bitlen = bytelen << 3; + + if (typeof data === "string") { + data = sjcl.codec.utf8String.toBits(data); + } + + // Add hashing rounds until we exceed desired length in bits + var counter = 0, output = []; + while (sjcl.bitArray.bitLength(output) < bitlen) { + var hash = sjcl.hash.sha512.hash(sjcl.bitArray.concat([counter], data)); + output = sjcl.bitArray.concat(output, hash); + counter++; + } + + // Truncate to desired length + output = sjcl.bitArray.clamp(output, bitlen); + + return output; +} + + +// This is a function to derive different hashes from the same key. Each hash +// is derived as HMAC-SHA512HALF(key, token). +function keyHash(key, token) { + var hmac = new sjcl.misc.hmac(key, sjcl.hash.sha512); + return sjcl.codec.hex.fromBits(sjcl.bitArray.bitSlice(hmac.encrypt(token), 0, 256)); +} + + +/****** exposed functions ******/ + + +/** + * KEY DERIVATION FUNCTION + * + * This service takes care of the key derivation, i.e. converting low-entropy + * secret into higher entropy secret via either computationally expensive + * processes or peer-assisted key derivation (PAKDF). + */ +Crypt.derive = function (opts, purpose, username, secret, fn) { + + var tokens; + if (purpose=='login') tokens = ['id', 'crypt']; + else tokens = ['unlock']; + + var iExponent = new sjcl.bn(String(opts.exponent)), + iModulus = new sjcl.bn(String(opts.modulus)), + iAlpha = new sjcl.bn(String(opts.alpha)); + + var publicInfo = "PAKDF_1_0_0:"+opts.host.length+":"+opts.host+ + ":"+username.length+":"+username+ + ":"+purpose.length+":"+purpose+ + ":", + publicSize = Math.ceil(Math.min((7+iModulus.bitLength()) >>> 3, 256)/8), + publicHash = fdh(publicInfo, publicSize), + publicHex = sjcl.codec.hex.fromBits(publicHash), + iPublic = new sjcl.bn(String(publicHex)).setBitM(0), + secretInfo = publicInfo+":"+secret.length+":"+secret+":", + secretSize = (7+iModulus.bitLength()) >>> 3, + secretHash = fdh(secretInfo, secretSize), + secretHex = sjcl.codec.hex.fromBits(secretHash), + iSecret = new sjcl.bn(String(secretHex)).mod(iModulus); + + if (iSecret.jacobi(iModulus) !== 1) { + iSecret = iSecret.mul(iAlpha).mod(iModulus); + } + + var iRandom; + for (;;) { + iRandom = sjcl.bn.random(iModulus, 0); + if (iRandom.jacobi(iModulus) === 1) + break; + } + + var iBlind = iRandom.powermodMontgomery(iPublic.mul(iExponent), iModulus), + iSignreq = iSecret.mulmod(iBlind, iModulus), + signreq = sjcl.codec.hex.fromBits(iSignreq.toBits()); + + request.post(opts.url) + .send({ + info : publicInfo, + signreq : signreq + }).end(function(err, resp) { + + if (err || !resp) return fn(new Error("Could not query PAKDF server "+opts.host)); + + var data = resp.body || resp.text ? JSON.parse(resp.text) : {}; + + if (!data.result=='success') return fn(new Error("Could not query PAKDF server "+opts.host)); + + var iSignres = new sjcl.bn(String(data.signres)); + iRandomInv = iRandom.inverseMod(iModulus), + iSigned = iSignres.mulmod(iRandomInv, iModulus), + key = iSigned.toBits(), + result = {}; + + tokens.forEach(function (token) { + result[token] = keyHash(key, token); + }); + + fn (null, result); + }); +} + + +Crypt.encrypt = function(key, data) +{ + key = sjcl.codec.hex.toBits(key); + + var opts = extend(true, {}, cryptConfig); + + var encryptedObj = JSON.parse(sjcl.encrypt(key, data, opts)); + var version = [sjcl.bitArray.partial(8, 0)]; + var initVector = sjcl.codec.base64.toBits(encryptedObj.iv); + var ciphertext = sjcl.codec.base64.toBits(encryptedObj.ct); + + var encryptedBits = sjcl.bitArray.concat(version, initVector); + encryptedBits = sjcl.bitArray.concat(encryptedBits, ciphertext); + + return sjcl.codec.base64.fromBits(encryptedBits); +} + + +Crypt.decrypt = function(key, data) +{ + key = sjcl.codec.hex.toBits(key); + var encryptedBits = sjcl.codec.base64.toBits(data); + + var version = sjcl.bitArray.extract(encryptedBits, 0, 8); + + if (version !== 0) { + throw new Error("Unsupported encryption version: "+version); + } + + var encrypted = extend(true, {}, cryptConfig, { + iv: sjcl.codec.base64.fromBits(sjcl.bitArray.bitSlice(encryptedBits, 8, 8+128)), + ct: sjcl.codec.base64.fromBits(sjcl.bitArray.bitSlice(encryptedBits, 8+128)) + }); + + return sjcl.decrypt(key, JSON.stringify(encrypted)); +} + +Crypt.isValidAddress = function (address) { + return ripple.UInt160.is_valid(address); +} + +Crypt.createSecret = function (words) { + return sjcl.codec.hex.fromBits(sjcl.random.randomWords(words)); +} + +Crypt.createMaster = function () { + return ripple.Base.encode_base_check(33, sjcl.codec.bytes.fromBits(sjcl.random.randomWords(4))); +} + +/* +Crypt.getAddress = function (masterkey) { + return new RippleAddress(masterkey).getAddress(); +} +*/ + +Crypt.hashSha512 = function (data) { + return sjcl.codec.hex.fromBits(sjcl.hash.sha512.hash(data)); +} + +Crypt.signature = function (secret, data) { + var hmac = new sjcl.misc.hmac(sjcl.codec.hex.toBits(secret), sjcl.hash.sha512); + return sjcl.codec.hex.fromBits(hmac.mac(data)); +} + +Crypt.deriveRecoveryEncryptionKeyFromSecret = function(secret) { + var seed = ripple.Seed.from_json(secret).to_bits(); + var hmac = new sjcl.misc.hmac(seed, sjcl.hash.sha512); + var key = hmac.mac("ripple/hmac/recovery_encryption_key/v1"); + key = sjcl.bitArray.bitSlice(key, 0, 256); + return sjcl.codec.hex.fromBits(key); +} + +/** + * Convert base64 encoded data into base64url encoded data. + * + * @param {String} base64 Data + */ +Crypt.base64ToBase64Url = function (encodedData) { + return encodedData.replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]+$/, ''); +}; + +/** + * Convert base64url encoded data into base64 encoded data. + * + * @param {String} base64 Data + */ +Crypt.base64UrlToBase64 = function (encodedData) { + encodedData = encodedData.replace(/-/g, '+').replace(/_/g, '/'); + while (encodedData.length % 4) { + encodedData += '='; + } + return encodedData; +}; + + +//methods for signed requests +Crypt.getStringToSign = function (config, parsed, date, mechanism) { + // XXX This method doesn't handle signing GET requests correctly. The data + // field will be merged into the search string, not the request body. + + // Sort the properties of the JSON object into canonical form + var canonicalData = JSON.stringify(copyObjectWithSortedKeys(config.data)); + + // Canonical request using Amazon's v4 signature format + // See: http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + var canonicalRequest = [ + config.method || 'GET', + parsed.pathname || '', + parsed.search || '', + // XXX Headers signing not supported + '', + '', + Crypt.hashSha512(canonicalData).toLowerCase() + ].join('\n'); + + // String to sign inspired by Amazon's v4 signature format + // See: http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html + // + // We don't have a credential scope, so we skip it. + // + // But that modifies the format, so the format ID is RIPPLE1, instead of AWS4. + return stringToSign = [ + mechanism, + date, + Crypt.hashSha512(canonicalRequest).toLowerCase() + ].join('\n'); +} + +Crypt.signRequestHmac = function (config, auth_secret, blob_id) { + config = extend(true, {}, config); + + // Parse URL + var parsed = parser.parse(config.url); + + var date = dateAsIso8601(); + var signatureType = 'RIPPLE1-HMAC-SHA512'; + + var stringToSign = Crypt.getStringToSign(config, parsed, date, signatureType); + var signature = Crypt.signature(auth_secret, stringToSign); + + config.url += (parsed.search ? "&" : "?") + + 'signature='+Crypt.base64ToBase64Url(signature)+ + '&signature_date='+date+ + '&signature_blob_id='+blob_id+ + '&signature_type='+signatureType + + return config; +}; + + +Crypt.signRequestAsymmetric = function (config, secretKey, account, blob_id) { + config = extend(true, {}, config); + + // Parse URL + var parsed = parser.parse(config.url); + + var date = dateAsIso8601(); + var signatureType = 'RIPPLE1-ECDSA-SHA512'; + var stringToSign = Crypt.getStringToSign(config, parsed, date, signatureType); + var signature = message.signMessage(stringToSign, secretKey); + + config.url += (parsed.search ? "&" : "?") + + 'signature='+Crypt.base64ToBase64Url(signature)+ + '&signature_date='+date+ + '&signature_blob_id='+blob_id+ + '&signature_account='+account+ + '&signature_type='+signatureType; + + return config; +}; + + +function copyObjectWithSortedKeys(object) { + if (isPlainObject(object)) { + var newObj = {}; + var keysSorted = Object.keys(object).sort(); + var key; + for (var i in keysSorted) { + key = keysSorted[i]; + if (Object.prototype.hasOwnProperty.call(object, key)) { + newObj[key] = copyObjectWithSortedKeys(object[key]); + } + } + return newObj; + } else if (Array.isArray(object)) { + return object.map(copyObjectWithSortedKeys); + } else { + return object; + } +} + + +//from npm extend +function isPlainObject(obj) { + var hasOwn = Object.prototype.hasOwnProperty; + var toString = Object.prototype.toString; + + if (!obj || toString.call(obj) !== '[object Object]' || obj.nodeType || obj.setInterval) + return false; + + var has_own_constructor = hasOwn.call(obj, 'constructor'); + var has_is_property_of_method = hasOwn.call(obj.constructor.prototype, 'isPrototypeOf'); + // Not own constructor property must be Object + if (obj.constructor && !has_own_constructor && !has_is_property_of_method) + return false; + + // Own properties are enumerated firstly, so to speed up, + // if last one is own, then all properties are own. + var key; + for ( key in obj ) {} + + return key === undefined || hasOwn.call( obj, key ); +}; + + +var dateAsIso8601 = (function () { + function pad(n) { + return (n < 0 || n > 9 ? "" : "0") + n; + } + + return function dateAsIso8601() { + var date = new Date(); + return date.getUTCFullYear() + "-" + + pad(date.getUTCMonth() + 1) + "-" + + pad(date.getUTCDate()) + "T" + + pad(date.getUTCHours()) + ":" + + pad(date.getUTCMinutes()) + ":" + + pad(date.getUTCSeconds()) + ".000Z"; + }; +})(); + + +module.exports = Crypt; \ No newline at end of file diff --git a/src/js/ripple/index.js b/src/js/ripple/index.js index 7dfc7b80..1bb0585e 100644 --- a/src/js/ripple/index.js +++ b/src/js/ripple/index.js @@ -12,7 +12,7 @@ exports.Meta = require('./meta').Meta; exports.SerializedObject = require('./serializedobject').SerializedObject; exports.RippleError = require('./rippleerror').RippleError; exports.Message = require('./message'); -exports.Rippletxt = require('./rippletxt'); +exports.VaultClient = require('./vaultclient'); exports.binformat = require('./binformat'); exports.utils = require('./utils'); exports.Server = require('./server').Server; diff --git a/src/js/ripple/vaultclient.js b/src/js/ripple/vaultclient.js new file mode 100644 index 00000000..9e598f39 --- /dev/null +++ b/src/js/ripple/vaultclient.js @@ -0,0 +1,269 @@ +var AuthInfo = require('./authinfo'); +var blobClient = require('./blob'); +var crypt = require('./crypt'); + +function VaultClient(opts) { + if (!opts) opts = {}; + else if (typeof opts === "string") opts = {domain:opts}; + + this.domain = opts.domain || 'ripple.com'; + this.authInfo = new AuthInfo; + this.infos = {}; +}; + + +/* + * normalizeUsername - + * Reduce username to standardized form. + * Strips whitespace at beginning and end. + */ +VaultClient.prototype.normalizeUsername = function (username) { + username = ""+username; + username = username.trim(); + return username; +}; + + +/* + * normalizePassword - + * Reduce password to standardized form. + * Strips whitespace at beginning and end. + */ +VaultClient.prototype.normalizePassword = function (password) { + password = ""+password; + password = password.trim(); + return password; +}; + +VaultClient.prototype.getRippleName = function(address, url, fn) { + + //use the url from previously retrieved authInfo, if necessary + if (!url && this.infos[id]) url = this.infos[id].blobvault; + if (!url) return fn(new Error("Blob vault URL is required")); + blobClient.getRippleName(url, address, fn); +}; + +/* + * Login - + * authenticate and retrieve a decrypted blob using a ripple name and password + * + */ +VaultClient.prototype.login = function(username, password, fn) { + var self = this; + + self.authInfo.get(self.domain, username, function(err, authInfo){ + if (err) return fn(err); + + if (authInfo.version !== 3) { + return fn(new Error("This wallet is incompatible with this version of the vault-client.")); + } + + if (!authInfo.pakdf) { + return fn(new Error("No settings for PAKDF in auth packet.")); + } + + if (!authInfo.exists) { + return fn(new Error("User does not exist.")); + } + + if ("string" !== typeof authInfo.blobvault) { + return fn(new Error("No blobvault specified in the authinfo.")); + } + + + //derive login keys + crypt.derive(authInfo.pakdf, 'login', username.toLowerCase(), password, function(err, keys){ + if (err) return fn(err); + + blobClient.get(authInfo.blobvault, keys.id, keys.crypt, function (err, blob) { + if (err) return fn(err); + + self.infos[keys.id] = authInfo; //save for relogin + + fn (null, { + blob : blob, + username : authInfo.username, + verified : authInfo.emailVerified + }); + }); + }); + }); +}; + + +/* + * Relogin - + * retreive and decrypt blob using a blob url, id and crypt derived previously. + * + */ +VaultClient.prototype.relogin = function(url, id, cryptKey, fn) { + + //use the url from previously retrieved authInfo, if necessary + if (!url && this.infos[id]) url = this.infos[id].blobvault; + + if (!url) return fn(new Error("Blob vault URL is required")); + + blobClient.get(url, id, cryptKey, function (err, blob) { + if (err) return fn(err); + + fn (null, { + blob : blob, + }); + }); +}; + + +/* + * Unlock - + * decrypt the secret key using a username and password + */ +VaultClient.prototype.unlock = function(username, password, encryptSecret, fn) { + var self = this; + + self.authInfo.get(self.domain, username, function(err, authInfo){ + if (err) return fn(err); + + //derive unlock key + crypt.derive(authInfo.pakdf, 'unlock', username.toLowerCase(), password, function(err, keys){ + if (err) return fn(err); + + fn(null, { + keys : keys, + secret : crypt.decrypt(keys.unlock, encryptSecret) + }); + }); + }); +}; + + +/* + * LoginAndUnlock + * retrieve the decrypted blob and secret key in one step using + * the username and password + * + */ +VaultClient.prototype.loginAndUnlock = function(username, password, fn) { + var self = this; + + this.login(username, password, function(err, resp){ + if (err) return fn(err); + + if (!resp.blob || !resp.blob.encrypted_secret) + return fn(new Error("Unable to retrieve blob and secret.")); + + if (!resp.blob.id || !resp.blob.key) + return fn(new Error("Unable to retrieve keys.")); + + //get authInfo via id - would have been saved from login + var authInfo = self.infos[resp.blob.id]; + if (!authInfo) return fn(new Error("Unable to find authInfo")); + + //derive unlock key + crypt.derive(authInfo.pakdf, 'unlock', username.toLowerCase(), password, function(err, keys){ + if (err) return fn(err); + + fn(null, { + blob : resp.blob, + unlock : keys.unlock, + secret : crypt.decrypt(keys.unlock, resp.blob.encrypted_secret), + username : authInfo.username, + verified : authInfo.emailVerified + }); + }); + }); +}; + + +/* + * Exists - + * check blobvault for existance of username + * + */ +VaultClient.prototype.exists = function (username, fn) { + this.authInfo.get(this.domain, username.toLowerCase(), function(err, authInfo){ + if (err) return fn(err); + return fn(null, !!authInfo.exists); + }); +} + + +/* + * Verify - + * verify an email address for an existing user + * + */ +VaultClient.prototype.verify = function (username, token, fn) { + + this.authInfo.get(this.domain, username.toLowerCase(), function (err, authInfo) { + if (err) return fn(err); + + if ("string" !== typeof authInfo.blobvault) { + return fn(new Error("No blobvault specified in the authinfo.")); + } + + blobClient.verify(authInfo.blobvault, username.toLowerCase(), token, fn); + }); +} + +/* + * Register - + * register a new user and save to the blob vault + * + * @param {object} options + * @param {string} options.username + * @param {string} options.password + * @param {string} options.masterkey //optional, will create if absent + * @param {string} options.email + * @param {string} options.activateLink + * @param {object} options.oldUserBlob //optional + * @param {function} fn + */ +VaultClient.prototype.register = function (options, fn) { + var self = this, + username = this.normalizeUsername(options.username), + password = this.normalizePassword(options.password); + + + self.authInfo.get(self.domain, username, function(err, authInfo){ + + if (err) return fn(err); + + if ("string" !== typeof authInfo.blobvault) { + return fn(new Error("No blobvault specified in the authinfo.")); + } + + + if (!authInfo.pakdf) { + return fn(new Error("No settings for PAKDF in auth packet.")); + } + + //derive login keys + crypt.derive(authInfo.pakdf, 'login', username.toLowerCase(), password, function(err, loginKeys){ + if (err) return fn(err); + + //derive unlock key + crypt.derive(authInfo.pakdf, 'unlock', username.toLowerCase(), password, function(err, unlockKeys){ + if (err) return fn(err); + + var params = { + 'url' : authInfo.blobvault, + 'id' : loginKeys.id, + 'crypt' : loginKeys.crypt, + 'unlock' : unlockKeys.unlock, + 'username' : username, + 'email' : options.email, + 'masterkey' : options.masterkey || crypt.createMaster(), + 'activateLink' : options.activateLink, + 'oldUserBlob' : options.oldUserBlob + } + + blobClient.create(params, function(err, blob){ + if (err) return fn(err); + fn(null, blob, loginKeys, authInfo.username); + }); + }); + }); + }); +}; + +module.exports = VaultClient; \ No newline at end of file diff --git a/test/vault-test.js b/test/vault-test.js index f05aed40..45119573 100644 --- a/test/vault-test.js +++ b/test/vault-test.js @@ -1,32 +1,241 @@ -var assert = require('assert'); -var RippleTxt = require('../src/js/ripple/rippletxt'); -var AuthInfo = require('../src/js/ripple/authinfo'); +var assert = require('assert'), + RippleTxt = require('../src/js/ripple/rippletxt'), + AuthInfo = require('../src/js/ripple/authinfo'), + VaultClient = require('../src/js/ripple/vaultclient'), + Blob = require('../src/js/ripple/blob').Blob, + UInt256 = require('../src/js/ripple/uint256').UInt256; + +var exampleData = { + id : "57d6ed12d3b98ca91b61afac2fb30212f642daabefd9c7cda623f145f384830c", + crypt : "1733480ceea2970e5f979c8d8e508d79e446b42d54f593e640814ea91deb53ef", + unlock : "452b02b80469a6a2ad692264c04d2a3794ea0ab11d8c902ef774190294db2ce2", + blobURL : "https://id.staging.ripple.com", + username : "testUser", + password : "pass word", + domain : "staging.ripple.com", + encrypted_secret : "QUh5dnBqR0pTTVpjcjVoY0FhN1cxcEdTdW1XS1hLS2VzNlpQT2ZvQkFJWmg1UHRYS1RobUhKTkZUcWNyNlZEVlZYZDNhS1l0" +}; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; //must be set for self signed certs -describe('Vault Client', function() { - describe('Ripple Txt', function() { - it('should get the context of a ripple.txt file from a given domain', function(done){ - var rt = new RippleTxt(); - rt.get("ripple.com", function(err, resp){ +describe('Ripple Txt', function() { + it('should get the context of a ripple.txt file from a given domain', function(done){ + var rt = new RippleTxt(); + rt.get(exampleData.domain, function(err, resp){ + assert.ifError(err); + assert.equal(typeof resp, 'object'); + done(); + }); + }); +}); + + +describe('AuthInfo', function() { + var auth = new AuthInfo(); + + it ('should', function(done){ + auth.get(exampleData.domain, exampleData.user, function(err, resp){ + assert.ifError(err); + assert.equal(typeof resp, 'object'); + done(); + }); + }); +}); + +describe('VaultClient', function() { + var client = new VaultClient(exampleData.domain); + + describe('initialization', function() { + it('should be initialized with a domain', function() { + var client = new VaultClient({ domain: exampleData.domain }); + assert.strictEqual(client.domain, exampleData.domain); + }); + + it('should default to ripple.com without a domain', function() { + var client = new VaultClient(); + assert.strictEqual(client.domain, 'ripple.com'); + }); + }); + + describe('#exists', function() { + it('should determine if a username exists on the domain', function(done) { + this.timeout(10000); + client.exists(exampleData.username, function(err, resp) { + + assert.ifError(err); + assert.equal(typeof resp, 'boolean'); + done(); + }); + }); + }); + + describe('#login', function() { + it('with username and password should retrive the blob, crypt key, and id', function(done) { + this.timeout(10000); + client.login(exampleData.username, exampleData.password, function(err, resp) { + assert.ifError(err); assert.equal(typeof resp, 'object'); + assert(resp.blob instanceof Blob); + assert.equal(typeof resp.blob.id, 'string'); + assert(UInt256.from_json(resp.blob.id).is_valid(), true); + assert.equal(typeof resp.blob.key, 'string'); + assert(UInt256.from_json(resp.blob.key).is_valid(), true); + assert.equal(typeof resp.username, 'string'); + assert.equal(typeof resp.verified, 'boolean'); done(); }); }); }); - describe('AuthInfo', function() { - var auth = new AuthInfo(); - it ('should', function(done){ - auth.get("staging.ripple.com", "testUser", function(err, resp){ + describe('#relogin', function() { + it('should retrieve the decrypted blob with blob vault url, id, and crypt key', function(done) { + this.timeout(10000); + client.relogin(exampleData.blobURL, exampleData.id, exampleData.crypt, function(err, resp) { + assert.ifError(err); assert.equal(typeof resp, 'object'); + assert(resp.blob instanceof Blob); done(); - }); - }); + }); + }); }); -}); + + describe('#unlock', function() { + it('should access the wallet secret using encryption secret, username and password', function(done) { + this.timeout(10000); + client.unlock(exampleData.username, exampleData.password, exampleData.encrypted_secret, function(err, resp) { + + assert.ifError(err); + assert.equal(typeof resp, 'object'); + assert.equal(typeof resp.keys, 'object'); + assert.equal(typeof resp.keys.unlock, 'string'); + assert(UInt256.from_json(resp.keys.unlock).is_valid(), true); + done(); + }); + }); + }); + + describe('#loginAndUnlock', function() { + it('should get the decrypted blob and decrypted secret given name and password', function(done) { + this.timeout(10000); + client.loginAndUnlock(exampleData.username, exampleData.password, function(err, resp) { + + assert.ifError(err); + assert.equal(typeof resp, 'object'); + assert(resp.blob instanceof Blob); + assert.equal(typeof resp.blob.id, 'string'); + assert(UInt256.from_json(resp.blob.id).is_valid(), true); + assert.equal(typeof resp.blob.key, 'string'); + assert(UInt256.from_json(resp.blob.key).is_valid(), true); + assert.equal(typeof resp.unlock, 'string'); + assert(UInt256.from_json(resp.unlock).is_valid(), true); + assert.equal(typeof resp.secret, 'string'); + assert.equal(typeof resp.username, 'string'); + assert.equal(typeof resp.verified, 'boolean'); + done(); + }); + }); + }); +}); + + +describe('Blob', function() { + var vaultClient; + + vaultClient = new VaultClient({ domain: exampleData.domain }); + vaultClient.login(exampleData.username, exampleData.password, function(err,resp){ + assert.ifError(err); + var blob = resp.blob; + + + describe('#set', function() { + it('should set a new property in the blob', function(done) { + this.timeout(10000) + + blob.extend("/testObject", { + foo : [], + }, function(err, resp){ + assert.ifError(err); + assert.equal(resp.result, 'success'); + done(); + }); + }); + }); + + describe('#extend', function() { + it('should extend an object in the blob', function(done) { + this.timeout(10000) + + blob.extend("/testObject", { + foobar : "baz", + }, function(err, resp){ + assert.ifError(err); + assert.equal(resp.result, 'success'); + done(); + }); + }); + }); + + describe('#unset', function() { + it('should remove a property from the blob', function(done) { + this.timeout(10000) + + blob.unset("/testObject", function(err, resp){ + assert.ifError(err); + assert.equal(resp.result, 'success'); + done(); + }); + }); + }); + + describe('#unshift', function() { + it('should prepend an item to an array in the blob', function(done) { + this.timeout(10000) + + blob.unshift("/testArray", { + name : "bob", + address : "1234" + }, function(err, resp){ + assert.ifError(err); + assert.equal(resp.result, 'success'); + done(); + }); + }); + }); + + describe('#filter', function() { + it('should find a specific entity in an array and apply subcommands to it', function(done) { + this.timeout(10000) + + blob.filter('/testArray', 'name', 'bob', 'extend', '', {description:"Alice"}, function(err, resp){ + assert.ifError(err); + assert.equal(resp.result, 'success'); + done(); + }); + }); + }); + + describe('#consolidate', function() { + it('should consolidate and save changes to the blob', function(done) { + this.timeout(10000) + + blob.unset('/testArray', function(err, resp){ + assert.ifError(err); + assert.equal(resp.result, 'success'); + + blob.consolidate(function(err, resp){ + assert.ifError(err); + assert.equal(resp.result, 'success'); + done(); + }); + }); + }); + }); + }); +}); +