diff --git a/lib/account.js b/lib/account.js index e1f8953..24f44ec 100644 --- a/lib/account.js +++ b/lib/account.js @@ -1,558 +1,559 @@ 'use strict'; import debug from 'debug'; import md5 from 'blueimp-md5'; import transform from 'sdp-transform'; import utils from './utils'; import { EventEmitter } from 'events'; import { Call } from './call'; import { ConferenceCall } from './conference'; import { Message } from './message'; import { PGP } from './pgp'; const DEBUG = debug('sylkrtc:Account'); class Account extends EventEmitter { constructor(options, connection) { if (options.account.indexOf('@') === -1) { throw new Error('Invalid account id specified'); } super(); const id = options.account; const [username, domain] = id.split('@'); this._id = id; this._displayName = options.displayName; if (options.hasOwnProperty('ha1') && !options.ha1) { this._password = options.password; } else { this._password = md5(username + ':' + (options.realm || domain) + ':' + options.password); } this._pgp = null; this._connection = connection; this._registrationState = null; this._calls = new Map(); this._confCalls = new Map(); this._messages = new Map(); } get id() { return this._id; } get password() { return this._password; } get displayName() { return this._displayName; } get registrationState() { return this._registrationState; } get messages() { return Array.from(this._messages.values()); } get pgp() { return this._pgp; } register() { const req = { sylkrtc: 'account-register', account: this._id }; this._sendRequest(req, (error) => { if (error) { DEBUG('Register error: %s', error); const oldState = this._registrationState; const newState = 'failed'; const data = {reason: error.toString()}; this._registrationState = newState; this.emit('registrationStateChanged', oldState, newState, data); } }); } unregister() { const req = { sylkrtc: 'account-unregister', account: this._id, }; this._sendRequest(req, (error) => { if (error) { DEBUG('Unregister error: %s', error); } const oldState = this._registrationState; const newState = null; this._registrationState = newState; this.emit('registrationStateChanged', oldState, newState, {}); }); } call(uri, options={}) { const callObj = new Call(this); callObj._initOutgoing(uri, options); this._calls.set(callObj.id, callObj); this.emit('outgoingCall', callObj); return callObj; } joinConference(uri, options={}) { const confCall = new ConferenceCall(this); confCall._initialize(uri, options); this._confCalls.set(confCall.id, confCall); this.emit('conferenceCall', confCall); return confCall; } setDeviceToken(token, platform, device, silent, app) { DEBUG('Setting device token: %s', token); const req = { sylkrtc: 'account-devicetoken', account: this._id, token, platform, device, silent, app }; this._sendRequest(req, (error) => { if (error) { DEBUG('Error setting device token: %s', error); } }); } addPGPKeys(keys) { this._pgp = new PGP(keys, this._connection); } generatePGPKeys(cb=null) { if (this._pgp === null) { this._pgp = new PGP({}, this._connection); } this._pgp.generatePGPKeys((result) => { this.emit('privateKeysGenerated', result); cb(result); }); } checkIfKeyExists(cb=null) { this._connection.lookupPublicKey(this._id); new Promise((resolve, reject) => { this._connection.once('publicKey', (message) => { if (message.publicKey) { message.publicKey = message.publicKey.trim(); } resolve(message.publicKey); }); }).then(publicKey => cb(publicKey)); } decryptKeyImport(message, password, cb=null) { if (this._pgp === null) { this._pgp = new PGP({}, this._connection); } this._pgp.decryptKeyImport(message, password, (result) => { + this._pgp.terminateWorker(); this._pgp = null; cb(result); }); } exportPrivateKey(password) { if (this._pgp === null) { return; } this._pgp.exportKeys(password).then(result => { if (result.didEncrypt) { this.sendMessage(this._id, result.message, 'text/pgp-private-key'); } }); } sendMessage(uri, message, contentType='text/plain', options={}, cb=null) { const outgoingMessage = new Message({ account: uri, content: message, contentType, timestamp: options.timestamp || new Date().toISOString(), type: 'normal' }, new utils.Identity(this._id, this._displayName), 'pending'); if (contentType !== 'text/pgp-private-key' && contentType !== 'text/pgp-public-key') { this._messages.set(outgoingMessage.id, outgoingMessage); } (async() => { let result = {}; if (this._pgp !== null && contentType !== 'text/pgp-private-key' && contentType !== 'text/pgp-public-key') { result = await this._pgp.encryptMessage(uri, outgoingMessage); if (result.didEncrypt) { outgoingMessage._isSecure = true; } } const req = { sylkrtc: 'account-message', account: this._id, uri: uri, message_id: outgoingMessage.id, content: result.message || message, content_type: outgoingMessage.contentType, timestamp: outgoingMessage.timestamp }; if (contentType !== 'text/pgp-private-key' && contentType !== 'text/pgp-public-key') { this.emit('sendingMessage', outgoingMessage); } DEBUG('Sending message: %o', outgoingMessage); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending message: %s', error); outgoingMessage._setState('failed'); } if (cb) { cb(error); } }); })(); return outgoingMessage; } sendDispositionNotification(uri, id, timestamp, state, cb=null) { const req = { sylkrtc: 'account-disposition-notification', account: this._id, uri: uri, message_id: id, state, timestamp }; DEBUG('Sending disposition notification: %o', req); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending disposition notification: %s', error); } else { const incomingMessage = this._messages.get(id); if (incomingMessage) { incomingMessage._setDispositionState(state); } } this.emit('sendingDispositionNotification', id, state, error); if (cb) { cb(error); } }); } syncConversations(id=null, cb=null) { const req = { sylkrtc: 'account-sync-conversations', account: this._id, message_id: id }; DEBUG('Sending replay journal: %o', req); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending sync request: %s', error); } if (cb) { cb(error); } }); } markConversationRead(contact) { const req = { sylkrtc: 'account-mark-conversation-read', account: this._id, contact: contact }; DEBUG('Sending markConversationRead: %o', req); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending markConversationRead request: %s', error); } }); this._readConversation(contact); } removeMessage(message, cb=null) { this._removeMessage(message.id); let contact = message.receiver; if (message.state === 'received') { contact = message.sender.uri; } const req = { sylkrtc: 'account-remove-message', account: this._id, message_id: message.id, contact: contact }; DEBUG('Sending remove message: %o', req); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending remove message: %s', error); } if (cb) { cb(error); } }); } removeConversation(uri, cb=null) { this._removeConversation(uri); const req = { sylkrtc: 'account-remove-conversation', account: this._id, contact: uri }; DEBUG('Sending remove conversation: %o', req); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending remove conversation: %s', error); } if (cb) { cb(error); } }); } // Private API _handleEvent(message) { DEBUG('Received account event: %s', message.event); const data = {}; switch (message.event) { case 'registration-state': const oldState = this._registrationState; const newState = message.state; this._registrationState = newState; if (newState === 'failed') { data.reason = message.reason; } this.emit('registrationStateChanged', oldState, newState, data); break; case 'incoming-session': let call = new Call(this); call._initIncoming(message.session, message.originator, message.sdp, message.call_id); this._calls.set(call.id, call); // see what media types are offered const mediaTypes = { audio: false, video: false }; const parsedSdp = transform.parse(message.sdp); for (let media of parsedSdp.media) { if (media.type === 'audio' && media.port !== 0 && media.direction === 'sendrecv') { mediaTypes.audio = true; } else if (media.type === 'video' && media.port !== 0 && media.direction === 'sendrecv') { mediaTypes.video = true; } } DEBUG('Incoming call from %s with media types: %o', message.originator.uri, mediaTypes); this.emit('incomingCall', call, mediaTypes); break; case 'missed-session': data.originator = new utils.Identity(message.originator.uri, message.originator.display_name); this.emit('missedCall', data); break; case 'conference-invite': data.originator = new utils.Identity(message.originator.uri, message.originator.display_name); data.room = message.room; data.id = message.session_id; this.emit('conferenceInvite', data); break; case 'message': DEBUG('Incoming message from %s: %o', message.sender.uri, message); const incomingMessage = this._messages.get(message.message_id); if (!incomingMessage) { (async() => { if (message.content.includes(`-----BEGIN PGP MESSAGE-----`) && message.content.trim().endsWith(`-----END PGP MESSAGE-----`) && message.content_type !== 'text/pgp-private-key' ) { if (this._pgp !== null) { message = await this._pgp.decryptMessage(message); if (message.didDecrypt) { message.isSecure = true; } else { // Decryption failed, add failure disposition return; } } } if (message.content_type === 'text/pgp-private-key') { DEBUG("Skipping message"); return; } const mappedMessage = new Message( message, new utils.Identity(message.sender.uri, message.sender.display_name), 'received' ); if (message.content_type === 'text/pgp-public-key') { if (this._pgp !== null) { this._pgp.addPublicPGPKeys({[message.sender.uri]: mappedMessage.content}); return; } } this._messages.set(mappedMessage.id, mappedMessage); this.emit('incomingMessage', mappedMessage); if (message.disposition_notification && message.disposition_notification.indexOf('positive-delivery') !== -1) { this.sendDispositionNotification(message.sender.uri, message.message_id, message.timestamp, 'delivered'); } })(); } break; case 'disposition-notification': const outgoingMessage = this._messages.get(message.message_id); if (outgoingMessage) { if (outgoingMessage.state === 'displayed') { break; } outgoingMessage._setState(message.state); } const {reason, code} = message; this.emit('messageStateChanged', message.message_id, message.state, {reason, code}); break; case 'sync-conversations': const specialContentTypes = new Set(['application/sylk-message-remove', 'message/imdn']); let results = []; (async() => { if (this._pgp !== null) { for (const message of message.messages) { if ( message.content.includes(`-----BEGIN PGP MESSAGE-----`) && message.content.trim().endsWith(`-----END PGP MESSAGE-----`) ) { await this._pgp.decryptMessage(message); } }; } const messageList = message.messages.filter((message, idx) => { if (message.content_type === 'text/pgp-public-key') { DEBUG("Public key found, adding: %s", message.content); if (this._pgp !== null) { this._pgp.addPublicPGPKeys({[message.sender.uri]: message.content}); } return false; } if (message.didDecrypt === false) { return false; } return true; }).map((message, idx) => { if (specialContentTypes.has(message.content_type)) { message.content = JSON.parse(message.content); } message.isSecure = message.didDecrypt; if (message.direction === "outgoing") { message.account = message.contact; return new Message(message, new utils.Identity(this._id, this._displayName), message.state); } else { message.account = this._id; return new Message(message, new utils.Identity(message.contact, ''), message.state); } }); this.emit('syncConversations', messageList); })(); break; case 'sync': if (message.type === 'message') { let content = message.content; switch (message.action) { case 'remove': const existingMessage = this._messages.get(content.message_id); if (existingMessage) { this.emit('removeMessage', existingMessage); this._removeMessage(message.content.message_id); } else { content.account = content.contact; this.emit('removeMessage', new Message(content, new utils.Identity(content.contact, ''), '')); } break; case 'add': content.account = content.uri; (async() => { if (content.content.includes(`-----BEGIN PGP MESSAGE-----`) && content.content.trim().endsWith(`-----END PGP MESSAGE-----`) && content.content_type !== 'text/pgp-private-key' ) { if (this._pgp !== null) { content = await this._pgp.decryptMessage(content); if (content.didDecrypt) { content.isSecure = true; } else { return; } } } const outgoingMessage = new Message(content, new utils.Identity(this._id, this._displayName), 'pending'); if (content.content_type !== 'text/pgp-private-key' || content.content_type !== 'text/pgp-public-key') { this._messages.set(outgoingMessage.id, outgoingMessage); this.emit('sendingMessage', outgoingMessage); } this.emit('outgoingMessage', outgoingMessage); })(); break; default: break; } } if (message.type === 'conversation') { switch(message.action) { case 'remove': this._removeConversation(message.content.contact); this.emit('removeConversation', message.content.contact); break; case 'read': this._readConversation(message.content.contact); this.emit('readConversation', message.content.contact); break; default: break; } } break; default: break; } } _removeMessage(id) { this._messages.delete(id); } _readConversation(uri) { for (let [id, message] of this._messages.entries()) { if (message.state === 'received' && message.sender.uri === uri && message.dispositionState !== 'displayed') { message._setDispositionState('displayed'); } } } _removeConversation(uri) { for (let [id, message] of this._messages.entries()) { if (message.state === 'received' && message.sender.uri === uri) { this._messages.delete(id); } else if (message.receiver === uri) { this._messages.delete(id); } } } _sendRequest(req, cb) { this._connection._sendRequest(req, cb); } } export { Account }; diff --git a/lib/pgp.js b/lib/pgp.js index 475c93f..3776598 100644 --- a/lib/pgp.js +++ b/lib/pgp.js @@ -1,176 +1,186 @@ 'use strict'; import debug from 'debug'; import { EventEmitter } from 'events'; import * as openpgp from 'openpgp'; const DEBUG = debug('sylkrtc:PGP'); +const worker = new Worker('./worker.js'); class PGP extends EventEmitter { constructor(options={}, connection) { super(); this._privateKey = options.privateKey || null; this._publicKey = options.publicKey || null; this._armoredPrivateKey = options.privateKey || null; this._armoredPublicKey = options.publicKey || null; this._cachedPublicKeys = new Map(); this._connection = connection; if (this._privateKey !== null) { openpgp.readPrivateKey({armoredKey: this._privateKey}).then(privateKey => { if (options.password) { return openpgp.decryptKey({privateKey: privateKey, passphrase: options.password}); } return Promise.resolve(privateKey); }).then(privateKey => { this._privateKey = privateKey; }); } if (this._publicKey !== null) { openpgp.readKey({ armoredKey: this._publicKey }).then(publicKey => this._publicKey = publicKey ); } if (this._privateKey && this._publicKey) { DEBUG('PGP messaging loaded and enabled'); } } addPublicPGPKeys(keys) { for (let key of Object.keys(keys)) { this._cachedPublicKeys.set(key, keys[key]); this.emit('publicKeyAdded', {contact: key, key: keys[key]}); } } generatePGPKeys(cb=null) { DEBUG('Generating PGP key'); openpgp.generateKey({ // we have to use rsa, Rreact native can't use elliptic curves type: 'rsa', rsaBits: 2048, // type: 'ecc', // curve: 'curve25519', userIDs: [{ name: this._displayName, email: this._id }], // you can pass multiple user IDs format: 'armored' }).then(result => { DEBUG('PGP key generated'); this._armoredPublicKey = result.publicKey; this._armoredPrivateKey = result.privateKey; openpgp.readPrivateKey({ armoredKey: result.privateKey }).then(privateKey => this._privateKey = privateKey ); openpgp.readKey({ armoredKey: result.publicKey }).then(publicKey => this._publicKey = publicKey ); cb(result); }); } exportKeys(password) { // let message = `${this._armoredPublicKey}\n${this._armoredPrivateKey}`.trim() const message = `${this._armoredPrivateKey}`.trim(); return openpgp.createMessage({ text: message}).then(pgpMessage => { return openpgp.encrypt({ message: pgpMessage, passwords: [password], config: { preferredCompressionAlgorithm: openpgp.enums.compression.zlib } }); }).then(encryptedMessage => { let fullMessage = `${this._armoredPublicKey}\n${encryptedMessage}`; return {message: fullMessage, didEncrypt: true}; }).catch(() => { return {message: '', didEncrypt: false}; }); } decryptKeyImport(message, password, cb=null) { const regexp = /(?[^]*)(?-----BEGIN PGP MESSAGE-----[^]*-----END PGP MESSAGE-----)(?[^]*)/ig; let pgpMessage, after, before = null; let match = regexp.exec(message.content); do { pgpMessage = match.groups.pgpMessage; before = match.groups.before; after = match.groups.after; } while((match = regexp.exec(message.content)) !== null); return openpgp.readMessage({ armoredMessage: pgpMessage // parse armored message }).then(message => { return openpgp.decrypt({message, passwords: [password]}); }).then(data => { message._content = `${before}${data.data}${after}`; message.didDecrypt = true; cb(message); return message; }).catch((error) => { DEBUG("Can't decrypt key: %s", error); let result = Object.assign({}, {didDecrypt: false}); cb(result); return result; }); } encryptMessage(uri, message) { let pgpMessage = ''; let key = ''; DEBUG("Attempt to encrypt message (%s)", message.id); return this._lookupPublicKey(uri).then(publicKey => { key = publicKey; if (key === null) { DEBUG("No public key found"); } return openpgp.createMessage({ text: message.content}); }).then(message => { pgpMessage = message; return openpgp.readKey({ armoredKey: key }); }).then(publicKey => { return openpgp.encrypt({message: pgpMessage, encryptionKeys: [this._publicKey, publicKey]}); }).then(encryptedMessage => { DEBUG("Message encrypted (%s)", message.id); return {message: encryptedMessage, didEncrypt: true}; }).catch((error) => { DEBUG("Message not encrypted (%s): %s", message.id, error); return {message: message, didEncrypt: false}; }); } + terminateWorker() { + worker.terminate(); + } + decryptMessage(message) { DEBUG("Attempt to decrypt message (%s)", message.message_id); - return openpgp.readMessage({ - armoredMessage: message.content // parse armored message - }).then(message => { - return openpgp.decrypt({message, decryptionKeys: this._privateKey}); - }).then(data => { - DEBUG("Message decrypted (%s)", message.message_id); - return Object.assign(message, {content: data.data, didDecrypt: true}); - }).catch((error) => { - DEBUG("Can't decrypt message (%s) %s", message.message_id, error); - return Object.assign(message, {didDecrypt: false}); + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + channel.port1.onmessage = function({ data }) { + if (data.error !== undefined) { + DEBUG("Can't decrypt message (%s) %s", message.message_id, data.error); + resolve(Object.assign(message, {didDecrypt: false})); + } else { + DEBUG("Message decrypted (%s)", message.message_id); + resolve(Object.assign(message, {content: data.result, didDecrypt: true})); + } + }; + let action = 'decrypt'; + let keys = { privateKey: this._armoredPrivateKey, publicKey: this._publicKey}; + const msg = message.content; + worker.postMessage({ action, keys, msg }, [channel.port2]); }); } _lookupPublicKey(uri) { let key = this._cachedPublicKeys.get(uri); if (key === undefined) { this._connection.lookupPublicKey(uri); return new Promise((resolve, reject) => { this._connection.once('publicKey', (message) => { DEBUG("Fetched public key from server for %s", message.uri); this.addPublicPGPKeys({[message.uri]: message.publicKey}); resolve(message.public_key); }); }); } return Promise.resolve(key); } } export { PGP }; diff --git a/lib/worker.js b/lib/worker.js new file mode 100644 index 0000000..c45fd1a --- /dev/null +++ b/lib/worker.js @@ -0,0 +1,34 @@ +'use strict'; + +/* globals openpgp: true */ + +import 'regenerator-runtime/runtime'; +import * as openpgp from 'openpgp'; + +onmessage = async function({ data: { action, keys, msg, pass}, ports: [port] }) { + try { + let result; + switch (action) { + case 'decrypt': { + let privateKey; + if (pass) { + privateKey = await openpgp.decryptKey({ + privateKey: await openpgp.readKey({ armoredKey: keys.privateKey }), + passphrase: pass + }); + } else { + privateKey = await openpgp.readKey({ armoredKey: keys.privateKey }); + } + const { data } = await openpgp.decrypt({ + message: await openpgp.readMessage({ armoredMessage: msg }), + decryptionKeys: privateKey + }); + result = data; + break; + } + } + port.postMessage({ result }); + } catch (e) { + port.postMessage({ error: e.message }); + } +};