aladinnetwork-blockstack
Version:
The Aladin Javascript library for authentication, identity, and storage.
608 lines • 25.1 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
const hub_1 = require("./hub");
exports.connectToGaiaHub = hub_1.connectToGaiaHub;
exports.uploadToGaiaHub = hub_1.uploadToGaiaHub;
exports.ALADIN_GAIA_HUB_LABEL = hub_1.ALADIN_GAIA_HUB_LABEL;
// export { type GaiaHubConfig } from './hub'
const ec_1 = require("../encryption/ec");
const keys_1 = require("../keys");
const profileLookup_1 = require("../profiles/profileLookup");
const errors_1 = require("../errors");
const logger_1 = require("../logger");
const userSession_1 = require("../auth/userSession");
const utils_1 = require("../utils");
const fetchUtil_1 = require("../fetchUtil");
const 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 Aladin 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
* aladin.js's [[getNameInfo]] function instead.
* @return {Promise<string>} that resolves to the public read URL of the file
* or rejects with an error
*/
function getUserAppFileUrl(path, username, appOrigin, zoneFileLookupURL) {
return __awaiter(this, void 0, void 0, function* () {
const profile = yield profileLookup_1.lookupProfile(username, zoneFileLookupURL);
let bucketUrl = null;
if (profile.hasOwnProperty('apps')) {
if (profile.apps.hasOwnProperty(appOrigin)) {
const url = profile.apps[appOrigin];
const bucket = url.replace(/\/?(\?|#|$)/, '/$1');
bucketUrl = `${bucket}${path}`;
}
}
return bucketUrl;
});
}
exports.getUserAppFileUrl = getUserAppFileUrl;
/**
*
*
* @deprecated
* #### v19 Use [[UserSession.encryptContent]].
*
* 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 appPublicKey.
* @return {String} Stringified ciphertext object
*/
function encryptContent(content, options, caller) {
const opts = Object.assign({}, options);
if (!opts.publicKey) {
const privateKey = (caller || new userSession_1.UserSession()).loadUserData().appPrivateKey;
opts.publicKey = keys_1.getPublicKeyFromPrivate(privateKey);
}
const cipherObject = ec_1.encryptECIES(opts.publicKey, content);
return JSON.stringify(cipherObject);
}
exports.encryptContent = encryptContent;
/**
*
* @deprecated
* #### v19 Use [[UserSession.decryptContent]].
*
* 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, caller) {
const opts = Object.assign({}, options);
if (!opts.privateKey) {
opts.privateKey = (caller || new userSession_1.UserSession()).loadUserData().appPrivateKey;
}
try {
const cipherObject = JSON.parse(content);
return ec_1.decryptECIES(opts.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;
}
}
}
exports.decryptContent = decryptContent;
/* Get the gaia address used for servicing multiplayer reads for the given
* (username, app) pair.
* @private
* @ignore
*/
function getGaiaAddress(app, username, zoneFileLookupURL, caller) {
return __awaiter(this, void 0, void 0, function* () {
const opts = normalizeOptions({ app, username }, caller);
let fileUrl;
if (username) {
fileUrl = yield getUserAppFileUrl('/', opts.username, opts.app, zoneFileLookupURL);
}
else {
if (!caller) {
caller = new userSession_1.UserSession();
}
const gaiaHubConfig = yield caller.getOrSetLocalGaiaHubConnection();
fileUrl = yield hub_1.getFullReadUrl('/', gaiaHubConfig);
}
const 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];
});
}
/**
* @param {Object} [options=null] - options object
* @param {String} options.username - the Aladin ID to lookup for multi-player storage
* @param {String} options.app - the app to lookup for multi-player storage -
* defaults to current origin
*
* @ignore
*/
function normalizeOptions(options, caller) {
const opts = Object.assign({}, options);
if (opts.username) {
if (!opts.app) {
const appConfig = (caller || new userSession_1.UserSession()).appConfig;
if (!appConfig) {
throw new errors_1.InvalidStateError('Missing AppConfig');
}
opts.app = appConfig.appDomain;
}
}
return opts;
}
/**
* @deprecated
* #### v19 Use [[UserSession.getFileUrl]] instead.
*
* @param {String} path - the path to the file to read
* @returns {Promise<string>} that resolves to the URL or rejects with an error
*/
function getFileUrl(path, options, caller) {
return __awaiter(this, void 0, void 0, function* () {
const opts = normalizeOptions(options, caller);
let readUrl;
if (opts.username) {
readUrl = yield getUserAppFileUrl(path, opts.username, opts.app, opts.zoneFileLookupURL);
}
else {
const gaiaHubConfig = yield (caller || new userSession_1.UserSession()).getOrSetLocalGaiaHubConnection();
readUrl = yield hub_1.getFullReadUrl(path, gaiaHubConfig);
}
if (!readUrl) {
throw new Error('Missing readURL');
}
else {
return readUrl;
}
});
}
exports.getFileUrl = getFileUrl;
/* Handle fetching the contents from a given path. Handles both
* multi-player reads and reads from own storage.
* @private
* @ignore
*/
function getFileContents(path, app, username, zoneFileLookupURL, forceText, caller) {
return Promise.resolve()
.then(() => {
const opts = { app, username, zoneFileLookupURL };
return getFileUrl(path, opts, caller);
})
.then(readUrl => fetchUtil_1.fetchPrivate(readUrl))
.then((response) => {
if (response.status !== 200) {
if (response.status === 404) {
logger_1.Logger.debug(`getFile ${path} returned 404, returning null`);
return null;
}
else {
throw new Error(`getFile ${path} failed with HTTP status ${response.status}`);
}
}
const 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
* @ignore
*/
function getFileSignedUnencrypted(path, opt, caller) {
// 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, caller),
getFileContents(`${path}${SIGNATURE_FILE_SUFFIX}`, opt.app, opt.username, opt.zoneFileLookupURL, true, caller),
getGaiaAddress(opt.app, opt.username, opt.zoneFileLookupURL, caller)])
.then(([fileContents, signatureContents, gaiaAddress]) => {
if (!fileContents) {
return fileContents;
}
if (!gaiaAddress) {
throw new errors_1.SignatureVerificationError('Failed to get gaia address for verification of: '
+ `${path}`);
}
if (!signatureContents || typeof signatureContents !== 'string') {
throw new errors_1.SignatureVerificationError('Failed to obtain signature for file: '
+ `${path} -- looked in ${path}${SIGNATURE_FILE_SUFFIX}`);
}
let signature;
let publicKey;
try {
const 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;
}
}
const signerAddress = keys_1.publicKeyToAddress(publicKey);
if (gaiaAddress !== signerAddress) {
throw new errors_1.SignatureVerificationError(`Signer pubkey address (${signerAddress}) doesn't`
+ ` match gaia address (${gaiaAddress})`);
}
else if (!ec_1.verifyECDSA(fileContents, publicKey, signature)) {
throw new errors_1.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
* @ignore
*/
function handleSignedEncryptedContents(caller, path, storedContents, app, username, zoneFileLookupURL) {
const appPrivateKey = caller.loadUserData().appPrivateKey;
const appPublicKey = keys_1.getPublicKeyFromPrivate(appPrivateKey);
let addressPromise;
if (username) {
addressPromise = getGaiaAddress(app, username, zoneFileLookupURL, caller);
}
else {
const address = keys_1.publicKeyToAddress(appPublicKey);
addressPromise = Promise.resolve(address);
}
return addressPromise.then((address) => {
if (!address) {
throw new errors_1.SignatureVerificationError('Failed to get gaia address for verification of: '
+ `${path}`);
}
let sigObject;
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;
}
}
const signature = sigObject.signature;
const signerPublicKey = sigObject.publicKey;
const cipherText = sigObject.cipherText;
const signerAddress = keys_1.publicKeyToAddress(signerPublicKey);
if (!signerPublicKey || !cipherText || !signature) {
throw new errors_1.SignatureVerificationError('Failed to get signature verification data from file:'
+ ` ${path}`);
}
else if (signerAddress !== address) {
throw new errors_1.SignatureVerificationError(`Signer pubkey address (${signerAddress}) doesn't`
+ ` match gaia address (${address})`);
}
else if (!ec_1.verifyECDSA(cipherText, signerPublicKey, signature)) {
throw new errors_1.SignatureVerificationError('Contents do not match ECDSA signature in file:'
+ ` ${path}`);
}
else {
return caller.decryptContent(cipherText);
}
});
}
/**
* Retrieves the specified file from the app's data store.
* @param {String} path - the path to the file to read
* @returns {Promise} that resolves to the raw data in the file
* or rejects with an error
*/
function getFile(path, options, caller) {
const defaults = {
decrypt: true,
verify: false,
username: null,
app: utils_1.getGlobalObject('location', { returnEmptyObject: true }).origin,
zoneFileLookupURL: null
};
const opt = Object.assign({}, defaults, options);
if (!caller) {
caller = new userSession_1.UserSession();
}
// in the case of signature verification, but no
// encryption expected, need to fetch _two_ files.
if (opt.verify && !opt.decrypt) {
return getFileSignedUnencrypted(path, opt, caller);
}
return getFileContents(path, opt.app, opt.username, opt.zoneFileLookupURL, !!opt.decrypt, caller)
.then((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 caller.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(caller, path, storedContents, opt.app, opt.username, opt.zoneFileLookupURL);
}
else if (!opt.verify && !opt.decrypt) {
return storedContents;
}
else {
throw new Error('Should be unreachable.');
}
});
}
exports.getFile = getFile;
/**
* 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
* @return {Promise} that resolves if the operation succeed and rejects
* if it failed
*/
function putFile(path, content, options, caller) {
return __awaiter(this, void 0, void 0, function* () {
const defaults = {
encrypt: true,
sign: false,
contentType: ''
};
const opt = Object.assign({}, defaults, options);
let { contentType } = opt;
if (!contentType) {
contentType = (typeof (content) === 'string') ? 'text/plain; charset=utf-8' : 'application/octet-stream';
}
if (!caller) {
caller = new userSession_1.UserSession();
}
// First, let's figure out if we need to get public/private keys,
// or if they were passed in
let privateKey = '';
let publicKey = '';
if (opt.sign) {
if (typeof (opt.sign) === 'string') {
privateKey = opt.sign;
}
else {
privateKey = caller.loadUserData().appPrivateKey;
}
}
if (opt.encrypt) {
if (typeof (opt.encrypt) === 'string') {
publicKey = opt.encrypt;
}
else {
if (!privateKey) {
privateKey = caller.loadUserData().appPrivateKey;
}
publicKey = keys_1.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) {
const signatureObject = ec_1.signECDSA(privateKey, content);
const signatureContent = JSON.stringify(signatureObject);
const gaiaHubConfig = yield caller.getOrSetLocalGaiaHubConnection();
try {
const fileUrls = yield Promise.all([
hub_1.uploadToGaiaHub(path, content, gaiaHubConfig, contentType),
hub_1.uploadToGaiaHub(`${path}${SIGNATURE_FILE_SUFFIX}`, signatureContent, gaiaHubConfig, 'application/json')
]);
return fileUrls[0];
}
catch (error) {
const freshHubConfig = yield caller.setLocalGaiaHubConnection();
const fileUrls = yield Promise.all([
hub_1.uploadToGaiaHub(path, content, freshHubConfig, contentType),
hub_1.uploadToGaiaHub(`${path}${SIGNATURE_FILE_SUFFIX}`, signatureContent, freshHubConfig, 'application/json')
]);
return fileUrls[0];
}
}
// In all other cases, we only need one upload.
if (opt.encrypt && !opt.sign) {
content = encryptContent(content, { publicKey });
contentType = 'application/json';
}
else if (opt.encrypt && opt.sign) {
const cipherText = encryptContent(content, { publicKey });
const signatureObject = ec_1.signECDSA(privateKey, cipherText);
const signedCipherObject = {
signature: signatureObject.signature,
publicKey: signatureObject.publicKey,
cipherText
};
content = JSON.stringify(signedCipherObject);
contentType = 'application/json';
}
const gaiaHubConfig = yield caller.getOrSetLocalGaiaHubConnection();
try {
return yield hub_1.uploadToGaiaHub(path, content, gaiaHubConfig, contentType);
}
catch (error) {
const freshHubConfig = yield caller.setLocalGaiaHubConnection();
const file = yield hub_1.uploadToGaiaHub(path, content, freshHubConfig, contentType);
return file;
}
});
}
exports.putFile = putFile;
/**
* Deletes the specified file from the app's data store.
* @param path - The path to the file to delete.
* @param options - Optional options object.
* @param options.wasSigned - Set to true if the file was originally signed
* in order for the corresponding signature file to also be deleted.
* @returns Resolves when the file has been removed or rejects with an error.
*/
function deleteFile(path, options, caller) {
return __awaiter(this, void 0, void 0, function* () {
if (!caller) {
caller = new userSession_1.UserSession();
}
const gaiaHubConfig = yield caller.getOrSetLocalGaiaHubConnection();
const opts = Object.assign({}, options);
if (opts.wasSigned) {
// If signed, delete both the content file and the .sig file
try {
yield hub_1.deleteFromGaiaHub(path, gaiaHubConfig);
yield hub_1.deleteFromGaiaHub(`${path}${SIGNATURE_FILE_SUFFIX}`, gaiaHubConfig);
}
catch (error) {
const freshHubConfig = yield caller.setLocalGaiaHubConnection();
yield hub_1.deleteFromGaiaHub(path, freshHubConfig);
yield hub_1.deleteFromGaiaHub(`${path}${SIGNATURE_FILE_SUFFIX}`, gaiaHubConfig);
}
}
else {
try {
yield hub_1.deleteFromGaiaHub(path, gaiaHubConfig);
}
catch (error) {
const freshHubConfig = yield caller.setLocalGaiaHubConnection();
yield hub_1.deleteFromGaiaHub(path, freshHubConfig);
}
}
});
}
exports.deleteFile = deleteFile;
/**
* 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 hub_1.getBucketUrl(gaiaHubUrl, appPrivateKey);
}
exports.getAppBucketUrl = getAppBucketUrl;
/**
* 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
* @ignore
*/
function listFilesLoop(caller, hubConfig, page, callCount, fileCount, callback) {
return __awaiter(this, void 0, void 0, function* () {
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');
}
hubConfig = hubConfig || (yield caller.getOrSetLocalGaiaHubConnection());
let response;
try {
const pageRequest = JSON.stringify({ page });
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': `${pageRequest.length}`,
Authorization: `bearer ${hubConfig.token}`
},
body: pageRequest
};
response = yield fetchUtil_1.fetchPrivate(`${hubConfig.server}/list-files/${hubConfig.address}`, fetchOptions);
if (!response.ok) {
throw new Error(`listFiles failed with HTTP status ${response.status}`);
}
}
catch (error) {
// If error occurs on the first call, perform a gaia re-connection and retry.
// Same logic as other gaia requests (putFile, getFile, etc).
if (callCount === 0) {
const freshHubConfig = yield caller.setLocalGaiaHubConnection();
return listFilesLoop(caller, freshHubConfig, page, callCount + 1, 0, callback);
}
throw error;
}
const responseText = yield response.text();
const responseJSON = JSON.parse(responseText);
const entries = responseJSON.entries;
const 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 (let i = 0; i < entries.length; i++) {
const rc = callback(entries[i]);
if (!rc) {
// callback indicates that we're done
return fileCount + i;
}
}
if (nextPage && entries.length > 0) {
// keep going -- have more entries
return listFilesLoop(caller, hubConfig, nextPage, callCount + 1, fileCount + entries.length, callback);
}
else {
// no more entries -- end of data
return 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, caller) {
caller = caller || new userSession_1.UserSession();
return listFilesLoop(caller, null, null, 0, 0, callback);
}
exports.listFiles = listFiles;
//# sourceMappingURL=index.js.map
;