From def677e1f4b2e656384789759eca4f4845ffc291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Wed, 1 May 2024 18:10:50 +0000 Subject: [PATCH 01/17] WIP 1 --- frontend/controller/actions/chatroom.js | 11 +- frontend/controller/actions/group.js | 9 +- frontend/controller/actions/identity.js | 50 ++++++---- frontend/controller/actions/index.js | 3 +- frontend/main.js | 127 +++++++++++++++++++++--- frontend/model/contracts/chatroom.js | 15 ++- frontend/model/contracts/group.js | 48 ++++----- frontend/model/contracts/identity.js | 43 ++++---- frontend/model/database.js | 64 +++++++----- frontend/model/state.js | 16 +-- shared/domains/chelonia/chelonia.js | 23 +++-- shared/domains/chelonia/internals.js | 18 ++-- test/backend.test.js | 2 +- 13 files changed, 274 insertions(+), 155 deletions(-) diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 7daa2d92e..4fb8f6fa8 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -4,7 +4,6 @@ import sbp from '@sbp/sbp' import { GIErrorUIRuntimeError, L } from '@common/common.js' import { has, omit } from '@model/contracts/shared/giLodash.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' -import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js' import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deserializeKey, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' @@ -68,7 +67,7 @@ export default (sbp('sbp/selectors/register', { () => [cekOpts._rawKey, cskOpts._rawKey].map(key => ({ key, transient: true })) ) - const userCSKid = findKeyIdByName(rootState[userID], 'csk') + const userCSKid = sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') if (!userCSKid) throw new Error('User CSK id not found') const SAK = keygen(EDWARDS25519SHA512BATCH) @@ -178,11 +177,11 @@ export default (sbp('sbp/selectors/register', { const originatingContractID = state.attributes.groupContractID ? state.attributes.groupContractID : contractID // $FlowFixMe - return Promise.all(Object.keys(state.members).map((pContractID) => { - const CEKid = findKeyIdByName(rootState[pContractID], 'cek') + return Promise.all(Object.keys(state.members).map(async (pContractID) => { + const CEKid = await sbp('chelonia/contract/currentKeyIdByName', pContractID, 'cek') if (!CEKid) { console.warn(`Unable to share rotated keys for ${originatingContractID} with ${pContractID}: Missing CEK`) - return Promise.resolve() + return } return { contractID, @@ -221,7 +220,7 @@ export default (sbp('sbp/selectors/register', { const CEKid = params.encryptionKeyId || sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek') - const userCSKid = sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') + const userCSKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') return await sbp('chelonia/out/atomic', { ...params, contractName: 'gi.contracts/chatroom', diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index fdfdd07e5..2288e2cbc 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -30,7 +30,6 @@ import { } from '@utils/events.js' import { imageUpload } from '@utils/image.js' import { GIMessage } from '~/shared/domains/chelonia/chelonia.js' -import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js' import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' import { CONTRACT_HAS_RECEIVED_KEYS } from '~/shared/domains/chelonia/events.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug @@ -116,10 +115,10 @@ export default (sbp('sbp/selectors/register', { () => [CEK, CSK].map(key => ({ key, transient: true })) ) - const userCSKid = findKeyIdByName(rootState[userID], 'csk') + const userCSKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') if (!userCSKid) throw new Error('User CSK id not found') - const userCEKid = findKeyIdByName(rootState[userID], 'cek') + const userCEKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'cek') if (!userCEKid) throw new Error('User CEK id not found') const message = await sbp('chelonia/out/registerContract', { @@ -322,7 +321,7 @@ export default (sbp('sbp/selectors/register', { // perform all operations in the group? If we haven't, we are not // able to participate in the group yet and may need to send a key // request. - const hasSecretKeys = sbp('chelonia/contract/receivedKeysToPerformOperation', userID, state, '*') + const hasSecretKeys = sbp('chelonia/contract/receivedKeysToPerformOperation', userID, params.contractID, '*') // Do we need to send a key request? // If we don't have the group contract in our state and @@ -330,7 +329,7 @@ export default (sbp('sbp/selectors/register', { // through an invite link, and we must send a key request to complete // the joining process. const sendKeyRequest = (!hasKeyShareBeenRespondedBy && !hasSecretKeys && params.originatingContractID) - const pendingKeyShares = sbp('chelonia/contract/waitingForKeyShareTo', state, userID, params.reference) + const pendingKeyShares = sbp('chelonia/contract/waitingForKeyShareTo', params.contractID, userID, params.reference) // If we are expecting to receive keys, set up an event listener // We are expecting to receive keys if: diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 9755c4723..4fe0a8240 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -13,7 +13,6 @@ import { SETTING_CURRENT_USER } from '~/frontend/model/database.js' import { LOGIN, LOGIN_ERROR, LOGOUT } from '~/frontend/utils/events.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import { boxKeyPair, buildRegisterSaltRequest, computeCAndHc, decryptContractSalt, hash, hashPassword, randomNonce } from '~/shared/zkpp.js' -import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js' import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deriveKeyFromPassword, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' @@ -306,40 +305,47 @@ export default (sbp('sbp/selectors/register', { try { sbp('appLogs/startCapture', identityContractID) - const { encryptionParams, value: state } = await sbp('gi.db/settings/loadEncrypted', identityContractID, password && ((stateEncryptionKeyId, salt) => { + const { encryptionParams, value: state } = await sbp('gi.db/settings/loadEncrypted', identityContractID, (stateEncryptionKeyId, salt) => { return deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt + stateEncryptionKeyId) - })) + }) + + const cheloniaState = state?.cheloniaState + if (cheloniaState) { + delete state.cheloniaState + } + // TODO: The following is needed only if `!!password` const contractIDs = Object.create(null) // login can be called when no settings are saved (e.g. from Signup.vue) - if (state) { + if (cheloniaState) { // The retrieved local data might need to be completed in case it was originally saved // under an older version of the app where fewer/other Vuex modules were implemented. - sbp('state/vuex/postUpgradeVerification', state) - sbp('state/vuex/replace', state) + Object.assign(sbp('chelonia/rootState'), cheloniaState) + console.error('@@@@SET CHELONIA STATE[identity.js]', { cRS: sbp('chelonia/rootState'), cheloniaState, stateC: JSON.parse(JSON.stringify(state)), state }) sbp('chelonia/pubsub/update') // resubscribe to contracts since we replaced the state // $FlowFixMe[incompatible-use] - Object.entries(state.contracts).forEach(([id, { type }]) => { + Object.entries(cheloniaState.contracts).forEach(([id, { type }]) => { if (!contractIDs[type]) { contractIDs[type] = [] } contractIDs[type].push(id) }) } + if (state) { + // The retrieved local data might need to be completed in case it was originally saved + // under an older version of the app where fewer/other Vuex modules were implemented. + sbp('state/vuex/postUpgradeVerification', state) + sbp('state/vuex/replace', state) + } await sbp('gi.db/settings/save', SETTING_CURRENT_USER, identityContractID) - const loginAttributes = { identityContractID, encryptionParams, username } - - // If username was not provided, retrieve it from the state - if (!loginAttributes.username) { - loginAttributes.username = Object.entries(state.namespaceLookups) - .find(([k, v]) => v === identityContractID) - ?.[0] - } + const loginAttributes = { identityContractID, encryptionParams } sbp('state/vuex/commit', 'login', loginAttributes) - await sbp('chelonia/storeSecretKeys', () => transientSecretKeys) + if (password) { + await sbp('chelonia/storeSecretKeys', () => transientSecretKeys) + } // We need to sync contracts in this order to ensure that we have all the // corresponding secret keys. Group chatrooms use group keys but there's @@ -359,7 +365,7 @@ export default (sbp('sbp/selectors/register', { // loading the website instead of stalling out. try { - if (!state) { + if (!cheloniaState) { // Make sure we don't unsubscribe from our own identity contract // Note that this should be done _after_ calling // `chelonia/storeSecretKeys`: If the following line results in @@ -517,6 +523,8 @@ export default (sbp('sbp/selectors/register', { // we could avoid waiting on these 2nd layer of actions) await sbp('okTurtles.eventQueue/queueEvent', 'encrypted-action', () => {}) // See comment below for 'gi.db/settings/delete' + sbp('state/vuex/state').cheloniaState = sbp('chelonia/rootState') + await sbp('gi.db/settings/delete', 'CHELONIA_STATE') await sbp('state/vuex/save') // If there is a state encryption key in the app settings, remove it @@ -572,15 +580,15 @@ export default (sbp('sbp/selectors/register', { const rootState = sbp('state/vuex/state') const state = rootState[contractID] // TODO: Also share PEK with DMs - await Promise.all(Object.keys(state.groups || {}).filter(groupID => !!rootState.contracts[groupID]).map(groupID => { - const CEKid = findKeyIdByName(rootState[groupID], 'cek') - const CSKid = findKeyIdByName(rootState[groupID], 'csk') + await Promise.all(Object.keys(state.groups || {}).filter(groupID => !!rootState.contracts[groupID]).map(async groupID => { + const CEKid = await sbp('chelonia/contract/currentKeyIdByName', groupID, 'cek') + const CSKid = await sbp('chelonia/contract/currentKeyIdByName', groupID, 'csk') if (!CEKid || !CSKid) { console.warn(`Unable to share rotated keys for ${contractID} with ${groupID}: Missing CEK or CSK`) // We intentionally don't throw here to be able to share keys with the // remaining groups - return Promise.resolve() + return } return sbp('chelonia/out/keyShare', { contractID: groupID, diff --git a/frontend/controller/actions/index.js b/frontend/controller/actions/index.js index 2c5b03d4d..0f8255684 100644 --- a/frontend/controller/actions/index.js +++ b/frontend/controller/actions/index.js @@ -42,7 +42,8 @@ sbp('sbp/selectors/register', { throw new Error('Missing CEK; unable to proceed sharing keys') } - const secretKeys = sbp('state/vuex/state')['secretKeys'] + // TODO: Use 'chelonia/haveSecretKey' + const secretKeys = sbp('chelonia/rootState')['secretKeys'] const keysToShare = Array.isArray(keyIds) ? pick(secretKeys, keyIds) diff --git a/frontend/main.js b/frontend/main.js index 170d5f853..42899e358 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -10,7 +10,7 @@ import 'wicg-inert' import '@model/captureLogs.js' import type { GIMessage } from '~/shared/domains/chelonia/chelonia.js' import '~/shared/domains/chelonia/chelonia.js' -import { CONTRACT_IS_SYNCING } from '~/shared/domains/chelonia/events.js' +import { CONTRACT_IS_SYNCING, CONTRACTS_MODIFIED, EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' import { NOTIFICATION_TYPE, REQUEST_TYPE } from '../shared/pubsub.js' import * as Common from '@common/common.js' import { LOGIN, LOGOUT, LOGIN_ERROR, SWITCH_GROUP, THEME_CHANGE, CHATROOM_USER_TYPING, CHATROOM_USER_STOP_TYPING } from './utils/events.js' @@ -101,11 +101,41 @@ async function startApp () { // Since a runtime error just occured, we likely want to persist app logs to local storage now. sbp('appLogs/save') } + await sbp('gi.db/settings/load', 'CHELONIA_STATE').then(async (cheloniaState) => { + // TODO: PLACEHOLDER TO SIMULATE CHELONIA IN A SW + if (!cheloniaState) return + const identityContractID = await sbp('gi.db/settings/load', SETTING_CURRENT_USER) + if (!identityContractID) return + Object.assign(sbp('chelonia/rootState'), cheloniaState) + console.error('@@@@SET CHELONIA STATE[main.js]', identityContractID, sbp('chelonia/rootState'), cheloniaState) + }) await sbp('chelonia/configure', { connectionURL: sbp('okTurtles.data/get', 'API_URL'), + /* stateSelector: 'state/vuex/state', reactiveSet: Vue.set, reactiveDel: Vue.delete, + */ + reactiveSet: (o: Object, k: string, v: string) => { + // TODO: PLACEHOLDER TO SIMULATE CHELONIA SERVICE WORKER SAVING STATE + // TODO: DOES THE STATE EVEN NEED TO BE SAVED OR IS RAM ENOUGH? + if (o[k] !== v) { + o[k] = v + sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', () => { + return sbp('gi.db/settings/save', 'CHELONIA_STATE', sbp('chelonia/rootState')) + }) + } + }, + reactiveDel: (o: Object, k: string) => { + // TODO: PLACEHOLDER TO SIMULATE CHELONIA SERVICE WORKER SAVING STATE + // TODO: DOES THE STATE EVEN NEED TO BE SAVED OR IS RAM ENOUGH? + if (Object.prototype.hasOwnProperty.call(o, k)) { + delete o[k] + sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', () => { + return sbp('gi.db/settings/save', 'CHELONIA_STATE', sbp('chelonia/rootState')) + }) + } + }, contracts: { ...manifests, defaults: { @@ -113,6 +143,7 @@ async function startApp () { allowedSelectors: [ 'namespace/lookup', 'namespace/lookupCached', 'state/vuex/state', 'state/vuex/settings', 'state/vuex/commit', 'state/vuex/getters', + 'chelonia/contract/state', 'chelonia/contract/sync', 'chelonia/contract/isSyncing', 'chelonia/contract/remove', 'chelonia/contract/retain', 'chelonia/contract/release', 'controller/router', 'chelonia/contract/suitableSigningKey', 'chelonia/contract/currentKeyIdByName', 'chelonia/storeSecretKeys', 'chelonia/crypto/keyId', @@ -170,6 +201,54 @@ async function startApp () { } }) + sbp('okTurtles.events/on', EVENT_HANDLED, (contractID) => { + const cheloniaState = sbp('chelonia/rootState') + const state = cheloniaState[contractID] + const contractState = cheloniaState.contracts[contractID] + const vuexState = sbp('state/vuex/state') + if (contractState) { + if (!vuexState.contracts) { + Vue.set(vuexState, 'contracts', Object.create(null)) + } + Vue.set(vuexState.contracts, contractID, JSON.parse(JSON.stringify(contractState))) + } else if (vuexState.contracts) { + Vue.delete(vuexState.contracts, contractState) + } + if (state) { + Vue.set(vuexState, contractID, JSON.parse(JSON.stringify(state))) + } else { + Vue.delete(vuexState, contractID) + } + }) + + sbp('okTurtles.events/on', CONTRACTS_MODIFIED, (subscriptionSet) => { + const cheloniaState = sbp('chelonia/rootState') + const vuexState = sbp('state/vuex/state') + + if (!vuexState.contracts) { + Vue.set(vuexState, 'contracts', Object.create(null)) + } + + const oldContracts = Object.keys(vuexState.contracts) + const oldContractsToRemove = oldContracts.filter(x => !subscriptionSet.has(x)) + const newContracts = Array.from(subscriptionSet).filter(x => !oldContracts.includes(x)) + + oldContractsToRemove.forEach(x => { + Vue.delete(vuexState.contracts, x) + Vue.delete(vuexState, x) + }) + newContracts.forEach(x => { + const state = cheloniaState[x] + const contractState = cheloniaState.contracts[x] + if (contractState) { + Vue.set(vuexState.contracts, x, JSON.parse(JSON.stringify(contractState))) + } + if (state) { + Vue.set(vuexState, x, JSON.parse(JSON.stringify(state))) + } + }) + }) + // NOTE: setting 'EXPOSE_SBP' in production will make it easier for users to generate contract // actions that they shouldn't be generating, which can lead to bugs or trigger the automated // ban system. Only enable it if you know what you're doing and don't mind the risk. @@ -379,18 +458,40 @@ async function startApp () { // to ensure that we don't override user interactions that have already // happened (an example where things can happen this quickly is in the // tests). - sbp('gi.db/settings/load', SETTING_CURRENT_USER).then(identityContractID => { - if (!identityContractID || this.ephemeral.finishedLogin === 'yes') return - return sbp('gi.actions/identity/login', { identityContractID }).catch((e) => { - console.error(`[main] caught ${e?.name} while logging in: ${e?.message || e}`, e) - console.warn(`It looks like the local user '${identityContractID}' does not exist anymore on the server 😱 If this is unexpected, contact us at https://gitter.im/okTurtles/group-income`) - }) - }).catch(e => { - console.error(`[main] caught ${e?.name} while fetching settings or handling a login error: ${e?.message || e}`, e) - }).finally(() => { - this.ephemeral.ready = true - this.removeLoadingAnimation() - }) + sbp('gi.db/settings/load', 'CHELONIA_STATE').then(async (cheloniaState) => { + // TODO: PLACEHOLDER TO SIMULATE CHELONIA IN A SW + const identityContractID = await sbp('gi.db/settings/load', SETTING_CURRENT_USER) + if (!cheloniaState || !identityContractID) return + const contractSyncPriorityList = [ + 'gi.contracts/identity', + 'gi.contracts/group', + 'gi.contracts/chatroom' + ] + const getContractSyncPriority = (key) => { + const index = contractSyncPriorityList.indexOf(key) + return index === -1 ? contractSyncPriorityList.length : index + } + await sbp('chelonia/contract/sync', identityContractID, { force: true }) + const contractIDs = Object.keys(cheloniaState.contracts) + await Promise.all(Object.entries(contractIDs).sort(([a], [b]) => { + // Sync contracts in order based on type + return getContractSyncPriority(a) - getContractSyncPriority(b) + }).map(([, ids]) => { + return sbp('okTurtles.eventQueue/queueEvent', `appStart:${identityContractID ?? '(null)'}`, ['chelonia/contract/sync', ids, { force: true }]) + })) + }).then(() => + sbp('gi.db/settings/load', SETTING_CURRENT_USER).then(identityContractID => { + if (!identityContractID || this.ephemeral.finishedLogin === 'yes') return + return sbp('gi.actions/identity/login', { identityContractID }).catch((e) => { + console.error(`[main] caught ${e?.name} while logging in: ${e?.message || e}`, e) + console.warn(`It looks like the local user '${identityContractID}' does not exist anymore on the server 😱 If this is unexpected, contact us at https://gitter.im/okTurtles/group-income`) + }) + }).catch(e => { + console.error(`[main] caught ${e?.name} while fetching settings or handling a login error: ${e?.message || e}`, e) + }).finally(() => { + this.ephemeral.ready = true + this.removeLoadingAnimation() + })) }, computed: { ...mapGetters(['groupsByName', 'ourUnreadMessages', 'totalUnreadNotificationCount']), diff --git a/frontend/model/contracts/chatroom.js b/frontend/model/contracts/chatroom.js index 777f4d7d0..ab3be6c2d 100644 --- a/frontend/model/contracts/chatroom.js +++ b/frontend/model/contracts/chatroom.js @@ -227,9 +227,8 @@ sbp('chelonia/defineContract', { }, sideEffect ({ data, contractID, hash, meta, innerSigningContractID }, { state }) { if (state.onlyRenderMessage) return - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - const state = rootState[contractID] + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) const memberID = data.memberID || innerSigningContractID if (!state?.members?.[memberID]) { @@ -357,16 +356,15 @@ sbp('chelonia/defineContract', { }, sideEffect ({ data, hash, contractID, meta, innerSigningContractID }, { state }) { if (state.onlyRenderMessage) return - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - const state = rootState[contractID] + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) const memberID = data.memberID || innerSigningContractID if (!state || !!state.members?.[data.memberID]) { return } - if (memberID === rootState.loggedIn.identityContractID) { + if (memberID === sbp('state/vuex/state').loggedIn.identityContractID) { leaveChatRoom({ contractID }).catch((e) => { console.error(`[gi.contracts/chatroom/leave/sideEffect] Error for ${contractID}`, e) }) @@ -477,8 +475,7 @@ sbp('chelonia/defineContract', { } }, sideEffect ({ contractID, hash, meta, data, innerSigningContractID }, { getters }) { - const rootState = sbp('state/vuex/state') - const me = rootState.loggedIn.identityContractID + const me = sbp('state/vuex/state').loggedIn.identityContractID if (me === innerSigningContractID || getters.chatRoomAttributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { return } diff --git a/frontend/model/contracts/group.js b/frontend/model/contracts/group.js index 7ee0a2156..0d9c26862 100644 --- a/frontend/model/contracts/group.js +++ b/frontend/model/contracts/group.js @@ -362,8 +362,7 @@ const leaveChatRoomAction = (state, chatRoomID, memberID, actorID, leavingGroup) console.warn('[gi.contracts/group] Error sending chatroom leave action', e) }) - const rootState = sbp('state/vuex/state') - if (memberID === rootState.loggedIn.identityContractID) { + if (memberID === sbp('state/vuex/state').loggedIn.identityContractID) { sbp('chelonia/contract/release', chatRoomID).catch(e => { console.error(`[leaveChatRoomAction] Error releasing chatroom ${chatRoomID}`, e) }) @@ -394,6 +393,7 @@ export const actionRequireActiveMember = (next: Function): Function => (data, pr } export const GIGroupAlreadyJoinedError: typeof Error = ChelErrorGenerator('GIGroupAlreadyJoinedError') +export const GIGroupNotJoinedError: typeof Error = ChelErrorGenerator('GIGroupNotJoinedError') sbp('chelonia/defineContract', { name: 'gi.contracts/group', @@ -720,9 +720,9 @@ sbp('chelonia/defineContract', { sideEffect ({ contractID }, { state }) { if (!state.generalChatRoomId) { // create a 'General' chatroom contract - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - if (!rootState[contractID] || rootState[contractID].generalChatRoomId) return + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) + if (!state || state.generalChatRoomId) return const CSKid = findKeyIdByName(state, 'csk') const CEKid = findKeyIdByName(state, 'cek') @@ -1051,7 +1051,7 @@ sbp('chelonia/defineContract', { const isGroupCreator = innerSigningContractID === getters.currentGroupOwnerID if (!state.profiles[memberToRemove]) { - throw new TypeError(L('Not part of the group.')) + throw new GIGroupNotJoinedError(L('Not part of the group.')) } if (membersCount === 1) { throw new TypeError(L('Cannot remove the last member.')) @@ -1419,12 +1419,11 @@ sbp('chelonia/defineContract', { removeGroupChatroomProfile(state, data.chatRoomID, memberID) }, sideEffect ({ meta, data, contractID, innerSigningContractID }, { state }) { - const rootState = sbp('state/vuex/state') const memberID = data.memberID || innerSigningContractID - if (innerSigningContractID === rootState.loggedIn.identityContractID) { - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - if (rootState[contractID]?.profiles?.[innerSigningContractID]?.status === PROFILE_STATUS.ACTIVE) { + if (innerSigningContractID === sbp('state/vuex/state').loggedIn.identityContractID) { + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) + if (state?.profiles?.[innerSigningContractID]?.status === PROFILE_STATUS.ACTIVE) { return leaveChatRoomAction(state, data.chatRoomID, memberID, innerSigningContractID) } }).catch((e) => { @@ -1463,23 +1462,22 @@ sbp('chelonia/defineContract', { Vue.set(state.chatRooms[data.chatRoomID].members, memberID, { status: PROFILE_STATUS.ACTIVE }) }, sideEffect ({ meta, data, contractID, innerSigningContractID }, { state }) { - const rootState = sbp('state/vuex/state') const memberID = data.memberID || innerSigningContractID // If we added someone to the chatroom (including ourselves), we issue // the relevant action to the chatroom contract - if (innerSigningContractID === rootState.loggedIn.identityContractID) { + if (innerSigningContractID === sbp('state/vuex/state').loggedIn.identityContractID) { sbp('chelonia/queueInvocation', contractID, () => sbp('gi.contracts/group/joinGroupChatrooms', contractID, data.chatRoomID, memberID)).catch((e) => { console.warn(`[gi.contracts/group/joinChatRoom/sideEffect] Error adding member to group chatroom for ${contractID}`, { e, data }) }) - } else if (memberID === rootState.loggedIn.identityContractID) { + } else if (memberID === sbp('state/vuex/state').loggedIn.identityContractID) { // If we were the ones added to the chatroom, we sync the chatroom. // This is an `else` block because joinGroupChatrooms already calls // sync - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) - if (rootState[contractID]?.chatRooms[data.chatRoomID]?.members[memberID]?.status === PROFILE_STATUS.ACTIVE) { + if (state?.chatRooms[data.chatRoomID]?.members[memberID]?.status === PROFILE_STATUS.ACTIVE) { // If we were added by someone else, we might sync the chatroom // contract before the corresponding `/join` action is issued. // If we were previously a member of the chatroom, we would have @@ -1723,9 +1721,8 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/joinGroupChatrooms': async function (contractID, chatRoomId, memberID) { - const rootState = sbp('state/vuex/state') - const state = rootState[contractID] - const actorID = rootState.loggedIn.identityContractID + const state = await sbp('chelonia/contract/state', contractID) + const actorID = sbp('state/vuex/state').loggedIn.identityContractID if (state?.profiles?.[actorID]?.status !== PROFILE_STATUS.ACTIVE || state?.profiles?.[memberID]?.status !== PROFILE_STATUS.ACTIVE || state.chatRooms?.[chatRoomId]?.members[memberID]?.status !== PROFILE_STATUS.ACTIVE) { return @@ -1776,11 +1773,10 @@ sbp('chelonia/defineContract', { }, // eslint-disable-next-line require-await 'gi.contracts/group/leaveGroup': async ({ data, meta, contractID, height, getters, innerSigningContractID }) => { - const rootState = sbp('state/vuex/state') const rootGetters = sbp('state/vuex/getters') - const state = rootState[contractID] - const { identityContractID } = rootState.loggedIn + const { identityContractID } = sbp('state/vuex/state').loggedIn const memberID = data.memberID || innerSigningContractID + const state = await sbp('chelonia/contract/state', contractID) if (!state) { console.info(`[gi.contracts/group/leaveGroup] for ${contractID}: contract has been removed`) @@ -1885,9 +1881,9 @@ sbp('chelonia/defineContract', { // TODO - #850 verify open proposals and see if they need some re-adjustment. }, - 'gi.contracts/group/rotateKeys': (contractID) => { - const rootState = sbp('state/vuex/state') - const pendingKeyRevocations = rootState[contractID]?._volatile?.pendingKeyRevocations + 'gi.contracts/group/rotateKeys': async (contractID) => { + const state = await sbp('chelonia/contract/state', contractID) + const pendingKeyRevocations = state?._volatile?.pendingKeyRevocations if (!pendingKeyRevocations || Object.keys(pendingKeyRevocations).length === 0) { // Don't rotate keys for removed contracts return diff --git a/frontend/model/contracts/identity.js b/frontend/model/contracts/identity.js index c6483cf2e..2b0aa0783 100644 --- a/frontend/model/contracts/identity.js +++ b/frontend/model/contracts/identity.js @@ -58,11 +58,11 @@ const checkUsernameConsistency = async (contractID: string, username: string) => // If there was a mismatch, wait until the contract is finished processing // (because the username could have been updated), and if the situation // persists, warn the user - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - if (!has(rootState, contractID)) return + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) + if (!state) return - const username = rootState[contractID].attributes.username + const username = state[contractID].attributes.username if (sbp('namespace/lookupCached', username) !== contractID) { sbp('gi.notifications/emit', 'WARNING', { contractID, @@ -216,12 +216,11 @@ sbp('chelonia/defineContract', { key: inviteSecret, transient: true }]) - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - const state = rootState[contractID] + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) // If we've logged out, return - if (!state || contractID !== rootState.loggedIn.identityContractID) { + if (!state || contractID !== sbp('state/vuex/state').loggedIn.identityContractID) { return } @@ -285,12 +284,11 @@ sbp('chelonia/defineContract', { Vue.delete(state.groups, groupContractID) }, sideEffect ({ meta, data, contractID, innerSigningContractID }, { state }) { - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - const state = rootState[contractID] + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) // If we've logged out, return - if (!state || contractID !== rootState.loggedIn.identityContractID) { + if (!state || contractID !== sbp('state/vuex/state').loggedIn.identityContractID) { return } @@ -301,33 +299,32 @@ sbp('chelonia/defineContract', { return } - if (has(rootState.contracts, groupContractID)) { - sbp('gi.actions/group/removeOurselves', { - contractID: groupContractID - }).catch(e => { - console.warn(`[gi.contracts/identity/leaveGroup/sideEffect] Error removing ourselves from group contract ${data.groupContractID}`, e) - }) - } + sbp('gi.actions/group/removeOurselves', { + contractID: groupContractID + }).catch(e => { + if (e?.name === 'GIErrorUIRuntimeError' && e.cause?.name === 'GIGroupNotJoinedError') return + console.warn(`[gi.contracts/identity/leaveGroup/sideEffect] Error removing ourselves from group contract ${data.groupContractID}`, e) + }) sbp('chelonia/contract/release', data.groupContractID).catch((e) => { console.error('[gi.contracts/identity/leaveGroup/sideEffect] Error calling release', e) }) // grab the groupID of any group that we're a part of - if (!rootState.currentGroupId || rootState.currentGroupId === data.groupContractID) { + if (!sbp('state/vuex/state').currentGroupId || sbp('state/vuex/state').currentGroupId === data.groupContractID) { const groupIdToSwitch = Object.keys(state.groups) .filter(cID => cID !== data.groupContractID ).sort(cID => // prefer successfully joined groups - rootState[cID]?.profiles?.[contractID] ? -1 : 1 + sbp('state/vuex/state')[cID]?.profiles?.[contractID] ? -1 : 1 )[0] || null sbp('state/vuex/commit', 'setCurrentChatRoomId', {}) sbp('state/vuex/commit', 'setCurrentGroupId', groupIdToSwitch) } // Remove last logged in information - Vue.delete(rootState.lastLoggedIn, contractID) + Vue.delete(sbp('state/vuex/state').lastLoggedIn, contractID) // this looks crazy, but doing this was necessary to fix a race condition in the // group-member-removal Cypress tests where due to the ordering of asynchronous events @@ -337,7 +334,7 @@ sbp('chelonia/defineContract', { try { const router = sbp('controller/router') const switchFrom = router.currentRoute.path - const switchTo = rootState.currentGroupId ? '/dashboard' : '/' + const switchTo = sbp('state/vuex/state').currentGroupId ? '/dashboard' : '/' if (switchFrom !== '/join' && switchFrom !== switchTo) { router.push({ path: switchTo }).catch((e) => console.error('Error switching groups', e)) } diff --git a/frontend/model/database.js b/frontend/model/database.js index 0c4d9f198..c5065c9f6 100644 --- a/frontend/model/database.js +++ b/frontend/model/database.js @@ -4,6 +4,35 @@ import sbp from '@sbp/sbp' import localforage from 'localforage' import { CURVE25519XSALSA20POLY1305, decrypt, encrypt, generateSalt, keyId, keygen, serializeKey } from '../../shared/domains/chelonia/crypto.js' +const generateEncryptionParams = async (stateKeyEncryptionKeyFn?: (stateEncryptionKeyId: string, salt: string) => Promise<*>) => { + // Create the necessary keys + // First, we generate the state encryption key + const stateEncryptionKey = keygen(CURVE25519XSALSA20POLY1305) + const stateEncryptionKeyId = keyId(stateEncryptionKey) + const stateEncryptionKeyS = serializeKey(stateEncryptionKey, true) + + // Once we have the state encryption key, we generate a salt + const salt = generateSalt() + + // We use the salt, the state encryption key ID and the password to + // derive a key to encrypt the state encryption key + // This key is not stored anywhere, but is used for reconstructing + // the state on a fresh session + const stateKeyEncryptionKey = await stateKeyEncryptionKeyFn(stateEncryptionKeyId, salt) + + // Once everything is place, encrypt the state encryption key + const encryptedStateEncryptionKey = encrypt(stateKeyEncryptionKey, stateEncryptionKeyS, stateEncryptionKeyId) + + return { + encryptionParams: { + stateEncryptionKeyId, + salt, + encryptedStateEncryptionKey + }, + stateEncryptionKeyS + } +} + if (process.env.LIGHTWEIGHT_CLIENT !== 'true') { const log = localforage.createInstance({ name: 'Group Income', @@ -54,20 +83,21 @@ sbp('sbp/selectors/register', { if (!stateEncryptionKeyS) throw new Error(`Unable to retrieve the key corresponding to key ID ${stateEncryptionKeyId}`) // Encrypt the current state const encryptedState = encrypt(stateEncryptionKeyS, JSON.stringify(value), user) - // Save the three fields of the encrypted state: + // Save the four fields of the encrypted state. We use base64 encoding to + // allow saving any incoming data. // (1) stateEncryptionKeyId // (2) salt // (3) encryptedStateEncryptionKey (used for recovery when re-logging in) // (4) encryptedState - return appSettings.setItem(user, `${stateEncryptionKeyId}.${salt}.${encryptedStateEncryptionKey}.${encryptedState}}`) + return appSettings.setItem(user, `${btoa(stateEncryptionKeyId)}.${btoa(salt)}.${btoa(encryptedStateEncryptionKey)}.${btoa(encryptedState)}`) }, - 'gi.db/settings/loadEncrypted': function (user: string, stateKeyEncryptionKeyFn): Promise<*> { + 'gi.db/settings/loadEncrypted': function (user: string, stateKeyEncryptionKeyFn?: (stateEncryptionKeyId: string, salt: string) => Promise<*>): Promise<*> { return appSettings.getItem(user).then(async (encryptedValue) => { if (!encryptedValue || typeof encryptedValue !== 'string') { throw new EmptyValue(`Unable to retrive state for ${user || ''}`) } // Split the encrypted state into its constituent parts - const [stateEncryptionKeyId, salt, encryptedStateEncryptionKey, data] = encryptedValue.split('.') + const [stateEncryptionKeyId, salt, encryptedStateEncryptionKey, data] = encryptedValue.split('.').map(x => atob(x)) // If the state encryption key is in appSettings, retrieve it let stateEncryptionKeyS = await appSettings.getItem(stateEncryptionKeyId) @@ -116,33 +146,13 @@ sbp('sbp/selectors/register', { console.warn('Error while retrieving local state', e) } - // Create the necessary keys - // First, we generate the state encryption key - const stateEncryptionKey = keygen(CURVE25519XSALSA20POLY1305) - const stateEncryptionKeyId = keyId(stateEncryptionKey) - const stateEncryptionKeyS = serializeKey(stateEncryptionKey, true) - - // Once we have the state encryption key, we generate a salt - const salt = generateSalt() - - // We use the salt, the state encryption key ID and the password to - // derive a key to encrypt the state encryption key - // This key is not stored anywhere, but is used for reconstructing - // the state on a fresh session - const stateKeyEncryptionKey = await stateKeyEncryptionKeyFn(stateEncryptionKeyId, salt) - - // Once everything is place, encrypt the state encryption key - const encryptedStateEncryptionKey = encrypt(stateKeyEncryptionKey, stateEncryptionKeyS, stateEncryptionKeyId) + const { encryptionParams, stateEncryptionKeyS } = await generateEncryptionParams(stateKeyEncryptionKeyFn) // Save the state encryption key to local storage - await appSettings.setItem(stateEncryptionKeyId, stateEncryptionKeyS) + await appSettings.setItem(encryptionParams.stateEncryptionKeyId, stateEncryptionKeyS) return { - encryptionParams: { - stateEncryptionKeyId, - salt, - encryptedStateEncryptionKey - }, + encryptionParams, value: null } }) diff --git a/frontend/model/state.js b/frontend/model/state.js index a9173bc49..602f82459 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -27,7 +27,7 @@ const initialState = { loggedIn: false, // false | { username: string, identityContractID: string } namespaceLookups: Object.create(null), // { [username]: sbp('namespace/lookup') } periodicNotificationAlreadyFiredMap: {}, // { notificationKey: boolean }, - contractSiginingKeys: Object.create(null), + contractSigningKeys: Object.create(null), lastLoggedIn: {} // Group last logged in information } @@ -81,11 +81,13 @@ sbp('sbp/selectors/register', { const state = store.state // IMPORTANT! DO NOT CALL VUEX commit() in here in any way shape or form! // Doing so will cause an infinite loop because of store.subscribe below! - if (state.loggedIn) { - const { identityContractID, encryptionParams } = state.loggedIn - state.notifications = applyStorageRules(state.notifications || []) - await sbp('gi.db/settings/saveEncrypted', identityContractID, state, encryptionParams) + if (!state.loggedIn) { + return } + + const { identityContractID, encryptionParams } = state.loggedIn + state.notifications = applyStorageRules(state.notifications || []) + await sbp('gi.db/settings/saveEncrypted', identityContractID, state, encryptionParams) } }) @@ -139,8 +141,8 @@ const getters = { currentIdentityState (state) { return (state.loggedIn && state[state.loggedIn.identityContractID]) || {} }, - ourUsername (state) { - return state.loggedIn && state.loggedIn.username + ourUsername (state, getters) { + return state.loggedIn && getters.usernameFromID(state.loggedIn.identityContractID) }, ourProfileActive (state, getters) { return getters.profileActive(getters.ourIdentityContractId) diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index 3570e6c50..578860467 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -645,11 +645,13 @@ export default (sbp('sbp/selectors/register', { [`${contract.manifest}/${action}/process`]: (message: Object, state: Object) => { const { meta, data, contractID } = message // TODO: optimize so that you're creating a proxy object only when needed - const gProxy = gettersProxy(state, contract.getters) - state = state || contract.state(contractID) - contract.metadata.validate(meta, { state, ...gProxy, contractID }) - contract.actions[action].validate(data, { state, ...gProxy, meta, message, contractID }) - contract.actions[action].process(message, { state, ...gProxy }) + // TODO: Copy to simulate a sandbox boundary without direct access + const stateCopy = cloneDeep(state || contract.state(contractID)) + const gProxy = gettersProxy(stateCopy, contract.getters) + contract.metadata.validate(meta, { state: stateCopy, ...gProxy, contractID }) + contract.actions[action].validate(data, { state: stateCopy, ...gProxy, meta, message, contractID }) + contract.actions[action].process(message, { state: stateCopy, ...gProxy }) + Object.assign(state, stateCopy) }, // 'mutation' is an object that's similar to 'message', but not identical [`${contract.manifest}/${action}/sideEffect`]: async (mutation: Object, state: ?Object) => { @@ -659,8 +661,12 @@ export default (sbp('sbp/selectors/register', { console.warn(`[${contract.manifest}/${action}/sideEffect]: Skipping side-effect since there is no contract state for contract ${mutation.contractID}`) return } - const gProxy = gettersProxy(state, contract.getters) - await contract.actions[action].sideEffect(mutation, { state, ...gProxy }) + // TODO: Copy to simulate a sandbox boundary without direct access + // as well as to enforce the rule that side-effects must not mutate + // state + const stateCopy = cloneDeep(state) + const gProxy = gettersProxy(stateCopy, contract.getters) + await contract.actions[action].sideEffect(mutation, { state: stateCopy, ...gProxy }) } // since both /process and /sideEffect could call /pushSideEffect, we make sure // to process the side effects on the stack after calling /sideEffect. @@ -994,6 +1000,9 @@ export default (sbp('sbp/selectors/register', { } } }, + 'chelonia/contract/state': async function (contractID: string) { + return await sbp(this.config.stateSelector)[contractID] + }, // 'chelonia/out' - selectors that send data out to the server 'chelonia/out/registerContract': async function (params: ChelRegParams) { const { contractName, keys, hooks, publishOptions, signingKeyId, actionSigningKeyId, actionEncryptionKeyId } = params diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index f8dcaea78..a16176126 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -178,26 +178,26 @@ export default (sbp('sbp/selectors/register', { // Now, start the signature verification process const rootState = sbp(this.config.stateSelector) - if (!has(rootState, 'contractSiginingKeys')) { - this.config.reactiveSet(rootState, 'contractSiginingKeys', Object.create(null)) + if (!has(rootState, 'contractSigningKeys')) { + this.config.reactiveSet(rootState, 'contractSigningKeys', Object.create(null)) } // Because `contractName` comes from potentially unsafe sources (for // instance, from `processMessage`), the key isn't used directly because // it could overlap with current or future 'special' key names in JavaScript, // such as `prototype`, `__proto__`, etc. We also can't guarantee that the - // `contractSiginingKeys` always has a null prototype, and, because of the + // `contractSigningKeys` always has a null prototype, and, because of the // way we manage state, neither can we use `Map`. So, we use prefix for the // lookup key that's unlikely to ever be part of a special JS name. const contractNameLookupKey = `name:${contractName}` // If the contract name has been seen before, validate its signature now let signatureValidated = false - if (has(rootState.contractSiginingKeys, contractNameLookupKey)) { + if (has(rootState.contractSigningKeys, contractNameLookupKey)) { console.info(`[chelonia] verifying signature for ${manifestHash} with an existing key`) - if (!has(rootState.contractSiginingKeys[contractNameLookupKey], manifest.signature.keyId)) { - console.error(`The manifest with ${manifestHash} (named ${contractName}) claims to be signed with a key with ID ${manifest.signature.keyId}, which is not trusted. The trusted key IDs for this name are:`, Object.keys(rootState.contractSiginingKeys[contractNameLookupKey])) + if (!has(rootState.contractSigningKeys[contractNameLookupKey], manifest.signature.keyId)) { + console.error(`The manifest with ${manifestHash} (named ${contractName}) claims to be signed with a key with ID ${manifest.signature.keyId}, which is not trusted. The trusted key IDs for this name are:`, Object.keys(rootState.contractSigningKeys[contractNameLookupKey])) throw new Error(`Invalid or missing signature in manifest ${manifestHash} (named ${contractName}). It claims to be signed with a key with ID ${manifest.signature.keyId}, which has not been authorized for this contract before.`) } - const signingKey = rootState.contractSiginingKeys[contractNameLookupKey][manifest.signature.keyId] + const signingKey = rootState.contractSigningKeys[contractNameLookupKey][manifest.signature.keyId] verifySignature(signingKey, manifest.body + manifest.head, manifest.signature.value) console.info(`[chelonia] successful signature verification for ${manifestHash} (named ${contractName}) using the already-trusted key ${manifest.signature.keyId}.`) signatureValidated = true @@ -230,7 +230,7 @@ export default (sbp('sbp/selectors/register', { verifySignature(contractSigningKeys[manifest.signature.keyId], manifest.body + manifest.head, manifest.signature.value) console.info(`[chelonia] successful signature verification for ${manifestHash} (named ${contractName}) using ${manifest.signature.keyId}. The following key IDs will now be trusted for this contract name`, Object.keys(contractSigningKeys)) signatureValidated = true - rootState.contractSiginingKeys[contractNameLookupKey] = contractSigningKeys + rootState.contractSigningKeys[contractNameLookupKey] = contractSigningKeys } // If verification was successful, return the parsed body to make the newly- @@ -1860,7 +1860,7 @@ const handleEvent = { if (message.isFirstMessage()) { // Allow having _volatile but nothing else if this is the first message, // as we should be starting off with a clean state - if (Object.keys(state).length > 0 && !('_volatile' in state)) { + if (Object.keys(state).some(k => k !== '_volatile')) { throw new ChelErrorUnrecoverable(`state for ${contractID} is already set`) } } diff --git a/test/backend.test.js b/test/backend.test.js index 6bca5b3b2..6cdda6887 100644 --- a/test/backend.test.js +++ b/test/backend.test.js @@ -53,7 +53,7 @@ const vuexState = { namespaceLookups: Object.create(null), reducedMotion: false, appLogsFilter: ['error', 'info', 'warn'], - contractSiginingKeys: Object.create(null) + contractSigningKeys: Object.create(null) } // this is to ensure compatibility between frontend and test/backend.test.js From c1dab49251fc5ef4e615fe44edeaf898265d8dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Thu, 2 May 2024 15:01:07 +0000 Subject: [PATCH 02/17] WIP --- frontend/controller/actions/identity.js | 5 ++--- frontend/main.js | 17 +++++++++++------ frontend/model/database.js | 3 +++ frontend/views/components/tabs/TabWrapper.vue | 1 + shared/domains/chelonia/chelonia.js | 4 ++-- shared/domains/chelonia/internals.js | 2 ++ 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 4fe0a8240..8a34f120d 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -318,8 +318,6 @@ export default (sbp('sbp/selectors/register', { const contractIDs = Object.create(null) // login can be called when no settings are saved (e.g. from Signup.vue) if (cheloniaState) { - // The retrieved local data might need to be completed in case it was originally saved - // under an older version of the app where fewer/other Vuex modules were implemented. Object.assign(sbp('chelonia/rootState'), cheloniaState) console.error('@@@@SET CHELONIA STATE[identity.js]', { cRS: sbp('chelonia/rootState'), cheloniaState, stateC: JSON.parse(JSON.stringify(state)), state }) sbp('chelonia/pubsub/update') // resubscribe to contracts since we replaced the state @@ -523,8 +521,9 @@ export default (sbp('sbp/selectors/register', { // we could avoid waiting on these 2nd layer of actions) await sbp('okTurtles.eventQueue/queueEvent', 'encrypted-action', () => {}) // See comment below for 'gi.db/settings/delete' - sbp('state/vuex/state').cheloniaState = sbp('chelonia/rootState') + await sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', () => {}) await sbp('gi.db/settings/delete', 'CHELONIA_STATE') + sbp('state/vuex/state').cheloniaState = sbp('chelonia/rootState') await sbp('state/vuex/save') // If there is a state encryption key in the app settings, remove it diff --git a/frontend/main.js b/frontend/main.js index 42899e358..7c6d67f76 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -41,6 +41,7 @@ import './model/notifications/periodicNotifications.js' import notificationsMixin from './model/notifications/mainNotificationsMixin.js' import { showNavMixin } from './views/utils/misc.js' import FaviconBadge from './utils/faviconBadge.js' +import { debounce } from '@model/contracts/shared/giLodash.js' const { Vue, L } = Common @@ -61,6 +62,9 @@ async function startApp () { // In the future we might move it elsewhere. // ?debug=true // force debug output even in production + // @@@@ TODO: Wait for db to be ready + await sbp('gi.db/ready') + const debugParam = new URLSearchParams(window.location.search).get('debug') if (process.env.NODE_ENV !== 'production' || debugParam === 'true') { const reducer = (o, v) => { o[v] = true; return o } @@ -109,6 +113,11 @@ async function startApp () { Object.assign(sbp('chelonia/rootState'), cheloniaState) console.error('@@@@SET CHELONIA STATE[main.js]', identityContractID, sbp('chelonia/rootState'), cheloniaState) }) + console.error('@@@@@@@@') + const save = debounce(() => sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', () => { + return sbp('gi.db/settings/save', 'CHELONIA_STATE', sbp('chelonia/rootState')) + })) + await sbp('chelonia/configure', { connectionURL: sbp('okTurtles.data/get', 'API_URL'), /* @@ -121,9 +130,7 @@ async function startApp () { // TODO: DOES THE STATE EVEN NEED TO BE SAVED OR IS RAM ENOUGH? if (o[k] !== v) { o[k] = v - sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', () => { - return sbp('gi.db/settings/save', 'CHELONIA_STATE', sbp('chelonia/rootState')) - }) + save() } }, reactiveDel: (o: Object, k: string) => { @@ -131,9 +138,7 @@ async function startApp () { // TODO: DOES THE STATE EVEN NEED TO BE SAVED OR IS RAM ENOUGH? if (Object.prototype.hasOwnProperty.call(o, k)) { delete o[k] - sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', () => { - return sbp('gi.db/settings/save', 'CHELONIA_STATE', sbp('chelonia/rootState')) - }) + save() } }, contracts: { diff --git a/frontend/model/database.js b/frontend/model/database.js index c5065c9f6..d496021b9 100644 --- a/frontend/model/database.js +++ b/frontend/model/database.js @@ -63,6 +63,9 @@ class EmptyValue extends Error {} export const SETTING_CURRENT_USER = '@settings/currentUser' sbp('sbp/selectors/register', { + 'gi.db/ready': function () { + return localforage.ready() + }, 'gi.db/settings/save': function (user: string, value: any): Promise<*> { return appSettings.setItem(user, value) }, diff --git a/frontend/views/components/tabs/TabWrapper.vue b/frontend/views/components/tabs/TabWrapper.vue index 9bd1eac9c..d30b902f5 100644 --- a/frontend/views/components/tabs/TabWrapper.vue +++ b/frontend/views/components/tabs/TabWrapper.vue @@ -121,6 +121,7 @@ export default ({ // The action could be asynchronous, so we wrap it in a try-catch block try { await sbp(tabItem.action) + console.error('@@@@tabClick-CLOSING', tabItem) this.$emit('close') } catch (e) { console.error(`Error on tabClick: [${e?.name}] ${e?.message || e}`, tabItem, e) diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index 578860467..1e03bb8a7 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -346,13 +346,13 @@ export default (sbp('sbp/selectors/register', { await postCleanupFn?.() // The following are all synchronous operations const rootState = sbp(this.config.stateSelector) - const contracts = rootState.contracts // Cancel all outgoing messages by replacing this._instance this._instance = Object.create(null) this.abortController.abort() this.abortController = new AbortController() // Remove all contracts, including all contracts from pending - reactiveClearObject(contracts, this.config.reactiveDel) + reactiveClearObject(rootState, this.config.reactiveDel) + this.config.reactiveSet(rootState, 'contracts', Object.create(null)) clearObject(this.ephemeralReferenceCount) this.pending.splice(0) clearObject(this.currentSyncs) diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index a16176126..bcd38ebf1 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -1166,6 +1166,7 @@ export default (sbp('sbp/selectors/register', { let latestHashFound = false const eventReader = eventsStream.getReader() // remove the first element in cases where we are not getting the contract for the first time + console.error('@@@sync, just before process', contractID, { recentHeight, currentHeight: state.contracts[contractID]?.height || '-', state: state[contractID] || '-' }) for (let skip = has(state.contracts, contractID) && has(state.contracts[contractID], 'HEAD'); ; skip = false) { const { done, value: event } = await eventReader.read() if (done) { @@ -1861,6 +1862,7 @@ const handleEvent = { // Allow having _volatile but nothing else if this is the first message, // as we should be starting off with a clean state if (Object.keys(state).some(k => k !== '_volatile')) { + console.error('@@@@', state) throw new ChelErrorUnrecoverable(`state for ${contractID} is already set`) } } From de516d1e5acf8542cbf9bd7984c7b94d3cca5794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Fri, 10 May 2024 18:43:28 +0000 Subject: [PATCH 03/17] WIP; not working --- frontend/controller/actions/chatroom.js | 15 +- frontend/controller/actions/group.js | 66 ++-- frontend/controller/actions/identity.js | 286 +++++------------- frontend/controller/actions/index.js | 2 +- frontend/controller/actions/utils.js | 8 +- frontend/controller/service-worker.js | 4 + .../controller/serviceworkers/sw-primary.js | 238 +++++++++++++++ frontend/main.js | 205 ++++++++++--- frontend/model/contracts/chatroom.js | 68 ++--- frontend/model/contracts/group.js | 65 ++-- frontend/model/contracts/identity.js | 54 ++-- frontend/model/database.js | 169 ++++++++--- frontend/model/notifications/templates.js | 2 +- frontend/model/state.js | 276 ++++++++++++++++- frontend/views/components/Avatar.vue | 3 +- .../views/containers/access/LoginForm.vue | 8 +- .../views/containers/access/SignupForm.vue | 8 +- .../file-attachment/ChatAttachmentPreview.vue | 3 +- .../containers/user-settings/settings.js | 2 +- frontend/views/pages/Payments.vue | 2 +- frontend/views/pages/PendingApproval.vue | 5 +- package-lock.json | 10 +- package.json | 2 +- shared/domains/chelonia/chelonia.js | 12 +- shared/domains/chelonia/files.js | 13 +- shared/domains/chelonia/internals.js | 9 +- shared/domains/chelonia/utils.js | 7 +- shared/functions.js | 5 +- test/avatar-caching.test.js | 3 +- test/backend.test.js | 5 +- .../cypress/integration/notifications.spec.js | 2 +- test/cypress/support/commands.js | 6 +- 32 files changed, 1083 insertions(+), 480 deletions(-) diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 4fb8f6fa8..3663da016 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -4,6 +4,7 @@ import sbp from '@sbp/sbp' import { GIErrorUIRuntimeError, L } from '@common/common.js' import { has, omit } from '@model/contracts/shared/giLodash.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deserializeKey, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' @@ -62,12 +63,12 @@ export default (sbp('sbp/selectors/register', { } // Before creating the contract, put all keys into transient store - sbp('chelonia/storeSecretKeys', + await sbp('chelonia/storeSecretKeys', // $FlowFixMe[incompatible-use] - () => [cekOpts._rawKey, cskOpts._rawKey].map(key => ({ key, transient: true })) + new Secret([cekOpts._rawKey, cskOpts._rawKey].map(key => ({ key, transient: true }))) ) - const userCSKid = sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') + const userCSKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') if (!userCSKid) throw new Error('User CSK id not found') const SAK = keygen(EDWARDS25519SHA512BATCH) @@ -159,9 +160,9 @@ export default (sbp('sbp/selectors/register', { }) // After the contract has been created, store pesistent keys - sbp('chelonia/storeSecretKeys', + await sbp('chelonia/storeSecretKeys', // $FlowFixMe[incompatible-use] - () => [cekOpts._rawKey, cskOpts._rawKey].map(key => ({ key })) + new Secret([cekOpts._rawKey, cskOpts._rawKey].map(key => ({ key }))) ) return chatroom @@ -218,7 +219,7 @@ export default (sbp('sbp/selectors/register', { throw new Error(`Unable to send gi.actions/chatroom/join on ${params.contractID} because user ID contract ${userID} is missing`) } - const CEKid = params.encryptionKeyId || sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek') + const CEKid = params.encryptionKeyId || await sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek') const userCSKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') return await sbp('chelonia/out/atomic', { @@ -250,7 +251,7 @@ export default (sbp('sbp/selectors/register', { ...encryptedAction('gi.actions/chatroom/changeDescription', L('Failed to change chat channel description.')), ...encryptedAction('gi.actions/chatroom/leave', L('Failed to leave chat channel.'), async (sendMessage, params, signingKeyId) => { const userID = params.data.memberID - const keyIds = userID && sbp('chelonia/contract/foreignKeysByContractID', params.contractID, userID) + const keyIds = userID && await sbp('chelonia/contract/foreignKeysByContractID', params.contractID, userID) if (keyIds?.length) { return await sbp('chelonia/out/atomic', { diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 2288e2cbc..77256652b 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -29,7 +29,8 @@ import { JOINED_GROUP } from '@utils/events.js' import { imageUpload } from '@utils/image.js' -import { GIMessage } from '~/shared/domains/chelonia/chelonia.js' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' import { CONTRACT_HAS_RECEIVED_KEYS } from '~/shared/domains/chelonia/events.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug @@ -54,7 +55,7 @@ export default (sbp('sbp/selectors/register', { }, publishOptions }) { - let finalPicture = `${window.location.origin}/assets/images/group-avatar-default.png` + let finalPicture = `${self.location.origin}/assets/images/group-avatar-default.png` const rootState = sbp('state/vuex/state') const userID = rootState.loggedIn.identityContractID @@ -111,8 +112,8 @@ export default (sbp('sbp/selectors/register', { } // Before creating the contract, put all keys into transient store - sbp('chelonia/storeSecretKeys', - () => [CEK, CSK].map(key => ({ key, transient: true })) + await sbp('chelonia/storeSecretKeys', + new Secret([CEK, CSK].map(key => ({ key, transient: true }))) ) const userCSKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') @@ -237,18 +238,20 @@ export default (sbp('sbp/selectors/register', { const contractID = message.contractID() // After the contract has been created, store pesistent keys - sbp('chelonia/storeSecretKeys', - () => [CEK, CSK, inviteKey].map(key => ({ key })) + await sbp('chelonia/storeSecretKeys', + new Secret([CEK, CSK, inviteKey].map(key => ({ key }))) ) - await sbp('chelonia/queueInvocation', contractID, ['gi.actions/identity/joinGroup', { - contractID: userID, - data: { - groupContractID: contractID, - inviteSecret: serializeKey(CSK, true), - creatorID: true - } - }]) + await sbp('chelonia/contract/wait', contractID).then(() => { + return sbp('gi.actions/identity/joinGroup', { + contractID: userID, + data: { + groupContractID: contractID, + inviteSecret: serializeKey(CSK, true), + creatorID: true + } + }) + }) return message } catch (e) { @@ -313,7 +316,7 @@ export default (sbp('sbp/selectors/register', { // automatically, even if we have a valid invitation secret and are // technically able to. However, in the previous situation we *should* // attempt to rejoin if the action was user-initiated. - const hasKeyShareBeenRespondedBy = sbp('chelonia/contract/hasKeyShareBeenRespondedBy', userID, params.contractID, params.reference) + const hasKeyShareBeenRespondedBy = await sbp('chelonia/contract/hasKeyShareBeenRespondedBy', userID, params.contractID, params.reference) const state = rootState[params.contractID] @@ -321,7 +324,7 @@ export default (sbp('sbp/selectors/register', { // perform all operations in the group? If we haven't, we are not // able to participate in the group yet and may need to send a key // request. - const hasSecretKeys = sbp('chelonia/contract/receivedKeysToPerformOperation', userID, params.contractID, '*') + const hasSecretKeys = await sbp('chelonia/contract/receivedKeysToPerformOperation', userID, params.contractID, '*') // Do we need to send a key request? // If we don't have the group contract in our state and @@ -329,7 +332,7 @@ export default (sbp('sbp/selectors/register', { // through an invite link, and we must send a key request to complete // the joining process. const sendKeyRequest = (!hasKeyShareBeenRespondedBy && !hasSecretKeys && params.originatingContractID) - const pendingKeyShares = sbp('chelonia/contract/waitingForKeyShareTo', params.contractID, userID, params.reference) + const pendingKeyShares = await sbp('chelonia/contract/waitingForKeyShareTo', params.contractID, userID, params.reference) // If we are expecting to receive keys, set up an event listener // We are expecting to receive keys if: @@ -384,7 +387,7 @@ export default (sbp('sbp/selectors/register', { // (originating) contract. await sbp('chelonia/out/keyRequest', { ...omit(params, ['options']), - innerEncryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek'), + innerEncryptionKeyId: await sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek'), permissions: [GIMessage.OP_ACTION_ENCRYPTED], allowedActions: ['gi.contracts/identity/joinDirectMessage'], reference: params.reference, @@ -415,10 +418,10 @@ export default (sbp('sbp/selectors/register', { // synchronously, before any await calls. // If reading after an asynchronous operation, we might get inconsistent // values, as new operations could have been received on the contract - const CEKid = sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek') - const PEKid = sbp('chelonia/contract/currentKeyIdByName', userID, 'pek') - const CSKid = sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'csk') - const userCSKid = sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') + const CEKid = await sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek') + const PEKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'pek') + const CSKid = await sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'csk') + const userCSKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') const userCSKdata = rootState[userID]._vm.authorizedKeys[userCSKid].data try { @@ -531,7 +534,7 @@ export default (sbp('sbp/selectors/register', { }, 'gi.actions/group/switch': function (groupId) { sbp('state/vuex/commit', 'setCurrentGroupId', groupId) - sbp('okTurtles.events/emit', SWITCH_GROUP) + sbp('okTurtles.events/emit', SWITCH_GROUP, { contractID: groupId }) }, 'gi.actions/group/shareNewKeys': (contractID: string, newKeys) => { const rootState = sbp('state/vuex/state') @@ -541,8 +544,8 @@ export default (sbp('sbp/selectors/register', { return Promise.all( Object.entries(state.profiles) .filter(([_, p]) => (p: any).status === PROFILE_STATUS.ACTIVE) - .map(([pContractID]) => { - const CEKid = sbp('chelonia/contract/currentKeyIdByName', rootState[pContractID], 'cek') + .map(async ([pContractID]) => { + const CEKid = await sbp('chelonia/contract/currentKeyIdByName', rootState[pContractID], 'cek') if (!CEKid) { console.warn(`Unable to share rotated keys for ${contractID} with ${pContractID}: Missing CEK`) return Promise.resolve() @@ -572,14 +575,14 @@ export default (sbp('sbp/selectors/register', { } } - const cskId = sbp('chelonia/contract/currentKeyIdByName', contractState, 'csk') + const cskId = await sbp('chelonia/contract/currentKeyIdByName', contractState, 'csk') const csk = { id: cskId, foreignKey: `sp:${encodeURIComponent(params.contractID)}?keyName=${encodeURIComponent('csk')}`, data: contractState._vm.authorizedKeys[cskId].data } - const cekId = sbp('chelonia/contract/currentKeyIdByName', contractState, 'cek') + const cekId = await sbp('chelonia/contract/currentKeyIdByName', contractState, 'cek') const cek = { id: cekId, foreignKey: `sp:${encodeURIComponent(params.contractID)}?keyName=${encodeURIComponent('cek')}`, @@ -641,14 +644,9 @@ export default (sbp('sbp/selectors/register', { }), ...encryptedAction('gi.actions/group/joinChatRoom', L('Failed to join chat channel.'), async function (sendMessage, params) { const rootState = sbp('state/vuex/state') - const rootGetters = sbp('state/vuex/getters') const me = rootState.loggedIn.identityContractID const memberID = params.data.memberID || me - if (!rootGetters.isJoinedChatRoom(params.data.chatRoomID) && memberID !== me) { - throw new GIErrorUIRuntimeError(L('Only channel members can invite others to join.')) - } - // If we are inviting someone else to join, we need to share the chatroom's keys // with them so that they are able to read messages and participate if (memberID !== me && rootState[params.data.chatRoomID].attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE) { @@ -949,8 +947,8 @@ export default (sbp('sbp/selectors/register', { const current = await sbp('chelonia/kv/get', contractID, 'lastLoggedIn')?.data || {} current[userID] = now await sbp('chelonia/kv/set', contractID, 'lastLoggedIn', current, { - encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', contractID, 'cek'), - signingKeyId: sbp('chelonia/contract/currentKeyIdByName', contractID, 'csk') + encryptionKeyId: await sbp('chelonia/contract/currentKeyIdByName', contractID, 'cek'), + signingKeyId: await sbp('chelonia/contract/currentKeyIdByName', contractID, 'csk') }) }) } catch (e) { diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 8a34f120d..05c900662 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -6,70 +6,38 @@ import { CHATROOM_TYPES, PROFILE_STATUS } from '@model/contracts/shared/constants.js' -import { has, omit } from '@model/contracts/shared/giLodash.js' +import { has, omit, cloneDeep } from '@model/contracts/shared/giLodash.js' import sbp from '@sbp/sbp' import { imageUpload, objectURLtoBlob } from '@utils/image.js' import { SETTING_CURRENT_USER } from '~/frontend/model/database.js' -import { LOGIN, LOGIN_ERROR, LOGOUT } from '~/frontend/utils/events.js' +import { LOGIN, LOGOUT } from '~/frontend/utils/events.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' -import { boxKeyPair, buildRegisterSaltRequest, computeCAndHc, decryptContractSalt, hash, hashPassword, randomNonce } from '~/shared/zkpp.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug -import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deriveKeyFromPassword, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' import type { Key } from '../../../shared/domains/chelonia/crypto.js' -import { handleFetchResult } from '../utils/misc.js' +import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deserializeKey, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' import { encryptedAction } from './utils.js' export default (sbp('sbp/selectors/register', { - 'gi.actions/identity/retrieveSalt': async (username: string, passwordFn: () => string) => { - const r = randomNonce() - const b = hash(r) - const authHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/auth_hash?b=${encodeURIComponent(b)}`) - .then(handleFetchResult('json')) - - const { authSalt, s, sig } = authHash - - const h = await hashPassword(passwordFn(), authSalt) - - const [c, hc] = computeCAndHc(r, s, h) - - const contractHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/contract_hash?${(new URLSearchParams({ - 'r': r, - 's': s, - 'sig': sig, - 'hc': Buffer.from(hc).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') - })).toString()}`).then(handleFetchResult('text')) - - return decryptContractSalt(c, contractHash) - }, 'gi.actions/identity/create': async function ({ - data: { username, email, passwordFn, picture }, - publishOptions + IPK, + IEK, + publishOptions, + username, + email, + picture, + r, + s, + sig, + Eh }) { - const password = passwordFn() - let finalPicture = `${window.location.origin}/assets/images/user-avatar-default.png` - - // proceed with creation - const keyPair = boxKeyPair() - const r = Buffer.from(keyPair.publicKey).toString('base64').replace(/\//g, '_').replace(/\+/g, '-') - const b = hash(r) - // TODO: use the contractID instead, and move this code down below the registration - const registrationRes = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/${encodeURIComponent(username)}`, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded' - }, - body: `b=${encodeURIComponent(b)}` - }) - .then(handleFetchResult('json')) - - const { p, s, sig } = registrationRes + let finalPicture = `${self.location.origin}/assets/images/user-avatar-default.png` - const [contractSalt, Eh] = await buildRegisterSaltRequest(p, keyPair.secretKey, password) + IPK = typeof IPK === 'string' ? deserializeKey(IPK) : IPK + IEK = typeof IEK === 'string' ? deserializeKey(IEK) : IEK // Create the necessary keys to initialise the contract - const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, password, contractSalt) - const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, contractSalt) const CSK = keygen(EDWARDS25519SHA512BATCH) const CEK = keygen(CURVE25519XSALSA20POLY1305) const PEK = keygen(CURVE25519XSALSA20POLY1305) @@ -98,8 +66,8 @@ export default (sbp('sbp/selectors/register', { const SAKs = encryptedOutgoingDataWithRawKey(IEK, serializeKey(SAK, true)) // Before creating the contract, put all keys into transient store - sbp('chelonia/storeSecretKeys', - () => [IPK, IEK, CEK, CSK, PEK, SAK].map(key => ({ key, transient: true })) + await sbp('chelonia/storeSecretKeys', + new Secret([IPK, IEK, CEK, CSK, PEK, SAK].map(key => ({ key, transient: true }))) ) let userID @@ -208,7 +176,7 @@ export default (sbp('sbp/selectors/register', { const res = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/${encodeURIComponent(username)}`, { method: 'POST', headers: { - 'authorization': sbp('chelonia/shelterAuthorizationHeader', message.contractID()), + 'authorization': await sbp('chelonia/shelterAuthorizationHeader', message.contractID()), 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ @@ -244,83 +212,33 @@ export default (sbp('sbp/selectors/register', { }) // After the contract has been created, store pesistent keys - sbp('chelonia/storeSecretKeys', - () => [CEK, CSK, PEK].map(key => ({ key })) + await sbp('chelonia/storeSecretKeys', + new Secret([CEK, CSK, PEK].map(key => ({ key }))) ) - // And remove transient keys, which require a user password - sbp('chelonia/clearTransientSecretKeys', [IEKid, IPKid]) } catch (e) { console.error('gi.actions/identity/create failed!', e) throw new GIErrorUIRuntimeError(L('Failed to create user identity: {reportError}', LError(e))) + } finally { + // And remove transient keys, which require a user password + await sbp('chelonia/clearTransientSecretKeys', [IEKid, IPKid]) } return userID }, - 'gi.actions/identity/signup': async function ({ username, email, passwordFn }, publishOptions) { - try { - const randomAvatar = sbp('gi.utils/avatar/create') - const userID = await sbp('gi.actions/identity/create', { - data: { - username, - email, - passwordFn, - picture: randomAvatar - }, - publishOptions - }) - return userID - } catch (e) { - await sbp('gi.actions/identity/logout') // TODO: should this be here? - console.error('gi.actions/identity/signup failed!', e) - const message = LError(e) - if (e.name === 'GIErrorUIRuntimeError') { - // 'gi.actions/identity/create' also sets reportError - message.reportError = e.message - } - throw new GIErrorUIRuntimeError(L('Failed to signup: {reportError}', message)) - } - }, - 'gi.actions/identity/login': async function ({ username, passwordFn, identityContractID }: { - username: ?string, passwordFn: ?() => string, identityContractID: ?string - }) { - if (username) { - identityContractID = await sbp('namespace/lookup', username) - } + 'gi.actions/identity/login': async function ({ identityContractID, encryptionParams, cheloniaState, state, transientSecretKeys }) { + transientSecretKeys = transientSecretKeys.map(k => ({ key: deserializeKey(k), transient: true })) - if (!identityContractID) { - throw new GIErrorUIRuntimeError(L('Incorrect username or password')) - } - - const password = passwordFn?.() - const transientSecretKeys = [] - if (password) { - try { - const salt = await sbp('gi.actions/identity/retrieveSalt', username, passwordFn) - const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt) - transientSecretKeys.push({ key: IEK, transient: true }) - } catch (e) { - console.error('caught error calling retrieveSalt:', e) - throw new GIErrorUIRuntimeError(L('Incorrect username or password')) - } - } + await sbp('chelonia/storeSecretKeys', new Secret(transientSecretKeys)) try { - sbp('appLogs/startCapture', identityContractID) - const { encryptionParams, value: state } = await sbp('gi.db/settings/loadEncrypted', identityContractID, (stateEncryptionKeyId, salt) => { - return deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt + stateEncryptionKeyId) - }) - - const cheloniaState = state?.cheloniaState - if (cheloniaState) { - delete state.cheloniaState - } - - // TODO: The following is needed only if `!!password` const contractIDs = Object.create(null) + // login can be called when no settings are saved (e.g. from Signup.vue) if (cheloniaState) { + await sbp('chelonia/reset') + // TODO: Ability to pass state to reset instead of this Object.assign Object.assign(sbp('chelonia/rootState'), cheloniaState) - console.error('@@@@SET CHELONIA STATE[identity.js]', { cRS: sbp('chelonia/rootState'), cheloniaState, stateC: JSON.parse(JSON.stringify(state)), state }) - sbp('chelonia/pubsub/update') // resubscribe to contracts since we replaced the state + console.error('@@@@SET CHELONIA STATE[identity.js]') + await sbp('chelonia/pubsub/update') // resubscribe to contracts since we replaced the state // $FlowFixMe[incompatible-use] Object.entries(cheloniaState.contracts).forEach(([id, { type }]) => { if (!contractIDs[type]) { @@ -329,21 +247,6 @@ export default (sbp('sbp/selectors/register', { contractIDs[type].push(id) }) } - if (state) { - // The retrieved local data might need to be completed in case it was originally saved - // under an older version of the app where fewer/other Vuex modules were implemented. - sbp('state/vuex/postUpgradeVerification', state) - sbp('state/vuex/replace', state) - } - - await sbp('gi.db/settings/save', SETTING_CURRENT_USER, identityContractID) - - const loginAttributes = { identityContractID, encryptionParams } - - sbp('state/vuex/commit', 'login', loginAttributes) - if (password) { - await sbp('chelonia/storeSecretKeys', () => transientSecretKeys) - } // We need to sync contracts in this order to ensure that we have all the // corresponding secret keys. Group chatrooms use group keys but there's @@ -361,40 +264,6 @@ export default (sbp('sbp/selectors/register', { return index === -1 ? contractSyncPriorityList.length : index } - // loading the website instead of stalling out. - try { - if (!cheloniaState) { - // Make sure we don't unsubscribe from our own identity contract - // Note that this should be done _after_ calling - // `chelonia/storeSecretKeys`: If the following line results in - // syncing the identity contract and fetching events, the secret keys - // for processing them will not be available otherwise. - await sbp('chelonia/contract/retain', identityContractID) - } else { - // If there is a state, we've already retained the identity contract - // but might need to fetch the latest events - await sbp('chelonia/contract/sync', identityContractID, { force: true }) - } - } catch (err) { - sbp('okTurtles.events/emit', LOGIN_ERROR, { username, identityContractID, error: err }) - const errMessage = err?.message || String(err) - console.error('Error during login contract sync', errMessage) - - const promptOptions = { - heading: L('Login error'), - question: L('Do you want to log out? Error details: {err}.', { err: err.message }), - primaryButton: L('No'), - secondaryButton: L('Yes') - } - - const result = await sbp('gi.ui/prompt', promptOptions) - if (!result) { - return sbp('gi.actions/identity/logout') - } else { - throw err - } - } - try { // $FlowFixMe[incompatible-call] await Promise.all(Object.entries(contractIDs).sort(([a], [b]) => { @@ -404,16 +273,16 @@ export default (sbp('sbp/selectors/register', { return sbp('okTurtles.eventQueue/queueEvent', `login:${identityContractID ?? '(null)'}`, ['chelonia/contract/sync', ids, { force: true }]) })) } catch (err) { - alert(L('Sync error during login: {msg}', { msg: err?.message || 'unknown error' })) console.error('Error during contract sync upon login (syncing all contractIDs)', err) + throw err } try { // The state above might be null, so we re-grab it - const state = sbp('state/vuex/state') + const cheloniaState = sbp('chelonia/rootState') // The updated list of groups - const groupIds = Object.keys(state[identityContractID].groups) + const groupIds = Object.keys(cheloniaState[identityContractID].groups) // contract sync might've triggered an async call to /remove, so // wait before proceeding @@ -422,67 +291,73 @@ export default (sbp('sbp/selectors/register', { // Call 'gi.actions/group/join' on all groups which may need re-joining await Promise.allSettled( - groupIds.map(groupId => ( + groupIds.map(async groupId => ( // (1) Check whether the contract exists (may have been removed // after sync) - has(state.contracts, groupId) && - has(state[identityContractID].groups, groupId) && + has(cheloniaState.contracts, groupId) && + has(cheloniaState[identityContractID].groups, groupId) && // (2) Check whether the join process is still incomplete // This needs to be re-checked because it may have changed after // sync // // We only check for groups where we don't have a profile, as // // re-joining is handled by the group contract itself. // !state[groupId]?.profiles?.[identityContractID] && // ?.status !== PROFILE_STATUS. - state[groupId]?.profiles?.[identityContractID]?.status !== PROFILE_STATUS.ACTIVE && + cheloniaState[groupId]?.profiles?.[identityContractID]?.status !== PROFILE_STATUS.ACTIVE && // (3) Call join sbp('gi.actions/group/join', { originatingContractID: identityContractID, originatingContractName: 'gi.contracts/identity', contractID: groupId, contractName: 'gi.contracts/group', - reference: state[identityContractID].groups[groupId].hash, - signingKeyId: state[identityContractID].groups[groupId].inviteSecretId, - innerSigningKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'csk'), - encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'cek') + reference: cheloniaState[identityContractID].groups[groupId].hash, + signingKeyId: cheloniaState[identityContractID].groups[groupId].inviteSecretId, + innerSigningKeyId: await sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'csk'), + encryptionKeyId: await sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'cek') }).catch((e) => { - alert(L('Join group error during login: {msg}', { msg: e?.message || 'unknown error' })) console.error(`Error during gi.actions/group/join for ${groupId} at login`, e) + alert(L('Join group error during login: {msg}', { msg: e?.message || 'unknown error' })) }) )) ) // update the 'lastLoggedIn' field in user's group profiles - Object.keys(state[identityContractID].groups) + Object.keys(cheloniaState[identityContractID].groups) .forEach(cId => { // We send this action only for groups we have fully joined (i.e., // accepted an invite and added our profile) - if (state[cId]?.profiles?.[identityContractID]?.status === PROFILE_STATUS.ACTIVE) { + if (cheloniaState[cId]?.profiles?.[identityContractID]?.status === PROFILE_STATUS.ACTIVE) { sbp('gi.actions/group/updateLastLoggedIn', { contractID: cId }).catch((e) => console.error('Error sending updateLastLoggedIn', e)) } }) // NOTE: users could notice that they leave the group by someone // else when they log in - if (!state.currentGroupId) { - const gId = Object.keys(state.contracts) - .find(cID => has(state[identityContractID].groups, cID)) + if (!cheloniaState.currentGroupId) { + const gId = Object.keys(cheloniaState.contracts) + .find(cID => has(cheloniaState[identityContractID].groups, cID)) if (gId) { sbp('gi.actions/group/switch', gId) } } } catch (e) { - alert(L('Error during login: {msg}', { msg: e?.message || 'unknown error' })) console.error('[gi.actions/identity/login] Error re-joining groups after login', e) + throw e } finally { - sbp('okTurtles.events/emit', LOGIN, { username, identityContractID }) + if (!sbp('chelonia/rootState').loggedIn) { + sbp('chelonia/rootState').loggedIn = {} + } + sbp('chelonia/rootState').loggedIn.identityContractID = identityContractID + console.error('@@LOGGED IN', sbp('chelonia/rootState')) + await sbp('gi.db/settings/save', SETTING_CURRENT_USER, identityContractID) + sbp('okTurtles.events/emit', LOGIN, { identityContractID, encryptionParams, state }) } return identityContractID } catch (e) { + // TODO: Remove transient secret keys console.error('gi.actions/identity/login failed!', e) const humanErr = L('Failed to login: {reportError}', LError(e)) - alert(humanErr) await sbp('gi.actions/identity/logout') .catch((e) => { console.error('[gi.actions/identity/login] Error calling logout (after failure to login)', e) @@ -490,14 +365,9 @@ export default (sbp('sbp/selectors/register', { throw new GIErrorUIRuntimeError(humanErr) } }, - 'gi.actions/identity/signupAndLogin': async function ({ username, email, passwordFn }) { - const contractIDs = await sbp('gi.actions/identity/signup', { username, email, passwordFn }) - await sbp('gi.actions/identity/login', { username, passwordFn }) - return contractIDs - }, 'gi.actions/identity/logout': async function () { + let cheloniaState try { - const state = sbp('state/vuex/state') console.info('logging out, waiting for any events to finish...') // wait for any pending operations to finish before calling state/vuex/save // This includes, in order: @@ -510,7 +380,7 @@ export default (sbp('sbp/selectors/register', { // the `encrypted-action` queue) await sbp('okTurtles.eventQueue/queueEvent', 'encrypted-action', () => {}) // reset will wait until we have processed any remaining actions - await sbp('chelonia/reset', async () => { + cheloniaState = await sbp('chelonia/reset', async () => { // some of the actions that reset waited for might have side-effects // that send actions // we wait for those as well (the duplication in this case is @@ -521,37 +391,35 @@ export default (sbp('sbp/selectors/register', { // we could avoid waiting on these 2nd layer of actions) await sbp('okTurtles.eventQueue/queueEvent', 'encrypted-action', () => {}) // See comment below for 'gi.db/settings/delete' - await sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', () => {}) - await sbp('gi.db/settings/delete', 'CHELONIA_STATE') - sbp('state/vuex/state').cheloniaState = sbp('chelonia/rootState') - await sbp('state/vuex/save') - - // If there is a state encryption key in the app settings, remove it - const encryptionParams = state.loggedIn?.encryptionParams - if (encryptionParams) { - await sbp('gi.db/settings/deleteStateEncryptionKey', encryptionParams) - } + const cheloniaState = await sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', async () => { + const cheloniaState = cloneDeep(sbp('chelonia/rootState')) + await sbp('gi.db/settings/delete', 'CHELONIA_STATE') + return cheloniaState + }) await sbp('gi.db/settings/save', SETTING_CURRENT_USER, null) - }).then(() => { + + return cheloniaState + }).then((cheloniaState) => { console.info('successfully logged out') + return cheloniaState }) } catch (e) { console.error(`${e.name} during logout: ${e.message}`, e) } // Clear the file cache when logging out to preserve privacy sbp('gi.db/filesCache/clear').catch((e) => { console.error('Error clearing file cache', e) }) - sbp('state/vuex/reset') sbp('okTurtles.events/emit', LOGOUT) - sbp('appLogs/pauseCapture', { wipeOut: true }) // clear stored logs to prevent someone else accessing sensitve data + /// sbp('appLogs/pauseCapture', { wipeOut: true }) // clear stored logs to prevent someone else accessing sensitve data + return cheloniaState }, 'gi.actions/identity/addJoinDirectMessageKey': async (contractID, foreignContractID, keyName) => { - const keyId = sbp('chelonia/contract/currentKeyIdByName', foreignContractID, keyName) - const CEKid = sbp('chelonia/contract/currentKeyIdByName', contractID, 'cek') + const keyId = await sbp('chelonia/contract/currentKeyIdByName', foreignContractID, keyName) + const CEKid = await sbp('chelonia/contract/currentKeyIdByName', contractID, 'cek') const rootState = sbp('state/vuex/state') const foreignContractState = rootState[foreignContractID] - const existingForeignKeys = sbp('chelonia/contract/foreignKeysByContractID', contractID, foreignContractID) + const existingForeignKeys = await sbp('chelonia/contract/foreignKeysByContractID', contractID, foreignContractID) if (existingForeignKeys?.includes(keyId)) { return @@ -572,7 +440,7 @@ export default (sbp('sbp/selectors/register', { ringLevel: Number.MAX_SAFE_INTEGER, name: `${foreignContractID}/${keyId}` })], - signingKeyId: sbp('chelonia/contract/suitableSigningKey', contractID, [GIMessage.OP_KEY_ADD], ['sig']) + signingKeyId: await sbp('chelonia/contract/suitableSigningKey', contractID, [GIMessage.OP_KEY_ADD], ['sig']) }) }, 'gi.actions/identity/shareNewPEK': async (contractID: string, newKeys) => { @@ -706,7 +574,7 @@ export default (sbp('sbp/selectors/register', { }, // For now, we assume that we're messaging someone which whom we // share a group - signingKeyId: sbp('chelonia/contract/suitableSigningKey', partnerIDs[index], [GIMessage.OP_ACTION_ENCRYPTED], ['sig'], undefined, ['gi.contracts/identity/joinDirectMessage']), + signingKeyId: await sbp('chelonia/contract/suitableSigningKey', partnerIDs[index], [GIMessage.OP_ACTION_ENCRYPTED], ['sig'], undefined, ['gi.contracts/identity/joinDirectMessage']), innerSigningContractID: currentGroupId, hooks }) diff --git a/frontend/controller/actions/index.js b/frontend/controller/actions/index.js index 0f8255684..5ca8d3390 100644 --- a/frontend/controller/actions/index.js +++ b/frontend/controller/actions/index.js @@ -43,7 +43,7 @@ sbp('sbp/selectors/register', { } // TODO: Use 'chelonia/haveSecretKey' - const secretKeys = sbp('chelonia/rootState')['secretKeys'] + const secretKeys = await sbp('chelonia/rootState')['secretKeys'] const keysToShare = Array.isArray(keyIds) ? pick(secretKeys, keyIds) diff --git a/frontend/controller/actions/utils.js b/frontend/controller/actions/utils.js index 0a4e818b7..4e226a0c9 100644 --- a/frontend/controller/actions/utils.js +++ b/frontend/controller/actions/utils.js @@ -113,12 +113,12 @@ export const encryptedAction = ( ) const encryptionKeyId = params.encryptionKeyId || findKeyIdByName(state[contractID], encryptionKeyName ?? 'cek') - if (!signingKeyId || !encryptionKeyId || !sbp('chelonia/haveSecretKey', signingKeyId)) { + if (!signingKeyId || !encryptionKeyId || !await sbp('chelonia/haveSecretKey', signingKeyId)) { console.warn(`Refusing to send action ${action} due to missing CSK or CEK`, { contractID, action, signingKeyName, encryptionKeyName, signingKeyId, encryptionKeyId, signingContractID: params.signingContractID, originatingContractID: params.originatingContractID }) throw new GIErrorMissingSigningKeyError(`No key found to send ${action} for contract ${contractID}`) } - if (innerSigningContractID && (!innerSigningKeyId || !sbp('chelonia/haveSecretKey', innerSigningKeyId))) { + if (innerSigningContractID && (!innerSigningKeyId || !await sbp('chelonia/haveSecretKey', innerSigningKeyId))) { console.warn(`Refusing to send action ${action} due to missing inner signing key ID`, { contractID, action, signingKeyName, encryptionKeyName, signingKeyId, encryptionKeyId, signingContractID: params.signingContractID, originatingContractID: params.originatingContractID, innerSigningKeyId }) throw new GIErrorMissingSigningKeyError(`No key found to send ${action} for contract ${contractID}`) } @@ -219,12 +219,12 @@ export const encryptedNotification = ( ) const encryptionKeyId = params.encryptionKeyId || findKeyIdByName(state[contractID], encryptionKeyName ?? 'cek') - if (!signingKeyId || !encryptionKeyId || !sbp('chelonia/haveSecretKey', signingKeyId)) { + if (!signingKeyId || !encryptionKeyId || !await sbp('chelonia/haveSecretKey', signingKeyId)) { console.warn(`Refusing to send action ${action} due to missing CSK or CEK`, { contractID, action, signingKeyName, encryptionKeyName, signingKeyId, encryptionKeyId, signingContractID: params.signingContractID, originatingContractID: params.originatingContractID }) throw new GIErrorMissingSigningKeyError(`No key found to send ${action} for contract ${contractID}`) } - if (innerSigningContractID && (!innerSigningKeyId || !sbp('chelonia/haveSecretKey', innerSigningKeyId))) { + if (innerSigningContractID && (!innerSigningKeyId || !await sbp('chelonia/haveSecretKey', innerSigningKeyId))) { console.warn(`Refusing to send action ${action} due to missing inner signing key ID`, { contractID, action, signingKeyName, encryptionKeyName, signingKeyId, encryptionKeyId, signingContractID: params.signingContractID, originatingContractID: params.originatingContractID, innerSigningKeyId }) throw new GIErrorMissingSigningKeyError(`No key found to send ${action} for contract ${contractID}`) } diff --git a/frontend/controller/service-worker.js b/frontend/controller/service-worker.js index 1b60eaa16..b38baf11c 100644 --- a/frontend/controller/service-worker.js +++ b/frontend/controller/service-worker.js @@ -34,6 +34,10 @@ sbp('sbp/selectors/register', { sbp('service-worker/resubscribe-push', data.subscription) break } + case 'event': { + sbp('okTurtles.events/emit', event.data.subtype, ...event.data.data) + break + } default: console.error('[sw] Received unknown message type from the service worker:', data) break diff --git a/frontend/controller/serviceworkers/sw-primary.js b/frontend/controller/serviceworkers/sw-primary.js index 62b6a0cb5..889b48941 100644 --- a/frontend/controller/serviceworkers/sw-primary.js +++ b/frontend/controller/serviceworkers/sw-primary.js @@ -7,6 +7,234 @@ // https://frontendian.co/service-workers // https://stackoverflow.com/a/49748437 => https://medium.com/@nekrtemplar/self-destroying-serviceworker-73d62921d717 => https://love2dev.com/blog/how-to-uninstall-a-service-worker/ +import sbp from '@sbp/sbp' +import '~/shared/domains/chelonia/chelonia.js' +import manifests from '@model/contracts/manifests.json' +import * as Common from '@common/common.js' +import { debounce, has } from '@model/contracts/shared/giLodash.js' +import { NOTIFICATION_TYPE, REQUEST_TYPE } from '~/shared/pubsub.js' +import { PUBSUB_INSTANCE } from '../instance-keys.js' +import { CONTRACT_IS_SYNCING, CONTRACTS_MODIFIED, EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' +import { LOGIN, LOGIN_ERROR, LOGOUT } from '~/frontend/utils/events.js' +import { CHATROOM_USER_TYPING, CHATROOM_USER_STOP_TYPING, JOINED_GROUP, SWITCH_GROUP } from '../../utils/events.js' +import '@sbp/okturtles.data' +import '../namespace.js' +import '../actions/index.js' +import '../../views/utils/avatar.js' + +// TODO: Temporary serializer. Ideally, we'd use a lightweight implementation +// that gives out objects that can be passed around as messages that +// structuredClone supports +function serializer (x: any) { + if (x === undefined) return x + // This coerces types into types that can be passed using structuredClone + // However, it also destroys information about the types, which means that + // things like 'Set', which are supported by structuredClone, no longer work + return JSON.parse(JSON.stringify(x)) +} + +sbp('sbp/filters/global/add', (domain, selector, data) => { + // if (domainBlacklist[domain] || selectorBlacklist[selector]) return + if (selector.includes('wait')) console.error(`[sbp] ${selector}`, data) + console.debug(`[sbp] ${selector}`, data) +}) + +// this is to ensure compatibility between frontend and test/backend.test.js +sbp('okTurtles.data/set', 'API_URL', self.location.origin) + +sbp('sbp/selectors/register', { + 'state/vuex/state': () => { + // TODO: Remove this selector once it's removed from contracts + sbp('chelonia/rootState') + }, + 'state/vuex/reset': () => { + console.error('[sw] CALLED state/vuex/reset WHICH IS UNDEFINED') + }, + 'state/vuex/save': () => { + console.error('[sw] CALLED state/vuex/save WHICH IS UNDEFINED') + }, + 'state/vuex/commit': () => { + console.error('[sw] CALLED state/vuex/commit WHICH IS UNDEFINED') + } +}) + +sbp('chelonia/rootState').namespaceLookups = Object.create(null) + +// Use queueInvocation to prevent 'save' calls to persist after calling +// `'chelonia/reset'` +const save = debounce(() => sbp('chelonia/queueInvocation', 'CHELONIA_STATE', () => { + return sbp('gi.db/settings/save', 'CHELONIA_STATE', sbp('chelonia/rootState')).catch(e => { + console.error('Error saving Chelonia state', e) + }) +}), 500) + +sbp('chelonia/configure', { + connectionURL: sbp('okTurtles.data/get', 'API_URL'), + reactiveSet: (o: Object, k: string, v: string) => { + if (!has(o, k) || o[k] !== v) { + o[k] = v + save() + } + }, + reactiveDel: (o: Object, k: string) => { + if (has(o, k)) { + delete o[k] + save() + } + }, + contracts: { + ...manifests, + defaults: { + modules: { '@common/common.js': Common }, + allowedSelectors: [ + 'namespace/lookup', 'namespace/lookupCached', + 'state/vuex/state', 'state/vuex/settings', 'state/vuex/commit', 'state/vuex/getters', + 'chelonia/contract/state', + 'chelonia/contract/sync', 'chelonia/contract/isSyncing', 'chelonia/contract/remove', 'chelonia/contract/retain', 'chelonia/contract/release', 'controller/router', + 'chelonia/contract/suitableSigningKey', 'chelonia/contract/currentKeyIdByName', + 'chelonia/storeSecretKeys', 'chelonia/crypto/keyId', + 'chelonia/queueInvocation', + 'chelonia/contract/waitingForKeyShareTo', + 'chelonia/contract/successfulKeySharesByContractID', + 'gi.actions/chatroom/leave', + 'gi.actions/group/removeOurselves', 'gi.actions/group/groupProfileUpdate', 'gi.actions/group/displayMincomeChangedPrompt', 'gi.actions/group/addChatRoom', + 'gi.actions/group/join', 'gi.actions/group/joinChatRoom', + 'gi.actions/identity/addJoinDirectMessageKey', 'gi.actions/identity/leaveGroup', + 'gi.notifications/emit', + 'gi.actions/out/rotateKeys', 'gi.actions/group/shareNewKeys', 'gi.actions/chatroom/shareNewKeys', 'gi.actions/identity/shareNewPEK', + 'chelonia/out/keyDel', + 'chelonia/contract/disconnect', + 'gi.actions/identity/removeFiles', + 'gi.actions/chatroom/join', + 'chelonia/contract/hasKeysToPerformOperation' + ], + allowedDomains: ['okTurtles.data', 'okTurtles.events', 'okTurtles.eventQueue', 'gi.db', 'gi.contracts'], + preferSlim: true, + exposedGlobals: { + // note: needs to be written this way and not simply "Notification" + // because that breaks on mobile where Notification is undefined + // Notification: window.Notification + } + } + } + /* hooks: { + handleEventError: (e: Error, message: GIMessage) => { + if (e.name === 'ChelErrorUnrecoverable') { + sbp('gi.ui/seriousErrorBanner', e) + } + if (sbp('okTurtles.data/get', 'sideEffectError') !== message.hash()) { + // Avoid duplicate notifications for the same message. + errorNotification('handleEvent', e, message) + } + }, + processError: (e: Error, message: GIMessage, msgMeta: { signingKeyId: string, signingContractID: string, innerSigningKeyId: string, innerSigningContractID: string }) => { + if (e.name === 'GIErrorIgnoreAndBan') { + sbp('okTurtles.eventQueue/queueEvent', message.contractID(), [ + 'gi.actions/group/autobanUser', message, e, msgMeta + ]) + } + // For now, we ignore all missing keys errors + if (e.name === 'ChelErrorDecryptionKeyNotFound') { + return + } + errorNotification('process', e, message) + }, + sideEffectError: (e: Error, message: GIMessage) => { + sbp('gi.ui/seriousErrorBanner', e) + sbp('okTurtles.data/set', 'sideEffectError', message.hash()) + errorNotification('sideEffect', e, message) + } + } */ +}) + +sbp('okTurtles.events/on', EVENT_HANDLED, (contractID) => { + const message = { + type: 'event', + subtype: EVENT_HANDLED, + data: [contractID] + } + self.clients.matchAll() + .then((clientList) => { + clientList.forEach((client) => { + client.postMessage(message) + }) + }) +}) + +sbp('okTurtles.events/on', CONTRACTS_MODIFIED, (subscriptionSet) => { + const message = { + type: 'event', + subtype: CONTRACTS_MODIFIED, + data: [subscriptionSet] + } + self.clients.matchAll() + .then((clientList) => { + clientList.forEach((client) => { + client.postMessage(message) + }) + }) +}); + +[CONTRACTS_MODIFIED, CONTRACT_IS_SYNCING, LOGIN, LOGIN_ERROR, LOGOUT, JOINED_GROUP, SWITCH_GROUP].forEach(et => { + sbp('okTurtles.events/on', et, (...args) => { + const message = { + type: 'event', + subtype: et, + data: serializer(args) + } + self.clients.matchAll() + .then((clientList) => { + clientList.forEach((client) => { + client.postMessage(message) + }) + }) + }) +}) + +sbp('okTurtles.data/set', PUBSUB_INSTANCE, sbp('chelonia/connect', { + messageHandlers: { + [NOTIFICATION_TYPE.VERSION_INFO] (msg) { + const ourVersion = process.env.GI_VERSION + const theirVersion = msg.data.GI_VERSION + + const ourContractsVersion = process.env.CONTRACTS_VERSION + const theirContractsVersion = msg.data.CONTRACTS_VERSION + if (ourVersion !== theirVersion || ourContractsVersion !== theirContractsVersion) { + sbp('okTurtles.events/emit', NOTIFICATION_TYPE.VERSION_INFO, { ...msg.data }) + } + }, + [REQUEST_TYPE.PUSH_ACTION] (msg) { + sbp('okTurtles.events/emit', REQUEST_TYPE.PUSH_ACTION, { data: msg.data }) + }, + [NOTIFICATION_TYPE.PUB] (msg) { + const { contractID, innerSigningContractID, data } = msg + + switch (data[0]) { + case 'gi.contracts/chatroom/user-typing-event': { + sbp('okTurtles.events/emit', CHATROOM_USER_TYPING, { contractID, innerSigningContractID }) + break + } + case 'gi.contracts/chatroom/user-stop-typing-event': { + sbp('okTurtles.events/emit', CHATROOM_USER_STOP_TYPING, { contractID, innerSigningContractID }) + break + } + default: { + console.log(`[pubsub] Received data from channel ${contractID}:`, data) + } + } + }, + [NOTIFICATION_TYPE.KV] ([key, data]) { + switch (key) { + case 'lastLoggedIn': { + // TODO THIS + // const rootState = sbp('state/vuex/state') + // Vue.set(rootState.lastLoggedIn, data.contractID, data.data) + } + } + } + } +})) + self.addEventListener('install', function (event) { console.debug('[sw] install') event.waitUntil(self.skipWaiting()) @@ -57,6 +285,16 @@ self.addEventListener('message', function (event) { case 'store-client-id': store.clientId = event.source.id break + case 'sbp': { + console.error('@@@@[sw] sbp', event) + const port = event.data.port + Promise.resolve(sbp(...event.data.data)).then((r) => { + port.postMessage([true, serializer(r)]) + }).catch((e) => { + port.postMessage([false, e]) + }) + break + } default: console.error('[sw] unknown message type:', event.data) break diff --git a/frontend/main.js b/frontend/main.js index 7c6d67f76..2bf065d56 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -8,17 +8,18 @@ import '@sbp/okturtles.eventqueue' import { mapMutations, mapGetters, mapState } from 'vuex' import 'wicg-inert' import '@model/captureLogs.js' -import type { GIMessage } from '~/shared/domains/chelonia/chelonia.js' -import '~/shared/domains/chelonia/chelonia.js' +import type { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +// import '~/shared/domains/chelonia/chelonia.js' import { CONTRACT_IS_SYNCING, CONTRACTS_MODIFIED, EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' import { NOTIFICATION_TYPE, REQUEST_TYPE } from '../shared/pubsub.js' import * as Common from '@common/common.js' -import { LOGIN, LOGOUT, LOGIN_ERROR, SWITCH_GROUP, THEME_CHANGE, CHATROOM_USER_TYPING, CHATROOM_USER_STOP_TYPING } from './utils/events.js' +import { LOGIN, LOGOUT, LOGIN_ERROR, SWITCH_GROUP, THEME_CHANGE, CHATROOM_USER_TYPING, CHATROOM_USER_STOP_TYPING, JOINED_GROUP } from './utils/events.js' import './controller/namespace.js' -import './controller/actions/index.js' +// import './controller/actions/index.js' +import './controller/app/index.js' import './controller/backend.js' import './controller/service-worker.js' -import '~/shared/domains/chelonia/persistent-actions.js' +// import '~/shared/domains/chelonia/persistent-actions.js' import manifests from './model/contracts/manifests.json' import router from './controller/router.js' import { PUBSUB_INSTANCE } from './controller/instance-keys.js' @@ -41,7 +42,7 @@ import './model/notifications/periodicNotifications.js' import notificationsMixin from './model/notifications/mainNotificationsMixin.js' import { showNavMixin } from './views/utils/misc.js' import FaviconBadge from './utils/faviconBadge.js' -import { debounce } from '@model/contracts/shared/giLodash.js' +import { has } from '@model/contracts/shared/giLodash.js' const { Vue, L } = Common @@ -105,6 +106,7 @@ async function startApp () { // Since a runtime error just occured, we likely want to persist app logs to local storage now. sbp('appLogs/save') } + /* await sbp('gi.db/settings/load', 'CHELONIA_STATE').then(async (cheloniaState) => { // TODO: PLACEHOLDER TO SIMULATE CHELONIA IN A SW if (!cheloniaState) return @@ -114,18 +116,70 @@ async function startApp () { console.error('@@@@SET CHELONIA STATE[main.js]', identityContractID, sbp('chelonia/rootState'), cheloniaState) }) console.error('@@@@@@@@') - const save = debounce(() => sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', () => { + /* const save = debounce(() => sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', () => { return sbp('gi.db/settings/save', 'CHELONIA_STATE', sbp('chelonia/rootState')) - })) + })) */ - await sbp('chelonia/configure', { + // register service-worker + await sbp('service-workers/setup') + + /* TODO: MOVE TO ANOTHER FILE */ + const swRpc = (...args) => { + return new Promise((resolve, reject) => { + console.error('@@CHELONIA', args) + const messageChannel = new MessageChannel() + messageChannel.port1.addEventListener('message', (event) => { + console.error('@@@RECEIVED', event) + if (event.data && Array.isArray(event.data)) { + if (event.data[0] === true) { + resolve(event.data[1]) + } else { + reject(event.data[1]) + } + messageChannel.port1.close() + } + }) + messageChannel.port1.addEventListener('messageerror', (event) => { + reject(event.data) + messageChannel.port1.close() + }) + messageChannel.port1.start() + navigator.serviceWorker.controller.postMessage({ + type: 'sbp', + port: messageChannel.port2, + data: args + }, [messageChannel.port2]) + }) + } + + sbp('sbp/selectors/register', { + 'gi.actions/*': swRpc + }) + sbp('sbp/selectors/register', { + 'chelonia/*': swRpc + }) + + sbp('okTurtles.events/on', JOINED_GROUP, ({ contractID }) => { + const rootState = sbp('state/vuex/state') + if (!rootState.currentGroupId) { + sbp('state/vuex/commit', 'setCurrentGroupId', contractID) + sbp('state/vuex/commit', 'setCurrentChatRoomId', {}) + } + }) + + sbp('okTurtles.events/on', SWITCH_GROUP, ({ contractID }) => { + sbp('state/vuex/commit', 'setCurrentGroupId', contractID) + }) + /* TODO: END MOVE TO ANOTHER FILE */ + + isNaN(0) && await sbp('chelonia/configure', { connectionURL: sbp('okTurtles.data/get', 'API_URL'), /* stateSelector: 'state/vuex/state', reactiveSet: Vue.set, reactiveDel: Vue.delete, */ - reactiveSet: (o: Object, k: string, v: string) => { + /* reactiveSet: (o: Object, k: string, v: string) => { // TODO: PLACEHOLDER TO SIMULATE CHELONIA SERVICE WORKER SAVING STATE // TODO: DOES THE STATE EVEN NEED TO BE SAVED OR IS RAM ENOUGH? if (o[k] !== v) { @@ -140,7 +194,7 @@ async function startApp () { delete o[k] save() } - }, + }, */ contracts: { ...manifests, defaults: { @@ -206,8 +260,10 @@ async function startApp () { } }) - sbp('okTurtles.events/on', EVENT_HANDLED, (contractID) => { - const cheloniaState = sbp('chelonia/rootState') + sbp('okTurtles.events/on', EVENT_HANDLED, async (contractID) => { + // TODO: WRITE THIS MORE EFFICIENTLY SO THAT ONLY THE RELEVANT PARTS ARE + // COPIED INSTEAD OF THE ENTIRE CHELONIA STATE + const cheloniaState = await sbp('chelonia/rootState') const state = cheloniaState[contractID] const contractState = cheloniaState.contracts[contractID] const vuexState = sbp('state/vuex/state') @@ -226,14 +282,20 @@ async function startApp () { } }) - sbp('okTurtles.events/on', CONTRACTS_MODIFIED, (subscriptionSet) => { - const cheloniaState = sbp('chelonia/rootState') + sbp('okTurtles.events/on', CONTRACTS_MODIFIED, async (subscriptionSet) => { + // TODO: WRITE THIS MORE EFFICIENTLY SO THAT ONLY THE RELEVANT PARTS ARE + // COPIED INSTEAD OF THE ENTIRE CHELONIA STATE + const cheloniaState = await sbp('chelonia/rootState') const vuexState = sbp('state/vuex/state') if (!vuexState.contracts) { Vue.set(vuexState, 'contracts', Object.create(null)) } + if (!subscriptionSet.has) { + console.error('@@@@@CM NO SUBS.HAS', subscriptionSet) + } + const oldContracts = Object.keys(vuexState.contracts) const oldContractsToRemove = oldContracts.filter(x => !subscriptionSet.has(x)) const newContracts = Array.from(subscriptionSet).filter(x => !oldContracts.includes(x)) @@ -282,7 +344,7 @@ async function startApp () { const initialSyncFn = syncFn.bind(initialSyncs) try { // must create the connection before we call login - sbp('okTurtles.data/set', PUBSUB_INSTANCE, sbp('chelonia/connect', { + isNaN(0) && sbp('okTurtles.data/set', PUBSUB_INSTANCE, sbp('chelonia/connect', { messageHandlers: { [NOTIFICATION_TYPE.VERSION_INFO] (msg) { const ourVersion = process.env.GI_VERSION @@ -332,9 +394,6 @@ async function startApp () { return } - // register service-worker - await sbp('service-workers/setup') - /* eslint-disable no-new */ new Vue({ router: router, @@ -382,23 +441,32 @@ async function startApp () { } sbp('okTurtles.events/off', CONTRACT_IS_SYNCING, initialSyncFn) sbp('okTurtles.events/on', CONTRACT_IS_SYNCING, syncFn.bind(this)) - sbp('okTurtles.events/on', LOGIN, async () => { + sbp('okTurtles.events/on', LOGIN, () => { this.ephemeral.finishedLogin = 'yes' if (this.$store.state.currentGroupId) { this.initOrResetPeriodicNotifications() this.checkAndEmitOneTimeNotifications() } - const databaseKey = `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}` + /* const databaseKey = `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}` sbp('chelonia.persistentActions/configure', { databaseKey }) await sbp('chelonia.persistentActions/load') + */ }) sbp('okTurtles.events/on', LOGOUT, () => { + const state = sbp('state/vuex/state') + if (!state.loggedIn) return this.ephemeral.finishedLogin = 'no' router.currentRoute.path !== '/' && router.push({ path: '/' }).catch(console.error) // Stop timers related to periodic notifications or persistent actions. sbp('gi.periodicNotifications/clearStatesAndStopTimers') + sbp('gi.db/settings/delete', state.loggedIn.identityContractID).catch(e => { + console.error('Logout event: error deleting settings') + }) + sbp('state/vuex/reset') + /* sbp('chelonia.persistentActions/unload') + */ }) sbp('okTurtles.events/once', LOGIN_ERROR, () => { // Remove the loading animation that sits on top of the Vue app, so that users can properly interact with the app for a follow-up action. @@ -413,7 +481,7 @@ async function startApp () { // Allow access to `L` inside event handlers. const L = this.L.bind(this) - Object.assign(pubsub.customEventHandlers, { + isNaN(0) && Object.assign(pubsub.customEventHandlers, { offline () { sbp('gi.ui/showBanner', L('Your device appears to be offline.'), 'wifi') }, @@ -463,7 +531,7 @@ async function startApp () { // to ensure that we don't override user interactions that have already // happened (an example where things can happen this quickly is in the // tests). - sbp('gi.db/settings/load', 'CHELONIA_STATE').then(async (cheloniaState) => { + isNaN(1) && sbp('gi.db/settings/load', 'CHELONIA_STATE').then(async (cheloniaState) => { // TODO: PLACEHOLDER TO SIMULATE CHELONIA IN A SW const identityContractID = await sbp('gi.db/settings/load', SETTING_CURRENT_USER) if (!cheloniaState || !identityContractID) return @@ -484,19 +552,20 @@ async function startApp () { }).map(([, ids]) => { return sbp('okTurtles.eventQueue/queueEvent', `appStart:${identityContractID ?? '(null)'}`, ['chelonia/contract/sync', ids, { force: true }]) })) - }).then(() => - sbp('gi.db/settings/load', SETTING_CURRENT_USER).then(identityContractID => { - if (!identityContractID || this.ephemeral.finishedLogin === 'yes') return - return sbp('gi.actions/identity/login', { identityContractID }).catch((e) => { - console.error(`[main] caught ${e?.name} while logging in: ${e?.message || e}`, e) - console.warn(`It looks like the local user '${identityContractID}' does not exist anymore on the server 😱 If this is unexpected, contact us at https://gitter.im/okTurtles/group-income`) - }) - }).catch(e => { - console.error(`[main] caught ${e?.name} while fetching settings or handling a login error: ${e?.message || e}`, e) - }).finally(() => { - this.ephemeral.ready = true - this.removeLoadingAnimation() - })) + }) + + sbp('gi.db/settings/load', SETTING_CURRENT_USER).then(identityContractID => { + if (!identityContractID || this.ephemeral.finishedLogin === 'yes') return + return sbp('gi.app/identity/login', { identityContractID }).catch((e) => { + console.error(`[main] caught ${e?.name} while logging in: ${e?.message || e}`, e) + console.warn(`It looks like the local user '${identityContractID}' does not exist anymore on the server 😱 If this is unexpected, contact us at https://gitter.im/okTurtles/group-income`) + }) + }).catch(e => { + console.error(`[main] caught ${e?.name} while fetching settings or handling a login error: ${e?.message || e}`, e) + }).finally(() => { + this.ephemeral.ready = true + this.removeLoadingAnimation() + }) }, computed: { ...mapGetters(['groupsByName', 'ourUnreadMessages', 'totalUnreadNotificationCount']), @@ -544,4 +613,68 @@ async function startApp () { }).$mount('#app') } +sbp('okTurtles.events/on', LOGIN, async ({ identityContractID, encryptionParams, state }) => { + const vuexState = sbp('state/vuex/state') + if (vuexState.loggedIn) { + throw new Error('Received login event but there already is an active session') + } + const cheloniaState = await sbp('chelonia/rootState') + if (state) { + // TODO Do this in a cleaner way + // Exclude contracts from the state + Object.keys(state).forEach(k => { + if (k.startsWith('z9br')) { + delete state[k] + } + }) + Object.keys(cheloniaState.contracts).forEach(k => { + if (cheloniaState[k]) { + state[k] = cheloniaState[k] + } + }) + state.contracts = cheloniaState.contracts + // End exclude contracts + sbp('state/vuex/postUpgradeVerification', state) + sbp('state/vuex/replace', state) + } else { + const state = vuexState + // Exclude contracts from the state + Object.keys(state).forEach(k => { + if (k.startsWith('z9br')) { + Vue.delete(state, k) + } + }) + Object.keys(cheloniaState.contracts).forEach(k => { + if (cheloniaState[k]) { + Vue.set(state, k, cheloniaState[k]) + } + }) + Vue.set(state, 'contracts', cheloniaState.contracts) + // End exclude contracts + } + + if (encryptionParams) { + sbp('state/vuex/commit', 'login', { identityContractID, encryptionParams }) + } + + // NOTE: users could notice that they leave the group by someone + // else when they log in + const currentState = sbp('state/vuex/state') + if (!currentState.currentGroupId) { + const gId = Object.keys(currentState.contracts) + .find(cID => has(currentState[identityContractID].groups, cID)) + + if (gId) { + // TODO: This should be gi.app/group/switch once implemented + sbp('gi.actions/group/switch', gId) + } + } + + // Whenever there's an anctive session, the encrypted save state should be + // removed, as it is only used for recovering the state when logging in + sbp('gi.db/settings/deleteEncrypted', identityContractID).catch(e => { + console.error('Error deleting encrypted settings after login') + }) +}) + startApp() diff --git a/frontend/model/contracts/chatroom.js b/frontend/model/contracts/chatroom.js index ab3be6c2d..4dfdacea2 100644 --- a/frontend/model/contracts/chatroom.js +++ b/frontend/model/contracts/chatroom.js @@ -46,12 +46,12 @@ function createNotificationData ( } } -function setReadUntilWhileJoining ({ contractID, hash, createdDate }: { +async function setReadUntilWhileJoining ({ contractID, hash, createdDate }: { contractID: string, hash: string, createdDate: string -}): void { - if (sbp('chelonia/contract/isSyncing', contractID, { firstSync: true })) { +}): Promise { + if (await sbp('chelonia/contract/isSyncing', contractID, { firstSync: true })) { sbp('state/vuex/commit', 'setChatRoomReadUntil', { chatRoomId: contractID, messageHash: hash, @@ -60,7 +60,7 @@ function setReadUntilWhileJoining ({ contractID, hash, createdDate }: { } } -function messageReceivePostEffect ({ +async function messageReceivePostEffect ({ contractID, messageHash, datetime, text, isDMOrMention, messageType, memberID, chatRoomName }: { @@ -72,8 +72,8 @@ function messageReceivePostEffect ({ isDMOrMention: boolean, memberID: string, chatRoomName: string -}): void { - if (sbp('chelonia/contract/isSyncing', contractID)) { +}): Promise { + if (await sbp('chelonia/contract/isSyncing', contractID)) { return } const rootGetters = sbp('state/vuex/getters') @@ -139,23 +139,7 @@ sbp('chelonia/defineContract', { } } }, - getters: { - currentChatRoomState (state) { - return state - }, - chatRoomSettings (state, getters) { - return getters.currentChatRoomState.settings || {} - }, - chatRoomAttributes (state, getters) { - return getters.currentChatRoomState.attributes || {} - }, - chatRoomMembers (state, getters) { - return getters.currentChatRoomState.members || {} - }, - chatRoomLatestMessages (state, getters) { - return getters.currentChatRoomState.messages || [] - } - }, + getters: {}, actions: { // This is the constructor of Chat contract 'gi.contracts/chatroom': { @@ -238,7 +222,7 @@ sbp('chelonia/defineContract', { const rootGetters = sbp('state/vuex/getters') const loggedIn = sbp('state/vuex/state').loggedIn - setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) + await setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) if (memberID === loggedIn.identityContractID) { if (state.attributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { @@ -285,8 +269,8 @@ sbp('chelonia/defineContract', { const newMessage = createMessage({ meta, hash, height, data: notificationData, state, innerSigningContractID }) state.messages.push(newMessage) }, - sideEffect ({ contractID, hash, meta }) { - setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) + async sideEffect ({ contractID, hash, meta }) { + await setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) } }, 'gi.contracts/chatroom/changeDescription': { @@ -306,8 +290,8 @@ sbp('chelonia/defineContract', { const newMessage = createMessage({ meta, hash, height, data: notificationData, state, innerSigningContractID }) state.messages.push(newMessage) }, - sideEffect ({ contractID, hash, meta }) { - setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) + async sideEffect ({ contractID, hash, meta }) { + await setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) } }, 'gi.contracts/chatroom/leave': { @@ -369,7 +353,7 @@ sbp('chelonia/defineContract', { console.error(`[gi.contracts/chatroom/leave/sideEffect] Error for ${contractID}`, e) }) } else { - setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) + await setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) if (state.attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE) { sbp('gi.contracts/chatroom/rotateKeys', contractID, state) @@ -429,8 +413,8 @@ sbp('chelonia/defineContract', { delete existingMsg.pending } }, - sideEffect ({ contractID, hash, height, meta, data, innerSigningContractID }, { state, getters }) { - setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) + async sideEffect ({ contractID, hash, height, meta, data, innerSigningContractID }, { state }) { + await setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) const me = sbp('state/vuex/state').loggedIn.identityContractID @@ -442,15 +426,15 @@ sbp('chelonia/defineContract', { const isMentionedMe = data.type === MESSAGE_TYPES.TEXT && (newMessage.text.includes(mentions.me) || newMessage.text.includes(mentions.all)) - messageReceivePostEffect({ + await messageReceivePostEffect({ contractID, messageHash: newMessage.hash, datetime: newMessage.datetime, text: newMessage.text, - isDMOrMention: isMentionedMe || getters.chatRoomAttributes.type === CHATROOM_TYPES.DIRECT_MESSAGE, + isDMOrMention: isMentionedMe || state.settings.type === CHATROOM_TYPES.DIRECT_MESSAGE, messageType: data.type, memberID: innerSigningContractID, - chatRoomName: getters.chatRoomAttributes.name + chatRoomName: state.settings.name }) } }, @@ -474,9 +458,9 @@ sbp('chelonia/defineContract', { } } }, - sideEffect ({ contractID, hash, meta, data, innerSigningContractID }, { getters }) { + async sideEffect ({ contractID, hash, meta, data, innerSigningContractID }, { state }) { const me = sbp('state/vuex/state').loggedIn.identityContractID - if (me === innerSigningContractID || getters.chatRoomAttributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { + if (me === innerSigningContractID || state.settings.type === CHATROOM_TYPES.DIRECT_MESSAGE) { return } @@ -486,7 +470,7 @@ sbp('chelonia/defineContract', { const isMentionedMe = data.text.includes(mentions.me) || data.text.includes(mentions.all) if (!isAlreadyAdded) { - messageReceivePostEffect({ + await messageReceivePostEffect({ contractID, messageHash: data.hash, /* @@ -500,7 +484,7 @@ sbp('chelonia/defineContract', { isDMOrMention: isMentionedMe, messageType: MESSAGE_TYPES.TEXT, memberID: innerSigningContractID, - chatRoomName: getters.chatRoomAttributes.name + chatRoomName: state.settings.name }) } else if (!isMentionedMe) { sbp('state/vuex/commit', 'deleteChatRoomUnreadMessage', { @@ -689,8 +673,8 @@ sbp('chelonia/defineContract', { const newMessage = createMessage({ meta, hash, height, data: notificationData, state, innerSigningContractID }) state.messages.push(newMessage) }, - sideEffect ({ contractID, hash, meta }) { - setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) + async sideEffect ({ contractID, hash, meta }) { + await setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) } }, 'gi.contracts/chatroom/changeVoteOnPoll': { @@ -741,8 +725,8 @@ sbp('chelonia/defineContract', { const newMessage = createMessage({ meta, hash, height, data: notificationData, state, innerSigningContractID }) state.messages.push(newMessage) }, - sideEffect ({ contractID, hash, meta }) { - setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) + async sideEffect ({ contractID, hash, meta }) { + await setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) } }, 'gi.contracts/chatroom/closePoll': { diff --git a/frontend/model/contracts/group.js b/frontend/model/contracts/group.js index 0d9c26862..c8c3461ef 100644 --- a/frontend/model/contracts/group.js +++ b/frontend/model/contracts/group.js @@ -24,6 +24,7 @@ import { inviteType, chatRoomAttributesType } from './shared/types.js' import { arrayOf, objectOf, objectMaybeOf, optional, string, number, boolean, object, unionOf, tupleOf, actionRequireInnerSignature } from '~/frontend/model/contracts/misc/flowTyper.js' import { findKeyIdByName, findForeignKeysByContractID } from '~/shared/domains/chelonia/utils.js' import { REMOVE_NOTIFICATION } from '~/frontend/model/notifications/mutationKeys.js' +import { JOINED_GROUP } from '@utils/events.js' function vueFetchInitKV (obj: Object, key: string, initialValue: any): any { let value = obj[key] @@ -302,7 +303,7 @@ const removeGroupChatroomProfile = (state, chatRoomID, member) => { ) } -const leaveChatRoomAction = (state, chatRoomID, memberID, actorID, leavingGroup) => { +const leaveChatRoomAction = async (state, chatRoomID, memberID, actorID, leavingGroup) => { const sendingData = leavingGroup || actorID !== memberID ? { memberID } : {} @@ -319,8 +320,8 @@ const leaveChatRoomAction = (state, chatRoomID, memberID, actorID, leavingGroup) // unconditionally in this situation, which should be a key in the // chatroom (either the CSK or the groupKey) if (leavingGroup) { - const encryptionKeyId = sbp('chelonia/contract/currentKeyIdByName', state, 'cek', true) - const signingKeyId = sbp('chelonia/contract/currentKeyIdByName', state, 'csk', true) + const encryptionKeyId = await sbp('chelonia/contract/currentKeyIdByName', state, 'cek', true) + const signingKeyId = await sbp('chelonia/contract/currentKeyIdByName', state, 'csk', true) // If we don't have a CSK, it is because we've already been removed. // Proceeding would cause an error @@ -715,7 +716,9 @@ sbp('chelonia/defineContract', { for (const key in initialState) { Vue.set(state, key, initialState[key]) } + console.error('CALLING INIT FETCH PERIOD PAYMENTS') initFetchPeriodPayments({ contractID, meta, state, getters }) + console.error('/CALLING INIT FETCH PERIOD PAYMENTS') }, sideEffect ({ contractID }, { state }) { if (!state.generalChatRoomId) { @@ -728,7 +731,7 @@ sbp('chelonia/defineContract', { const CEKid = findKeyIdByName(state, 'cek') // create a 'General' chatroom contract - return sbp('gi.actions/group/addChatRoom', { + sbp('gi.actions/group/addChatRoom', { contractID, data: { attributes: { @@ -743,6 +746,8 @@ sbp('chelonia/defineContract', { // The #General chatroom does not have an inner signature as it's part // of the group creation process innerSigningContractID: null + }).catch((e) => { + console.error(`[gi.contracts/group/sideEffect] Error creating #General chatroom for ${contractID} (unable to send action)`, e) }) }).catch((e) => { console.error(`[gi.contracts/group/sideEffect] Error creating #General chatroom for ${contractID}`, e) @@ -1119,9 +1124,7 @@ sbp('chelonia/defineContract', { const { loggedIn } = sbp('state/vuex/state') sbp('chelonia/queueInvocation', contractID, async () => { - const rootState = sbp('state/vuex/state') - const rootGetters = sbp('state/vuex/getters') - const state = rootState[contractID] + const state = await sbp('chelonia/contract/state', contractID) if (!state) { console.info(`[gi.contracts/group/inviteAccept] Contract ${contractID} has been removed`) @@ -1183,8 +1186,7 @@ sbp('chelonia/defineContract', { // subscribe to founder's IdentityContract & everyone else's const profileIds = Object.keys(profiles) .filter((id) => - id !== loggedIn.identityContractID && - !rootGetters.ourContactProfilesById[id] + id !== loggedIn.identityContractID ) if (profileIds.length !== 0) { sbp('chelonia/contract/retain', profileIds).catch((e) => { @@ -1192,11 +1194,7 @@ sbp('chelonia/defineContract', { }) } - // If we don't have a current group ID, select the group we've just joined - if (!rootState.currentGroupId) { - sbp('state/vuex/commit', 'setCurrentGroupId', contractID) - sbp('state/vuex/commit', 'setCurrentChatRoomId', {}) - } + sbp('okTurtles.events/emit', JOINED_GROUP, { contractID }) } else { // we're an existing member of the group getting notified that a // new member has joined, so subscribe to their identity contract @@ -1381,7 +1379,7 @@ sbp('chelonia/defineContract', { Vue.set(state, 'generalChatRoomId', data.chatRoomID) } }, - sideEffect ({ contractID }, { state }) { + sideEffect ({ contractID, data }, { state }) { if (Object.keys(state.chatRooms).length === 1) { // NOTE: only general chatroom exists, meaning group has just been created sbp('state/vuex/commit', 'setCurrentChatRoomId', { @@ -1389,6 +1387,25 @@ sbp('chelonia/defineContract', { chatRoomId: state.generalChatRoomId }) } + // If it's the #General chatroom being added, add ourselves to it + if (data.chatRoomID === state.generalChatRoomId) { + sbp('chelonia/queueInvocation', contractID, () => { + const { identityContractID } = sbp('state/vuex/state').loggedIn + if ( + state.profiles?.[identityContractID]?.status === PROFILE_STATUS.ACTIVE && + state.chatRooms?.[contractID]?.members[identityContractID]?.status !== PROFILE_STATUS.ACTIVE + ) { + sbp('gi.actions/group/joinChatRoom', { + contractID, + data: { + chatRoomID: data.chatRoomID + } + }).catch(e => { + console.error('Unable to add ourselves to the #General chatroom', e) + }) + } + }) + } } }, 'gi.contracts/group/deleteChatRoom': { @@ -1679,7 +1696,8 @@ sbp('chelonia/defineContract', { // 1) automatically switch that user to a 'pledging' member with 0 contribution, // 2) pop out the prompt message notifying them of this automatic change, // 3) and send 'MINCOME_CHANGED' notification. - const myProfile = sbp('state/vuex/getters').ourGroupProfile + const identityContractID = sbp('state/vuex/state').loggedIn.identityContractID + const myProfile = sbp('chelonia/rootState')[contractID].profiles[identityContractID] if (isActionYoungerThanUser(contractID, height, myProfile) && myProfile.incomeDetailsType) { const memberType = myProfile.incomeDetailsType === 'pledgeAmount' ? 'pledging' : 'receiving' @@ -1731,7 +1749,7 @@ sbp('chelonia/defineContract', { try { await sbp('chelonia/contract/retain', chatRoomId, { ephemeral: true }) - if (!sbp('chelonia/contract/hasKeysToPerformOperation', chatRoomId, 'gi.contracts/chatroom/join')) { + if (!await sbp('chelonia/contract/hasKeysToPerformOperation', chatRoomId, 'gi.contracts/chatroom/join')) { throw new Error(`Missing keys to join chatroom ${chatRoomId}`) } @@ -1773,7 +1791,7 @@ sbp('chelonia/defineContract', { }, // eslint-disable-next-line require-await 'gi.contracts/group/leaveGroup': async ({ data, meta, contractID, height, getters, innerSigningContractID }) => { - const rootGetters = sbp('state/vuex/getters') + // const rootGetters = sbp('state/vuex/getters') const { identityContractID } = sbp('state/vuex/state').loggedIn const memberID = data.memberID || innerSigningContractID const state = await sbp('chelonia/contract/state', contractID) @@ -1790,9 +1808,12 @@ sbp('chelonia/defineContract', { if (memberID === identityContractID) { // NOTE: remove all notifications whose scope is in this group + /* TODO: FIND ANOTHER WAY OF DOING THIS WITHOUT ROOTGETTERS for (const notification of rootGetters.notificationsByGroup(contractID)) { sbp('state/vuex/commit', REMOVE_NOTIFICATION, notification) } + */ + if (!isNaN(REMOVE_NOTIFICATION)) { console.error('REMOVEME') } // The following detects whether we're in the process of joining, and if // we are, it doesn't remove the contract and calls /join to complete @@ -1806,14 +1827,14 @@ sbp('chelonia/defineContract', { // the new keys or not. // First, we check if there are no pending key requests for us - const areWeRejoining = () => { - const pendingKeyShares = sbp('chelonia/contract/waitingForKeyShareTo', state, identityContractID) + const areWeRejoining = async () => { + const pendingKeyShares = await sbp('chelonia/contract/waitingForKeyShareTo', state, identityContractID) if (pendingKeyShares) { console.info('[gi.contracts/group/leaveGroup] Not removing group contract because it has a pending key share for ourselves', contractID) return true } // Now, let's see if we had a key request that's been answered - const sentKeyShares = sbp('chelonia/contract/successfulKeySharesByContractID', state, identityContractID) + const sentKeyShares = await sbp('chelonia/contract/successfulKeySharesByContractID', state, identityContractID) // We received a key share after the last time we left if (sentKeyShares?.[identityContractID]?.[0].height > state.profiles[memberID].departedHeight) { console.info('[gi.contracts/group/leaveGroup] Not removing group contract because it has shared keys with ourselves after we left', contractID) @@ -1822,7 +1843,7 @@ sbp('chelonia/defineContract', { return false } - if (areWeRejoining()) { + if (await areWeRejoining()) { console.info('[gi.contracts/group/leaveGroup] aborting as we\'re rejoining', contractID) // Previously we called `gi.actions/group/join` here, but it doesn't // seem necessary diff --git a/frontend/model/contracts/identity.js b/frontend/model/contracts/identity.js index 2b0aa0783..7fb6af479 100644 --- a/frontend/model/contracts/identity.js +++ b/frontend/model/contracts/identity.js @@ -12,6 +12,7 @@ import { noUppercase } from './shared/validators.js' import { findKeyIdByName, findForeignKeysByContractID } from '~/shared/domains/chelonia/utils.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' import { IDENTITY_USERNAME_MAX_CHARS } from './shared/constants.js' @@ -63,7 +64,7 @@ const checkUsernameConsistency = async (contractID: string, username: string) => if (!state) return const username = state[contractID].attributes.username - if (sbp('namespace/lookupCached', username) !== contractID) { + if (await sbp('namespace/lookupCached', username) !== contractID) { sbp('gi.notifications/emit', 'WARNING', { contractID, message: L('Unable to confirm that the username {username} belongs to this identity contract', { username }) @@ -74,17 +75,7 @@ const checkUsernameConsistency = async (contractID: string, username: string) => sbp('chelonia/defineContract', { name: 'gi.contracts/identity', - getters: { - currentIdentityState (state) { - return state - }, - loginState (state, getters) { - return getters.currentIdentityState.loginState - }, - ourDirectMessages (state, getters) { - return getters.currentIdentityState.chatRooms || {} - } - }, + getters: {}, actions: { 'gi.contracts/identity': { validate: (data, { state }) => { @@ -153,7 +144,7 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/identity/createDirectMessage': { - validate: (data, { state, getters }) => { + validate: (data, { state }) => { objectOf({ contractID: string // NOTE: chatroom contract id })(data) @@ -174,10 +165,10 @@ sbp('chelonia/defineContract', { validate: objectOf({ contractID: string }), - process ({ data }, { state, getters }) { + process ({ data }, { state }) { // NOTE: this method is always created by another const { contractID } = data - if (getters.ourDirectMessages[contractID]) { + if (state.chatRooms[contractID]) { throw new TypeError(L('Already joined direct message.')) } @@ -185,8 +176,8 @@ sbp('chelonia/defineContract', { visible: true }) }, - sideEffect ({ data }, { getters }) { - if (getters.ourDirectMessages[data.contractID].visible) { + sideEffect ({ data }, { state }) { + if (state.chatRooms[data.contractID].visible) { sbp('chelonia/contract/retain', data.contractID).catch((e) => { console.error('[gi.contracts/identity/createDirectMessage/sideEffect] Error calling retain', e) }) @@ -199,22 +190,22 @@ sbp('chelonia/defineContract', { inviteSecret: string, creatorID: optional(boolean) }), - process ({ hash, data, meta }, { state }) { + async process ({ hash, data, meta }, { state }) { const { groupContractID, inviteSecret } = data if (has(state.groups, groupContractID)) { throw new Error(`Cannot join already joined group ${groupContractID}`) } - const inviteSecretId = sbp('chelonia/crypto/keyId', () => inviteSecret) + const inviteSecretId = await sbp('chelonia/crypto/keyId', new Secret(inviteSecret)) Vue.set(state.groups, groupContractID, { hash, inviteSecretId }) }, - sideEffect ({ hash, data, contractID }, { state }) { + async sideEffect ({ hash, data, contractID }, { state }) { const { groupContractID, inviteSecret } = data - sbp('chelonia/storeSecretKeys', () => [{ + await sbp('chelonia/storeSecretKeys', new Secret([{ key: inviteSecret, transient: true - }]) + }])) sbp('chelonia/queueInvocation', contractID, async () => { const state = await sbp('chelonia/contract/state', contractID) @@ -229,7 +220,7 @@ sbp('chelonia/defineContract', { return } - const inviteSecretId = sbp('chelonia/crypto/keyId', () => inviteSecret) + const inviteSecretId = await sbp('chelonia/crypto/keyId', new Secret(inviteSecret)) // If the hash doesn't match (could happen after re-joining), return if (state.groups[groupContractID].hash !== hash) { @@ -237,7 +228,7 @@ sbp('chelonia/defineContract', { } return inviteSecretId - }).then((inviteSecretId) => { + }).then(async (inviteSecretId) => { // Calling 'gi.actions/group/join' here _after_ queueInvoication // and not inside of it. // This is because 'gi.actions/group/join' might (depending on @@ -260,8 +251,8 @@ sbp('chelonia/defineContract', { contractName: 'gi.contracts/group', reference: hash, signingKeyId: inviteSecretId, - innerSigningKeyId: sbp('chelonia/contract/currentKeyIdByName', state, 'csk'), - encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', state, 'cek') + innerSigningKeyId: await sbp('chelonia/contract/currentKeyIdByName', state, 'csk'), + encryptionKeyId: await sbp('chelonia/contract/currentKeyIdByName', state, 'cek') }).catch(e => { console.warn(`[gi.contracts/identity/joinGroup/sideEffect] Error sending gi.actions/group/join action for group ${data.groupContractID}`, e) }) @@ -349,18 +340,19 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/identity/setDirectMessageVisibility': { - validate: (data, { state, getters }) => { + validate: (data, { state }) => { objectOf({ contractID: string, visible: boolean })(data) - if (!getters.ourDirectMessages[data.contractID]) { + if (!state.chatRooms[data.contractID]) { throw new TypeError(L('Not existing direct message.')) } }, - process ({ data }, { state, getters }) { + process ({ data }, { state }) { Vue.set(state.chatRooms[data.contractID], 'visible', data.visible) } + // TODO: Side-effect to retain or release accordingly }, 'gi.contracts/identity/saveFileDeleteToken': { validate: objectOf({ @@ -369,7 +361,7 @@ sbp('chelonia/defineContract', { token: string })) }), - process ({ data }, { state, getters }) { + process ({ data }, { state }) { for (const { manifestCid, token } of data.tokensByManifestCid) { Vue.set(state.fileDeleteTokens, manifestCid, token) } @@ -379,7 +371,7 @@ sbp('chelonia/defineContract', { validate: objectOf({ manifestCids: arrayOf(string) }), - process ({ data }, { state, getters }) { + process ({ data }, { state }) { for (const manifestCid of data.manifestCids) { Vue.delete(state.fileDeleteTokens, manifestCid) } diff --git a/frontend/model/database.js b/frontend/model/database.js index d496021b9..55724cac5 100644 --- a/frontend/model/database.js +++ b/frontend/model/database.js @@ -1,15 +1,117 @@ 'use strict' import sbp from '@sbp/sbp' -import localforage from 'localforage' import { CURVE25519XSALSA20POLY1305, decrypt, encrypt, generateSalt, keyId, keygen, serializeKey } from '../../shared/domains/chelonia/crypto.js' -const generateEncryptionParams = async (stateKeyEncryptionKeyFn?: (stateEncryptionKeyId: string, salt: string) => Promise<*>) => { +const _instances = [] +// Localforage-like API for IndexedDB +const localforage = { + ready () { + return Promise.all(_instances).then(() => {}) + }, + createInstance ({ name, storeName }: { name: string, storeName: string }) { + // Open the IndexedDB database + const db = new Promise((resolve, reject) => { + if (name.includes('-') || storeName.includes('-')) { + reject(new Error('Unsupported characters in name: -')) + return + } + const request = self.indexedDB.open(name + '--' + storeName) + + // Create the object store if it doesn't exist + request.onupgradeneeded = (event) => { + const db = event.target.result + db.createObjectStore(storeName) + } + + request.onsuccess = (event) => { + const db = event.target.result + resolve(db) + } + + request.onerror = (error) => { + reject(error) + } + + request.onblocked = (event) => { + reject(new Error('DB is blocked')) + } + }) + + _instances.push(db) + + return { + clear () { + return db.then(db => { + const transaction = db.transaction([storeName], 'readwrite') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.clear() + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve() + } + request.onerror = (e) => { + reject(e) + } + }) + }) + }, + getItem (key: string) { + return db.then(db => { + const transaction = db.transaction([storeName], 'readonly') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.get(key) + return new Promise((resolve, reject) => { + request.onsuccess = (event) => { + resolve(event.target.result) + } + request.onerror = (e) => { + reject(e) + } + }) + }) + }, + removeItem (key: string) { + return db.then(db => { + const transaction = db.transaction([storeName], 'readwrite') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.delete(key) + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve() + } + request.onerror = (e) => { + reject(e.target.error) + } + }) + }) + }, + setItem (key: string, value: any) { + return db.then(db => { + const transaction = db.transaction([storeName], 'readwrite') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.put(value, key) + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve() + } + request.onerror = (e) => { + reject(e.target.error) + } + }) + }) + } + } + } +} + +export const generateEncryptionParams = async (stateKeyEncryptionKeyFn: (stateEncryptionKeyId: string, salt: string) => Promise<*>) => { // Create the necessary keys // First, we generate the state encryption key const stateEncryptionKey = keygen(CURVE25519XSALSA20POLY1305) const stateEncryptionKeyId = keyId(stateEncryptionKey) const stateEncryptionKeyS = serializeKey(stateEncryptionKey, true) + const stateEncryptionKeyP = serializeKey(stateEncryptionKey, false) // Once we have the state encryption key, we generate a salt const salt = generateSalt() @@ -29,7 +131,7 @@ const generateEncryptionParams = async (stateKeyEncryptionKeyFn?: (stateEncrypti salt, encryptedStateEncryptionKey }, - stateEncryptionKeyS + stateEncryptionKeyP } } @@ -67,13 +169,13 @@ sbp('sbp/selectors/register', { return localforage.ready() }, 'gi.db/settings/save': function (user: string, value: any): Promise<*> { - return appSettings.setItem(user, value) + return appSettings.setItem('u' + user, value) }, 'gi.db/settings/load': function (user: string): Promise { - return appSettings.getItem(user) + return appSettings.getItem('u' + user) }, 'gi.db/settings/delete': function (user: string): Promise { - return appSettings.removeItem(user) + return appSettings.removeItem('u' + user) }, 'gi.db/settings/saveEncrypted': async function (user: string, value: any, encryptionParams: any): Promise<*> { const { @@ -82,46 +184,38 @@ sbp('sbp/selectors/register', { encryptedStateEncryptionKey } = encryptionParams // Fetch the session encryption key - const stateEncryptionKeyS = await appSettings.getItem(stateEncryptionKeyId) - if (!stateEncryptionKeyS) throw new Error(`Unable to retrieve the key corresponding to key ID ${stateEncryptionKeyId}`) + const stateEncryptionKeyP = await appSettings.getItem('k' + stateEncryptionKeyId) + if (!stateEncryptionKeyP) throw new Error(`Unable to retrieve the key corresponding to key ID ${stateEncryptionKeyId}`) // Encrypt the current state - const encryptedState = encrypt(stateEncryptionKeyS, JSON.stringify(value), user) + const encryptedState = encrypt(stateEncryptionKeyP, JSON.stringify(value), user) // Save the four fields of the encrypted state. We use base64 encoding to // allow saving any incoming data. // (1) stateEncryptionKeyId // (2) salt // (3) encryptedStateEncryptionKey (used for recovery when re-logging in) // (4) encryptedState - return appSettings.setItem(user, `${btoa(stateEncryptionKeyId)}.${btoa(salt)}.${btoa(encryptedStateEncryptionKey)}.${btoa(encryptedState)}`) + return appSettings.setItem('e' + user, `${btoa(stateEncryptionKeyId)}.${btoa(salt)}.${btoa(encryptedStateEncryptionKey)}.${btoa(encryptedState)}`) }, - 'gi.db/settings/loadEncrypted': function (user: string, stateKeyEncryptionKeyFn?: (stateEncryptionKeyId: string, salt: string) => Promise<*>): Promise<*> { - return appSettings.getItem(user).then(async (encryptedValue) => { + 'gi.db/settings/loadEncrypted': function (user: string, stateKeyEncryptionKeyFn: (stateEncryptionKeyId: string, salt: string) => Promise<*>): Promise<*> { + return appSettings.getItem('e' + user).then(async (encryptedValue) => { if (!encryptedValue || typeof encryptedValue !== 'string') { throw new EmptyValue(`Unable to retrive state for ${user || ''}`) } // Split the encrypted state into its constituent parts const [stateEncryptionKeyId, salt, encryptedStateEncryptionKey, data] = encryptedValue.split('.').map(x => atob(x)) - // If the state encryption key is in appSettings, retrieve it - let stateEncryptionKeyS = await appSettings.getItem(stateEncryptionKeyId) - - // If the state encryption key wasn't in appSettings but we have a state - // state key encryption derivation function (stateKeyEncryptionKeyFn), - // call it - if (!stateEncryptionKeyS && stateKeyEncryptionKeyFn) { - // Derive a temporary key from the password to decrypt the state - // encryption key - const stateKeyEncryptionKey = await stateKeyEncryptionKeyFn(stateEncryptionKeyId, salt) - - // Decrypt the state encryption key - stateEncryptionKeyS = decrypt(stateKeyEncryptionKey, encryptedStateEncryptionKey, stateEncryptionKeyId) - - // Compute the key ID of the decrypted key and verify that it holds - // the expected value - const stateEncryptionKeyIdActual = keyId(stateEncryptionKeyS) - if (stateEncryptionKeyIdActual !== stateEncryptionKeyId) { - throw new Error(`Invalid state key ID: expected ${stateEncryptionKeyId} but got ${stateEncryptionKeyIdActual}`) - } + // Derive a temporary key from the password to decrypt the state + // encryption key + const stateKeyEncryptionKey = await stateKeyEncryptionKeyFn(stateEncryptionKeyId, salt) + + // Decrypt the state encryption key + const stateEncryptionKeyS = decrypt(stateKeyEncryptionKey, encryptedStateEncryptionKey, stateEncryptionKeyId) + + // Compute the key ID of the decrypted key and verify that it holds + // the expected value + const stateEncryptionKeyIdActual = keyId(stateEncryptionKeyS) + if (stateEncryptionKeyIdActual !== stateEncryptionKeyId) { + throw new Error(`Invalid state key ID: expected ${stateEncryptionKeyId} but got ${stateEncryptionKeyIdActual}`) } // Now, attempt to decrypt the state @@ -129,7 +223,7 @@ sbp('sbp/selectors/register', { // Saving the state encryption key in appSettings is necessary for // functionality such as refreshing the page to work - await appSettings.setItem(stateEncryptionKeyId, stateEncryptionKeyS) + await appSettings.setItem('k' + stateEncryptionKeyId, stateEncryptionKeyS) return { encryptionParams: { @@ -149,10 +243,10 @@ sbp('sbp/selectors/register', { console.warn('Error while retrieving local state', e) } - const { encryptionParams, stateEncryptionKeyS } = await generateEncryptionParams(stateKeyEncryptionKeyFn) + const { encryptionParams, stateEncryptionKeyP } = await generateEncryptionParams(stateKeyEncryptionKeyFn) // Save the state encryption key to local storage - await appSettings.setItem(encryptionParams.stateEncryptionKeyId, stateEncryptionKeyS) + await appSettings.setItem('k' + encryptionParams.stateEncryptionKeyId, stateEncryptionKeyP) return { encryptionParams, @@ -161,7 +255,10 @@ sbp('sbp/selectors/register', { }) }, 'gi.db/settings/deleteStateEncryptionKey': function ({ stateEncryptionKeyId }): Promise { - return appSettings.removeItem(stateEncryptionKeyId) + return appSettings.removeItem('k' + stateEncryptionKeyId) + }, + 'gi.db/settings/deleteEncrypted': function (user: string): Promise { + return appSettings.removeItem('e' + user) } }) diff --git a/frontend/model/notifications/templates.js b/frontend/model/notifications/templates.js index 4cdb6ad63..c9b16df6a 100644 --- a/frontend/model/notifications/templates.js +++ b/frontend/model/notifications/templates.js @@ -1,4 +1,4 @@ -import { GIMessage } from '~/shared/domains/chelonia/chelonia.js' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import type { NewProposalType, NotificationTemplate diff --git a/frontend/model/state.js b/frontend/model/state.js index 602f82459..8afcf9416 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -8,8 +8,8 @@ import { Vue, L } from '@common/common.js' import { EVENT_HANDLED, CONTRACT_REGISTERED } from '~/shared/domains/chelonia/events.js' import { LOGOUT } from '~/frontend/utils/events.js' import Vuex from 'vuex' -import { PROFILE_STATUS, INVITE_INITIAL_CREATOR } from '@model/contracts/shared/constants.js' -import { PAYMENT_NOT_RECEIVED } from '@model/contracts/shared/payments/index.js' +import { MAX_SAVED_PERIODS, PROFILE_STATUS, PROPOSAL_GENERIC, INVITE_INITIAL_CREATOR } from '@model/contracts/shared/constants.js' +import { PAYMENT_COMPLETED, PAYMENT_NOT_RECEIVED } from '@model/contracts/shared/payments/index.js' import { omit, cloneDeep, debounce } from '@model/contracts/shared/giLodash.js' import { unadjustedDistribution, adjustedDistribution } from '@model/contracts/shared/distribution/distribution.js' import { applyStorageRules } from '~/frontend/model/notifications/utils.js' @@ -19,6 +19,11 @@ import notificationModule from '~/frontend/model/notifications/vuexModule.js' import settingsModule from '~/frontend/model/settings/vuexModule.js' import chatroomModule from '~/frontend/model/chatroom/vuexModule.js' +import { addTimeToDate, dateToPeriodStamp, dateFromPeriodStamp, dateIsWithinPeriod, periodStampsForDate } from '@model/contracts/shared/time.js' +import { createPaymentInfo, paymentHashesFromPaymentPeriod } from '@model/contracts/shared/functions.js' +import { INVITE_STATUS } from '~/shared/domains/chelonia/constants.js' +import currencies from '@model/contracts/shared/currencies.js' + Vue.use(Vuex) const initialState = { @@ -77,8 +82,8 @@ sbp('sbp/selectors/register', { // state.notifications = [] // } }, - 'state/vuex/save': async function () { - const state = store.state + 'state/vuex/save': async function (encrypted: ?boolean, state: ?Object) { + state = state || store.state // IMPORTANT! DO NOT CALL VUEX commit() in here in any way shape or form! // Doing so will cause an infinite loop because of store.subscribe below! if (!state.loggedIn) { @@ -87,7 +92,11 @@ sbp('sbp/selectors/register', { const { identityContractID, encryptionParams } = state.loggedIn state.notifications = applyStorageRules(state.notifications || []) - await sbp('gi.db/settings/saveEncrypted', identityContractID, state, encryptionParams) + if (encrypted) { + await sbp('gi.db/settings/saveEncrypted', identityContractID, state, encryptionParams) + } else { + await sbp('gi.db/settings/save', identityContractID, state) + } } }) @@ -533,7 +542,260 @@ const getters = { const nameB = getters.ourContactProfilesByUsername[usernameB].displayName || usernameB return nameA.normalize().toUpperCase() > nameB.normalize().toUpperCase() ? 1 : -1 }) + }, + // identity getters follow + loginState (state, getters) { + return getters.currentIdentityState.loginState + }, + ourDirectMessages (state, getters) { + return getters.currentIdentityState.chatRooms || {} + }, + // end identity getters + // group getters follow + currentGroupOwnerID (state, getters) { + return getters.currentGroupState.groupOwnerID + }, + groupSettings (state, getters) { + return getters.currentGroupState.settings || {} + }, + profileActive (state, getters) { + return member => { + const profiles = getters.currentGroupState.profiles + return profiles?.[member]?.status === PROFILE_STATUS.ACTIVE + } + }, + pendingAccept (state, getters) { + return member => { + const profiles = getters.currentGroupState.profiles + return profiles?.[member]?.status === PROFILE_STATUS.PENDING + } + }, + groupProfile (state, getters) { + return member => { + const profiles = getters.currentGroupState.profiles + return profiles && profiles[member] && { + ...profiles[member], + get lastLoggedIn () { + return getters.currentGroupLastLoggedIn[member] || this.joinedDate + } + } + } + }, + groupProfiles (state, getters) { + const profiles = {} + for (const member in (getters.currentGroupState.profiles || {})) { + const profile = getters.groupProfile(member) + if (profile.status === PROFILE_STATUS.ACTIVE) { + profiles[member] = profile + } + } + return profiles + }, + groupCreatedDate (state, getters) { + return getters.groupProfile(getters.currentGroupOwnerID).joinedDate + }, + groupMincomeAmount (state, getters) { + return getters.groupSettings.mincomeAmount + }, + groupMincomeCurrency (state, getters) { + return getters.groupSettings.mincomeCurrency + }, + // Oldest period key first. + groupSortedPeriodKeys (state, getters) { + const { distributionDate, distributionPeriodLength } = getters.groupSettings + if (!distributionDate) return [] + // The .sort() call might be only necessary in older browser which don't maintain object key ordering. + // A comparator function isn't required for now since our keys are ISO strings. + const keys = Object.keys(getters.groupPeriodPayments).sort() + // Append the waiting period stamp if necessary. + if (!keys.length && MAX_SAVED_PERIODS > 0) { + keys.push(dateToPeriodStamp(addTimeToDate(distributionDate, -distributionPeriodLength))) + } + // Append the distribution date if necessary. + if (keys[keys.length - 1] !== distributionDate) { + keys.push(distributionDate) + } + return keys + }, + // paymentTotalfromMembertoMemberID (state, getters) { + // // this code was removed in https://github.com/okTurtles/group-income/pull/1691 + // // because it was unused. feel free to bring it back if needed. + // }, + // + // The following three getters return either a known period stamp for the given date, + // or a predicted one according to the period length. + // They may also return 'undefined', in which case the caller should check archived data. + periodStampGivenDate (state, getters) { + return (date: string | Date): string | void => { + return periodStampsForDate(date, { + knownSortedStamps: getters.groupSortedPeriodKeys, + periodLength: getters.groupSettings.distributionPeriodLength + }).current + } + }, + periodBeforePeriod (state, getters) { + return (periodStamp: string): string | void => { + return periodStampsForDate(periodStamp, { + knownSortedStamps: getters.groupSortedPeriodKeys, + periodLength: getters.groupSettings.distributionPeriodLength + }).previous + } + }, + periodAfterPeriod (state, getters) { + return (periodStamp: string): string | void => { + return periodStampsForDate(periodStamp, { + knownSortedStamps: getters.groupSortedPeriodKeys, + periodLength: getters.groupSettings.distributionPeriodLength + }).next + } + }, + dueDateForPeriod (state, getters) { + return (periodStamp: string) => { + // NOTE: logically it's should be 1 milisecond before the periodAfterPeriod + // 1 mili-second doesn't make any difference to the users + // so periodAfterPeriod is used to make it simple + return getters.periodAfterPeriod(periodStamp) + } + }, + paymentHashesForPeriod (state, getters) { + return (periodStamp) => { + const periodPayments = getters.groupPeriodPayments[periodStamp] + if (periodPayments) { + return paymentHashesFromPaymentPeriod(periodPayments) + } + } + }, + groupMembersByContractID (state, getters) { + return Object.keys(getters.groupProfiles) + }, + groupMembersCount (state, getters) { + return getters.groupMembersByContractID.length + }, + groupMembersPending (state, getters) { + const invites = getters.currentGroupState.invites + const vmInvites = getters.currentGroupState._vm.invites + const pendingMembers = Object.create(null) + for (const inviteKeyId in invites) { + if ( + vmInvites[inviteKeyId].status === INVITE_STATUS.VALID && + invites[inviteKeyId].creatorID !== INVITE_INITIAL_CREATOR + ) { + pendingMembers[inviteKeyId] = { + displayName: invites[inviteKeyId].invitee, + invitedBy: invites[inviteKeyId].creatorID, + expires: vmInvites[inviteKeyId].expires + } + } + } + return pendingMembers + }, + groupShouldPropose (state, getters) { + return getters.groupMembersCount >= 3 + }, + groupDistributionStarted (state, getters) { + return (currentDate: string) => currentDate >= getters.groupSettings?.distributionDate + }, + groupProposalSettings (state, getters) { + return (proposalType = PROPOSAL_GENERIC) => { + return getters.groupSettings.proposals?.[proposalType] + } + }, + groupCurrency (state, getters) { + const mincomeCurrency = getters.groupMincomeCurrency + return mincomeCurrency && currencies[mincomeCurrency] + }, + groupMincomeFormatted (state, getters) { + return getters.withGroupCurrency?.(getters.groupMincomeAmount) + }, + groupMincomeSymbolWithCode (state, getters) { + return getters.groupCurrency?.symbolWithCode + }, + groupPeriodPayments (state, getters): Object { + // note: a lot of code expects this to return an object, so keep the || {} below + return getters.currentGroupState.paymentsByPeriod || {} + }, + groupThankYousFrom (state, getters): Object { + return getters.currentGroupState.thankYousFrom || {} + }, + groupStreaks (state, getters): Object { + return getters.currentGroupState.streaks || {} + }, + groupTotalPledgeAmount (state, getters): number { + return getters.currentGroupState.totalPledgeAmount || 0 + }, + withGroupCurrency (state, getters) { + // TODO: If this group has no defined mincome currency, not even a default one like + // USD, then calling this function is probably an error which should be reported. + // Just make sure the UI doesn't break if an exception is thrown, since this is + // bound to the UI in some location. + return getters.groupCurrency?.displayWithCurrency + }, + getGroupChatRooms (state, getters) { + return getters.currentGroupState.chatRooms + }, + generalChatRoomId (state, getters) { + return getters.currentGroupState.generalChatRoomId + }, + // getter is named haveNeedsForThisPeriod instead of haveNeedsForPeriod because it uses + // getters.groupProfiles - and that is always based on the most recent values. we still + // pass in the current period because it's used to set the "when" property + haveNeedsForThisPeriod (state, getters) { + return (currentPeriod: string) => { + // NOTE: if we ever switch back to the "real-time" adjusted distribution algorithm, + // make sure that this function also handles userExitsGroupEvent + const groupProfiles = getters.groupProfiles // TODO: these should use the haveNeeds for the specific period's distribution period + const haveNeeds = [] + for (const memberID in groupProfiles) { + const { incomeDetailsType, joinedDate } = groupProfiles[memberID] + if (incomeDetailsType) { + const amount = groupProfiles[memberID][incomeDetailsType] + const haveNeed = incomeDetailsType === 'incomeAmount' ? amount - getters.groupMincomeAmount : amount + // construct 'when' this way in case we ever use a pro-rated algorithm + let when = dateFromPeriodStamp(currentPeriod).toISOString() + if (dateIsWithinPeriod({ + date: joinedDate, + periodStart: currentPeriod, + periodLength: getters.groupSettings.distributionPeriodLength + })) { + when = joinedDate + } + haveNeeds.push({ memberID, haveNeed, when }) + } + } + return haveNeeds + } + }, + paymentsForPeriod (state, getters) { + return (periodStamp) => { + const hashes = getters.paymentHashesForPeriod(periodStamp) + const events = [] + if (hashes && hashes.length > 0) { + const payments = getters.currentGroupState.payments + for (const paymentHash of hashes) { + const payment = payments[paymentHash] + if (payment.data.status === PAYMENT_COMPLETED) { + events.push(createPaymentInfo(paymentHash, payment)) + } + } + } + return events + } + }, + // end group getters + // chatroom getters follow + chatRoomSettings (state, getters) { + return getters.currentChatRoomState.settings || {} + }, + chatRoomAttributes (state, getters) { + return getters.currentChatRoomState.attributes || {} + }, + chatRoomMembers (state, getters) { + return getters.currentChatRoomState.members || {} + }, + chatRoomLatestMessages (state, getters) { + return getters.currentChatRoomState.messages || [] } + // end chatroom getters } const store: any = new Vuex.Store({ @@ -588,8 +850,8 @@ const omitGetters = { 'gi.contracts/identity': ['currentIdentityState'], 'gi.contracts/chatroom': ['currentChatRoomState'] } -sbp('okTurtles.events/on', CONTRACT_REGISTERED, (contract) => { - const { contracts: { manifests } } = sbp('chelonia/config') +sbp('okTurtles.events/on', CONTRACT_REGISTERED, async (contract) => { + const { contracts: { manifests } } = await sbp('chelonia/config') // check to make sure we're only loading the getters for the version of the contract // that this build of GI was compiled with if (manifests[contract.name] === contract.manifest) { diff --git a/frontend/views/components/Avatar.vue b/frontend/views/components/Avatar.vue index 1196b8576..3247159b7 100644 --- a/frontend/views/components/Avatar.vue +++ b/frontend/views/components/Avatar.vue @@ -15,6 +15,7 @@