import * as blockstack from 'blockstack'; import * as URL from 'url'; import * as crypto from 'crypto'; import * as jsontokens from 'jsontokens'; const ZoneFile = require('zone-file'); import { canonicalPrivateKey, getPrivateKeyAddress, checkUrl, SafetyError, getPublicKeyFromPrivateKey } from './utils'; import { CLINetworkAdapter, NameInfoType } from './network'; import { UserData } from 'blockstack/lib/auth/authApp'; import { GaiaHubConfig } from 'blockstack/lib/storage/hub'; /* * Set up a session for Gaia. * Generate an authentication response like what the browser would do, * and store the relevant data to our emulated localStorage. */ function makeFakeAuthResponseToken(appPrivateKey: string | null, hubURL: string | null, associationToken?: string) { const ownerPrivateKey = '24004db06ef6d26cdd2b0fa30b332a1b10fa0ba2b07e63505ffc2a9ed7df22b4'; const transitPrivateKey = 'f33fb466154023aba2003c17158985aa6603db68db0f1afc0fcf1d641ea6c2cb'; const transitPublicKey = '0496345da77fb5e06757b9c4fd656bf830a3b293f245a6cc2f11f8334ebb690f1' + '9582124f4b07172eb61187afba4514828f866a8a223e0d5c539b2e38a59ab8bb3'; // eslint-disable-next-line window.localStorage.setItem('blockstack-transit-private-key', transitPrivateKey) const authResponse = blockstack.makeAuthResponse( ownerPrivateKey, {type: '@Person', accounts: []}, null, {}, null, appPrivateKey, undefined, transitPublicKey, hubURL, blockstack.config.network.blockstackAPIUrl, associationToken ); return authResponse; } /* * Make an association token for the given address. * TODO belongs in a "gaia.js" library */ export function makeAssociationToken(appPrivateKey: string, identityKey: string) : string { const appPublicKey = getPublicKeyFromPrivateKey(`${canonicalPrivateKey(appPrivateKey)}01`); const FOUR_MONTH_SECONDS = 60 * 60 * 24 * 31 * 4; const salt = crypto.randomBytes(16).toString('hex'); const identityPublicKey = getPublicKeyFromPrivateKey(identityKey); const associationTokenClaim = { childToAssociate: appPublicKey, iss: identityPublicKey, exp: FOUR_MONTH_SECONDS + ((new Date().getTime())/1000), salt }; const associationToken = new jsontokens.TokenSigner('ES256K', identityKey) .sign(associationTokenClaim); return associationToken; } /* * Authenticate to Gaia. Used for reading, writing, and listing files. * Process a (fake) session token and set up a Gaia hub connection. * Returns a Promise that resolves to the (fake) userData */ export function gaiaAuth(network: CLINetworkAdapter, appPrivateKey: string | null, hubUrl: string | null, ownerPrivateKey?: string) : Promise { // Gaia speaks mainnet only! if (!network.isMainnet()) { throw new Error('Gaia only works with mainnet networks.'); } let associationToken; if (ownerPrivateKey && appPrivateKey) { associationToken = makeAssociationToken(appPrivateKey, ownerPrivateKey); } const authSessionToken = makeFakeAuthResponseToken(appPrivateKey, hubUrl, associationToken); const nameLookupUrl = `${network.blockstackAPIUrl}/v1/names/`; const transitPrivateKey = 'f33fb466154023aba2003c17158985aa6603db68db0f1afc0fcf1d641ea6c2cb'; // same as above return blockstack.handlePendingSignIn(nameLookupUrl, authSessionToken, transitPrivateKey); } /* * Connect to Gaia hub and generate a hub config. * Used for reading and writing profiles. * Make sure we use a mainnet address always, even in test mode. * Returns a Promise that resolves to a GaiaHubConfig */ export function gaiaConnect(network: CLINetworkAdapter, gaiaHubUrl: string, privateKey: string, ownerPrivateKey?: string ) { const addressMainnet = network.coerceMainnetAddress( getPrivateKeyAddress(network, `${canonicalPrivateKey(privateKey)}01`)); const addressMainnetCanonical = network.coerceMainnetAddress( getPrivateKeyAddress(network, canonicalPrivateKey(privateKey))); let associationToken; if (ownerPrivateKey) { associationToken = makeAssociationToken(privateKey, ownerPrivateKey); } return blockstack.connectToGaiaHub(gaiaHubUrl, canonicalPrivateKey(privateKey), associationToken) .then((hubConfig) => { // ensure that hubConfig always has a mainnet address, even if we're in testnet if (network.coerceMainnetAddress(hubConfig.address) === addressMainnet) { hubConfig.address = addressMainnet; } else if (network.coerceMainnetAddress(hubConfig.address) === addressMainnetCanonical) { hubConfig.address = addressMainnetCanonical; } else { throw new Error('Invalid private key: ' + `${network.coerceMainnetAddress(hubConfig.address)} is neither ` + `${addressMainnet} or ${addressMainnetCanonical}`); } return hubConfig; }); } /* * Find the profile.json path for a name * @network (object) the network to use * @blockstackID (string) the blockstack ID to query * * Returns a Promise that resolves to the filename to use for the profile * Throws an exception if the profile URL could not be determined */ function gaiaFindProfileName(network: CLINetworkAdapter, hubConfig: GaiaHubConfig, blockstackID?: string ): Promise { if (!blockstackID || blockstackID === null || blockstackID === undefined) { return Promise.resolve().then(() => 'profile.json'); } else { return network.getNameInfo(blockstackID) .then((nameInfo : NameInfoType) => { let profileUrl; try { const zonefileJSON = ZoneFile.parseZoneFile(nameInfo.zonefile); if (zonefileJSON.uri && zonefileJSON.hasOwnProperty('$origin')) { profileUrl = blockstack.getTokenFileUrl(zonefileJSON); } } catch(e) { throw new Error(`Could not determine profile URL for ${String(blockstackID)}: could not parse zone file`); } if (profileUrl === null || profileUrl === undefined) { throw new Error(`Could not determine profile URL for ${String(blockstackID)}: no URL in zone file`); } // profile URL path must match Gaia hub's URL prefix and address // (the host can be different) const gaiaReadPrefix = `${hubConfig.url_prefix}${hubConfig.address}`; const gaiaReadUrlPath = String(URL.parse(gaiaReadPrefix).path); const profileUrlPath = String(URL.parse(profileUrl).path); if (!profileUrlPath.startsWith(gaiaReadUrlPath)) { throw new Error(`Could not determine profile URL for ${String(blockstackID)}: wrong Gaia hub` + ` (${gaiaReadPrefix} does not correspond to ${profileUrl})`); } const profilePath = profileUrlPath.substring(gaiaReadUrlPath.length + 1); return profilePath; }); } } /* * Upload profile data to a Gaia hub. * * Legacy compat: * If a blockstack ID is given, then the zone file will be queried and the profile URL * inspected to make sure that we handle the special (legacy) case where a profile.json * file got stored to $GAIA_URL/$ADDRESS/$INDEX/profile.json (where $INDEX is a number). * In such cases, the profile will be stored to $INDEX/profile.json, instead of just * profile.json. * * @network (object) the network to use * @gaiaHubUrl (string) the base scheme://host:port URL to the Gaia hub * @gaiaData (string) the data to upload * @privateKey (string) the private key to use to sign the challenge * @blockstackID (string) optional; the blockstack ID for which this profile will be stored. */ export function gaiaUploadProfile(network: CLINetworkAdapter, gaiaHubURL: string, gaiaData: string, privateKey: string, blockstackID?: string ) { let hubConfig : GaiaHubConfig; return gaiaConnect(network, gaiaHubURL, privateKey) .then((hubconf : GaiaHubConfig) => { // make sure we use the *right* gaia path. // if the blockstackID is given, then we should inspect the zone file to // determine if the Gaia profile URL contains an index. If it does, then // we need to preserve it! hubConfig = hubconf; return gaiaFindProfileName(network, hubConfig, blockstackID); }) .then((profilePath : string) => { return blockstack.uploadToGaiaHub(profilePath, gaiaData, hubConfig); }); } /* * Upload profile data to all Gaia hubs, given a zone file. * @network (object) the network to use * @gaiaUrls (array) list of Gaia URLs * @gaiaData (string) the data to store * @privateKey (string) the hex-encoded private key * @return a promise with {'dataUrls': [urls to the data]}, or {'error': ...} */ export function gaiaUploadProfileAll(network: CLINetworkAdapter, gaiaUrls: string[], gaiaData: string, privateKey: string, blockstackID?: string ) : Promise<{dataUrls?: string[], error?: string}> { const sanitizedGaiaUrls = gaiaUrls.map((gaiaUrl) => { const urlInfo = URL.parse(gaiaUrl); if (!urlInfo.protocol) { return ''; } if (!urlInfo.host) { return ''; } // keep flow happy return `${String(urlInfo.protocol)}//${String(urlInfo.host)}`; }) .filter((gaiaUrl) => gaiaUrl.length > 0); const uploadPromises = sanitizedGaiaUrls.map((gaiaUrl) => gaiaUploadProfile(network, gaiaUrl, gaiaData, privateKey, blockstackID)); return Promise.all(uploadPromises) .then((publicUrls) => { return { error: null, dataUrls: publicUrls }; }) .catch((e) => { return { error: `Failed to upload: ${e.message}`, dataUrls: null }; }); } /* * Make a zone file from a Gaia hub---reach out to the Gaia hub, get its read URL prefix, * and generate a zone file with the profile mapped to the Gaia hub. * * @network (object) the network connection * @name (string) the name that owns the zone file * @gaiaHubUrl (string) the URL to the gaia hub write endpoint * @ownerKey (string) the owner private key * * Returns a promise that resolves to the zone file with the profile URL */ export function makeZoneFileFromGaiaUrl(network: CLINetworkAdapter, name: string, gaiaHubUrl: string, ownerKey: string) { const address = getPrivateKeyAddress(network, ownerKey); const mainnetAddress = network.coerceMainnetAddress(address); return gaiaConnect(network, gaiaHubUrl, ownerKey) .then((hubConfig) => { if (!hubConfig.url_prefix) { throw new Error('Invalid hub config: no read_url_prefix defined'); } const gaiaReadUrl = hubConfig.url_prefix.replace(/\/+$/, ''); const profileUrl = `${gaiaReadUrl}/${mainnetAddress}/profile.json`; try { checkUrl(profileUrl); } catch(e) { throw new SafetyError({ 'status': false, 'error': e.message, 'hints': [ 'Make sure the Gaia hub read URL scheme is present and well-formed.', `Check the "read_url_prefix" field of ${gaiaHubUrl}/hub_info` ] }); } return blockstack.makeProfileZoneFile(name, profileUrl); }); } /* * Given a Gaia bucket URL, extract its address */ export function getGaiaAddressFromURL(appUrl: string): string { const matches = appUrl.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]; } /* * Given a profile and an app origin, find its app address * Returns the address on success * Throws on error or not found */ export function getGaiaAddressFromProfile(network: CLINetworkAdapter, profile: any, appOrigin: string): string { if (!profile) { throw new Error('No profile'); } if (!profile.apps) { throw new Error('No profile apps'); } if (!profile.apps[appOrigin]) { throw new Error(`No app entry for ${appOrigin}`); } // do we already have an address set for this app? const appUrl = profile.apps[appOrigin]; let existingAppAddress; // what's the address? try { existingAppAddress = network.coerceMainnetAddress(getGaiaAddressFromURL(appUrl)); } catch (e) { throw new Error(`Failed to parse app URL ${appUrl}`); } return existingAppAddress; }