diff --git a/app/definitions/ISubscription.ts b/app/definitions/ISubscription.ts index ee84c17b..904e13d0 100644 --- a/app/definitions/ISubscription.ts +++ b/app/definitions/ISubscription.ts @@ -91,6 +91,7 @@ export interface ISubscription { livechatData?: any; tags?: string[]; E2EKey?: string; + E2ESuggestedKey?: string; encrypted?: boolean; e2eKeyId?: string; avatarETag?: string; @@ -145,6 +146,7 @@ export interface IServerSubscription extends IRocketChatRecord { onHold?: boolean; encrypted?: boolean; E2EKey?: string; + E2ESuggestedKey?: string; unreadAlert?: 'default' | 'all' | 'mentions' | 'nothing'; fname?: unknown; diff --git a/app/definitions/rest/v1/e2e.ts b/app/definitions/rest/v1/e2e.ts index f23c0fac..6c4ef7c8 100644 --- a/app/definitions/rest/v1/e2e.ts +++ b/app/definitions/rest/v1/e2e.ts @@ -12,6 +12,12 @@ export type E2eEndpoints = { 'e2e.updateGroupKey': { POST: (params: { uid: string; rid: string; key: string }) => {}; }; + 'e2e.acceptSuggestedGroupKey': { + POST: (params: { rid: string }) => {}; + }; + 'e2e.rejectSuggestedGroupKey': { + POST: (params: { rid: string }) => {}; + }; 'e2e.setRoomKeyID': { POST: (params: { rid: string; keyID: string }) => {}; }; diff --git a/app/lib/database/model/Subscription.js b/app/lib/database/model/Subscription.js index 26fb764d..bbc73211 100644 --- a/app/lib/database/model/Subscription.js +++ b/app/lib/database/model/Subscription.js @@ -123,6 +123,8 @@ export default class Subscription extends Model { @field('e2e_key') E2EKey; + @field('e2e_suggested_key') E2ESuggestedKey; + @field('encrypted') encrypted; @field('e2e_key_id') e2eKeyId; diff --git a/app/lib/database/model/migrations.js b/app/lib/database/model/migrations.js index 3a5374e5..678fb0e4 100644 --- a/app/lib/database/model/migrations.js +++ b/app/lib/database/model/migrations.js @@ -248,6 +248,15 @@ export default schemaMigrations({ columns: [{ name: 'tmid', type: 'string', isOptional: true }] }) ] + }, + { + toVersion: 20, + steps: [ + addColumns({ + table: 'subscriptions', + columns: [{ name: 'e2e_suggested_key', type: 'string', isOptional: true }] + }) + ] } ] }); diff --git a/app/lib/database/schema/app.js b/app/lib/database/schema/app.js index f354b34e..754c19af 100644 --- a/app/lib/database/schema/app.js +++ b/app/lib/database/schema/app.js @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb'; export default appSchema({ - version: 19, + version: 20, tables: [ tableSchema({ name: 'subscriptions', @@ -55,6 +55,7 @@ export default appSchema({ { name: 'livechat_data', type: 'string', isOptional: true }, { name: 'tags', type: 'string', isOptional: true }, { name: 'e2e_key', type: 'string', isOptional: true }, + { name: 'e2e_suggested_key', type: 'string', isOptional: true }, { name: 'encrypted', type: 'boolean', isOptional: true }, { name: 'e2e_key_id', type: 'string', isOptional: true }, { name: 'avatar_etag', type: 'string', isOptional: true }, diff --git a/app/lib/encryption/encryption.ts b/app/lib/encryption/encryption.ts index 18e026e3..d24df42c 100644 --- a/app/lib/encryption/encryption.ts +++ b/app/lib/encryption/encryption.ts @@ -34,6 +34,7 @@ class Encryption { handshake: Function; decrypt: Function; encrypt: Function; + importRoomKey: Function; }; }; @@ -97,6 +98,10 @@ class Encryption { }); }; + stopRoom = (rid: string) => { + delete this.roomInstances[rid]; + }; + // When a new participant join and request a new room encryption key provideRoomKeyToUser = async (keyId: string, rid: string) => { // If the client is not ready @@ -220,6 +225,19 @@ class Encryption { return roomE2E; }; + evaluateSuggestedKey = async (rid: string, E2ESuggestedKey: string) => { + try { + if (this.privateKey) { + const roomE2E = await this.getRoomInstance(rid); + await roomE2E.importRoomKey(E2ESuggestedKey, this.privateKey); + delete this.roomInstances[rid]; + await Services.e2eAcceptSuggestedGroupKey(rid); + } + } catch (e) { + await Services.e2eRejectSuggestedGroupKey(rid); + } + }; + // Logic to decrypt all pending messages/threads/threadMessages // after initialize the encryption client decryptPendingMessages = async (roomId?: string) => { diff --git a/app/lib/encryption/room.ts b/app/lib/encryption/room.ts index da1fe8b4..acaf7119 100644 --- a/app/lib/encryption/room.ts +++ b/app/lib/encryption/room.ts @@ -74,7 +74,10 @@ export default class EncryptionRoom { if (E2EKey && Encryption.privateKey) { // We're establishing a new room encryption client this.establishing = true; - await this.importRoomKey(E2EKey, Encryption.privateKey); + const { keyID, roomKey, sessionKeyExportedString } = await this.importRoomKey(E2EKey, Encryption.privateKey); + this.keyID = keyID; + this.roomKey = roomKey; + this.sessionKeyExportedString = sessionKeyExportedString; this.readyPromise.resolve(); return; } @@ -96,20 +99,33 @@ export default class EncryptionRoom { }; // Import roomKey as an AES Decrypt key - importRoomKey = async (E2EKey: string, privateKey: string) => { - const roomE2EKey = E2EKey.slice(12); + importRoomKey = async ( + E2EKey: string, + privateKey: string + ): Promise<{ sessionKeyExportedString: string | ByteBuffer; roomKey: ArrayBuffer; keyID: string }> => { + try { + const roomE2EKey = E2EKey.slice(12); - const decryptedKey = await SimpleCrypto.RSA.decrypt(roomE2EKey, privateKey); - this.sessionKeyExportedString = toString(decryptedKey); + const decryptedKey = await SimpleCrypto.RSA.decrypt(roomE2EKey, privateKey); + const sessionKeyExportedString = toString(decryptedKey); - this.keyID = Base64.encode(this.sessionKeyExportedString as string).slice(0, 12); + const keyID = Base64.encode(sessionKeyExportedString as string).slice(0, 12); - // Extract K from Web Crypto Secret Key - // K is a base64URL encoded array of bytes - // Web Crypto API uses this as a private key to decrypt/encrypt things - // Reference: https://www.javadoc.io/doc/com.nimbusds/nimbus-jose-jwt/5.1/com/nimbusds/jose/jwk/OctetSequenceKey.html - const { k } = EJSON.parse(this.sessionKeyExportedString as string); - this.roomKey = b64ToBuffer(k); + // Extract K from Web Crypto Secret Key + // K is a base64URL encoded array of bytes + // Web Crypto API uses this as a private key to decrypt/encrypt things + // Reference: https://www.javadoc.io/doc/com.nimbusds/nimbus-jose-jwt/5.1/com/nimbusds/jose/jwk/OctetSequenceKey.html + const { k } = EJSON.parse(sessionKeyExportedString as string); + const roomKey = b64ToBuffer(k); + + return { + sessionKeyExportedString, + roomKey, + keyID + }; + } catch (e: any) { + throw new Error(e); + } }; // Create a key to a room diff --git a/app/lib/methods/subscriptions/rooms.ts b/app/lib/methods/subscriptions/rooms.ts index 5bbeffe3..443b681f 100644 --- a/app/lib/methods/subscriptions/rooms.ts +++ b/app/lib/methods/subscriptions/rooms.ts @@ -102,6 +102,7 @@ const createOrUpdateSubscription = async (subscription: ISubscription, room: ISe encrypted: s.encrypted, e2eKeyId: s.e2eKeyId, E2EKey: s.E2EKey, + E2ESuggestedKey: s.E2ESuggestedKey, avatarETag: s.avatarETag, onHold: s.onHold, hideMentionStatus: s.hideMentionStatus @@ -165,6 +166,8 @@ const createOrUpdateSubscription = async (subscription: ISubscription, room: ISe tmp = (await Encryption.decryptSubscription(tmp)) as ISubscription; // Decrypt all pending messages of this room in parallel Encryption.decryptPendingMessages(tmp.rid); + } else if (sub && subscription.E2ESuggestedKey) { + await Encryption.evaluateSuggestedKey(sub.rid, subscription.E2ESuggestedKey); } const batch: Model[] = []; @@ -320,6 +323,8 @@ export default function subscribeRooms() { await db.batch(sub.prepareDestroyPermanently(), ...messagesToDelete, ...threadsToDelete, ...threadMessagesToDelete); }); + Encryption.stopRoom(data.rid); + const roomState = store.getState().room; // Delete and remove events come from this stream // Here we identify which one was triggered diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index 5fa3b155..75f256fb 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -74,6 +74,14 @@ export const e2eRequestRoomKey = (rid: string, e2eKeyId: string): Promise<{ mess // RC 0.70.0 sdk.methodCallWrapper('stream-notify-room-users', `${rid}/e2ekeyRequest`, rid, e2eKeyId); +export const e2eAcceptSuggestedGroupKey = (rid: string): Promise<{ success: boolean }> => + // RC 5.5 + sdk.post('e2e.acceptSuggestedGroupKey', { rid }); + +export const e2eRejectSuggestedGroupKey = (rid: string): Promise<{ success: boolean }> => + // RC 5.5 + sdk.post('e2e.rejectSuggestedGroupKey', { rid }); + export const updateJitsiTimeout = (roomId: string) => // RC 0.74.0 sdk.post('video-conference/jitsi.update-timeout', { roomId }); diff --git a/app/sagas/encryption.js b/app/sagas/encryption.js index fb94930f..9f1482f1 100644 --- a/app/sagas/encryption.js +++ b/app/sagas/encryption.js @@ -51,13 +51,6 @@ const handleEncryptionInit = function* handleEncryptionInit() { return; } - // If the user has a private key stored, but never entered the password - const storedRandomPassword = UserPreferences.getString(`${server}-${E2E_RANDOM_PASSWORD_KEY}`); - - if (storedRandomPassword) { - yield put(encryptionSet(true, E2E_BANNER_TYPE.SAVE_PASSWORD)); - } - // Fetch stored public e2e key for this server let storedPublicKey = UserPreferences.getString(`${server}-${E2E_PUBLIC_KEY}`); @@ -66,14 +59,21 @@ const handleEncryptionInit = function* handleEncryptionInit() { storedPublicKey = EJSON.parse(storedPublicKey); } - if (storedPublicKey && storedPrivateKey && !storedRandomPassword) { + if (storedPublicKey && storedPrivateKey) { // Persist these keys yield Encryption.persistKeys(server, storedPublicKey, storedPrivateKey); - yield put(encryptionSet(true)); } else { // Create new keys since the user doesn't have any yield Encryption.createKeys(user.id, server); + } + + // If the user has a private key stored, but never entered the password + const storedRandomPassword = UserPreferences.getString(`${server}-${E2E_RANDOM_PASSWORD_KEY}`); + + if (storedRandomPassword) { yield put(encryptionSet(true, E2E_BANNER_TYPE.SAVE_PASSWORD)); + } else { + yield put(encryptionSet(true)); } // Decrypt all pending messages/subscriptions