diff --git a/.gitignore b/.gitignore index 7b499e8f7..dc90e1a61 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ native/windows/BlockstackBrowser/Resources/node.exe native/windows/BlockstackBrowser/Resources/cors-proxy/corsproxy.js native/windows/BlockstackSetup/obj/ native/windows/BlockstackSetup/bin/ +native/windows/BlockstackSetup/*.msi native/macos/Blockstack/Blockstack/server/corsproxy.js native/macos/Blockstack/Blockstack/server/blockstackProxy.js native/macos/Blockstack/Blockstack/server/node diff --git a/app/js/account/store/account/actions.js b/app/js/account/store/account/actions.js index 30266fe24..0da796d45 100644 --- a/app/js/account/store/account/actions.js +++ b/app/js/account/store/account/actions.js @@ -11,7 +11,7 @@ import { } from '@utils' import { isCoreEndpointDisabled } from '@utils/window-utils' import { transactions, config, network } from 'blockstack' - +import { fetchIdentitySettings } from '../../../account/utils' import roundTo from 'round-to' import * as types from './types' import log4js from 'log4js' @@ -41,7 +41,8 @@ function createAccount( bitcoinPublicKeychain, firstBitcoinAddress, identityAddresses, - identityKeypairs + identityKeypairs, + identitySettings } = getBlockchainIdentities(masterKeychain, identitiesToGenerate) return { @@ -51,7 +52,8 @@ function createAccount( bitcoinPublicKeychain, firstBitcoinAddress, identityAddresses, - identityKeypairs + identityKeypairs, + identitySettings } } @@ -547,6 +549,74 @@ function usedIdentityAddress() { } } +function refreshAllIdentitySettings( + api: { gaiaHubConfig: GaiaHubConfig }, + ownerAddresses: Array, + identityKeyPairs: Array +) { + return dispatch => { + const promises: Array> = ownerAddresses.map((address, index) => { + const promise: Promise<*> = new Promise((resolve, reject) => { + const keyPair = identityKeyPairs[index] + return fetchIdentitySettings(api, address, keyPair) + .then((settings) => { + resolve(settings) + }) + .catch(error => reject(error)) + }) + return promise + }) + + return Promise.all(promises) + .then(settings => { + return dispatch(updateAllIdentitySettings(settings)) + }) + .catch((error) => { + logger.error( + 'refreshIdentitySettings: error refreshing identity settings', + error + ) + return Promise.reject(error) + }) + } +} + +function refreshIdentitySettings( + api: { gaiaHubConfig: GaiaHubConfig }, + identityIndex: int, + ownerAddress: string, + identityKeyPair: { key: string } +) { + return dispatch => fetchIdentitySettings(api, ownerAddress, identityKeyPair) + .then((settings) => { + return dispatch(updateIdentitySettings(identityIndex, settings)) + }) +} + +function updateAllIdentitySettings(settings) { + return { + type: types.UPDATE_ALL_IDENTITY_SETTINGS, + settings + } +} + +function updateIdentitySettings(identityIndex, settings) { + return { + type: types.UPDATE_IDENTITY_SETTINGS, + identityIndex, + settings + } +} + +function setIdentityCollectionSetting(identityIndex, collectionName, collectionSettings) { + return { + type: types.SET_IDENTITY_COLLECTION_SETTINGS, + identityIndex, + collectionName, + collectionSettings + } +} + const AccountActions = { createAccount, updateBackupPhrase, @@ -569,7 +639,12 @@ const AccountActions = { usedIdentityAddress, displayedRecoveryCode, newIdentityAddress, - updateEmail + updateEmail, + refreshAllIdentitySettings, + refreshIdentitySettings, + updateAllIdentitySettings, + updateIdentitySettings, + setIdentityCollectionSetting } export default AccountActions diff --git a/app/js/account/store/account/reducer.js b/app/js/account/store/account/reducer.js index 66c2c3910..8b130c739 100644 --- a/app/js/account/store/account/reducer.js +++ b/app/js/account/store/account/reducer.js @@ -9,7 +9,8 @@ const initialState = { encryptedBackupPhrase: null, // persist identityAccount: { addresses: [], - keypairs: [] + keypairs: [], + settings: [] }, bitcoinAccount: { addresses: [], @@ -43,6 +44,7 @@ function AccountReducer(state = initialState, action) { publicKeychain: action.identityPublicKeychain, addresses: action.identityAddresses, keypairs: action.identityKeypairs, + settings: action.identitySettings, addressIndex: 0 }, bitcoinAccount: { @@ -258,13 +260,42 @@ function AccountReducer(state = initialState, action) { ...state.identityAccount.addresses, action.keypair.address ], - keypairs: [...state.identityAccount.keypairs, action.keypair] + keypairs: [...state.identityAccount.keypairs, action.keypair], + settings: [...state.identityAccount.settings, {}] }) }) case types.CONNECTED_STORAGE: return Object.assign({}, state, { connectedStorageAtLeastOnce: true }) + case types.UPDATE_ALL_IDENTITY_SETTINGS: + return Object.assign({}, state, { + identityAccount: Object.assign({}, state.identityAccount, { + settings: action.settings + }) + }) + case types.UPDATE_IDENTITY_SETTINGS: + return Object.assign({}, state, { + identityAccount: Object.assign({}, state.identityAccount, { + settings: state.identityAccount.settings.map( + (settingsRow, i) => i === action.identityIndex ? action.settings : settingsRow + ) + }) + }) + case types.SET_IDENTITY_COLLECTION_SETTINGS: + const newIdentitySettings = Object.assign({}, state.identityAccount.settings) + + const identitySettingsAtIndex = newIdentitySettings[action.identityIndex] + if (!identitySettingsAtIndex.collections) { + identitySettingsAtIndex.collections = {} + } + identitySettingsAtIndex.collections[action.collectionName] = action.collectionSettings + + return Object.assign({}, state, { + identityAccount: Object.assign({}, state.identityAccount, { + settings: newIdentitySettings + }) + }) default: return state } diff --git a/app/js/account/store/account/types.js b/app/js/account/store/account/types.js index 2b4ecd189..5063d031b 100644 --- a/app/js/account/store/account/types.js +++ b/app/js/account/store/account/types.js @@ -23,3 +23,6 @@ export const RECOVERY_CODE_VERIFIED = 'account/RECOVERY_CODE_VERIFIED' export const INCREMENT_IDENTITY_ADDRESS_INDEX = 'account/INCREMENT_IDENTITY_ADDRESS_INDEX' export const CONNECTED_STORAGE = 'account/CONNECTED_STORAGE' export const UPDATE_EMAIL_ADDRESS = 'account/UPDATE_EMAIL_ADDRESS' +export const UPDATE_ALL_IDENTITY_SETTINGS = 'account/UPDATE_ALL_IDENTITY_SETTINGS' +export const UPDATE_IDENTITY_SETTINGS = 'account/UPDATE_IDENTITY_SETTINGS' +export const SET_IDENTITY_COLLECTION_SETTINGS = 'account/SET_IDENTITY_COLLECTION_SETTINGS' diff --git a/app/js/account/utils/index.js b/app/js/account/utils/index.js index 550432406..c59290bae 100644 --- a/app/js/account/utils/index.js +++ b/app/js/account/utils/index.js @@ -4,6 +4,7 @@ import { parseZoneFile } from 'zone-file' import type { GaiaHubConfig } from './blockstack-inc' import { connectToGaiaHub, uploadToGaiaHub } from './blockstack-inc' +import { encryptContent, decryptContent } from 'blockstack' import { getTokenFileUrlFromZoneFile } from '@utils/zone-utils' import log4js from 'log4js' @@ -11,6 +12,7 @@ const logger = log4js.getLogger(__filename) export const BLOCKSTACK_INC = 'gaia-hub' const DEFAULT_PROFILE_FILE_NAME = 'profile.json' +const DEFAULT_IDENTITY_SETTINGS_FILE_NAME = 'settings.json' function getProfileUploadLocation(identity: any, hubConfig: GaiaHubConfig) { if (identity.zoneFile) { @@ -23,6 +25,10 @@ function getProfileUploadLocation(identity: any, hubConfig: GaiaHubConfig) { } } +function getSettingsUploadLocation(hubConfig: GaiaHubConfig) { + return `${hubConfig.url_prefix}${hubConfig.address}/${DEFAULT_IDENTITY_SETTINGS_FILE_NAME}` +} + // aaron-debt: this should be moved into blockstack.js function canWriteUrl(url: string, hubConfig: GaiaHubConfig): ?string { const readPrefix = `${hubConfig.url_prefix}${hubConfig.address}/` @@ -116,3 +122,43 @@ export function uploadProfile( return uploadAttempt }) } + +export function uploadIdentitySettings( + api: { gaiaHubConfig: GaiaHubConfig, gaiaHubUrl: string}, + identityKeyPair: { key: string, keyID: string }, + settingsData: string +) { + const publicKey = identityKeyPair.keyID + const encryptedSettingsData = encryptContent(settingsData, { publicKey }) + return connectToGaiaHub(api.gaiaHubUrl, identityKeyPair.key).then(identityHubConfig => { + const urlToWrite = getSettingsUploadLocation(identityHubConfig) + return tryUpload( + urlToWrite, + encryptedSettingsData, + identityHubConfig, + 'application/json' + ) + }) +} + +export function fetchIdentitySettings( + api: { gaiaHubConfig: GaiaHubConfig }, + ownerAddress: string, + identityKeyPair: { key: string } +) { + const privateKey = identityKeyPair.key + const hubConfig = api.gaiaHubConfig + const url = `${hubConfig.url_prefix}${ownerAddress}/${DEFAULT_IDENTITY_SETTINGS_FILE_NAME}` + return fetch(url) + .then(response => { + if (response.ok) { + return response.text() + .then(encryptedSettingsData => decryptContent(encryptedSettingsData, { privateKey })) + .then(decryptedSettingsData => JSON.parse(decryptedSettingsData)) + } else if (response.status == 404) { + return {} + } else { + return Promise.reject('Could not fetch identity settings') + } + }) +} diff --git a/app/js/auth/index.js b/app/js/auth/index.js index 78b5f5c40..8bc945c19 100644 --- a/app/js/auth/index.js +++ b/app/js/auth/index.js @@ -7,6 +7,7 @@ import { connect } from 'react-redux' import { randomBytes } from 'crypto' import { AuthActions } from './store/auth' import { IdentityActions } from '../profiles/store/identity' +import { AccountActions } from '../account/store/account' import { decodeToken, TokenSigner } from 'jsontokens' import { parseZoneFile } from 'zone-file' import queryString from 'query-string' @@ -19,7 +20,10 @@ import { updateQueryStringParameter, getPublicKeyFromPrivate } from 'blockstack' -import { AppsNode } from '@utils/account-utils' +import { AppsNode, CollectionsNode } from '@utils/account-utils' +import { + processCollectionScopes +} from '@utils/collection-utils' import { fetchProfileLocations, getDefaultProfileUrl @@ -27,11 +31,12 @@ import { import { getTokenFileUrlFromZoneFile } from '@utils/zone-utils' import { HDNode } from 'bitcoinjs-lib' import log4js from 'log4js' -import { uploadProfile } from '../account/utils' +import { uploadProfile, uploadIdentitySettings } from '../account/utils' import { signProfileForUpload } from '@utils' import { validateScopes, - appRequestSupportsDirectHub + appRequestSupportsDirectHub, + getCollectionScopes } from './utils' import { selectApi, @@ -54,6 +59,7 @@ import { selectIdentityKeypairs, selectEmail, selectIdentityAddresses, + selectIdentitySettings, selectPublicKeychain } from '@common/store/selectors/account' import { formatAppManifest } from '@common' @@ -73,6 +79,7 @@ function mapStateToProps(state) { localIdentities: selectLocalIdentities(state), defaultIdentity: selectDefaultIdentity(state), identityKeypairs: selectIdentityKeypairs(state), + identitySettings: selectIdentitySettings(state), appManifest: selectAppManifest(state), appManifestLoading: selectAppManifestLoading(state), appManifestLoadingError: selectAppManifestLoadingError(state), @@ -88,7 +95,7 @@ function mapStateToProps(state) { } function mapDispatchToProps(dispatch) { - const actions = Object.assign({}, AuthActions, IdentityActions) + const actions = Object.assign({}, AuthActions, IdentityActions, AccountActions) return bindActionCreators(actions, dispatch) } @@ -122,6 +129,10 @@ class AuthPage extends React.Component { loginToApp: PropTypes.func.isRequired, api: PropTypes.object.isRequired, identityKeypairs: PropTypes.array.isRequired, + identitySettings: PropTypes.object.isRequired, + refreshIdentitySettings: PropTypes.func.isRequired, + refreshAllIdentitySettings: PropTypes.func.isRequired, + setIdentityCollectionSetting: PropTypes.func.isRequired, coreHost: PropTypes.string.isRequired, corePort: PropTypes.number.isRequired, appManifest: PropTypes.object, @@ -147,6 +158,7 @@ class AuthPage extends React.Component { storageConnected: this.props.api.storageConnected, processing: false, refreshingIdentities: true, + identitySettingsChanged: false, invalidScopes: false, sendEmail: false, blockchainId: null, @@ -179,7 +191,10 @@ class AuthPage extends React.Component { ...this.state.scopes, email: scopes.includes('email'), publishData: scopes.includes('publish_data') - } + }, + collectionScopes: [ + ...getCollectionScopes(scopes) + ] }) this.props.verifyAuthRequestAndLoadManifest(authRequest) @@ -214,188 +229,238 @@ class AuthPage extends React.Component { window.location = redirectURI } - componentWillReceiveProps(nextProps) { + componentWillReceiveProps() { if (!this.state.responseSent) { if (this.state.echoRequestId) { this.redirectUserToEchoReply() return } + } else { + logger.error( + 'componentWillReceiveProps: response already sent - doing nothing' + ) + } + } - const storageConnected = this.props.api.storageConnected - this.setState({ - storageConnected - }) - - const appDomain = this.state.decodedToken.payload.domain_name - const localIdentities = nextProps.localIdentities - const identityKeypairs = nextProps.identityKeypairs - if (!appDomain || !nextProps.coreSessionTokens[appDomain]) { - if (this.state.noCoreStorage) { - logger.debug( - 'componentWillReceiveProps: no core session token expected' - ) - } else { - logger.debug( - 'componentWillReceiveProps: no app domain or no core session token' - ) - return - } - } - - logger.info('componentWillReceiveProps') - - const coreSessionToken = nextProps.coreSessionTokens[appDomain] - let decodedCoreSessionToken = null - if (!this.state.noCoreStorage) { - logger.debug('componentWillReceiveProps: received coreSessionToken') - decodedCoreSessionToken = decodeToken(coreSessionToken) - } else { - logger.debug('componentWillReceiveProps: received no coreSessionToken') - } + sendAuthResponse = () => { + const storageConnected = this.props.api.storageConnected + const appDomain = this.state.decodedToken.payload.domain_name + const localIdentities = this.props.localIdentities + const identityKeypairs = this.props.identityKeypairs + if (!appDomain) { + logger.debug( + 'sendAuthResponse(): no app domain' + ) + } - const identityIndex = this.state.currentIdentityIndex + const identityIndex = this.state.currentIdentityIndex - const hasUsername = this.state.hasUsername - if (hasUsername) { - logger.debug(`login(): id index ${identityIndex} has no username`) - } + const hasUsername = this.state.hasUsername + if (hasUsername) { + logger.debug(`sendAuthResponse()(): id index ${identityIndex} has no username`) + } - // Get keypair corresponding to the current user identity - const profileSigningKeypair = identityKeypairs[identityIndex] - const identity = localIdentities[identityIndex] + // // Get keypair corresponding to the current user identity + const profileSigningKeypair = identityKeypairs[identityIndex] + const identity = localIdentities[identityIndex] - let blockchainId = null - if (decodedCoreSessionToken) { - blockchainId = decodedCoreSessionToken.payload.blockchain_id - } else { - blockchainId = this.state.blockchainId + const blockchainId = this.state.blockchainId + + const profile = identity.profile + const privateKey = profileSigningKeypair.key + const appsNodeKey = profileSigningKeypair.appsNodeKey + const collectionsNodeKey = profileSigningKeypair.collectionsNodeKey + const salt = profileSigningKeypair.salt + const appsNode = new AppsNode(HDNode.fromBase58(appsNodeKey), salt) + const appPrivateKey = appsNode.getAppNode(appDomain).getAppPrivateKey() + const collectionsNode = new CollectionsNode(HDNode.fromBase58(collectionsNodeKey), salt) + + let profileUrlPromise + + if (identity.zoneFile && identity.zoneFile.length > 0) { + const zoneFileJson = parseZoneFile(identity.zoneFile) + const profileUrlFromZonefile = getTokenFileUrlFromZoneFile(zoneFileJson) + if ( + profileUrlFromZonefile !== null && + profileUrlFromZonefile !== undefined + ) { + profileUrlPromise = Promise.resolve(profileUrlFromZonefile) } + } - const profile = identity.profile - const privateKey = profileSigningKeypair.key - const appsNodeKey = profileSigningKeypair.appsNodeKey - const salt = profileSigningKeypair.salt - const appsNode = new AppsNode(HDNode.fromBase58(appsNodeKey), salt) - const appPrivateKey = appsNode.getAppNode(appDomain).getAppPrivateKey() - - let profileUrlPromise - - if (identity.zoneFile && identity.zoneFile.length > 0) { - const zoneFileJson = parseZoneFile(identity.zoneFile) - const profileUrlFromZonefile = getTokenFileUrlFromZoneFile(zoneFileJson) - if ( - profileUrlFromZonefile !== null && - profileUrlFromZonefile !== undefined - ) { - profileUrlPromise = Promise.resolve(profileUrlFromZonefile) + const gaiaBucketAddress = this.props.identityKeypairs[0].address + const identityAddress = this.props.identityKeypairs[identityIndex].address + const gaiaUrlBase = this.props.api.gaiaHubConfig.url_prefix + + if (!profileUrlPromise) { + // use default Gaia hub if we can't tell from the profile where the profile Gaia hub is + profileUrlPromise = fetchProfileLocations( + gaiaUrlBase, + identityAddress, + gaiaBucketAddress, + identityIndex + ).then(fetchProfileResp => { + if (fetchProfileResp && fetchProfileResp.profileUrl) { + return fetchProfileResp.profileUrl + } else { + return getDefaultProfileUrl(gaiaUrlBase, identityAddress) } - } - - const gaiaBucketAddress = nextProps.identityKeypairs[0].address - const identityAddress = nextProps.identityKeypairs[identityIndex].address - const gaiaUrlBase = nextProps.api.gaiaHubConfig.url_prefix - - if (!profileUrlPromise) { - // use default Gaia hub if we can't tell from the profile where the profile Gaia hub is - profileUrlPromise = fetchProfileLocations( - gaiaUrlBase, - identityAddress, - gaiaBucketAddress, - identityIndex - ).then(fetchProfileResp => { - if (fetchProfileResp && fetchProfileResp.profileUrl) { - return fetchProfileResp.profileUrl - } else { - return getDefaultProfileUrl(gaiaUrlBase, identityAddress) - } - }) - } - - profileUrlPromise.then(profileUrl => { - // Add app storage bucket URL to profile if publish_data scope is requested - if (this.state.scopes.publishData) { - let apps = {} - if (profile.hasOwnProperty('apps')) { - apps = profile.apps - } + }) + } - if (storageConnected) { - const hubUrl = this.props.api.gaiaHubUrl - getAppBucketUrl(hubUrl, appPrivateKey) - .then(appBucketUrl => { - logger.debug( - `componentWillReceiveProps: appBucketUrl ${appBucketUrl}` - ) - apps[appDomain] = appBucketUrl - logger.debug( - `componentWillReceiveProps: new apps array ${JSON.stringify( - apps - )}` - ) - profile.apps = apps - const signedProfileTokenData = signProfileForUpload( - profile, - nextProps.identityKeypairs[identityIndex], - this.props.api - ) - logger.debug( - 'componentWillReceiveProps: uploading updated profile with new apps array' - ) - return uploadProfile( - this.props.api, - identity, - nextProps.identityKeypairs[identityIndex], - signedProfileTokenData - ) - }) - .then(() => { - this.completeAuthResponse( - privateKey, - blockchainId, - coreSessionToken, - appPrivateKey, - profile, - profileUrl - ) - }) - .catch(err => { - logger.error( - 'componentWillReceiveProps: add app index profile not uploaded', - err - ) - }) - } else { - logger.debug( - 'componentWillReceiveProps: storage is not connected. Doing nothing.' - ) - } + profileUrlPromise.then(profileUrl => { + // Add app storage bucket URL to profile if publish_data scope is requested + if (this.state.scopes.publishData) { + if (storageConnected) { + const identityKeyPair = this.props.identityKeypairs[identityIndex] + return this.handlePublishDataScope( + this.props.api, + profileUrl, + profile, + this.props.api.gaiaHubUrl, + identity, + identityKeyPair, + appDomain, + appPrivateKey + ) } else { - this.completeAuthResponse( - privateKey, - blockchainId, - coreSessionToken, - appPrivateKey, - profile, - profileUrl + logger.debug( + 'sendAuthResponse(): storage is not connected. Doing nothing.' ) + return false } + } else { + return profileUrl + } + }).then((profileUrl) => + // Refresh selected account's identity settings + this.props.refreshIdentitySettings( + this.props.api, + identityIndex, + this.props.addresses[identityIndex], + this.props.identityKeypairs[identityIndex] + ).then(() => + // Generate and write collection Gaia hub config and encryption keys + // to app storage bucket before sending auth response + processCollectionScopes( + appPrivateKey, + this.state.collectionScopes, + collectionsNode, + this.props.api.gaiaHubUrl, + this.props.identitySettings[identityIndex], + this.updateIdentityCollectionSettings + ) + ).then(() => { + if (this.state.identitySettingsChanged) { + // Upload identity settings if modified + this.uploadIdentitySettings() + } + return profileUrl }) - } else { - logger.error( - 'componentWillReceiveProps: response already sent - doing nothing' + ).then((profileUrl) => { + // Generate and send the auth response + const authResponse = this.generateAuthResponse( + privateKey, + blockchainId, + appPrivateKey, + profile, + profileUrl ) - } + + logger.info( + `sendAuthResponse(): id index ${this.state.currentIdentityIndex} is logging in` + ) + + this.setState({ responseSent: true }) + redirectUserToApp(this.state.authRequest, authResponse) + }) + } + + updateIdentityCollectionSettings = (collectionName, settings) => { + this.setState({ + identitySettingsChanged: true + }) + const identityIndex = this.state.currentIdentityIndex + return this.props.setIdentityCollectionSetting(identityIndex, collectionName, settings) + } + + uploadIdentitySettings = () => { + const identityIndex = this.state.currentIdentityIndex + const identitySigner = this.props.identityKeypairs[identityIndex] + const newIdentitySettings = this.props.identitySettings[identityIndex] + + // TODO: Make identity settings and profile upload more resistant to corruption/loss + return uploadIdentitySettings( + this.props.api, + identitySigner, + JSON.stringify(newIdentitySettings) + ) } getFreshIdentities = async () => { await this.props.refreshIdentities(this.props.api, this.props.addresses) + await this.props.refreshAllIdentitySettings( + this.props.api, + this.props.addresses, + this.props.identityKeypairs + ) this.setState({ refreshingIdentities: false }) } - completeAuthResponse = ( + handlePublishDataScope = ( + api, + profileUrl, + profile, + gaiaHubUrl, + identity, + identityKeyPair, + appDomain, + appPrivateKey + ) => { + let apps = {} + if (profile.hasOwnProperty('apps')) { + apps = profile.apps + } + + return getAppBucketUrl(gaiaHubUrl, appPrivateKey) + .then(appBucketUrl => { + logger.debug( + `componentWillReceiveProps: appBucketUrl ${appBucketUrl}` + ) + apps[appDomain] = appBucketUrl + logger.debug( + `componentWillReceiveProps: new apps array ${JSON.stringify( + apps + )}` + ) + profile.apps = apps + const signedProfileTokenData = signProfileForUpload( + profile, + identityKeyPair, + api + ) + logger.debug( + 'componentWillReceiveProps: uploading updated profile with new apps array' + ) + return uploadProfile( + api, + identity, + identityKeyPair, + signedProfileTokenData + ) + }) + .then(() => profileUrl) + .catch(err => { + logger.error( + 'componentWillReceiveProps: add app index profile not uploaded', + err + ) + }) + } + + generateAuthResponse = ( privateKey, blockchainId, - coreSessionToken, appPrivateKey, profile, profileUrl @@ -449,7 +514,7 @@ class AuthPage extends React.Component { profileResponseData, blockchainId, metadata, - coreSessionToken, + '', appPrivateKey, undefined, transitPublicKey, @@ -459,13 +524,7 @@ class AuthPage extends React.Component { ) this.props.clearSessionToken(appDomain) - - logger.info( - `login(): id index ${this.state.currentIdentityIndex} is logging in` - ) - - this.setState({ responseSent: true }) - redirectUserToApp(this.state.authRequest, authResponse) + return authResponse } closeModal() { @@ -558,6 +617,7 @@ class AuthPage extends React.Component { noCoreStorage: true }) this.props.noCoreSessionToken(appDomain) + this.sendAuthResponse() } else { logger.info('login(): No storage access requested.') this.setState({ diff --git a/app/js/auth/utils.js b/app/js/auth/utils.js index 14f99b30b..3095091c2 100644 --- a/app/js/auth/utils.js +++ b/app/js/auth/utils.js @@ -11,6 +11,8 @@ const VALID_SCOPES = { publish_data: true } +const COLLECTION_SCOPE_PREFIX = 'collection.' + export function appRequestSupportsDirectHub(requestPayload: Object): boolean { let version = '0' let supportsHubUrl = false @@ -39,7 +41,9 @@ export function validateScopes(scopes: Array): boolean { let valid = false for (let i = 0; i < scopes.length; i++) { const scope = scopes[i] - if (VALID_SCOPES[scope] === true) { + if (scope.startsWith(COLLECTION_SCOPE_PREFIX)) { + valid = true + } else if (VALID_SCOPES[scope] === true) { valid = true } else { return false @@ -47,3 +51,12 @@ export function validateScopes(scopes: Array): boolean { } return valid } + +export function getCollectionScopes(scopes: Array): Array { + const collectionScopes = scopes.filter(value => value.startsWith(COLLECTION_SCOPE_PREFIX)) + if (collectionScopes.length > 0) { + return collectionScopes.map(value => value.substr(COLLECTION_SCOPE_PREFIX.length)) + } else { + return [] + } +} diff --git a/app/js/common/store/selectors/account.js b/app/js/common/store/selectors/account.js index 9c2f80d35..24363578f 100644 --- a/app/js/common/store/selectors/account.js +++ b/app/js/common/store/selectors/account.js @@ -4,6 +4,8 @@ const selectEncryptedBackupPhrase = ({ account }) => const selectIdentityAddresses = ({ account }) => account.identityAccount.addresses const selectIdentityKeypairs = ({ account }) => account.identityAccount.keypairs +const selectIdentitySettings = ({ account }) => + account.identityAccount.settings const selectConnectedStorageAtLeastOnce = ({ account }) => account.connectedStorageAtLeastOnce const selectEmail = ({ account }) => account.email @@ -18,6 +20,7 @@ export { selectEncryptedBackupPhrase, selectIdentityAddresses, selectIdentityKeypairs, + selectIdentitySettings, selectConnectedStorageAtLeastOnce, selectEmail, selectPublicKeychain, diff --git a/app/js/profiles/DefaultProfilePage.js b/app/js/profiles/DefaultProfilePage.js index e7eb105bc..d29a4b697 100644 --- a/app/js/profiles/DefaultProfilePage.js +++ b/app/js/profiles/DefaultProfilePage.js @@ -66,7 +66,7 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch) { return bindActionCreators( - Object.assign({}, IdentityActions, AccountActions), + Object.assign({}, AccountActions, IdentityActions), dispatch ) } @@ -79,6 +79,7 @@ export class DefaultProfilePage extends Component { createNewProfile: PropTypes.func.isRequired, updateProfile: PropTypes.func.isRequired, refreshIdentities: PropTypes.func.isRequired, + refreshAllIdentitySettings: PropTypes.func.isRequired, refreshSocialProofVerifications: PropTypes.func.isRequired, api: PropTypes.object.isRequired, identityAddresses: PropTypes.array.isRequired, @@ -112,6 +113,11 @@ export class DefaultProfilePage extends Component { componentWillMount() { logger.info('componentWillMount') this.props.refreshIdentities(this.props.api, this.props.identityAddresses) + this.props.refreshAllIdentitySettings( + this.props.api, + this.props.identityAddresses, + this.props.identityKeypairs + ) } componentWillReceiveProps(nextProps) { diff --git a/app/js/store/reducers.js b/app/js/store/reducers.js index cf38a1572..4946f9717 100644 --- a/app/js/store/reducers.js +++ b/app/js/store/reducers.js @@ -39,7 +39,7 @@ export function initializeStateVersion() { * and other state is regenerated. * @type {number} */ -export const CURRENT_VERSION: number = 18 +export const CURRENT_VERSION: number = 19 const AppReducer = combineReducers({ account: AccountReducer, diff --git a/app/js/utils/account-utils.js b/app/js/utils/account-utils.js index fc238ff9a..e1274e1cf 100644 --- a/app/js/utils/account-utils.js +++ b/app/js/utils/account-utils.js @@ -19,6 +19,8 @@ function hashCode(string) { const APPS_NODE_INDEX = 0 const SIGNING_NODE_INDEX = 1 const ENCRYPTION_NODE_INDEX = 2 +const COLLECTIONS_NODE_INDEX = 3 +const COLLECTION_IDENTIFIER_DEFAULT = 'default' export const MAX_TRUST_LEVEL = 99 export class AppNode { @@ -65,6 +67,59 @@ export class AppsNode { } } +export class CollectionNode { + constructor(hdNode) { + this.hdNode = hdNode + } + + getCollectionPrivateKey() { + return this.hdNode.keyPair.d.toBuffer(32).toString('hex') + } + + getAddress() { + return this.hdNode.getAddress() + } +} + +export class CollectionsNode { + constructor(hdNode, salt) { + this.hdNode = hdNode + this.salt = salt + } + + getNode() { + return this.hdNode + } + + getCollectionNode(collectionTypeName, collectionIdentifier = COLLECTION_IDENTIFIER_DEFAULT) { + const hash = crypto + .createHash('sha256') + .update(`${collectionTypeName}${collectionIdentifier}${this.salt}`) + .digest('hex') + const collectionIndex = hashCode(hash) + const collectionNode = this.hdNode.deriveHardened(collectionIndex) + return new CollectionNode(collectionNode) + } + + getCollectionEncryptionNode(collectionTypeName, encryptionIndex, collectionIdentifier = COLLECTION_IDENTIFIER_DEFAULT) { + const hash = crypto + .createHash('sha256') + .update(`${collectionTypeName}${collectionIdentifier}${encryptionIndex}${this.salt}`) + .digest('hex') + const collectionEncIndex = hashCode(hash) + const collectionEncNode = this.hdNode.deriveHardened(collectionEncIndex) + return new CollectionNode(collectionEncNode) + } + + toBase58() { + return this.hdNode.toBase58() + } + + getSalt() { + return this.salt + } +} + class IdentityAddressOwnerNode { constructor(ownerHdNode, salt) { this.hdNode = ownerHdNode @@ -91,6 +146,10 @@ class IdentityAddressOwnerNode { return new AppsNode(this.hdNode.deriveHardened(APPS_NODE_INDEX), this.salt) } + getCollectionsNode() { + return new CollectionsNode(this.hdNode.deriveHardened(COLLECTIONS_NODE_INDEX), this.salt) + } + getAddress() { return this.hdNode.getAddress() } @@ -244,11 +303,13 @@ export function deriveIdentityKeyPair(identityOwnerAddressNode) { const identityKey = identityOwnerAddressNode.getIdentityKey() const identityKeyID = identityOwnerAddressNode.getIdentityKeyID() const appsNode = identityOwnerAddressNode.getAppsNode() + const collectionsNode = identityOwnerAddressNode.getCollectionsNode() const keyPair = { key: identityKey, keyID: identityKeyID, address, appsNodeKey: appsNode.toBase58(), + collectionsNodeKey: collectionsNode.toBase58(), salt: appsNode.getSalt() } return keyPair @@ -440,6 +501,7 @@ export function getBlockchainIdentities(masterKeychain, identitiesToGenerate) { const identityAddresses = [] const identityKeypairs = [] + const identitySettings = [] // We pre-generate a number of identity addresses so that we // don't have to prompt the user for the password on each new profile @@ -455,6 +517,7 @@ export function getBlockchainIdentities(masterKeychain, identitiesToGenerate) { const identityKeyPair = deriveIdentityKeyPair(identityOwnerAddressNode) identityKeypairs.push(identityKeyPair) identityAddresses.push(identityKeyPair.address) + identitySettings.push({}) logger.debug(`createAccount: identity index: ${addressIndex}`) } @@ -463,6 +526,7 @@ export function getBlockchainIdentities(masterKeychain, identitiesToGenerate) { bitcoinPublicKeychain, firstBitcoinAddress, identityAddresses, - identityKeypairs + identityKeypairs, + identitySettings } } diff --git a/app/js/utils/collection-utils.js b/app/js/utils/collection-utils.js new file mode 100644 index 000000000..d807a86b3 --- /dev/null +++ b/app/js/utils/collection-utils.js @@ -0,0 +1,145 @@ +import { + connectToGaiaHub, + uploadToGaiaHub, + encryptContent, + decryptContent, + getPublicKeyFromPrivate, + GAIA_HUB_COLLECTION_KEY_FILE_NAME +} from 'blockstack' + +const DEFAULT_NEW_COLLECTION_SETTING_ARRAY = [{ + identifier: 'default', + encryptionKeyIndex: 0 +}] + +const ARCHIVAL_GAIA_AUTH_SCOPE = 'putFileArchivalPrefix' +const COLLECTION_GAIA_PREFIX = 'collection' + +export function getCollectionEncryptionIndex(collectionName, settings, updateIdentityCollectionSettings) { + if(!settings.collection + || !settings.collection[collectionName] + || settings.collections[collectionName].length > 0) { + const defaultCollectionSettings = DEFAULT_NEW_COLLECTION_SETTING_ARRAY + updateIdentityCollectionSettings(collectionName, defaultCollectionSettings) + return Promise.resolve(defaultCollectionSettings[0].encryptionKeyIndex) + } else { + const collectionSetting = settings.collections[collectionName] + return Promise.resolve(collectionSetting[0].encryptionKeyIndex) + } +} + +export function fetchOrCreateCollectionKeys(scopes, node, settings, updateIdentityCollectionSettings) { + const collectionKeyPromises = scopes.map((scope) => + getCollectionEncryptionIndex(scope, settings, updateIdentityCollectionSettings) + .then((encryptionKeyIndex) => { + const collectionEncryptionPrivateKey = + node.getCollectionEncryptionNode(scope, encryptionKeyIndex).getCollectionPrivateKey() + return Promise.resolve(collectionEncryptionPrivateKey) + }) + ) + return Promise.all(collectionKeyPromises) +} + +export function getCollectionGaiaHubConfigs(scopes, node, gaiaHubUrl) { + const hubConfigPromises = scopes.map((scope) => { + const collectionPrivateKey = + node.getCollectionNode(scope).getCollectionPrivateKey() + const gaiaScopes = [{ + scope: ARCHIVAL_GAIA_AUTH_SCOPE, + domain: COLLECTION_GAIA_PREFIX + }] + return connectToGaiaHub(gaiaHubUrl, collectionPrivateKey, '', gaiaScopes) + }) + + return Promise.all(hubConfigPromises) +} + +function writeCollectionKeysToAppStorage(appPrivateKey, hubConfig, keyFile) { + const publicKey = getPublicKeyFromPrivate(appPrivateKey) + const encryptedKeyFile = encryptContent(JSON.stringify(keyFile), { publicKey }) + + return uploadToGaiaHub( + GAIA_HUB_COLLECTION_KEY_FILE_NAME, + encryptedKeyFile, + hubConfig, + 'application/json' + ) +} + +function getAppCollectionKeyFile(appPrivateKey, gaiaHubBucketUrl, appBucketAddress) { + const keyFileUrl = `${gaiaHubBucketUrl}${appBucketAddress}/${GAIA_HUB_COLLECTION_KEY_FILE_NAME}` + return fetch(keyFileUrl) + .then(response => { + if (response.ok) { + return response.text() + .then(encryptedKeyFile => decryptContent(encryptedKeyFile, { privateKey: appPrivateKey })) + .then(decryptedKeyFile => JSON.parse(decryptedKeyFile)) + } else if (response.status === 404) { + return {} + } else { + return Promise.reject('Could not get collection key file') + } + }) +} + +function updateAppCollectionKeys( + collectionScopes, + appPrivateKey, + gaiaHubUrl, + collectionKeys, + collectionHubConfigs +) { + return connectToGaiaHub(gaiaHubUrl, appPrivateKey).then(hubConfig => + getAppCollectionKeyFile(appPrivateKey, hubConfig.url_prefix, hubConfig.address) + .then((keyFile) => { + collectionScopes.map((scope, index) => { + keyFile[scope] = { + encryptionKey: collectionKeys[index], + hubConfig: collectionHubConfigs[index] + } + return true + }) + return keyFile + }) + .then(keyFile => writeCollectionKeysToAppStorage(appPrivateKey, hubConfig, keyFile)) + ) +} + +export function processCollectionScopes( + appPrivateKey, + collectionScopes, + collectionsNode, + gaiaHubUrl, + identitySettings, + updateIdentityCollectionSettings +) { + const encryptionKeyPromise = + fetchOrCreateCollectionKeys( + collectionScopes, + collectionsNode, + identitySettings, + updateIdentityCollectionSettings + ) + const hubConfigsPromise = + getCollectionGaiaHubConfigs(collectionScopes, collectionsNode, gaiaHubUrl) + return Promise.all([encryptionKeyPromise, hubConfigsPromise]) + .then(results => { + const collectionKeys = results[0] + const collectionHubConfigs = results[1] + return updateAppCollectionKeys( + collectionScopes, + appPrivateKey, + gaiaHubUrl, + collectionKeys, + collectionHubConfigs + ) + }) +} + + + + + + + + diff --git a/native/macos/Blockstack/Blockstack.xcodeproj/project.pbxproj b/native/macos/Blockstack/Blockstack.xcodeproj/project.pbxproj index 1f29575a0..47cd44ae9 100644 --- a/native/macos/Blockstack/Blockstack.xcodeproj/project.pbxproj +++ b/native/macos/Blockstack/Blockstack.xcodeproj/project.pbxproj @@ -353,6 +353,7 @@ developmentRegion = English; hasScannedForEncodings = 0; knownRegions = ( + English, en, Base, ); diff --git a/native/macos/Blockstack/Blockstack/Info.plist b/native/macos/Blockstack/Blockstack/Info.plist index 999ce4661..fa03262ad 100644 --- a/native/macos/Blockstack/Blockstack/Info.plist +++ b/native/macos/Blockstack/Blockstack/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.36.3 + 0.37.0 CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 119 + 120 LSApplicationCategoryType public.app-category.utilities LSMinimumSystemVersion diff --git a/native/macos/Blockstack/BlockstackLauncher/Info.plist b/native/macos/Blockstack/BlockstackLauncher/Info.plist index 6b93a140c..dbc2c7900 100644 --- a/native/macos/Blockstack/BlockstackLauncher/Info.plist +++ b/native/macos/Blockstack/BlockstackLauncher/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.36.3 + 0.37.0 CFBundleVersion - 119 + 120 LSApplicationCategoryType public.app-category.utilities LSBackgroundOnly diff --git a/package-lock.json b/package-lock.json index dc9856a86..3a215674d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "blockstack-browser", - "version": "0.36.3", + "version": "0.37.0-alpha.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2620,9 +2620,9 @@ } }, "blockstack": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/blockstack/-/blockstack-19.3.0.tgz", - "integrity": "sha512-P/HRS5n+buTeIssxs1v479EpDZOFGpfiivRrv9UjbHj/FdSJLxC1onVD8Hiyfm0mB8y7Ah9qT2lGqKX9P6r7+g==", + "version": "20.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/blockstack/-/blockstack-20.0.0-alpha.2.tgz", + "integrity": "sha512-5M8WR2NeVc1UEe0B5Qy/Jgfdg6KByNI04d+EAdE0ez9bgrwtggwBeLAm+ywnkt+7VDGNiltIrwswwlJ241KmSw==", "requires": { "@types/bn.js": "^4.11.5", "@types/elliptic": "^6.4.9", @@ -2631,7 +2631,7 @@ "bitcoinjs-lib": "^5.1.2", "bn.js": "^4.11.8", "cheerio": "^0.22.0", - "cross-fetch": "^2.2.2", + "cross-fetch": "^3.0.4", "elliptic": "^6.4.1", "form-data": "^2.3.3", "jsontokens": "^2.0.2", @@ -2680,9 +2680,9 @@ } }, "bitcoinjs-lib": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-5.1.5.tgz", - "integrity": "sha512-VAcdXH7cNocWwEF2dAVTozOZtDHB+FVZH9tc9OLbUKzRLiYxb3C5lvAzBmd1QJR3CtPRJELgw049MvM5fs2dow==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-5.1.6.tgz", + "integrity": "sha512-NgvnA8XXUuzpuBnVs1plzZvVOYsuont4KPzaGcVIwjktYQbCk1hUkXnt4wujIOBscNsXuu+plVbPYvtMosZI/w==", "requires": { "@types/node": "10.12.18", "bech32": "^1.1.2", @@ -2735,9 +2735,9 @@ } }, "query-string": { - "version": "6.8.2", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.8.2.tgz", - "integrity": "sha512-J3Qi8XZJXh93t2FiKyd/7Ec6GNifsjKXUsVFkSBj/kjLsDylWhnCz4NT1bkPcKotttPW+QbKGqqPH8OoI2pdqw==", + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.8.3.tgz", + "integrity": "sha512-llcxWccnyaWlODe7A9hRjkvdCKamEKTh+wH8ITdTc3OhchaqUZteiSCX/2ablWHVrkVIe04dntnaZJ7BdyW0lQ==", "requires": { "decode-uri-component": "^0.2.0", "split-on-first": "^1.0.0", @@ -4129,12 +4129,24 @@ } }, "cross-fetch": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-2.2.3.tgz", - "integrity": "sha512-PrWWNH3yL2NYIb/7WF/5vFG3DCQiXDOVf8k3ijatbrtnwNuhMWLC7YF7uqf53tbTFDzHIUD8oITw4Bxt8ST3Nw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.4.tgz", + "integrity": "sha512-MSHgpjQqgbT/94D4CyADeNoYh52zMkCX4pcJvPP5WqPsLFMKjr2TCMg381ox5qI0ii2dPwaLx/00477knXqXVw==", "requires": { - "node-fetch": "2.1.2", - "whatwg-fetch": "2.0.4" + "node-fetch": "2.6.0", + "whatwg-fetch": "3.0.0" + }, + "dependencies": { + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" + }, + "whatwg-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", + "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==" + } } }, "cross-spawn": { @@ -10519,7 +10531,8 @@ "node-fetch": { "version": "2.1.2", "resolved": "http://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz", - "integrity": "sha1-q4hOjn5X44qUR1POxwb3iNF2i7U=" + "integrity": "sha1-q4hOjn5X44qUR1POxwb3iNF2i7U=", + "dev": true }, "node-forge": { "version": "0.7.5", diff --git a/package.json b/package.json index 78315ae2d..e7a9bb870 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "blockstack-browser", "description": "The Blockstack browser", - "version": "0.36.3", + "version": "0.37.0-alpha.1", "author": "Blockstack PBC ", "dependencies": { "bigi": "^1.4.2", "bip39": "^2.2.0", "bitcoinjs-lib": "^3.2.0", "blockstack-ui": "^0.0.71", - "blockstack": "^19.3.0", + "blockstack": "^20.0.0-alpha.2", "body-parser": "^1.16.1", "bootstrap": "^4.0.0-beta", "browser-stdout": "^1.3.0", diff --git a/test/account/store/account/actions.test.js b/test/account/store/account/actions.test.js index d7aef8aa2..a6f8734d0 100644 --- a/test/account/store/account/actions.test.js +++ b/test/account/store/account/actions.test.js @@ -160,6 +160,9 @@ describe('AccountActions', () => { appsNodeKey: 'xprvA1y4zBndD83n6PWgVH6ivkTpNQ2WU1UGPg9hWa2q8sCANa7YrYMZFHWMhrbpsarx' + 'XMuQRa4jtaT2YXugwsKrjFgn765tUHu9XjyiDFEjB7f', + collectionsNodeKey: + 'xprvA1y4zBndD83nEPDq9NxZFJ4VAN7pjhKdhKLzTpjRBqvRMosxtGiedSmzQNjazwPR' + + 'Fb6V1oBu2M8YPhm1SK18YkDxv1yYDG5gZGwrUUkKSvi', salt: 'c15619adafe7e75a195a1a2b5788ca42e585a3fd181ae2ff009c6089de54ed9e' } diff --git a/test/profiles/DefaultProfilePage.test.js b/test/profiles/DefaultProfilePage.test.js index ba657c8bd..074a8753b 100644 --- a/test/profiles/DefaultProfilePage.test.js +++ b/test/profiles/DefaultProfilePage.test.js @@ -26,7 +26,8 @@ function setup(accounts = []) { encryptedBackupPhrase: 'onwards and upwards', setDefaultIdentity: () => {}, identityKeypairs: [], - storageConnected: false + storageConnected: false, + refreshAllIdentitySettings: () => {} } const wrapper = shallow() diff --git a/test/profiles/store/identity/actions/async.test.js b/test/profiles/store/identity/actions/async.test.js index c24664c30..cb1277f23 100644 --- a/test/profiles/store/identity/actions/async.test.js +++ b/test/profiles/store/identity/actions/async.test.js @@ -71,6 +71,8 @@ describe('Identity Store: Async Actions', () => { address: '13ssnrZTn4TJzQkwFZHajfeZXrGe6fQtrZ', appsNodeKey: 'xprv9zn1Mwu3hGDz9ytK4Pvp6ckfswdeMRngASKEcT9J4f2THGjo2UjyrMocunoohYQW2fMx9Cb21KvooM8pVrbuVVjHuqTJ2Kdru5VKGLR1MZa', + collectionsNodeKey: + 'xprv9zn1Mwu3hGDzJhReZ7415JbdqumsaT8pgJGgtC8W5wTEUUsxbvihEw6Ru74sKxayJKasbqXvpanMt7dngdGFkSgQE1wbYp6br6CqaDvxWMb', key: '5c21340bdc95b66c203f1080d6c83655137edbb2fcbbf35849e82b10b993b7ad', keyID: