import { SessionResetMessage } from '../messages/outgoing'; // import { MessageSender } from '../sending'; import { createOrUpdateItem, getItemById } from '../../../js/modules/data'; import { libloki, libsignal, textsecure } from '../../window'; import { MessageSender } from '../sending'; import * as MessageUtils from '../utils'; import { PubKey } from '../types'; interface StringToNumberMap { [key: string]: number; } // tslint:disable: function-name // tslint:disable: no-unnecessary-class export class SessionProtocol { private static dbLoaded: Boolean = false; /** * This map olds the sent session timestamps, i.e. session requests message effectively sent to the recipient. * It is backed by a database entry so it's loaded from db on startup. * This map should not be used directly, but instead through * `updateSendSessionTimestamp()`, or `hasSendSessionRequest()` */ private static sentSessionsTimestamp: StringToNumberMap; /** * This map olds the processed session timestamps, i.e. when we received a session request and handled it. * It is backed by a database entry so it's loaded from db on startup. * This map should not be used directly, but instead through * `updateProcessedSessionTimestamp()`, `getProcessedSessionRequest()` or `hasProcessedSessionRequest()` */ private static processedSessionsTimestamp: StringToNumberMap; /** * This map olds the timestamp on which a sent session reset is triggered for a specific device. * Once the message is sent or failed to sent, this device is removed from here. * This is a memory only map. Which means that on app restart it's starts empty. */ private static readonly pendingSendSessionsTimestamp: Set = new Set(); public static getSentSessionsTimestamp(): Readonly { return SessionProtocol.sentSessionsTimestamp; } public static getProcessedSessionsTimestamp(): Readonly { return SessionProtocol.processedSessionsTimestamp; } public static getPendingSendSessionTimestamp(): Readonly> { return SessionProtocol.pendingSendSessionsTimestamp; } /** Returns true if we already have a session with that device */ public static async hasSession(pubkey: PubKey): Promise { // Session does not use the concept of a deviceId, thus it's always 1 const address = new libsignal.SignalProtocolAddress(pubkey.key, 1); const sessionCipher = new libsignal.SessionCipher( textsecure.storage.protocol, address ); return sessionCipher.hasOpenSession(); } /** * Returns true if we sent a session request to that device already OR * if a session request to that device is right now being sent. */ public static async hasSentSessionRequest(pubkey: PubKey): Promise { const pendingSend = SessionProtocol.pendingSendSessionsTimestamp.has( pubkey.key ); const hasSent = await SessionProtocol._hasSentSessionRequest(pubkey.key); return pendingSend || hasSent; } /** * Triggers a SessionResetMessage to be sent if: * - we do not already have a session and * - we did not sent a session request already to that device and * - we do not have a session request currently being send to that device */ public static async sendSessionRequestIfNeeded( pubkey: PubKey ): Promise { if ( (await SessionProtocol.hasSession(pubkey)) || (await SessionProtocol.hasSentSessionRequest(pubkey)) ) { return Promise.resolve(); } const preKeyBundle = await libloki.storage.getPreKeyBundleForContact( pubkey.key ); const sessionReset = new SessionResetMessage({ preKeyBundle, timestamp: Date.now(), }); try { await SessionProtocol.sendSessionRequest(sessionReset, pubkey); } catch (error) { window.console.warn('Failed to send session request to:', pubkey.key, error); } } /** */ public static async sendSessionRequest( message: SessionResetMessage, pubkey: PubKey ): Promise { const timestamp = Date.now(); // mark the session as being pending send with current timestamp // so we know we already triggered a new session with that device SessionProtocol.pendingSendSessionsTimestamp.add(pubkey.key); try { const rawMessage = MessageUtils.toRawMessage(pubkey, message); await MessageSender.send(rawMessage); await SessionProtocol.updateSentSessionTimestamp(pubkey.key, timestamp); } catch (e) { throw e; } finally { SessionProtocol.pendingSendSessionsTimestamp.delete(pubkey.key); } } /** * Called when a session is establish so we store on database this info. */ public static async onSessionEstablished(pubkey: PubKey) { // remove our existing sent timestamp for that device return SessionProtocol.updateSentSessionTimestamp(pubkey.key, undefined); } public static async shouldProcessSessionRequest( pubkey: PubKey, messageTimestamp: number ): Promise { const existingSentTimestamp = (await SessionProtocol.getSentSessionRequest(pubkey.key)) || 0; const existingProcessedTimestamp = (await SessionProtocol.getProcessedSessionRequest(pubkey.key)) || 0; return ( messageTimestamp > existingSentTimestamp && messageTimestamp > existingProcessedTimestamp ); } public static async onSessionRequestProcessed(pubkey: PubKey) { return SessionProtocol.updateProcessedSessionTimestamp(pubkey.key, Date.now()); } public static reset() { SessionProtocol.dbLoaded = false; SessionProtocol.sentSessionsTimestamp = {}; SessionProtocol.processedSessionsTimestamp = {}; } /** * We only need to fetch once from the database, because we are the only one writing to it */ private static async fetchFromDBIfNeeded(): Promise { if (!SessionProtocol.dbLoaded) { const sentItem = await getItemById('sentSessionsTimestamp'); if (sentItem) { SessionProtocol.sentSessionsTimestamp = sentItem.value; } else { SessionProtocol.sentSessionsTimestamp = {}; } const processedItem = await getItemById('processedSessionsTimestamp'); if (processedItem) { SessionProtocol.processedSessionsTimestamp = processedItem.value; } else { SessionProtocol.processedSessionsTimestamp = {}; } SessionProtocol.dbLoaded = true; } } private static async writeToDBSentSessions(): Promise { const data = { id: 'sentSessionsTimestamp', value: JSON.stringify(SessionProtocol.sentSessionsTimestamp), }; await createOrUpdateItem(data); } private static async writeToDBProcessedSessions(): Promise { const data = { id: 'processedSessionsTimestamp', value: JSON.stringify(SessionProtocol.processedSessionsTimestamp), }; await createOrUpdateItem(data); } /** * This is a utility function to avoid duplicated code of updateSentSessionTimestamp and updateProcessedSessionTimestamp */ private static async updateSessionTimestamp( device: string, timestamp: number | undefined, map: StringToNumberMap ): Promise { if (!timestamp) { if (!!map[device]) { // tslint:disable-next-line: no-dynamic-delete delete map[device]; return true; } return false; } map[device] = timestamp; return true; } /** * * @param device the device id * @param timestamp undefined to remove the key/value pair, otherwise updates the sent timestamp and write to DB */ private static async updateSentSessionTimestamp( device: string, timestamp: number | undefined ): Promise { await SessionProtocol.fetchFromDBIfNeeded(); if ( SessionProtocol.updateSessionTimestamp( device, timestamp, SessionProtocol.sentSessionsTimestamp ) ) { await SessionProtocol.writeToDBSentSessions(); } } /** * timestamp undefined to remove the key/value pair, otherwise updates the processed timestamp and writes to DB */ private static async updateProcessedSessionTimestamp( device: string, timestamp: number | undefined ): Promise { await SessionProtocol.fetchFromDBIfNeeded(); if ( SessionProtocol.updateSessionTimestamp( device, timestamp, SessionProtocol.processedSessionsTimestamp ) ) { await SessionProtocol.writeToDBProcessedSessions(); } } /** * This is a utility function to avoid duplicate code between `getProcessedSessionRequest()` and `getSentSessionRequest()` */ private static async getSessionRequest( device: string, map: StringToNumberMap ): Promise { await SessionProtocol.fetchFromDBIfNeeded(); return map[device]; } private static async getSentSessionRequest( device: string ): Promise { return SessionProtocol.getSessionRequest( device, SessionProtocol.sentSessionsTimestamp ); } private static async getProcessedSessionRequest( device: string ): Promise { return SessionProtocol.getSessionRequest( device, SessionProtocol.processedSessionsTimestamp ); } private static async _hasSentSessionRequest( device: string ): Promise { await SessionProtocol.fetchFromDBIfNeeded(); return !!SessionProtocol.sentSessionsTimestamp[device]; } }