UNPKG

@stacks/cli

Version:
323 lines (293 loc) • 10.8 kB
import * as blockstack from 'blockstack'; import * as URL from 'url'; import * as crypto from 'crypto'; import * as jsontokens from 'jsontokens'; // eslint-disable-next-line @typescript-eslint/no-var-requires const ZoneFile = require('zone-file'); import { canonicalPrivateKey, getPublicKeyFromPrivateKey } from './utils'; import { getPrivateKeyAddress } from './common'; import { CLINetworkAdapter, NameInfoType } from './network'; import { UserData } from '@stacks/auth'; import { GaiaHubConfig, connectToGaiaHub } from '@stacks/storage'; /* * 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'; window.localStorage.setItem('blockstack-transit-private-key', transitPrivateKey); const authResponse = blockstack.makeAuthResponse( ownerPrivateKey, { type: '@Person', accounts: [] }, // @ts-ignore 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<UserData> { // 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.legacyNetwork.blockstackAPIUrl}/v1/names/`; const transitPrivateKey = 'f33fb466154023aba2003c17158985aa6603db68db0f1afc0fcf1d641ea6c2cb'; // same as above //@ts-ignore 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 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<string> { 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[] | null; error?: string | null }> { 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 }; }); } /* * 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; }