From 921a6110d8ca86a71fda3e2773afdd00c3011500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Mon, 29 Jan 2024 20:23:15 +0100 Subject: [PATCH] E2e protocol ricardo (#1826) * Remove unnecessary references * Encrypt pub notifications * Simplify. Fix types. * Log level --- frontend/controller/actions/chatroom.js | 16 +- frontend/controller/actions/utils.js | 106 ++++++++++++++ frontend/main.js | 14 +- .../views/containers/chatroom/ChatMain.vue | 2 - .../views/containers/chatroom/SendArea.vue | 29 ++-- shared/domains/chelonia/chelonia.js | 137 +++++++++++++++++- shared/domains/chelonia/internals.js | 8 +- shared/domains/chelonia/utils.js | 6 + 8 files changed, 274 insertions(+), 44 deletions(-) diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index f94a0b0f4..b1830ea28 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -1,7 +1,6 @@ 'use strict' import sbp from '@sbp/sbp' -import { PUBSUB_INSTANCE } from '@controller/instance-keys.js' import { GIErrorUIRuntimeError, L } from '@common/common.js' import { has, omit } from '@model/contracts/shared/giLodash.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' @@ -10,8 +9,7 @@ import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared // 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' import type { GIRegParams } from './types.js' -import { encryptedAction } from './utils.js' -import { CHATROOM_USER_TYPING, CHATROOM_USER_STOP_TYPING } from '@utils/events.js' +import { encryptedAction, encryptedNotification } from './utils.js' export default (sbp('sbp/selectors/register', { 'gi.actions/chatroom/create': async function (params: GIRegParams) { @@ -183,16 +181,8 @@ export default (sbp('sbp/selectors/register', { } })) }, - 'gi.actions/chatroom/emit-user-typing-event': (chatroomId: string, username: string) => { - // publish CHATROOM_USER_TYPING event to every subscribers of the pubsub channel with chatroomId - const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) - pubsub.pub(chatroomId, { type: CHATROOM_USER_TYPING, username }) - }, - 'gi.actions/chatroom/emit-user-stop-typing-event': (chatroomId: string, username: string) => { - // publish CHATROOM_USER_STOP_TYPING event to every subscribers of the pubsub channel with chatroomId - const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) - pubsub.pub(chatroomId, { type: CHATROOM_USER_STOP_TYPING, username }) - }, + ...encryptedNotification('gi.actions/chatroom/user-typing-event', L('Failed to send typing notification')), + ...encryptedNotification('gi.actions/chatroom/user-stop-typing-event', L('Failed to send stopped typing notification')), ...encryptedAction('gi.actions/chatroom/addMessage', L('Failed to add message.')), ...encryptedAction('gi.actions/chatroom/editMessage', L('Failed to edit message.')), ...encryptedAction('gi.actions/chatroom/deleteMessage', L('Failed to delete message.')), diff --git a/frontend/controller/actions/utils.js b/frontend/controller/actions/utils.js index 78ee5aaa5..5ad5a3aa0 100644 --- a/frontend/controller/actions/utils.js +++ b/frontend/controller/actions/utils.js @@ -145,6 +145,112 @@ export const encryptedAction = ( } } +export const encryptedNotification = ( + action: string, + humanError: string | Function, + handler?: (sendMessage: (params: $Shape) => any, params: GIActionParams, signingKeyId: string, encryptionKeyId: string, originatingContractID: ?string) => Promise, + encryptionKeyName?: string, + signingKeyName?: string, + innerSigningKeyName?: string +): Object => { + const sendMessageFactory = (outerParams: GIActionParams, signingKeyId: string, innerSigningKeyId: ?string, encryptionKeyId: string, originatingContractID: ?string) => (innerParams?: $Shape): any[] | Promise => { + const params = innerParams ?? outerParams + + const actionReplaced = action.replace('gi.actions', 'gi.contracts') + + return sbp('chelonia/out/encryptedOrUnencryptedPubMessage', { + contractID: params.contractID, + contractName: actionReplaced.split('/', 2).join('/'), + innerSigningKeyId, + encryptionKeyId, + signingKeyId, + data: [actionReplaced, params.data] + }) + } + return { + [action]: async function (params: GIActionParams) { + const contractID = params.contractID + if (!contractID) { + throw new Error('Missing contract ID') + } + + try { + // Writing to a contract requires being subscribed to it + await sbp('chelonia/contract/sync', contractID, { deferredRemove: true }) + const state = { + [contractID]: await sbp('chelonia/latestContractState', contractID) + } + const rootState = sbp('state/vuex/state') + + // Default signingContractID is the current contract + const signingContractID = params.signingContractID || contractID + if (!state[signingContractID]) { + state[signingContractID] = await sbp('chelonia/latestContractState', signingContractID) + } + + // Default innerSigningContractID is the current logged in identity + // contract ID, unless we are signing for the current identity contract + // If params.innerSigningContractID is explicitly set to null, then + // no default value will be used. + const innerSigningContractID = params.innerSigningContractID !== undefined + ? params.innerSigningContractID + : contractID === rootState.loggedIn.identityContractID + ? null + : rootState.loggedIn.identityContractID + + if (innerSigningContractID && !state[innerSigningContractID]) { + state[innerSigningContractID] = await sbp('chelonia/latestContractState', innerSigningContractID) + } + + const signingKeyId = params.signingKeyId || findKeyIdByName(state[signingContractID], signingKeyName ?? 'csk') + // Inner signing key ID: + // (1) If params.innerSigningKeyId is set, honor it + // (a) If it's null, then no inner signature will be used + // (b) If it's undefined, it's treated the same as if it's not set + // (2) If params.innerSigningKeyId is not set: + // (a) If innerSigningContractID is not set, then no inner + // signature will be used + // (b) Else, use the key by name `innerSigningKeyName` in + // `innerSigningContractID` + + const innerSigningKeyId = params.innerSigningKeyId || ( + params.innerSigningKeyId !== null && + innerSigningContractID && + findKeyIdByName(state[innerSigningContractID], innerSigningKeyName ?? 'csk') + ) + const encryptionKeyId = params.encryptionKeyId || findKeyIdByName(state[contractID], encryptionKeyName ?? 'cek') + + if (!signingKeyId || !encryptionKeyId || !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))) { + 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}`) + } + + const sm = sendMessageFactory(params, signingKeyId, innerSigningKeyId || null, encryptionKeyId, params.originatingContractID) + + // make sure to await here so that if there's an error we show user-facing string + if (handler) { + return await handler(sm, params, signingKeyId, encryptionKeyId, params.originatingContractID) + } else { + return await sm() + } + } catch (e) { + console.error(`${action} failed!`, e) + const userFacingErrStr = typeof humanError === 'string' + ? `${humanError} ${LError(e).reportError}` + : humanError(params, e) + throw new GIErrorUIRuntimeError(userFacingErrStr, { cause: e }) + } finally { + await sbp('chelonia/contract/remove', contractID, { removeIfPending: true }) + } + } + } +} + export async function createInvite ({ quantity = 1, creator, expires, invitee }: { quantity: number, creator: string, expires: number, invitee?: string }): Promise<{inviteKeyId: string; creator: string; invitee?: string; }> { diff --git a/frontend/main.js b/frontend/main.js index 573d3a9c0..12c9c3d09 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -213,19 +213,19 @@ async function startApp () { sbp('okTurtles.events/emit', REQUEST_TYPE.PUSH_ACTION, { data: msg.data }) }, [NOTIFICATION_TYPE.PUB] (msg) { - const { channelID, data } = msg + const { contractID, innerSigningContractID, data } = msg - switch (data.type) { - case CHATROOM_USER_TYPING: { - sbp('okTurtles.events/emit', CHATROOM_USER_TYPING, { username: data.username }) + switch (data[0]) { + case 'gi.contracts/chatroom/user-typing-event': { + sbp('okTurtles.events/emit', CHATROOM_USER_TYPING, { contractID, innerSigningContractID }) break } - case CHATROOM_USER_STOP_TYPING: { - sbp('okTurtles.events/emit', CHATROOM_USER_STOP_TYPING, { username: data.username }) + 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 ${channelID}:`, data) + console.log(`[pubsub] Received data from channel ${contractID}:`, data) } } } diff --git a/frontend/views/containers/chatroom/ChatMain.vue b/frontend/views/containers/chatroom/ChatMain.vue index d935cacb8..12e9a8dc4 100644 --- a/frontend/views/containers/chatroom/ChatMain.vue +++ b/frontend/views/containers/chatroom/ChatMain.vue @@ -227,8 +227,6 @@ export default ({ }, data () { return { - GIMessage, - JSON, config: { isPhone: null }, diff --git a/frontend/views/containers/chatroom/SendArea.vue b/frontend/views/containers/chatroom/SendArea.vue index 048377f21..8a5abe9ba 100644 --- a/frontend/views/containers/chatroom/SendArea.vue +++ b/frontend/views/containers/chatroom/SendArea.vue @@ -356,6 +356,7 @@ export default ({ 'currentChatRoomId', 'chatRoomAttributes', 'ourContactProfiles', + 'ourContactProfilesById', 'globalProfile', 'ourUsername' ]), @@ -509,7 +510,11 @@ export default ({ if (!newValue) { // if the textarea has become empty, emit CHATROOM_USER_STOP_TYPING event. - sbp('gi.actions/chatroom/emit-user-stop-typing-event', this.currentChatRoomId, this.ourUsername) + sbp('gi.actions/chatroom/user-stop-typing-event', { + contractID: this.currentChatRoomId + }).catch(e => { + console.error('Error emitting user stopped typing event', e) + }) } else if (this.ephemeral.textWithLines.length < newValue.length) { // if the user is typing and the textarea value is growing, emit CHATROOM_USER_TYPING event. this.throttledEmitUserTypingEvent() @@ -697,9 +702,10 @@ export default ({ } }, onUserTyping (data) { - const typingUser = data.username + if (data.contractID !== this.currentChatRoomId) return + const typingUser = this.ourContactProfilesById[data.innerSigningContractID]?.username - if (typingUser !== this.ourUsername) { + if (typingUser && typingUser !== this.ourUsername) { const addToList = username => { this.ephemeral.typingUsers = uniq([...this.ephemeral.typingUsers, username]) } @@ -710,8 +716,11 @@ export default ({ } }, onUserStopTyping (data) { - if (data.username !== this.ourUsername) { - this.removeFromTypingUsersArray(data.username) + if (data.contractID !== this.currentChatRoomId) return + const typingUser = this.ourContactProfilesById[data.innerSigningContractID]?.username + + if (typingUser && typingUser !== this.ourUsername) { + this.removeFromTypingUsersArray(typingUser) } }, removeFromTypingUsersArray (username) { @@ -723,14 +732,14 @@ export default ({ } }, emitUserTypingEvent () { - sbp('gi.actions/chatroom/emit-user-typing-event', - this.currentChatRoomId, - this.ourUsername - ) + sbp('gi.actions/chatroom/user-typing-event', { + contractID: this.currentChatRoomId + }).catch(e => { + console.error('Error emitting user typing event', e) + }) }, onBtnClick (e) { e.preventDefault() - console.log('!@# on btn click: ', e) } } }: Object) diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index a9c4ad4fd..afdd70bf5 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -14,11 +14,11 @@ import { ChelErrorUnexpected, ChelErrorUnrecoverable } from './errors.js' import { CONTRACTS_MODIFIED, CONTRACT_REGISTERED } from './events.js' // TODO: rename this to ChelMessage import { GIMessage } from './GIMessage.js' -import { encryptedOutgoingData, isEncryptedData } from './encryptedData.js' +import { encryptedOutgoingData, isEncryptedData, maybeEncryptedIncomingData, unwrapMaybeEncryptedData } from './encryptedData.js' import type { EncryptedData } from './encryptedData.js' -import { signedOutgoingData, signedOutgoingDataWithRawKey } from './signedData.js' +import { isSignedData, signedIncomingData, signedOutgoingData, signedOutgoingDataWithRawKey } from './signedData.js' import './internals.js' -import { findForeignKeysByContractID, findKeyIdByName, findRevokedKeyIdsByName, findSuitableSecretKeyId } from './utils.js' +import { findForeignKeysByContractID, findKeyIdByName, findRevokedKeyIdsByName, findSuitableSecretKeyId, getContractIDfromKeyId } from './utils.js' // TODO: define ChelContractType for /defineContract @@ -492,7 +492,37 @@ export default (sbp('sbp/selectors/register', { this.pubsub = createClient(pubsubURL, { ...this.config.connectionOptions, messageHandlers: { - ...(options.messageHandlers || {}), + ...(Object.fromEntries( + Object.entries(options.messageHandlers || {}).map(([k, v]) => [ + k, + k === NOTIFICATION_TYPE.PUB + ? (msg) => { + if (!msg.channelID) { + console.info('[chelonia] Discarding pub event without channelID') + return + } + if (!this.subscriptionSet.has(msg.channelID)) { + console.info(`[chelonia] Discarding pub event for ${msg.channelID} because it's not in the current subscriptionSet`) + return + } + const rootState = sbp(this.config.stateSelector) + if (!rootState.contracts[msg.channelID]?.type) { + console.warn(`[chelonia] Discarding pub event for ${msg.channelID} because its contract name could not be determined`) + return + } + try { + (v: Function)(parseEncryptedOrUnencryptedMessage.call(this, { + contractID: msg.channelID, + contractName: rootState.contracts[msg.channelID].type, + serializedData: msg.data + })) + } catch (e) { + console.error(`[chelonia] Error processing pub event for ${msg.channelID}`, e) + } + } + : v + ]) + )), [NOTIFICATION_TYPE.ENTRY] (msg) { // We MUST use 'chelonia/private/in/enqueueHandleEvent' to ensure handleEvent() // is called AFTER any currently-running calls to 'chelonia/contract/sync' @@ -1167,7 +1197,8 @@ export default (sbp('sbp/selectors/register', { }, 'chelonia/out/propDel': async function () { - } + }, + 'chelonia/out/encryptedOrUnencryptedPubMessage': outputEncryptedOrUnencryptedPubMessage }): string[]) function contractNameFromAction (action: string): string { @@ -1177,6 +1208,102 @@ function contractNameFromAction (action: string): string { return contractName } +function outputEncryptedOrUnencryptedPubMessage ({ + contractID, + contractName, + innerSigningKeyId, + encryptionKeyId, + signingKeyId, + data +}: { + contractID: string, + contractName: string, + innerSigningKeyId: ?string, + encryptionKeyId: ?string, + signingKeyId: string, + data: Object +}) { + const manifestHash = this.config.contracts.manifests[contractName] + const { contract } = this.manifestToContract[manifestHash] + const state = contract.state(contractID) + const signedMessage = innerSigningKeyId + ? (state._vm.authorizedKeys[innerSigningKeyId] && state._vm.authorizedKeys[innerSigningKeyId]?._notAfterHeight == null) + ? signedOutgoingData(contractID, innerSigningKeyId, (data: any), this.transientSecretKeys) + : signedOutgoingDataWithRawKey(this.transientSecretKeys[innerSigningKeyId], (data: any), this.transientSecretKeys) + : data + const payload = !encryptionKeyId + ? signedMessage + : encryptedOutgoingData(contractID, encryptionKeyId, signedMessage) + const message = signedOutgoingData(contractID, signingKeyId, (payload: any), this.transientSecretKeys) + const rootState = sbp(this.config.stateSelector) + const height = String(rootState.contracts[contractID].height) + const serializedData = { ...message.serialize(height), height } + this.pubsub.pub(contractID, serializedData) +} + +function parseEncryptedOrUnencryptedMessage ({ + contractID, + contractName, + serializedData +}: { + contractID: string, + contractName: string, + serializedData: Object +}) { + if (!serializedData) { + throw new TypeError('[chelonia] parseEncryptedOrUnencryptedMessage: serializedData is required') + } + const manifestHash = this.config.contracts.manifests[contractName] + const { contract } = this.manifestToContract[manifestHash] + const state = contract.state(contractID) + + const numericHeight = parseInt(serializedData.height) + const rootState = sbp(this.config.stateSelector) + const currentHeight = rootState.contracts[contractID].height + if (!(numericHeight >= 0) || !(numericHeight <= currentHeight)) { + throw new Error(`[chelonia] parseEncryptedOrUnencryptedMessage: Invalid height ${serializedData.height}; it must be between 0 and ${currentHeight}`) + } + + const v = signedIncomingData(contractID, state, serializedData, numericHeight, serializedData.height, (message) => { + return maybeEncryptedIncomingData(contractID, state, message, numericHeight, this.transientSecretKeys, serializedData.height, undefined) + }) + + const encryptedData = unwrapMaybeEncryptedData(v.valueOf()) + + const result = { + get contractID () { + return contractID + }, + get innerSigningKeyId () { + if (!encryptedData) return + if (isSignedData(encryptedData.data)) { + return encryptedData.data.signingKeyId + } + }, + get encryptionKeyId () { + return encryptedData?.encryptionKeyId + }, + get signingKeyId () { + return v.signingKeyId + }, + get data () { + if (!encryptedData) return + if (isSignedData(encryptedData.data)) { + return encryptedData.data.valueOf() + } + return encryptedData.data + }, + get signingContractID () { + return getContractIDfromKeyId(contractID, result.signingKeyId, state) + }, + get innerSigningContractID () { + return getContractIDfromKeyId(contractID, result.innerSigningKeyId, state) + } + } + + return result +} + async function outEncryptedOrUnencryptedAction ( opType: 'ae' | 'au', params: ChelActionParams diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index 2a5d4b192..703835383 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -13,16 +13,10 @@ import { encryptedIncomingData, encryptedOutgoingData, unwrapMaybeEncryptedData import type { EncryptedData } from './encryptedData.js' import { ChelErrorUnrecoverable, ChelErrorWarning } from './errors.js' import { CONTRACTS_MODIFIED, CONTRACT_HAS_RECEIVED_KEYS, CONTRACT_IS_SYNCING, EVENT_HANDLED, EVENT_PUBLISHED, EVENT_PUBLISHING_ERROR } from './events.js' -import { findKeyIdByName, findSuitablePublicKeyIds, findSuitableSecretKeyId, keyAdditionProcessor, recreateEvent, validateKeyPermissions, validateKeyAddPermissions, validateKeyDelPermissions, validateKeyUpdatePermissions } from './utils.js' +import { findKeyIdByName, findSuitablePublicKeyIds, findSuitableSecretKeyId, getContractIDfromKeyId, keyAdditionProcessor, recreateEvent, validateKeyPermissions, validateKeyAddPermissions, validateKeyDelPermissions, validateKeyUpdatePermissions } from './utils.js' import { isSignedData, signedIncomingData } from './signedData.js' // import 'ses' -const getContractIDfromKeyId = (contractID: string, signingKeyId?: string, state: Object) => { - return signingKeyId && state._vm.authorizedKeys[signingKeyId].foreignKey - ? new URL(state._vm.authorizedKeys[signingKeyId].foreignKey).pathname - : contractID -} - const keysToMap = (keys: (GIKey | EncryptedData)[], height: number, authorizedKeys?: Object): Object => { // Using cloneDeep to ensure that the returned object is serializable // Keys in a GIMessage may not be serializable (i.e., supported by the diff --git a/shared/domains/chelonia/utils.js b/shared/domains/chelonia/utils.js index ed13ba0b7..0d1a751e4 100644 --- a/shared/domains/chelonia/utils.js +++ b/shared/domains/chelonia/utils.js @@ -523,3 +523,9 @@ export const recreateEvent = (entry: GIMessage, state: Object, contractsState: O return entry } + +export const getContractIDfromKeyId = (contractID: string, signingKeyId?: string, state: Object): string => { + return signingKeyId && state._vm.authorizedKeys[signingKeyId].foreignKey + ? new URL(state._vm.authorizedKeys[signingKeyId].foreignKey).pathname + : contractID +}