UNPKG

@jsxc/jsxc

Version:

Real-time XMPP chat application with video calls, file transfer and encrypted communication

345 lines (259 loc) 10.8 kB
import { IPluginAPI } from '../../plugin/PluginAPI.interface'; import { EncryptionPlugin } from '../../plugin/EncryptionPlugin'; import { EncryptionState, IMetaData } from '../../plugin/AbstractPlugin'; import { IMessage } from '../../Message.interface'; import { IContact, ContactType } from '../../Contact.interface'; import Omemo from './lib/Omemo'; import ChatWindow from '../../ui/ChatWindow'; import { NS_BASE, NS_DEVICELIST } from './util/Const'; import OmemoDevicesDialog from '../../ui/dialogs/omemoDevices'; import { Trust } from './lib/Device'; import Translation from '@util/Translation'; import ArrayBufferUtils from './util/ArrayBuffer'; import Attachment from '@src/Attachment'; import attachmentHandler from './AttachmentHandler'; const MIN_VERSION = '4.0.0'; const MAX_VERSION = '99.0.0'; const AESGCM_REGEX = /^aesgcm:\/\/([^#]+\/([^\/]+\.([a-z0-9]+)))#([a-z0-9]+)/i; Attachment.registerHandler(attachmentHandler.getId(), attachmentHandler.handler); export default class OMEMOPlugin extends EncryptionPlugin { private omemo: Omemo; public static getId(): string { return 'omemo'; } public static getName(): string { return 'OMEMO'; } public static getMetaData(): IMetaData { return { description: Translation.t('setting-omemo-enable'), xeps: [ { id: 'XEP-0384', name: 'OMEMO Encryption', version: '0.3.0', }, ], }; } public static updateEncryptionState(contact: IContact, trust: Trust) { let state = trust === Trust.confirmed ? EncryptionState.VerifiedEncrypted : EncryptionState.UnverifiedEncrypted; contact.setEncryptionState(state, OMEMOPlugin.getId()); } constructor(pluginAPI: IPluginAPI) { super(MIN_VERSION, MAX_VERSION, pluginAPI); if (!this.isLibSignalAvailable()) { throw new Error('LibSignal is not available'); } pluginAPI.getConnection().getPEPService().subscribe(NS_DEVICELIST, this.onDeviceListUpdate); pluginAPI.addPreSendMessageProcessor(this.preSendMessageProcessor, 10); pluginAPI.addPreSendMessageStanzaProcessor(this.preSendMessageStanzaProcessor, 90); pluginAPI.addAfterReceiveMessageProcessor(this.afterReceiveMessageProcessor); pluginAPI.registerChatWindowInitializedHook((chatWindow: ChatWindow) => { if (chatWindow.getContact().getType() !== ContactType.CHAT) { return; } chatWindow.addMenuEntry('omemo-devices', 'OMEMO devices', () => this.openDeviceDialog(chatWindow)); }); } private openDeviceDialog = async (chatWindow: ChatWindow) => { let peerContact = chatWindow.getContact(); await this.getOmemo().prepare(); await this.refreshDeviceList(peerContact); return OmemoDevicesDialog(peerContact, this.getOmemo()); }; public toggleTransfer(contact: IContact): Promise<void> { if (!this.isLibSignalAvailable()) { return; } if (contact.getEncryptionPluginId() === OMEMOPlugin.getId()) { contact.setEncryptionState(EncryptionState.Plaintext, OMEMOPlugin.getId()); return; } return this.getOmemo() .prepare() .then(async () => { if (!this.getOmemo().isSupported(contact)) { if (!(await this.refreshDeviceList(contact))) { throw new Error(Translation.t('Your_contact_does_not_support_OMEMO')); } } if (!this.getOmemo().isTrusted(contact) && !(await this.getOmemo().trustOnFirstUse(contact))) { throw new Error(Translation.t('There_are_new_OMEMO_devices')); } let trust = this.getOmemo().getTrust(contact); OMEMOPlugin.updateEncryptionState(contact, trust); }); } private async refreshDeviceList(contact: IContact) { let pepService = this.pluginAPI.getConnection().getPEPService(); try { let stanza = await pepService.retrieveItems(NS_DEVICELIST, contact.getJid().bare); this.onDeviceListUpdate(stanza); } catch (err) { this.pluginAPI.Log.debug('Can not retrieve device list', err); return false; } return this.getOmemo().isSupported(contact); } private onDeviceListUpdate = stanza => { let messageStanza = $(stanza); let itemsElement = messageStanza.find(`items[node="${NS_DEVICELIST}"]`); let listElement = messageStanza.find(`list[xmlns="${NS_BASE}"]`); let fromString = messageStanza.attr('from'); if (listElement.length !== 1 || itemsElement.length !== 1) { return true; } if (!fromString) { return true; } let fromJid = this.pluginAPI.createJID(fromString); let deviceIds = listElement .find('device') .get() .map(function (deviceElement) { return parseInt($(deviceElement).attr('id'), 10); }); deviceIds = deviceIds.filter(id => typeof id === 'number' && !isNaN(id)); this.getOmemo().storeDeviceList(fromJid.bare, deviceIds); return true; }; private afterReceiveMessageProcessor = (contact: IContact, message: IMessage, stanza: Element): Promise<{}> => { if ($(stanza).find(`>encrypted[xmlns="${NS_BASE}"]`).length === 0) { return Promise.resolve([contact, message, stanza]); } return this.getOmemo() .decrypt(stanza) .then(decrypted => { if (!decrypted || !decrypted.plaintext) { throw new Error('No decrypted message found'); } if (decrypted.trust !== Trust.recognized && decrypted.trust !== Trust.confirmed) { message.setErrorMessage(Translation.t('Message_received_from_unknown_OMEMO_device')); } if (decrypted.plaintext.indexOf('aesgcm://') === 0) { decrypted.plaintext = this.processEncryptedAttachment(decrypted.plaintext, message, contact); } message.setPlaintextMessage(decrypted.plaintext); message.setEncrypted(true); return [contact, message, stanza]; }) .catch(msg => { this.pluginAPI.Log.warn(msg); return [contact, message, stanza]; }); }; private processEncryptedAttachment(plaintext: string, message: IMessage, contact: IContact) { let lines = plaintext.split('\n'); //@REVIEW do we want to support attachments without a newline? let matches = lines[0].match(AESGCM_REGEX); if (!matches) { return plaintext; } lines[0] = undefined; let [match, , filename, extension] = matches; let mime = /^(jpeg|jpg|gif|png|svg)$/i.test(extension) ? `image/${extension.toLowerCase()}` : 'application/octet-stream'; let attachment = new Attachment(decodeURIComponent(filename), mime, match); attachment.setData(match); attachment.setHandler(attachmentHandler.getId()); if (lines[1] && lines[1].indexOf('data:') === 0) { if (/^data:image\/(jpeg|jpg|gif|png|svg);base64,[/+=a-z0-9]+$/i.test(lines[1])) { attachment.setThumbnailData(lines[1]); } lines[1] = undefined; } message.setAttachment(attachment); try { attachmentHandler.handler(attachment, false); } catch (err) {} return lines.filter(line => line !== undefined).join('\n'); } private preSendMessageProcessor = async (contact, message) => { let attachment = message.getAttachment(); if ( !attachment || contact.getEncryptionPluginId() !== OMEMOPlugin.getId() || contact.getEncryptionState() === EncryptionState.Plaintext ) { return [contact, message]; } let encryptedFile = await this.encryptFile(attachment.getFile()); attachment.setFile(encryptedFile); return [contact, message]; }; private async encryptFile(file: File) { let iv = crypto.getRandomValues(new Uint8Array(12)); let key = await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256, }, true, ['encrypt', 'decrypt'] ); let encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, }, key, await file.arrayBuffer() ); let keydata = await window.crypto.subtle.exportKey('raw', <CryptoKey>key); let encryptedFile = new File([encrypted], file.name, { type: file.type, lastModified: file.lastModified, }); (<any>encryptedFile).aesgcm = ArrayBufferUtils.toHex(iv) + ArrayBufferUtils.toHex(keydata); return encryptedFile; } private preSendMessageStanzaProcessor = async (message: IMessage, xmlElement: Strophe.Builder) => { let contact = this.pluginAPI.getContact(message.getPeer()); if (!contact) { return [message, xmlElement]; } if (contact.getEncryptionPluginId() !== OMEMOPlugin.getId()) { return [message, xmlElement]; } let isTrustUnknown = this.getOmemo().isTrustUnknown(contact); if (isTrustUnknown) { await this.handleNewDevice(contact); } return this.getOmemo().encrypt(contact, message, xmlElement); }; private handleNewDevice(contact: IContact): Promise<void> { let chatWindow = contact.getChatWindow(); let overlayElement = chatWindow.getOverlay(); overlayElement.append('<p>' + Translation.t('There_are_new_devices') + '</p>'); let continueButton = $('<button class="jsxc-button jsxc-button--block jsxc-button--primary" />'); continueButton.text(Translation.t('Configure')); overlayElement.append(continueButton); let cancelButton = $('<a href="#" class="jsxc-button jsxc-button--block" />'); cancelButton.text(Translation.t('Cancel')); overlayElement.append(cancelButton); chatWindow.showOverlay(); return new Promise(resolve => { continueButton.on('click', async ev => { ev.preventDefault(); chatWindow.hideOverlay(); await this.openDeviceDialog(chatWindow); resolve(); }); cancelButton.on('click', ev => { ev.preventDefault(); chatWindow.hideOverlay(); resolve(); }); }); } private getOmemo() { if (!this.omemo) { this.omemo = new Omemo(this.pluginAPI.getStorage(), this.pluginAPI.getConnection()); } return this.omemo; } private isLibSignalAvailable() { return typeof (<any>window).libsignal !== 'undefined'; } }