UNPKG

tiny-crypto-suite

Version:

Tiny tools, big crypto — seamless encryption and certificate handling for modern web and Node apps.

1,247 lines 69.1 kB
import { TinyPromiseQueue } from 'tiny-essentials'; import { EventEmitter } from 'events'; import TinyCryptoParser from '../lib/TinyCryptoParser.mjs'; import tinyOlm from './TinyOlmModule.mjs'; import TinyOlmEvents from './TinyOlmEvents.mjs'; /** * TinyOlm instance is a lightweight wrapper for handling encryption sessions using the Olm cryptographic library. * * This class is **not available for production mode**. * * @beta * @class */ class TinyOlmInstance { /** * Creates a new TinyOlm instance for a specific userId. * * @param {string} userId - The userId to associate with the account and sessions. * @param {string} deviceId - The device id to associate with the account and sessions. * @param {string} [password] - The optional password to associate with the account and sessions. */ constructor(userId, deviceId, password = '') { /** @type {string} */ this.password = password; /** @type {string} */ this.userId = userId; /** @type {string} */ this.deviceId = deviceId; /** @type {Olm.Account|null} */ this.account = null; /** @type {Map<string, Olm.Session>} */ this.sessions = new Map(); /** @type {Map<string, Olm.OutboundGroupSession>} */ this.groupSessions = new Map(); /** @type {Map<string, Olm.InboundGroupSession>} */ this.groupInboundSessions = new Map(); } /** * Important instance used to make event emitter. * @type {EventEmitter} */ #events = new EventEmitter(); /** * Important instance used to make system event emitter. * @type {EventEmitter} */ #sysEvents = new EventEmitter(); #sysEventsUsed = false; /** * Emits an event with optional arguments to all system emit. * @param {string | symbol} event - The name of the event to emit. * @param {...any} args - Arguments passed to event listeners. */ #emit(event, ...args) { this.#events.emit(event, ...args); if (this.#sysEventsUsed) this.#sysEvents.emit(event, ...args); } /** * Provides access to a secure internal EventEmitter for subclass use only. * * This method exposes a dedicated EventEmitter instance intended specifically for subclasses * that extend the main class. It prevents subclasses from accidentally or intentionally using * the primary class's public event system (`emit`), which could lead to unpredictable behavior * or interference in the base class's event flow. * * For security and consistency, this method is designed to be accessed only once. * Multiple accesses are blocked to avoid leaks or misuse of the internal event bus. * * @returns {EventEmitter} A special internal EventEmitter instance for subclass use. * @throws {Error} If the method is called more than once. */ getSysEvents() { if (this.#sysEventsUsed) throw new Error('Access denied: getSysEvents() can only be called once. ' + 'This restriction ensures subclass event isolation and prevents accidental interference ' + 'with the main class event emitter.'); this.#sysEventsUsed = true; return this.#sysEvents; } /** * @typedef {(...args: any[]) => void} ListenerCallback * A generic callback function used for event listeners. */ /** * Sets the maximum number of listeners for the internal event emitter. * * @param {number} max - The maximum number of listeners allowed. */ setMaxListeners(max) { this.#events.setMaxListeners(max); } /** * Emits an event with optional arguments. * @param {string | symbol} event - The name of the event to emit. * @param {...any} args - Arguments passed to event listeners. * @returns {boolean} `true` if the event had listeners, `false` otherwise. */ emit(event, ...args) { return this.#events.emit(event, ...args); } /** * Registers a listener for the specified event. * @param {string | symbol} event - The name of the event to listen for. * @param {ListenerCallback} listener - The callback function to invoke. * @returns {this} The current class instance (for chaining). */ on(event, listener) { this.#events.on(event, listener); return this; } /** * Registers a one-time listener for the specified event. * @param {string | symbol} event - The name of the event to listen for once. * @param {ListenerCallback} listener - The callback function to invoke. * @returns {this} The current class instance (for chaining). */ once(event, listener) { this.#events.once(event, listener); return this; } /** * Removes a listener from the specified event. * @param {string | symbol} event - The name of the event. * @param {ListenerCallback} listener - The listener to remove. * @returns {this} The current class instance (for chaining). */ off(event, listener) { this.#events.off(event, listener); return this; } /** * Alias for `on`. * @param {string | symbol} event - The name of the event. * @param {ListenerCallback} listener - The callback to register. * @returns {this} The current class instance (for chaining). */ addListener(event, listener) { this.#events.addListener(event, listener); return this; } /** * Alias for `off`. * @param {string | symbol} event - The name of the event. * @param {ListenerCallback} listener - The listener to remove. * @returns {this} The current class instance (for chaining). */ removeListener(event, listener) { this.#events.removeListener(event, listener); return this; } /** * Removes all listeners for a specific event, or all events if no event is specified. * @param {string | symbol} [event] - The name of the event. If omitted, all listeners from all events will be removed. * @returns {this} The current class instance (for chaining). */ removeAllListeners(event) { this.#events.removeAllListeners(event); return this; } /** * Returns the number of times the given `listener` is registered for the specified `event`. * If no `listener` is passed, returns how many listeners are registered for the `event`. * @param {string | symbol} eventName - The name of the event. * @param {Function} [listener] - Optional listener function to count. * @returns {number} Number of matching listeners. */ listenerCount(eventName, listener) { return this.#events.listenerCount(eventName, listener); } /** * Adds a listener function to the **beginning** of the listeners array for the specified event. * The listener is called every time the event is emitted. * @param {string | symbol} eventName - The event name. * @param {ListenerCallback} listener - The callback function. * @returns {this} The current class instance (for chaining). */ prependListener(eventName, listener) { this.#events.prependListener(eventName, listener); return this; } /** * Adds a **one-time** listener function to the **beginning** of the listeners array. * The next time the event is triggered, this listener is removed and then invoked. * @param {string | symbol} eventName - The event name. * @param {ListenerCallback} listener - The callback function. * @returns {this} The current class instance (for chaining). */ prependOnceListener(eventName, listener) { this.#events.prependOnceListener(eventName, listener); return this; } /** * Returns an array of event names for which listeners are currently registered. * @returns {(string | symbol)[]} Array of event names. */ eventNames() { return this.#events.eventNames(); } /** * Gets the current maximum number of listeners allowed for any single event. * @returns {number} The max listener count. */ getMaxListeners() { return this.#events.getMaxListeners(); } /** * Returns a copy of the listeners array for the specified event. * @param {string | symbol} eventName - The event name. * @returns {Function[]} An array of listener functions. */ listeners(eventName) { return this.#events.listeners(eventName); } /** * Returns a copy of the internal listeners array for the specified event, * including wrapper functions like those used by `.once()`. * @param {string | symbol} eventName - The event name. * @returns {Function[]} An array of raw listener functions. */ rawListeners(eventName) { return this.#events.rawListeners(eventName); } /** * Important instance used to validate values. * @type {TinyCryptoParser} */ #parser = new TinyCryptoParser(); /** * Important instance used to make request queue. * @type {TinyPromiseQueue} */ #queue = new TinyPromiseQueue(); /** * Returns the internal TinyPromiseQueue instance (tiny-essentials module) used to manage queued operations. * * @returns {TinyPromiseQueue} The internal request queue instance. */ getQueue() { return this.#queue; } /** * Add a new value type and its converter function. * @param {string} typeName * @param {(data: any) => any} getFunction * @param {(data: any) => { __type: string, value?: any }} convertFunction */ addValueType(typeName, getFunction, convertFunction) { return this.#parser.addValueType(typeName, getFunction, convertFunction); } /** * Indicates whether the serialization or deserialization should be performed deeply. * @type {boolean} */ isDeep = true; /** * Sets the deep serialization and deserialization mode. * If the argument is a boolean, updates the deep mode accordingly. * Throws an error if the value is not a boolean. * * @param {boolean} value - A boolean indicating whether deep mode should be enabled. * @throws {Error} Throws if the provided value is not a boolean. */ setDeepMode(value) { if (typeof value !== 'boolean') throw new Error('The value provided to setDeepMode must be a boolean.'); this.isDeep = value; } /** * Checks if the current environment is a browser with IndexedDB support. * @throws {Error} If not running in a browser or if IndexedDB is unavailable. */ validateIsBrowser() { if (typeof indexedDB === 'undefined') throw new Error('IndexedDB is only available in browser environments.'); } /** @type {string|null} */ #dbName = null; /** @type {IDBDatabase|null} */ #db = null; /** @type {number} */ #dbVersion = 1; /** @type {boolean} */ #useLocal = true; /** * Enables or disables the functions of use in-memory storage. * * When set to `true`, operations will use local in-memory storage, * useful for tests or temporary sessions that don't require persistence. * * @param {boolean} value - `true` to use in-memory storage, `false` to disable it. */ setUseLocal(value) { this.#useLocal = value; } /** * Returns whether in-memory storage is currently being used. * * @returns {boolean} `true` if the system is set to use in-memory storage, otherwise `false`. */ isUseLocal() { return this.#useLocal; } /** * Saves the current account to IndexedDB using a predefined key. * If no account is set, the method does nothing. * * @returns {Promise<void | IDBValidKey>} */ async #saveAccount() { if (this.existsDb() && this.account) return this.#idbPut('account', 'main', this.account.pickle(this.password)); } /** * Gets a value from IndexedDB by key. * * @param {string} store - The store name. * @param {IDBValidKey} key - The key to retrieve. * @returns {Promise<any>} Resolves with the stored value or undefined. */ #idbGet(store, key) { return new Promise((resolve, reject) => { const tx = this.getDb().transaction([store], 'readonly'); const req = tx.objectStore(store).get(key); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } /** * Gets all key-value entries from a store in IndexedDB. * * @param {string} store - The store name. * @returns {Promise<Record<string, any>>} Resolves with all entries. */ #idbGetAll(store) { return new Promise((resolve, reject) => { const tx = this.getDb().transaction([store], 'readonly'); const storeObj = tx.objectStore(store); const req = storeObj.getAllKeys(); /** @type {Record<string, any>} */ const result = {}; req.onsuccess = () => { const keys = req.result; Promise.all(keys.map(async (/** @type {*} */ key) => { result[key] = await this.#idbGet(store, key); })) .then(() => resolve(result)) .catch(reject); }; req.onerror = () => reject(req.error); }); } /** * Stores a value in the specified IndexedDB store. * * @param {string} store - The store name. * @param {IDBValidKey} key - The key to store under. * @param {any} value - The value to store. * @returns {Promise<IDBValidKey>} */ #idbPut(store, key, value) { return this.#queue.enqueue(() => { const tx = this.getDb().transaction([store], 'readwrite'); const req = tx.objectStore(store).put(value, key); return new Promise((resolve, reject) => { req.onsuccess = () => { this.#events.emit(TinyOlmEvents.DbPut, store, key, value); resolve(req.result); }; req.onerror = () => reject(req.error); }); }); } /** * Deletes a value from the specified IndexedDB store. * * @param {string} store - The store name. * @param {IDBValidKey} key - The key to delete. * @returns {Promise<void>} */ #idbDelete(store, key) { return this.#queue.enqueue(() => { const tx = this.getDb().transaction([store], 'readwrite'); const req = tx.objectStore(store).delete(key); return new Promise((resolve, reject) => { req.onsuccess = () => { this.#events.emit(TinyOlmEvents.DbDelete, store, key); resolve(req.result); }; req.onerror = () => reject(req.error); }); }); } /** * Clears all values from the specified IndexedDB store. * * @param {string} store - The store name. * @returns {Promise<void>} */ #idbClear(store) { return this.#queue.enqueue(() => { const tx = this.getDb().transaction([store], 'readwrite'); const req = tx.objectStore(store).clear(); return new Promise((resolve, reject) => { req.onsuccess = () => { this.#events.emit(TinyOlmEvents.DbClear, store); resolve(req.result); }; req.onerror = () => reject(req.error); }); }); } /** * Returns the name of the current IndexedDB database. * * @returns {string} The database name. * @throws {Error} If the database name is not set. */ getDbName() { if (typeof this.#dbName !== 'string') throw new Error('Invalid internal state: #dbName must be a string.'); return this.#dbName; } /** * Returns the active IndexedDB database instance. * * @returns {IDBDatabase} The open database instance. * @throws {Error} If the database has not been initialized. */ getDb() { if (this.#db === null) throw new Error('Database has not been initialized. Call the initIndexedDb() method first.'); return this.#db; } /** * Checks whether the internal IndexedDB instance has been initialized. * * @returns {boolean} `true` if the database instance exists and is ready for use, otherwise `false`. */ existsDb() { return this.#db ? true : false; } /** * Initializes the IndexedDB database and restores previously saved state. * * @param {string} [dbName='TinyOlmInstance'] - The name of the IndexedDB database. * @returns {Promise<IDBDatabase>} Resolves when the database is ready. * @throws {Error} If not in a browser or if the database is already initialized. */ async initIndexedDb(dbName = 'TinyOlmInstance') { // Check all await tinyOlm.fetchOlm(); this.validateIsBrowser(); if (typeof dbName !== 'string') throw new Error('Invalid database name: expected a string.'); if (this.existsDb()) throw new Error('Database is already open or initialized.'); this.#dbName = dbName; // Get db const db = await new Promise((resolve, reject) => { const dbName = this.getDbName(); const req = indexedDB.open(dbName, this.#dbVersion); req.onupgradeneeded = () => { // Start database const db = req.result; db.createObjectStore('account'); db.createObjectStore('sessions'); db.createObjectStore('groupSessions'); db.createObjectStore('groupInboundSessions'); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); this.#db = db; await this.#idbPut('account', 'password', this.password); this.#emit(TinyOlmEvents.SetPassword, this.password); await this.#idbPut('account', 'userId', this.userId); this.#emit(TinyOlmEvents.SetUserId, this.userId); await this.#idbPut('account', 'deviceId', this.deviceId); this.#emit(TinyOlmEvents.SetDeviceId, this.deviceId); // Load and restore account const accountPickle = await this.#idbGet('account', 'main'); if (accountPickle) this.importAccount(accountPickle); // Load and restore sessions const sessionPickles = await this.#idbGetAll('sessions'); for (const [userId, pickle] of Object.entries(sessionPickles)) this.importSession(userId, pickle); const groupSessionPickles = await this.#idbGetAll('groupSessions'); for (const [roomId, pickle] of Object.entries(groupSessionPickles)) this.importGroupSession(roomId, pickle); const groupInboundPickles = await this.#idbGetAll('groupInboundSessions'); for (const [roomId, pickle] of Object.entries(groupInboundPickles)) this.importInboundGroupSession(roomId, pickle); // Db value is ready now return db; } async _testIndexedDb() { console.log('ACCOUNT'); const accountPickles = await this.#idbGetAll('account'); console.log(accountPickles); console.log('SESSIONS'); const sessionPickles = await this.#idbGetAll('sessions'); console.log(sessionPickles); console.log('GROUP SESSIONS'); const groupSessionPickles = await this.#idbGetAll('groupSessions'); console.log(groupSessionPickles); console.log('GROUP INBOUND SESSIONS'); const groupInboundPickles = await this.#idbGetAll('groupInboundSessions'); console.log(groupInboundPickles); } /** * Validates that a given userId follows the user ID format. * * A valid Matrix user ID must start with '@', contain at least one character, * then a ':', followed by at least one character (e.g., "@user:domain.com"). * * @param {string} userId - The Matrix user ID to validate. * @throws {Error} Throws an error if the userId does not match the expected format. * @returns {void} */ checkUserId(userId) { if (!/^@.+:.+$/.test(userId)) throw new Error('Invalid Matrix user ID format.'); } /** * Sets the new password of instance. * * @param {string} newPassword - The new password. * @throws {Error} Throws if the provided value is not a string. */ async setPassword(newPassword) { if (typeof newPassword !== 'string') throw new Error('The value provided to password must be a string.'); this.password = newPassword; if (this.existsDb()) await this.#idbPut('account', 'password', this.password); this.#emit(TinyOlmEvents.SetPassword, newPassword); } /** * Returns the current password used for (un)pickling. * * @returns {string} The current password. * @throws {Error} Throws if the password is not set. */ getPassword() { if (typeof this.password !== 'string') throw new Error('No password is set.'); return this.password; } /** * Sets the userId of this instance. * * @param {string} newUserId - The new userId. * @throws {Error} Throws if the provided value is not a string. */ async setUserId(newUserId) { if (typeof newUserId !== 'string') throw new Error('The value provided to userId must be a string.'); this.userId = newUserId; if (this.existsDb()) await this.#idbPut('account', 'userId', this.userId); this.#emit(TinyOlmEvents.SetUserId, newUserId); } /** * Returns the current userId. * * @returns {string} The current userId. * @throws {Error} Throws if the userId is not set. */ getUserId() { if (typeof this.userId !== 'string') throw new Error('No userId is set.'); return this.userId; } /** * Sets the device ID of this instance. * * @param {string} newDeviceId - The new device ID. * @throws {Error} Throws if the provided value is not a string. */ async setDeviceId(newDeviceId) { if (typeof newDeviceId !== 'string') throw new Error('The value provided to deviceId must be a string.'); this.deviceId = newDeviceId; if (this.existsDb()) await this.#idbPut('account', 'deviceId', this.deviceId); this.#emit(TinyOlmEvents.SetDeviceId, newDeviceId); } /** * Returns the current device ID. * * @returns {string} The current device ID. * @throws {Error} Throws if the device ID is not set. */ getDeviceId() { if (typeof this.deviceId !== 'string') throw new Error('No deviceId is set.'); return this.deviceId; } /** * @typedef {Object} ExportedOlmInstance * @property {string|null} account - Pickled Olm.Account object. * @property {Record<string, string>|null} sessions - Pickled Olm.Session objects, indexed by session ID. * @property {Record<string, string>|null} groupSessions - Pickled Olm.OutboundGroupSession objects, indexed by room/session ID. * @property {Record<string, string>|null} groupInboundSessions - Pickled Olm.InboundGroupSession objects, indexed by sender key or session ID. */ /** * Export the current Olm account as a pickled string. * * @param {string} [password=this.password] - The password used to encrypt the pickle. * @returns {string} The pickled Olm account. * @throws {Error} If the account is not initialized. */ exportAccount(password = this.getPassword()) { if (!this.account) throw new Error('Account is not initialized.'); return this.account.pickle(password); } /** * Export a specific Olm session with a given user. * * @param {string} userId - The userId of the remote device. * @param {string} [password=this.password] - The password used to encrypt the pickle. * @returns {string} The pickled Olm session. * @throws {Error} If the session is not found. */ exportSession(userId, password = this.password) { const sess = this.getSession(userId); return sess.pickle(password); } /** * Export an outbound group session for a specific room. * * @param {string} roomId - The ID of the room. * @param {string} [password=this.password] - The password used to encrypt the pickle. * @returns {string} The pickled outbound group session. * @throws {Error} If the group session is not found. */ exportGroupSession(roomId, password = this.getPassword()) { const sess = this.getGroupSession(roomId); return sess.pickle(password); } /** * Export an inbound group session for a specific room and sender. * * @param {string} roomId - The ID of the room. * @param {string} userId - The sender's userId or session owner. * @param {string} [password=this.password] - The password used to encrypt the pickle. * @returns {string} The pickled inbound group session. * @throws {Error} If the inbound group session is not found. */ exportInboundGroupSession(roomId, userId, password = this.getPassword()) { const sess = this.getInboundGroupSession(roomId, userId); return sess.pickle(password); } /** * @param {string} [password] The password used to pickle. If you do not enter any, the predefined password will be used. * @returns {ExportedOlmInstance} Serial structure */ exportInstance(password = this.getPassword()) { return { account: this.exportAccount(), sessions: Object.fromEntries(Array.from(this.sessions.entries()).map(([key, sess]) => [key, sess.pickle(password)])), groupSessions: Object.fromEntries(Array.from(this.groupSessions.entries()).map(([key, group]) => [ key, group.pickle(password), ])), groupInboundSessions: Object.fromEntries(Array.from(this.groupInboundSessions.entries()).map(([key, inbound]) => [ key, inbound.pickle(password), ])), }; } /** * Export a specific Olm session with a given user from indexedDb. * * @param {string} userId - The userId of the remote device. * @returns {Promise<string>} The pickled Olm session. * @throws {Error} If the session is not found. */ async exportDbSession(userId) { return this.#idbGet('sessions', userId); } /** * Export an outbound group session for a specific room from indexedDb. * * @param {string} roomId - The ID of the room. * @returns {Promise<string>} The pickled outbound group session. * @throws {Error} If the group session is not found. */ async exportDbGroupSession(roomId) { return this.#idbGet('groupSessions', roomId); } /** * Export an inbound group session for a specific room and sender from indexedDb. * * @param {string} roomId - The ID of the room. * @param {string} userId - The sender's userId or session owner. * @returns {Promise<string>} The pickled inbound group session. * @throws {Error} If the inbound group session is not found. */ async exportDbInboundGroupSession(roomId, userId) { return this.#idbGet('groupInboundSessions', this.#getGroupSessionId(roomId, userId)); } /** * @returns {Promise<ExportedOlmInstance>} Serial structure */ async exportDbInstance() { return { account: this.exportAccount(), sessions: await this.#idbGetAll('sessions'), groupSessions: await this.#idbGetAll('groupSessions'), groupInboundSessions: await this.#idbGetAll('groupInboundSessions'), }; } /** * Import and restore an Olm account from a pickled string. * * @param {string} pickled - The pickled Olm account string. * @param {string} [password=this.password] - The password used to decrypt the pickle. * @returns {Promise<void>} */ async importAccount(pickled, password = this.getPassword()) { const Olm = tinyOlm.getOlm(); const account = new Olm.Account(); account.unpickle(password, pickled); this.account = account; if (this.existsDb()) await this.#idbPut('account', 'main', account.pickle(this.password)); this.#emit(TinyOlmEvents.ImportAccount, account); } /** * Import and restore an Olm session from a pickled string. * * @param {string} key - The session key used to index this session (usually userId or `userId|deviceId`). * @param {string} pickled - The pickled Olm session string. * @param {string} [password=this.password] - The password used to decrypt the pickle. * @returns {Promise<void>} */ async importSession(key, pickled, password = this.getPassword()) { const Olm = tinyOlm.getOlm(); const sess = new Olm.Session(); sess.unpickle(password, pickled); if (this.isUseLocal()) this.sessions.set(key, sess); await this.#idbPut('sessions', key, sess.pickle(this.password)); this.#emit(TinyOlmEvents.ImportSession, key, sess); } /** * Import and restore an outbound group session from a pickled string. * * @param {string} key - The key used to index the group session (usually the roomId). * @param {string} pickled - The pickled Olm.OutboundGroupSession string. * @param {string} [password=this.password] - The password used to decrypt the pickle. * @returns {Promise<void>} */ async importGroupSession(key, pickled, password = this.getPassword()) { const Olm = tinyOlm.getOlm(); const group = new Olm.OutboundGroupSession(); group.unpickle(password, pickled); if (this.isUseLocal()) this.groupSessions.set(key, group); if (this.existsDb()) await this.#idbPut('groupSessions', key, group.pickle(this.password)); this.#emit(TinyOlmEvents.ImportGroupSession, key, group); } /** * Import and restore an inbound group session from a pickled string. * * @param {string} key - The key used to index the inbound group session (usually sender key or `roomId|sender`). * @param {string} pickled - The pickled Olm.InboundGroupSession string. * @param {string} [password=this.password] - The password used to decrypt the pickle. * @returns {Promise<void>} */ async importInboundGroupSession(key, pickled, password = this.getPassword()) { const Olm = tinyOlm.getOlm(); const inbound = new Olm.InboundGroupSession(); inbound.unpickle(password, pickled); if (this.isUseLocal()) this.groupInboundSessions.set(key, inbound); if (this.existsDb()) await this.#idbPut('groupInboundSessions', key, inbound.pickle(this.password)); this.#emit(TinyOlmEvents.ImportInboundGroupSession, key, inbound); } /** * @param {ExportedOlmInstance} data Returned object of exportInstance * @param {string} [password] The password used to pickle * @returns {Promise<void[]>} */ async importInstance(data, password = '') { await tinyOlm.fetchOlm(); const promises = []; if (data.account) promises.push(this.importAccount(data.account, password)); if (data.sessions) for (const [key, pickled] of Object.entries(data.sessions)) promises.push(this.importSession(key, pickled, password)); if (data.groupSessions) for (const [key, pickled] of Object.entries(data.groupSessions)) promises.push(this.importGroupSession(key, pickled, password)); if (data.groupInboundSessions) for (const [key, pickled] of Object.entries(data.groupInboundSessions)) promises.push(this.importInboundGroupSession(key, pickled, password)); return Promise.all(promises); } /** * Retrieves all active sessions. * * @returns {Map<string, Olm.Session>} A map of all active sessions where the key is the userId. */ getAllSessions() { return this.sessions; } /** * Retrieves the session for a specific userId. * * @param {string} userId - The userId whose session is to be retrieved. * @returns {Olm.Session} The session for the specified userId, or null if no session exists. * @throws {Error} Throws an error if no session exists for the specified userId. */ getSession(userId) { const session = this.sessions.get(userId); if (!session) throw new Error(`No session found with ${userId}`); return session; } /** * Removes the session for a specific userId. * * @param {string} userId - The userId whose session is to be removed. * @returns {Promise<boolean>} Returns true if the session was removed, otherwise false. * @throws {Error} Throws an error if no session exists for the specified userId. */ async removeSession(userId) { const session = this.getSession(userId); session.free(); if (this.existsDb()) await this.#idbDelete('sessions', userId); this.#emit(TinyOlmEvents.RemoveSession, userId, session); return this.isUseLocal() ? this.sessions.delete(userId) : true; } /** * Clears all active sessions. * * @returns {Promise<void>} */ async clearSessions() { for (const session of this.sessions.values()) session.free(); this.sessions.clear(); if (this.existsDb()) await this.#idbClear('sessions'); this.#emit(TinyOlmEvents.ClearSessions); } /** * Initializes the Olm library and creates a new account. * * @returns {Promise<void>} */ async init() { const Olm = await tinyOlm.fetchOlm(); this.account = new Olm.Account(); this.account.create(); if (this.existsDb()) await this.#idbPut('account', 'main', this.account.pickle(this.password)); this.#emit(TinyOlmEvents.CreateAccount, this.account); } /** * Gets the unique session ID for a group session per user. * @param {string} roomId * @param {string} [userId] * @returns {string} */ #getGroupSessionId(roomId, userId) { return `${userId}${typeof roomId === 'string' ? `:${roomId}` : ''}`; } /** * Creates a new outbound group session for a specific room. * @param {string} roomId * @returns {Promise<Olm.OutboundGroupSession>} */ async createGroupSession(roomId) { const Olm = tinyOlm.getOlm(); const outboundSession = new Olm.OutboundGroupSession(); outboundSession.create(); if (this.isUseLocal()) this.groupSessions.set(roomId, outboundSession); if (this.existsDb()) await this.#idbPut('groupSessions', roomId, outboundSession.pickle(this.password)); this.#emit(TinyOlmEvents.CreateGroupSession, roomId, outboundSession); return outboundSession; } /** * Exports the current outbound group session key for a room. * @param {string} roomId * @returns {string} * @throws {Error} If no outbound session exists for the given room. */ exportGroupSessionId(roomId) { const outboundSession = this.groupSessions.get(roomId); if (!outboundSession) throw new Error(`No outbound group session found for room: ${roomId}`); return outboundSession.session_key(); } /** * Exports the current outbound group session key for a room from the indexdedDb * @param {string} roomId * @returns {Promise<string>} * @throws {Error} If no outbound session exists for the given room. */ async exportDbGroupSessionId(roomId) { const Olm = tinyOlm.getOlm(); const pickled = await this.#idbGet('groupSessions', roomId); if (!pickled) throw new Error(`No outbound group session found for room: ${roomId}`); const outboundSession = new Olm.OutboundGroupSession(); outboundSession.unpickle(this.password, pickled); return outboundSession.session_key(); } /** * Imports an inbound group session using a provided session key. * @param {string} roomId * @param {string} userId * @param {string} sessionKey * @returns {Promise<void>} */ async importGroupSessionId(roomId, userId, sessionKey) { const Olm = tinyOlm.getOlm(); const inboundSession = new Olm.InboundGroupSession(); inboundSession.create(sessionKey); const sessionId = this.#getGroupSessionId(roomId, userId); if (this.isUseLocal()) this.groupInboundSessions.set(sessionId, inboundSession); if (this.existsDb()) await this.#idbPut('groupInboundSessions', sessionId, inboundSession.pickle(this.password)); this.#emit(TinyOlmEvents.ImportGroupSessionId, sessionId, inboundSession); } /** * Returns all outbound group sessions. * @returns {Map<string, Olm.OutboundGroupSession>} */ getAllGroupSessions() { return this.groupSessions; } /** * Returns a specific outbound group session by room ID. * @param {string} roomId * @returns {Olm.OutboundGroupSession} * @throws {Error} If no outbound session exists for the given room. */ getGroupSession(roomId) { const session = this.groupSessions.get(roomId); if (!session) throw new Error(`No outbound group session found for room: ${roomId}`); return session; } /** * Removes a specific outbound group session by room ID. * @param {string} roomId * @returns {Promise<boolean>} True if a session was removed, false otherwise. */ async removeGroupSession(roomId) { const session = this.getGroupSession(roomId); session.free(); if (this.existsDb()) await this.#idbDelete('groupSessions', roomId); this.#emit(TinyOlmEvents.RemoveGroupSession, roomId, session); return this.isUseLocal() ? this.groupSessions.delete(roomId) : true; } /** * Clears all group sessions. * * @returns {Promise<void>} */ async clearGroupSessions() { for (const groupSession of this.groupSessions.values()) groupSession.free(); this.groupSessions.clear(); if (this.existsDb()) await this.#idbClear('groupSessions'); this.#emit(TinyOlmEvents.ClearGroupSessions); } /** * Returns all inbound group sessions. * @returns {Map<string, Olm.InboundGroupSession>} */ getAllInboundGroupSessions() { return this.groupInboundSessions; } /** * Returns a specific inbound group session by room ID and userId. * @param {string} roomId * @param {string} [userId] * @returns {Olm.InboundGroupSession} * @throws {Error} If no inbound session exists for the given room and userId. */ getInboundGroupSession(roomId, userId) { const sessionId = this.#getGroupSessionId(roomId, userId); const session = this.groupInboundSessions.get(sessionId); if (!session) throw new Error(`No inbound group session found for room: ${roomId}, userId: ${userId}`); return session; } /** * Removes a specific inbound group session by room ID and userId. * @param {string} roomId * @param {string} userId * @returns {Promise<boolean>} True if a session was removed, false otherwise. */ async removeInboundGroupSession(roomId, userId) { const sessionId = this.#getGroupSessionId(roomId, userId); const session = this.getInboundGroupSession(sessionId); session.free(); if (this.existsDb()) await this.#idbDelete('groupInboundSessions', sessionId); this.#emit(TinyOlmEvents.RemoveInboundGroupSession, sessionId, session); return this.isUseLocal() ? this.groupInboundSessions.delete(sessionId) : true; } /** * Clears all inbound group sessions. * * @returns {Promise<void>} */ async clearInboundGroupSessions() { for (const inbound of this.groupInboundSessions.values()) inbound.free(); this.groupInboundSessions.clear(); if (this.existsDb()) await this.#idbClear('groupInboundSessions'); this.#emit(TinyOlmEvents.ClearInboundGroupSessions); } /** * Retrieves the identity keys (curve25519 and ed25519) for the account. * * @returns {{curve25519: Record<string, string>, ed25519: string}} * @throws {Error} Throws an error if account is not initialized. */ getIdentityKeys() { if (!this.account) throw new Error('Account is not initialized.'); return JSON.parse(this.account.identity_keys()); } /** * Generates a specified number of one-time keys for the account and signs them. * * @param {number} [number=10] - The number of one-time keys to generate. * @returns {Promise<Record<string, { * key: string, * signatures: Record<string, Record<string, string>> * }>>} * @throws {Error} Throws an error if account is not initialized. */ async generateOneTimeKeys(number = 10) { if (!this.account) throw new Error('Account is not initialized.'); this.account.generate_one_time_keys(number); if (!this.account) throw new Error('Account is not initialized.'); const oneTimeKeys = this.getOneTimeKeys(); /** @type {Record<string, { key: string, signatures: Record<string, Record<string, string>> }>} */ const signedKeys = {}; const identityKeys = this.getIdentityKeys(); for (const [keyId, key] of Object.entries(oneTimeKeys.curve25519)) { const payload = JSON.stringify({ key }); const signature = this.account.sign(payload); signedKeys[keyId] = { key, signatures: { [this.getUserId()]: { [`ed25519:${identityKeys.ed25519}`]: signature, }, }, }; } /** @type {Record<string, { key: string, signatures: Record<string, Record<string, string>> }>} */ this.signedOneTimeKeys = signedKeys; await this.#saveAccount(); this.#emit(TinyOlmEvents.SignOneTimeKeys, signedKeys); return signedKeys; } /** * Retrieves the one-time keys currently available for the account. * * @returns {{curve25519: Record<string, string>}} * @throws {Error} Throws an error if account is not initialized. */ getOneTimeKeys() { if (!this.account) throw new Error('Account is not initialized.'); return JSON.parse(this.account.one_time_keys()); } /** * Marks the current set of keys as published, preventing them from being reused. * * @returns {Promise<void>} * @throws {Error} Throws an error if account is not initialized. */ async markKeysAsPublished() { if (!this.account) throw new Error('Account is not initialized.'); this.account.mark_keys_as_published(); await this.#saveAccount(); this.#emit(TinyOlmEvents.MarkKeysAsPublished); } /** * Creates an outbound session with another user using their identity and one-time keys. * * @param {string} theirIdentityKey - The identity key of the target user. * @param {string} theirOneTimeKey - The one-time key of the target user. * @param {string} theirUsername - The userId of the target user. * @returns {Promise<void>} * @throws {Error} Throws an error if account is not initialized. */ async createOutboundSession(theirIdentityKey, theirOneTimeKey, theirUsername) { if (!this.account) throw new Error('Account is not initialized.'); if (!theirOneTimeKey) throw new Error('No one-time key available for the user.'); const Olm = tinyOlm.getOlm(); const session = new Olm.Session(); session.create_outbound(this.account, theirIdentityKey, theirOneTimeKey); if (this.isUseLocal()) this.sessions.set(theirUsername, session); if (this.existsDb()) await this.#idbPut('sessions', theirUsername, session.pickle(this.password)); this.#emit(TinyOlmEvents.CreateOutboundSession, theirUsername, session); } /** * Creates an inbound session from a received encrypted message. * * @param {string} senderIdentityKey - The sender's identity key. * @param {string} ciphertext - The ciphertext received. * @param {string} senderUsername - The userId of the sender. * @returns {Promise<void>} * @throws {Error} Throws an error if account is not initialized. */ async createInboundSession(senderIdentityKey, ciphertext, senderUsername) { if (!this.account) throw new Error('Account is not initialized.'); const Olm = tinyOlm.getOlm(); const session = new Olm.Session(); session.create_inbound_from(this.account, senderIdentityKey, ciphertext); this.account.remove_one_time_keys(session); if (this.isUseLocal()) this.sessions.set(senderUsername, session); if (this.existsDb()) await this.#idbPut('sessions', senderUsername, session.pickle(this.password)); this.#emit(TinyOlmEvents.CreateInboundSession, senderUsername, session); } /** * Checks if there is an active session with a specific userId. * * @param {string} userId - The userId to check. * @returns {boolean} True if a session exists, false otherwise. */ hasSession(userId) { return this.sessions.has(userId); } /** * Exports the device identity keys and available one-time keys format. * * @returns {object} * @throws {Error} Throws an error if account is not initialized. */ exportIdentityAndOneTimeKeys() { if (!this.account) throw new Error('Account is not initialized.'); const identityKeys = this.getIdentityKeys(); const oneTimeKeys = this.getOneTimeKeys(); const deviceId = this.getDeviceId(); const userId = this.getUserId(); return { device_id: deviceId, user_id: userId, algorithms: ['m.olm.v1.curve25519-aes-sha2'], keys: { [`curve25519:${deviceId}`]: identityKeys.curve25519, [`ed25519:${deviceId}`]: identityKeys.ed25519, }, signatures: { [userId]: { [`ed25519:${deviceId}`]: this.account.sign(JSON.stringify({ algorithms: ['m.olm.v1.curve25519-aes-sha2'], device_id: deviceId, user_id: userId, keys: { [`curve25519:${deviceId}`]: identityKeys.curve25519, [`ed25519:${deviceId}`]: identityKeys.ed25519, }, })), }, }, one_time_keys: Object.entries(oneTimeKeys.curve25519).reduce( /** * @param {*} obj * @param {[any, any]} param0 * @returns {*} */ (obj, [keyId, key]) => { obj[`curve25519:${keyId}`] = key; return obj; }, {}), }; } /** * Disposes the instance by clearing all sessions and the account. * * @returns {Promise<void>} */ async dispose() { await Promise.all([ this.clearGroupSessions(), this.clearInboundGroupSessions(), this.clearSessions(), ]); if (this.account) { this.account.free(); this.account = null; if (this.existsDb()) await this.#idbDelete('account', 'main'); this.#emit(TinyOlmEvents.ResetAccount); } } /** * Regenerates the identity keys by creating a new account. * * This process will: * - Free the current Olm.Account and create a new one. * - Generate new curve25519 and ed25519 identity keys. * * Important: After regenerating the identity keys, you must: * - Generate new one-time keys by calling `generateOneTimeKeys()`. * - Mark the keys as published by calling `markKeysAsPublished()`. * - Update your device information on the server to broadcast the new keys. * * @returns {Promise<void>} */ async regenerateIdentityKeys() { const Olm = await tinyOlm.fetchOlm(); if (this.account) this.account.free(); if (this.existsDb()) await this.#idbDelete('account', 'main'); this.#emit(TinyOlmEvents.ResetAccount); this.account = new Olm.Account(); this.account.create()