diff --git a/lib/account.js b/lib/account.js index dca0b48..dc750a3 100644 --- a/lib/account.js +++ b/lib/account.js @@ -1,639 +1,652 @@ '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._incomingHeaderPrefixes = options.incomingHeaderPrefixes this._pgp = null; this._connection = connection; this._registrationState = null; this._calls = new Map(); this._confCalls = new Map(); this._messages = new Map(); this._pendingMessageDecryption = new Set(); this._delayedDispositionMessages = new Map(); } get id() { return this._id; } get password() { return this._password; } get displayName() { return this._displayName; } get incomingHeaderPrefixes() { return this._incomingHeaderPrefixes; } 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); // Wipe received messages, they could be encrypted. After this you should always fetch from the server.... if (this._messages.size > 0) { this._messages.clear(); } } 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) => { if (!this._pgp._privateKey && !this._pgp_publicKey) { 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'); } }); } + encryptFile(uri, file) { + if (this._pgp === null) { + return; + } + return this._pgp.encryptFile(uri, file); + } + decryptFile(file, filename) { + if (this._pgp === null) { + return; + } + return this._pgp.decryptFile(file, filename); + } + 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, message.headers); 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 { this._sendError(message); // Decryption failed, add failure disposition return; } } } if (message.content_type === 'text/pgp-private-key') { DEBUG('Skipping message'); return; } if (message.content_type === 'application/sylk-contact-update') { 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); } // Delay state changes if message is being decrypted if (this._pendingMessageDecryption.has(message.message_id)) { const delayedMessage = this._delayedDispositionMessages.get(message.message_id) || []; delayedMessage.push(message); this._delayedDispositionMessages.set(message.message_id, delayedMessage); } else { 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']); this.emit('processingFetchedMessages'); (async () => { if (this._pgp !== null) { let progress = 1; const items = message.messages.length; if (items >= 75) { this.emit('processingFetchedMessages', progress); } for (const [idx, messageEntry] of message.messages.entries()) { if (messageEntry.content.includes(`-----BEGIN PGP MESSAGE-----`) && messageEntry.content.trim().endsWith(`-----END PGP MESSAGE-----`) ) { await this._pgp.decryptMessage(messageEntry); } if (items >= 100) { const tempProgress = Math.round((100 / items) * idx); if (tempProgress !== progress && tempProgress % 5 === 0) { progress = tempProgress; this.emit('processingFetchedMessages', progress); } } } } const messageList = message.messages.filter((message) => { 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) { this._sendError(message); // send disposition error return false; } if (message.content_type === 'application/sylk-contact-update') { return false; } return true; }).map((message) => { 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); } 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) { this._pendingMessageDecryption.add(content.message_id); content = await this._pgp.decryptMessage(content); this._pendingMessageDecryption.delete(content.message_id); if (content.didDecrypt) { content.isSecure = true; } else { return; } } } const outgoingMessage = new Message( content, new utils.Identity(this._id, this._displayName), content.account == this._id ? 'accepted' : 'pending' ); if (content.content_type === 'text/pgp-public-key' || content.content_type === 'application/sylk-contact-update') { return; } if (content.content_type !== 'text/pgp-private-key') { this._messages.set(outgoingMessage.id, outgoingMessage); this.emit('sendingMessage', outgoingMessage); } this.emit('outgoingMessage', outgoingMessage); const delayedMessages = this._delayedDispositionMessages.get(outgoingMessage.id); if (delayedMessages) { setImmediate(() => { while (delayedMessages.length) { const delayedMessage = delayedMessages.shift(); this._handleEvent(delayedMessage); } this._delayedDispositionMessages.delete(outgoingMessage.id); }); } })(); 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; } } _sendError(message) { const disposition = message.disposition_notification || message.disposition || false; if (disposition && disposition.indexOf('display') !== -1) { this.sendDispositionNotification( message.sender ? message.sender.uri : message.contact, message.message_id, message.timestamp, 'error' ); } } _removeMessage(id) { this._messages.delete(id); } _readConversation(uri) { for (const [id, message] of this._messages.entries()) { if (message.state === 'received' && message.sender.uri === uri && message.dispositionState !== 'displayed') { message._setDispositionState('displayed'); } } } _removeConversation(uri) { for (const [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 3c3fd1c..2cc3760 100644 --- a/lib/pgp.js +++ b/lib/pgp.js @@ -1,188 +1,245 @@ 'use strict'; import debug from 'debug'; import { EventEmitter } from 'events'; import 'regenerator-runtime/runtime'; 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'); const channel = new MessageChannel(); channel.port1.onmessage = function({ data }) { if (data.error !== undefined) { DEBUG("Can't generate key: %s", data.error); } else { DEBUG('PGP key generated'); const result = data.result; 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); } }; let action = 'generateKey'; let inputData = { name: this._displayName, email: this._id }; worker.postMessage({ action, inputData }, [channel.port2]); } 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 === undefined) { throw new Error("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.content, didEncrypt: false }; }); } + encryptFile(uri, file) { + let pgpMessage = ''; + let key = ''; + const fr = new FileReader(); + + DEBUG("Attempt to encrypt file (%s)", file.name); + return this._lookupPublicKey(uri).then(publicKey => { + key = publicKey; + if (key === undefined) { + throw new Error("No public key found"); + } + return new Promise((resolve, reject) => { + fr.onload = () => { + const array = new Uint8Array(fr.result); + resolve(array); + } + fr.onerror = reject; + fr.readAsArrayBuffer(file); + }); + }).then(array => { + return openpgp.createMessage({ binary: array, format: 'binary', filename: file.name }); + }).then(message => { + pgpMessage = message; + return openpgp.readKey({ armoredKey: key }); + }).then(publicKey => { + return openpgp.encrypt({ message: pgpMessage, encryptionKeys: [this._publicKey, publicKey] }); + }).then(encryptedMessage => { + DEBUG("File encrypted (%s)", file.name); + return { file: new File([encryptedMessage], `${file.name}.asc`, { type: file.type, lastModified: file.lastModified }), didEncrypt: true }; + }).catch((error) => { + DEBUG("File not encrypted (%s): %s", file.name, error); + return { file: file, didEncrypt: false }; + }); + } + decryptFile(file, filename) { + let pgpMessage = ''; + let key = ''; + + DEBUG("Attempt to decrypt file (%s)", filename); + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + channel.port1.onmessage = function({ data }) { + if (data.error !== undefined) { + DEBUG("Can't decrypt file (%s) %s", filename, data.error); + resolve({ file: file, didDecrypt: false }); + } else { + DEBUG("File decrypted (%s)", filename); + resolve({ file: new File([data.result], data.filename), didDecrypt: true }); + } + }; + let action = 'decrypt'; + let inputData = { privateKey: this._armoredPrivateKey, publicKey: this._publicKey, format: 'binary' }; + const msg = file; + worker.postMessage({ action, inputData, msg }, [channel.port2]); + }); + } + terminateWorker() { worker.terminate(); } decryptMessage(message) { DEBUG("Attempt to decrypt message (%s)", message.message_id); 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 inputData = { privateKey: this._armoredPrivateKey, publicKey: this._publicKey }; const msg = message.content; worker.postMessage({ action, inputData, 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.publicKey); }); }); } return Promise.resolve(key); } } export { PGP }; diff --git a/lib/worker.js b/lib/worker.js index a2b2e7d..35c1209 100644 --- a/lib/worker.js +++ b/lib/worker.js @@ -1,46 +1,49 @@ 'use strict'; /* globals openpgp: true */ import 'regenerator-runtime/runtime'; import * as openpgp from 'openpgp'; onmessage = async function({ data: { action, inputData, msg, pass }, ports: [port] }) { try { let result; + let fileName; switch (action) { case 'generateKey': { result = await openpgp.generateKey({ // we have to use rsa, Rreact native can't use elliptic curves type: 'rsa', rsaBits: 2048, // type: 'ecc', // curve: 'curve25519', userIDs: [{ name: inputData.name, email: inputData.email }], // you can pass multiple user IDs format: 'armored' }); break; } case 'decrypt': { let privateKey; if (pass) { privateKey = await openpgp.decryptKey({ privateKey: await openpgp.readKey({ armoredKey: inputData.privateKey }), passphrase: pass }); } else { privateKey = await openpgp.readKey({ armoredKey: inputData.privateKey }); } const { data } = await openpgp.decrypt({ message: await openpgp.readMessage({ armoredMessage: msg }), - decryptionKeys: privateKey + decryptionKeys: privateKey, + ...(inputData.format) && { format: inputData.format } }); result = data; + fileName = filename break; } } - port.postMessage({ result }); + port.postMessage({ result, filename: fileName }); } catch (e) { port.postMessage({ error: e.message }); } };