import { SessionRequestMessage } from '../messages/outgoing'; import { createOrUpdateItem, getItemById } from '../../../js/modules/data'; import { MessageSender } from '../sending'; import { MessageUtils } from '../utils'; import { PubKey } from '../types'; import { Constants } from '..'; interface StringToNumberMap { [key: string]: number; } // tslint:disable: no-unnecessary-class export class SessionProtocol { private static dbLoaded: Boolean = false; /** * This map holds 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 window.libsignal.SignalProtocolAddress(pubkey.key, 1); const sessionCipher = new window.libsignal.SessionCipher( window.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.hasAlreadySentSessionRequest( pubkey.key ); return pendingSend || hasSent; } /** * Checks to see if any outgoing session requests have expired and re-sends them again if they have. */ public static async checkSessionRequestExpiry(): Promise { await this.fetchFromDBIfNeeded(); const now = Date.now(); const sentTimestamps = Object.entries(this.sentSessionsTimestamp); const promises = sentTimestamps.map(async ([device, sent]) => { const expireTime = sent + Constants.TTL_DEFAULT.SESSION_REQUEST; // Check if we need to send a session request if (now < expireTime) { return; } // Unset the timestamp, so that if it fails to send in this function, it will be guaranteed to send later on. await this.updateSentSessionTimestamp(device, undefined); await this.sendSessionRequestIfNeeded(new PubKey(device)); }); return Promise.all(promises) as Promise; } /** * Triggers a SessionRequestMessage 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 sent to that device */ public static async sendSessionRequestIfNeeded( pubkey: PubKey ): Promise { if ( (await SessionProtocol.hasSession(pubkey)) || (await SessionProtocol.hasSentSessionRequest(pubkey)) ) { return; } const preKeyBundle = await window.libloki.storage.getPreKeyBundleForContact( pubkey.key ); const sessionReset = new SessionRequestMessage({ preKeyBundle, timestamp: Date.now(), }); try { await SessionProtocol.sendSessionRequest(sessionReset, pubkey); } catch (error) { console.warn('Failed to send session request to:', pubkey.key, error); } } /** * Sends a session request message to that pubkey. * We store the sent timestamp only if the message is effectively sent. */ public static async sendSessionRequest( message: SessionRequestMessage, 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 // so sendSessionRequestIfNeeded does not sent another session request SessionProtocol.pendingSendSessionsTimestamp.add(pubkey.key); try { const rawMessage = await 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: SessionProtocol.sentSessionsTimestamp, }; await createOrUpdateItem(data); } private static async writeToDBProcessedSessions(): Promise { const data = { id: 'processedSessionsTimestamp', value: 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 (device === undefined) { throw new Error('Device cannot be undefined'); } if (map[device] === timestamp) { return false; } if (!timestamp) { // tslint:disable-next-line: no-dynamic-delete delete map[device]; } else { 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 ( await 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 database */ private static async updateProcessedSessionTimestamp( device: string, timestamp: number | undefined ): Promise { await SessionProtocol.fetchFromDBIfNeeded(); if ( await SessionProtocol.updateSessionTimestamp( device, timestamp, SessionProtocol.processedSessionsTimestamp ) ) { await SessionProtocol.writeToDBProcessedSessions(); } } private static async getSentSessionRequest( device: string ): Promise { await SessionProtocol.fetchFromDBIfNeeded(); return SessionProtocol.sentSessionsTimestamp[device]; } private static async getProcessedSessionRequest( device: string ): Promise { await SessionProtocol.fetchFromDBIfNeeded(); return SessionProtocol.processedSessionsTimestamp[device]; } private static async hasAlreadySentSessionRequest( device: string ): Promise { await SessionProtocol.fetchFromDBIfNeeded(); return !!SessionProtocol.sentSessionsTimestamp[device]; } }