UNPKG

blockstack

Version:

The Blockstack Javascript library for authentication, identity, and storage.

531 lines (486 loc) 21 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.BLOCKSTACK_GAIA_HUB_LABEL = exports.uploadToGaiaHub = exports.connectToGaiaHub = undefined; var _slicedToArray = function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; }(); // export { type GaiaHubConfig } from './hub' exports.getUserAppFileUrl = getUserAppFileUrl; exports.encryptContent = encryptContent; exports.decryptContent = decryptContent; exports.getFile = getFile; exports.putFile = putFile; exports.getAppBucketUrl = getAppBucketUrl; exports.deleteFile = deleteFile; exports.listFiles = listFiles; var _hub = require('./hub'); var _encryption = require('../encryption'); var _auth = require('../auth'); var _keys = require('../keys'); var _profiles = require('../profiles'); var _errors = require('../errors'); var _logger = require('../logger'); var SIGNATURE_FILE_SUFFIX = '.sig'; /** * Fetch the public read URL of a user file for the specified app. * @param {String} path - the path to the file to read * @param {String} username - The Blockstack ID of the user to look up * @param {String} appOrigin - The app origin * @param {String} [zoneFileLookupURL=null] - The URL * to use for zonefile lookup. If falsey, this will use the * blockstack.js's getNameInfo function instead. * @return {Promise} that resolves to the public read URL of the file * or rejects with an error */ function getUserAppFileUrl(path, username, appOrigin) { var zoneFileLookupURL = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null; return (0, _profiles.lookupProfile)(username, zoneFileLookupURL).then(function (profile) { if (profile.hasOwnProperty('apps')) { if (profile.apps.hasOwnProperty(appOrigin)) { return profile.apps[appOrigin]; } else { return null; } } else { return null; } }).then(function (bucketUrl) { if (bucketUrl) { var bucket = bucketUrl.replace(/\/?(\?|#|$)/, '/$1'); return '' + bucket + path; } else { return null; } }); } /** * Encrypts the data provided with the app public key. * @param {String|Buffer} content - data to encrypt * @param {Object} [options=null] - options object * @param {String} options.publicKey - the hex string of the ECDSA public * key to use for encryption. If not provided, will use user's appPrivateKey. * @return {String} Stringified ciphertext object */ function encryptContent(content, options) { var defaults = { publicKey: null }; var opt = Object.assign({}, defaults, options); if (!opt.publicKey) { var privateKey = (0, _auth.loadUserData)().appPrivateKey; opt.publicKey = (0, _keys.getPublicKeyFromPrivate)(privateKey); } var cipherObject = (0, _encryption.encryptECIES)(opt.publicKey, content); return JSON.stringify(cipherObject); } /** * Decrypts data encrypted with `encryptContent` with the * transit private key. * @param {String|Buffer} content - encrypted content. * @param {Object} [options=null] - options object * @param {String} options.privateKey - the hex string of the ECDSA private * key to use for decryption. If not provided, will use user's appPrivateKey. * @return {String|Buffer} decrypted content. */ function decryptContent(content, options) { var defaults = { privateKey: null }; var opt = Object.assign({}, defaults, options); var privateKey = opt.privateKey; if (!privateKey) { privateKey = (0, _auth.loadUserData)().appPrivateKey; } try { var cipherObject = JSON.parse(content); return (0, _encryption.decryptECIES)(privateKey, cipherObject); } catch (err) { if (err instanceof SyntaxError) { throw new Error('Failed to parse encrypted content JSON. The content may not ' + 'be encrypted. If using getFile, try passing { decrypt: false }.'); } else { throw err; } } } /* Get the gaia address used for servicing multiplayer reads for the given * (username, app) pair. * @private */ function getGaiaAddress(app, username, zoneFileLookupURL) { return Promise.resolve().then(function () { if (username) { return getUserAppFileUrl('/', username, app, zoneFileLookupURL); } else { return (0, _hub.getOrSetLocalGaiaHubConnection)().then(function (gaiaHubConfig) { return (0, _hub.getFullReadUrl)('/', gaiaHubConfig); }); } }).then(function (fileUrl) { var matches = fileUrl.match(/([13][a-km-zA-HJ-NP-Z0-9]{26,35})/); if (!matches) { throw new Error('Failed to parse gaia address'); } return matches[matches.length - 1]; }); } /* Handle fetching the contents from a given path. Handles both * multi-player reads and reads from own storage. * @private */ function getFileContents(path, app, username, zoneFileLookupURL, forceText) { return Promise.resolve().then(function () { if (username) { return getUserAppFileUrl(path, username, app, zoneFileLookupURL); } else { return (0, _hub.getOrSetLocalGaiaHubConnection)().then(function (gaiaHubConfig) { return (0, _hub.getFullReadUrl)(path, gaiaHubConfig); }); } }).then(function (readUrl) { return new Promise(function (resolve, reject) { if (!readUrl) { reject(null); } else { resolve(readUrl); } }); }).then(function (readUrl) { return fetch(readUrl); }).then(function (response) { if (response.status !== 200) { if (response.status === 404) { _logger.Logger.debug('getFile ' + path + ' returned 404, returning null'); return null; } else { throw new Error('getFile ' + path + ' failed with HTTP status ' + response.status); } } var contentType = response.headers.get('Content-Type'); if (forceText || contentType === null || contentType.startsWith('text') || contentType === 'application/json') { return response.text(); } else { return response.arrayBuffer(); } }); } /* Handle fetching an unencrypted file, its associated signature * and then validate it. Handles both multi-player reads and reads * from own storage. * @private */ function getFileSignedUnencrypted(path, opt) { // future optimization note: // in the case of _multi-player_ reads, this does a lot of excess // profile lookups to figure out where to read files // do browsers cache all these requests if Content-Cache is set? return Promise.all([getFileContents(path, opt.app, opt.username, opt.zoneFileLookupURL, false), getFileContents('' + path + SIGNATURE_FILE_SUFFIX, opt.app, opt.username, opt.zoneFileLookupURL, true), getGaiaAddress(opt.app, opt.username, opt.zoneFileLookupURL)]).then(function (_ref) { var _ref2 = _slicedToArray(_ref, 3), fileContents = _ref2[0], signatureContents = _ref2[1], gaiaAddress = _ref2[2]; if (!fileContents) { return fileContents; } if (!gaiaAddress) { throw new _errors.SignatureVerificationError('Failed to get gaia address for verification of: ' + ('' + path)); } if (!signatureContents || typeof signatureContents !== 'string') { throw new _errors.SignatureVerificationError('Failed to obtain signature for file: ' + (path + ' -- looked in ' + path + SIGNATURE_FILE_SUFFIX)); } var signature = void 0; var publicKey = void 0; try { var sigObject = JSON.parse(signatureContents); signature = sigObject.signature; publicKey = sigObject.publicKey; } catch (err) { if (err instanceof SyntaxError) { throw new Error('Failed to parse signature content JSON ' + ('(path: ' + path + SIGNATURE_FILE_SUFFIX + ')') + ' The content may be corrupted.'); } else { throw err; } } var signerAddress = (0, _keys.publicKeyToAddress)(publicKey); if (gaiaAddress !== signerAddress) { throw new _errors.SignatureVerificationError('Signer pubkey address (' + signerAddress + ') doesn\'t' + (' match gaia address (' + gaiaAddress + ')')); } else if (!(0, _encryption.verifyECDSA)(Buffer.from(fileContents), publicKey, signature)) { throw new _errors.SignatureVerificationError('Contents do not match ECDSA signature: ' + ('path: ' + path + ', signature: ' + path + SIGNATURE_FILE_SUFFIX)); } else { return fileContents; } }); } /* Handle signature verification and decryption for contents which are * expected to be signed and encrypted. This works for single and * multiplayer reads. In the case of multiplayer reads, it uses the * gaia address for verification of the claimed public key. * @private */ function handleSignedEncryptedContents(path, storedContents, app, username, zoneFileLookupURL) { var appPrivateKey = (0, _auth.loadUserData)().appPrivateKey; var appPublicKey = (0, _keys.getPublicKeyFromPrivate)(appPrivateKey); var addressPromise = void 0; if (username) { addressPromise = getGaiaAddress(app, username, zoneFileLookupURL); } else { var address = (0, _keys.publicKeyToAddress)(appPublicKey); addressPromise = Promise.resolve(address); } return addressPromise.then(function (address) { if (!address) { throw new _errors.SignatureVerificationError('Failed to get gaia address for verification of: ' + ('' + path)); } var sigObject = void 0; try { sigObject = JSON.parse(storedContents); } catch (err) { if (err instanceof SyntaxError) { throw new Error('Failed to parse encrypted, signed content JSON. The content may not ' + 'be encrypted. If using getFile, try passing' + ' { verify: false, decrypt: false }.'); } else { throw err; } } var signature = sigObject.signature; var signerPublicKey = sigObject.publicKey; var cipherText = sigObject.cipherText; var signerAddress = (0, _keys.publicKeyToAddress)(signerPublicKey); if (!signerPublicKey || !cipherText || !signature) { throw new _errors.SignatureVerificationError('Failed to get signature verification data from file:' + (' ' + path)); } else if (signerAddress !== address) { throw new _errors.SignatureVerificationError('Signer pubkey address (' + signerAddress + ') doesn\'t' + (' match gaia address (' + address + ')')); } else if (!(0, _encryption.verifyECDSA)(cipherText, signerPublicKey, signature)) { throw new _errors.SignatureVerificationError('Contents do not match ECDSA signature in file:' + (' ' + path)); } else { return decryptContent(cipherText); } }); } /** * Retrieves the specified file from the app's data store. * @param {String} path - the path to the file to read * @param {Object} [options=null] - options object * @param {Boolean} [options.decrypt=true] - try to decrypt the data with the app private key * @param {String} options.username - the Blockstack ID to lookup for multi-player storage * @param {Boolean} options.verify - Whether the content should be verified, only to be used * when `putFile` was set to `sign = true` * @param {String} options.app - the app to lookup for multi-player storage - * defaults to current origin * @param {String} [options.zoneFileLookupURL=null] - The URL * to use for zonefile lookup. If falsey, this will use the * blockstack.js's getNameInfo function instead. * @returns {Promise} that resolves to the raw data in the file * or rejects with an error */ function getFile(path, options) { var defaults = { decrypt: true, verify: false, username: null, app: window.location.origin, zoneFileLookupURL: null }; var opt = Object.assign({}, defaults, options); // in the case of signature verification, but no // encryption expected, need to fetch _two_ files. if (opt.verify && !opt.decrypt) { return getFileSignedUnencrypted(path, opt); } return getFileContents(path, opt.app, opt.username, opt.zoneFileLookupURL, !!opt.decrypt).then(function (storedContents) { if (storedContents === null) { return storedContents; } else if (opt.decrypt && !opt.verify) { if (typeof storedContents !== 'string') { throw new Error('Expected to get back a string for the cipherText'); } return decryptContent(storedContents); } else if (opt.decrypt && opt.verify) { if (typeof storedContents !== 'string') { throw new Error('Expected to get back a string for the cipherText'); } return handleSignedEncryptedContents(path, storedContents, opt.app, opt.username, opt.zoneFileLookupURL); } else if (!opt.verify && !opt.decrypt) { return storedContents; } else { throw new Error('Should be unreachable.'); } }); } /** * Stores the data provided in the app's data store to to the file specified. * @param {String} path - the path to store the data in * @param {String|Buffer} content - the data to store in the file * @param {Object} [options=null] - options object * @param {Boolean|String} [options.encrypt=true] - encrypt the data with the app private key * or the provided public key * @param {Boolean} [options.sign=false] - sign the data using ECDSA on SHA256 hashes with * the app private key * @param {String} [options.contentType=''] - set a Content-Type header for unencrypted data * @return {Promise} that resolves if the operation succeed and rejects * if it failed */ function putFile(path, content, options) { var defaults = { encrypt: true, sign: false, contentType: '' }; var opt = Object.assign({}, defaults, options); var contentType = opt.contentType; if (!contentType) { contentType = typeof content === 'string' ? 'text/plain; charset=utf-8' : 'application/octet-stream'; } // First, let's figure out if we need to get public/private keys, // or if they were passed in var privateKey = ''; var publicKey = ''; if (opt.sign) { if (typeof opt.sign === 'string') { privateKey = opt.sign; } else { privateKey = (0, _auth.loadUserData)().appPrivateKey; } } if (opt.encrypt) { if (typeof opt.encrypt === 'string') { publicKey = opt.encrypt; } else { if (!privateKey) { privateKey = (0, _auth.loadUserData)().appPrivateKey; } publicKey = (0, _keys.getPublicKeyFromPrivate)(privateKey); } } // In the case of signing, but *not* encrypting, // we perform two uploads. So the control-flow // here will return there. if (!opt.encrypt && opt.sign) { var signatureObject = (0, _encryption.signECDSA)(privateKey, content); var signatureContent = JSON.stringify(signatureObject); return (0, _hub.getOrSetLocalGaiaHubConnection)().then(function (gaiaHubConfig) { return new Promise(function (resolve, reject) { return Promise.all([(0, _hub.uploadToGaiaHub)(path, content, gaiaHubConfig, contentType), (0, _hub.uploadToGaiaHub)('' + path + SIGNATURE_FILE_SUFFIX, signatureContent, gaiaHubConfig, 'application/json')]).then(resolve).catch(function () { (0, _hub.setLocalGaiaHubConnection)().then(function (freshHubConfig) { return Promise.all([(0, _hub.uploadToGaiaHub)(path, content, freshHubConfig, contentType), (0, _hub.uploadToGaiaHub)('' + path + SIGNATURE_FILE_SUFFIX, signatureContent, freshHubConfig, 'application/json')]).then(resolve).catch(reject); }); }); }); }).then(function (fileUrls) { return fileUrls[0]; }); } // In all other cases, we only need one upload. if (opt.encrypt && !opt.sign) { content = encryptContent(content, { publicKey: publicKey }); contentType = 'application/json'; } else if (opt.encrypt && opt.sign) { var cipherText = encryptContent(content, { publicKey: publicKey }); var _signatureObject = (0, _encryption.signECDSA)(privateKey, cipherText); var signedCipherObject = { signature: _signatureObject.signature, publicKey: _signatureObject.publicKey, cipherText: cipherText }; content = JSON.stringify(signedCipherObject); contentType = 'application/json'; } return (0, _hub.getOrSetLocalGaiaHubConnection)().then(function (gaiaHubConfig) { return new Promise(function (resolve, reject) { (0, _hub.uploadToGaiaHub)(path, content, gaiaHubConfig, contentType).then(resolve).catch(function () { (0, _hub.setLocalGaiaHubConnection)().then(function (freshHubConfig) { return (0, _hub.uploadToGaiaHub)(path, content, freshHubConfig, contentType).then(resolve).catch(reject); }); }); }); }); } /** * Get the app storage bucket URL * @param {String} gaiaHubUrl - the gaia hub URL * @param {String} appPrivateKey - the app private key used to generate the app address * @returns {Promise} That resolves to the URL of the app index file * or rejects if it fails */ function getAppBucketUrl(gaiaHubUrl, appPrivateKey) { return (0, _hub.getBucketUrl)(gaiaHubUrl, appPrivateKey); } /** * Deletes the specified file from the app's data store. Currently not implemented. * @param {String} path - the path to the file to delete * @returns {Promise} that resolves when the file has been removed * or rejects with an error * @private */ function deleteFile(path) { Promise.reject(new Error('Delete of ' + path + ' not supported by gaia hubs')); } /** * Loop over the list of files in a Gaia hub, and run a callback on each entry. * Not meant to be called by external clients. * @param {GaiaHubConfig} hubConfig - the Gaia hub config * @param {String | null} page - the page ID * @param {number} callCount - the loop count * @param {number} fileCount - the number of files listed so far * @param {function} callback - the callback to invoke on each file. If it returns a falsey * value, then the loop stops. If it returns a truthy value, the loop continues. * @returns {Promise} that resolves to the number of files listed. * @private */ function listFilesLoop(hubConfig, page, callCount, fileCount, callback) { if (callCount > 65536) { // this is ridiculously huge, and probably indicates // a faulty Gaia hub anyway (e.g. on that serves endless data) throw new Error('Too many entries to list'); } var httpStatus = void 0; var pageRequest = JSON.stringify({ page: page }); var fetchOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': '' + pageRequest.length, Authorization: 'bearer ' + hubConfig.token }, body: pageRequest }; return fetch(hubConfig.server + '/list-files/' + hubConfig.address, fetchOptions).then(function (response) { httpStatus = response.status; if (httpStatus >= 400) { throw new Error('listFiles failed with HTTP status ' + httpStatus); } return response.text(); }).then(function (responseText) { return JSON.parse(responseText); }).then(function (responseJSON) { var entries = responseJSON.entries; var nextPage = responseJSON.page; if (entries === null || entries === undefined) { // indicates a misbehaving Gaia hub or a misbehaving driver // (i.e. the data is malformed) throw new Error('Bad listFiles response: no entries'); } for (var i = 0; i < entries.length; i++) { var rc = callback(entries[i]); if (!rc) { // callback indicates that we're done return Promise.resolve(fileCount + i); } } if (nextPage && entries.length > 0) { // keep going -- have more entries return listFilesLoop(hubConfig, nextPage, callCount + 1, fileCount + entries.length, callback); } else { // no more entries -- end of data return Promise.resolve(fileCount + entries.length); } }); } /** * List the set of files in this application's Gaia storage bucket. * @param {function} callback - a callback to invoke on each named file that * returns `true` to continue the listing operation or `false` to end it * @return {Promise} that resolves to the number of files listed */ function listFiles(callback) { return (0, _hub.getOrSetLocalGaiaHubConnection)().then(function (gaiaHubConfig) { return listFilesLoop(gaiaHubConfig, null, 0, 0, callback); }); } exports.connectToGaiaHub = _hub.connectToGaiaHub; exports.uploadToGaiaHub = _hub.uploadToGaiaHub; exports.BLOCKSTACK_GAIA_HUB_LABEL = _hub.BLOCKSTACK_GAIA_HUB_LABEL;