diff --git a/js/modules/backup.js b/js/modules/backup.js index 9a7690394..0243396a2 100644 --- a/js/modules/backup.js +++ b/js/modules/backup.js @@ -19,6 +19,9 @@ const archiver = require('archiver'); const rimraf = require('rimraf'); const electronRemote = require('electron').remote; +const crypto = require('./crypto'); + + const { dialog, BrowserWindow, @@ -31,11 +34,12 @@ module.exports = { getDirectoryForImport, importFromDirectory, // for testing - sanitizeFileName, - trimFileName, - getExportAttachmentFileName, - getConversationDirName, - getConversationLoggingName, + _sanitizeFileName, + _trimFileName, + _getExportAttachmentFileName, + _getAnonymousAttachmentFileName, + _getConversationDirName, + _getConversationLoggingName, }; @@ -136,6 +140,9 @@ function exportContactsAndGroups(db, fileWriter) { stream.write('{'); _.each(storeNames, (storeName) => { + // Both the readwrite permission and the multi-store transaction are required to + // keep this function working. They serve to serialize all of these transactions, + // one per store to be exported. const transaction = db.transaction(storeNames, 'readwrite'); transaction.onerror = () => { Whisper.Database.handleDOMException( @@ -349,7 +356,7 @@ function importFromJsonString(db, jsonString, targetPath, options) { function createDirectory(parent, name) { return new Promise((resolve, reject) => { - const sanitized = sanitizeFileName(name); + const sanitized = _sanitizeFileName(name); const targetDir = path.join(parent, sanitized); if (fs.existsSync(targetDir)) { resolve(targetDir); @@ -369,7 +376,7 @@ function createDirectory(parent, name) { function createFileAndWriter(parent, name) { return new Promise((resolve) => { - const sanitized = sanitizeFileName(name); + const sanitized = _sanitizeFileName(name); const targetPath = path.join(parent, sanitized); const options = { flags: 'wx', @@ -406,7 +413,7 @@ function readFileAsArrayBuffer(targetPath) { }); } -function trimFileName(filename) { +function _trimFileName(filename) { const components = filename.split('.'); if (components.length <= 1) { return filename.slice(0, 30); @@ -422,9 +429,9 @@ function trimFileName(filename) { } -function getExportAttachmentFileName(message, index, attachment) { +function _getExportAttachmentFileName(message, index, attachment) { if (attachment.fileName) { - return trimFileName(attachment.fileName); + return _trimFileName(attachment.fileName); } let name = attachment.id; @@ -437,15 +444,18 @@ function getExportAttachmentFileName(message, index, attachment) { return name; } -function getAnonymousAttachmentFileName(message, index) { +function _getAnonymousAttachmentFileName(message, index) { if (!index) { return message.id; } return `${message.id}-${index}`; } -async function readAttachment(dir, attachment, name) { - const anonymousName = sanitizeFileName(name); +async function readAttachment(dir, attachment, name, options) { + options = options || {}; + const { key, encrypted } = options; + + const anonymousName = _sanitizeFileName(name); const targetPath = path.join(dir, anonymousName); if (!fs.existsSync(targetPath)) { @@ -453,27 +463,51 @@ async function readAttachment(dir, attachment, name) { return; } - attachment.data = await readFileAsArrayBuffer(targetPath); + const data = await readFileAsArrayBuffer(targetPath); + + if (encrypted && key) { + attachment.data = await crypto.decryptSymmetric(key, data); + } else { + attachment.data = data; + } } -async function writeAttachment(dir, message, index, attachment) { - const filename = getAnonymousAttachmentFileName(message, index); +async function writeAttachment(attachment, options) { + const { + dir, + message, + index, + key, + newKey, + } = options; + const filename = _getAnonymousAttachmentFileName(message, index); const target = path.join(dir, filename); if (fs.existsSync(target)) { - console.log(`Skipping attachment ${filename}; already exists`); - return; + if (newKey) { + console.log(`Deleting attachment ${filename}; key has changed`); + fs.unlinkSync(target); + } else { + console.log(`Skipping attachment ${filename}; already exists`); + return; + } } + const encrypted = await crypto.encryptSymmetric(key, attachment.data); + const writer = await createFileAndWriter(dir, filename); const stream = createOutputStream(writer); - stream.write(Buffer.from(attachment.data)); + stream.write(Buffer.from(encrypted)); await stream.close(); } -async function writeAttachments(dir, name, message, attachments) { +async function writeAttachments(attachments, options) { + const { name } = options; + const promises = _.map( attachments, - (attachment, index) => writeAttachment(dir, message, index, attachment) + (attachment, index) => writeAttachment(attachment, Object.assign({}, options, { + index, + })) ); try { await Promise.all(promises); @@ -488,7 +522,7 @@ async function writeAttachments(dir, name, message, attachments) { } } -function sanitizeFileName(filename) { +function _sanitizeFileName(filename) { return filename.toString().replace(/[^a-z0-9.,+()'#\- ]/gi, '_'); } @@ -498,6 +532,7 @@ async function exportConversation(db, conversation, options) { name, dir, attachmentsDir, + key, } = options; if (!name) { throw new Error('Need a name!'); @@ -508,6 +543,9 @@ async function exportConversation(db, conversation, options) { if (!attachmentsDir) { throw new Error('Need an attachments directory!'); } + if (!key) { + throw new Error('Need a key to encrypt with!'); + } console.log('exporting conversation', name); const writer = await createFileAndWriter(dir, 'messages.json'); @@ -583,8 +621,12 @@ async function exportConversation(db, conversation, options) { stream.write(jsonString); if (attachments && attachments.length) { - const exportAttachments = () => - writeAttachments(attachmentsDir, name, message, attachments); + const exportAttachments = () => writeAttachments(attachments, { + dir: attachmentsDir, + name, + message, + key, + }); // eslint-disable-next-line more/no-then promiseChain = promiseChain.then(exportAttachments); @@ -621,8 +663,8 @@ async function exportConversation(db, conversation, options) { // 1. Human-readable, for easy use and verification by user (names not just ids) // 2. Sorted just like the list of conversations in the left-pan (active_at) // 3. Disambiguated from other directories (active_at, truncated name, id) -function getConversationDirName(conversation) { - const name = conversation.active_at || 'never'; +function _getConversationDirName(conversation) { + const name = conversation.active_at || 'inactive'; if (conversation.name) { return `${name} (${conversation.name.slice(0, 30)} ${conversation.id})`; } @@ -634,8 +676,8 @@ function getConversationDirName(conversation) { // 2. Adequately disambiguated to enable debugging flow of execution // 3. Can be shared to the web without privacy concerns (there's no global redaction // logic for group ids, so we do it manually here) -function getConversationLoggingName(conversation) { - let name = conversation.active_at || 'never'; +function _getConversationLoggingName(conversation) { + let name = conversation.active_at || 'inactive'; if (conversation.type === 'private') { name += ` (${conversation.id})`; } else { @@ -649,6 +691,7 @@ function exportConversations(db, options) { const { messagesDir, attachmentsDir, + key, } = options; if (!messagesDir) { @@ -685,8 +728,8 @@ function exportConversations(db, options) { const cursor = event.target.result; if (cursor && cursor.value) { const conversation = cursor.value; - const dirName = getConversationDirName(conversation); - const name = getConversationLoggingName(conversation); + const dirName = _getConversationDirName(conversation); + const name = _getConversationLoggingName(conversation); const process = async () => { const dir = await createDirectory(messagesDir, dirName); @@ -694,6 +737,7 @@ function exportConversations(db, options) { name, dir, attachmentsDir, + key, }); }; @@ -751,10 +795,13 @@ function getDirContents(dir) { }); } -function loadAttachments(dir, message, getName) { +function loadAttachments(dir, getName, options) { + options = options || {}; + const { message } = options; + const promises = _.map(message.attachments, (attachment, index) => { const name = getName(message, index, attachment); - return readAttachment(dir, attachment, name); + return readAttachment(dir, attachment, name, options); }); return Promise.all(promises); } @@ -830,6 +877,7 @@ async function importConversation(db, dir, options) { const { messageLookup, attachmentsDir, + key, } = options; let conversationId = 'unknown'; @@ -862,11 +910,15 @@ async function importConversation(db, dir, options) { if (message.attachments && message.attachments.length) { const importMessage = async () => { const getName = attachmentsDir - ? getAnonymousAttachmentFileName - : getExportAttachmentFileName; - const parent = attachmentsDir || path.join(dir, message.received_at.toString()); - - await loadAttachments(parent, message, getName); + ? _getAnonymousAttachmentFileName + : _getExportAttachmentFileName; + const parentDir = attachmentsDir || + path.join(dir, message.received_at.toString()); + + await loadAttachments(parentDir, getName, { + message, + key, + }); return saveMessage(db, message); }; @@ -1005,12 +1057,45 @@ function createZip(zipDir, targetDir) { archive.pipe(output); + // The empty string ensures that the base location of the files added to the zip + // is nothing. If you provide null, you get the absolute path you pulled the files + // from in the first place. archive.directory(targetDir, ''); archive.finalize(); }); } +function writeFile(targetPath, contents) { + return pify(fs.writeFile)(targetPath, contents); +} + +async function encryptFile(sourcePath, targetPath, options) { + options = options || {}; + + const { key } = options; + if (!key) { + throw new Error('Need key to do encryption!'); + } + + const plaintext = await readFileAsArrayBuffer(sourcePath); + const encrypted = await crypto.encryptSymmetric(key, plaintext); + return writeFile(targetPath, encrypted); +} + +async function decryptFile(sourcePath, targetPath, options) { + options = options || {}; + + const { key } = options; + if (!key) { + throw new Error('Need key to do encryption!'); + } + + const encrypted = await readFileAsArrayBuffer(sourcePath); + const plaintext = await crypto.decryptSymmetric(key, encrypted); + return writeFile(targetPath, Buffer.from(plaintext)); +} + function createTempDir() { return pify(tmp.dir)(); } @@ -1020,37 +1105,45 @@ function deleteAll(pattern) { return pify(rimraf)(pattern); } -async function backupToDirectory(directory) { - let tempDir; +async function backupToDirectory(directory, options) { + options = options || {}; + + if (!options.key) { + throw new Error('Encrypted backup requires a key to encrypt with!'); + } + + let stagingDir; + let encryptionDir; try { - tempDir = await createTempDir(); + stagingDir = await createTempDir(); + encryptionDir = await createTempDir(); const db = await Whisper.Database.open(); const attachmentsDir = await createDirectory(directory, 'attachments'); - await exportContactAndGroupsToFile(db, tempDir); - await exportConversations(db, { - messagesDir: tempDir, + await exportContactAndGroupsToFile(db, stagingDir); + await exportConversations(db, Object.assign({}, options, { + messagesDir: stagingDir, attachmentsDir, - }); - - await createZip(directory, tempDir); + })); - // now that we've made the zip file, we can delete the temp messages directory - await deleteAll(tempDir); - tempDir = null; + const zip = await createZip(encryptionDir, stagingDir); + await encryptFile(zip, path.join(directory, 'messages.zip'), options); console.log('done backing up!'); return directory; } catch (error) { console.log( - 'the backup went wrong:', + 'The backup went wrong!', error && error.stack ? error.stack : error ); throw error; } finally { - if (tempDir) { - await deleteAll(tempDir); + if (stagingDir) { + await deleteAll(stagingDir); + } + if (encryptionDir) { + await deleteAll(encryptionDir); } } } @@ -1083,24 +1176,38 @@ async function importFromDirectory(directory, options) { const zipPath = path.join(directory, 'messages.zip'); if (fs.existsSync(zipPath)) { // we're in the world of an encrypted, zipped backup - let tempDir; + if (!options.key) { + throw new Error('Importing an encrypted backup; decryption key is required!'); + } + + let stagingDir; + let decryptionDir; try { - tempDir = await createTempDir(); + stagingDir = await createTempDir(); + decryptionDir = await createTempDir(); + const attachmentsDir = path.join(directory, 'attachments'); - await decompress(zipPath, tempDir); + const decryptedZip = path.join(decryptionDir, 'messages.zip'); + await decryptFile(zipPath, decryptedZip, options); + await decompress(decryptedZip, stagingDir); options = Object.assign({}, options, { attachmentsDir, }); - const result = await importNonMessages(db, tempDir, options); - await importConversations(db, tempDir, options); + const result = await importNonMessages(db, stagingDir, options); + await importConversations(db, stagingDir, Object.assign({}, options, { + encrypted: true, + })); - console.log('done importing from backup!'); + console.log('Done importing from backup!'); return result; } finally { - if (tempDir) { - await deleteAll(tempDir); + if (stagingDir) { + await deleteAll(stagingDir); + } + if (decryptionDir) { + await deleteAll(decryptionDir); } } } @@ -1108,11 +1215,11 @@ async function importFromDirectory(directory, options) { const result = await importNonMessages(db, directory, options); await importConversations(db, directory, options); - console.log('done importing!'); + console.log('Done importing!'); return result; } catch (error) { console.log( - 'the import went wrong:', + 'The import went wrong!', error && error.stack ? error.stack : error ); throw error; diff --git a/js/modules/crypto.js b/js/modules/crypto.js new file mode 100644 index 000000000..b4605ebcd --- /dev/null +++ b/js/modules/crypto.js @@ -0,0 +1,151 @@ +/* eslint-env browser */ + +/* eslint-disable camelcase */ + +module.exports = { + encryptSymmetric, + decryptSymmetric, + constantTimeEqual, +}; + +const IV_LENGTH = 16; +const MAC_LENGTH = 16; +const NONCE_LENGTH = 16; + +async function encryptSymmetric(key, plaintext) { + const iv = _getZeros(IV_LENGTH); + const nonce = _getRandomBytes(NONCE_LENGTH); + + const cipherKey = await _hmac_SHA256(key, nonce); + const macKey = await _hmac_SHA256(key, cipherKey); + + const cipherText = await _encrypt_aes256_CBC_PKCSPadding(cipherKey, iv, plaintext); + const mac = _getFirstBytes(await _hmac_SHA256(macKey, cipherText), MAC_LENGTH); + + return _concatData([nonce, cipherText, mac]); +} + +async function decryptSymmetric(key, data) { + const iv = _getZeros(IV_LENGTH); + + const nonce = _getFirstBytes(data, NONCE_LENGTH); + const cipherText = _getBytes( + data, + NONCE_LENGTH, + data.byteLength - NONCE_LENGTH - MAC_LENGTH + ); + const theirMac = _getBytes(data, data.byteLength - MAC_LENGTH, MAC_LENGTH); + + const cipherKey = await _hmac_SHA256(key, nonce); + const macKey = await _hmac_SHA256(key, cipherKey); + + const ourMac = _getFirstBytes(await _hmac_SHA256(macKey, cipherText), MAC_LENGTH); + if (!constantTimeEqual(theirMac, ourMac)) { + throw new Error('decryptSymmetric: Failed to decrypt; MAC verification failed'); + } + + return _decrypt_aes256_CBC_PKCSPadding(cipherKey, iv, cipherText); +} + +function constantTimeEqual(left, right) { + if (left.byteLength !== right.byteLength) { + return false; + } + let result = 0; + const ta1 = new Uint8Array(left); + const ta2 = new Uint8Array(right); + for (let i = 0, max = left.byteLength; i < max; i += 1) { + // eslint-disable-next-line no-bitwise + result |= ta1[i] ^ ta2[i]; + } + return result === 0; +} + + +async function _hmac_SHA256(key, data) { + const extractable = false; + const cryptoKey = await window.crypto.subtle.importKey( + 'raw', + key, + { name: 'HMAC', hash: { name: 'SHA-256' } }, + extractable, + ['sign'] + ); + + return window.crypto.subtle.sign({ name: 'HMAC', hash: 'SHA-256' }, cryptoKey, data); +} + +async function _encrypt_aes256_CBC_PKCSPadding(key, iv, data) { + const extractable = false; + const cryptoKey = await window.crypto.subtle.importKey( + 'raw', + key, + { name: 'AES-CBC' }, + extractable, + ['encrypt'] + ); + + return window.crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, data); +} + +async function _decrypt_aes256_CBC_PKCSPadding(key, iv, data) { + const extractable = false; + const cryptoKey = await window.crypto.subtle.importKey( + 'raw', + key, + { name: 'AES-CBC' }, + extractable, + ['decrypt'] + ); + + return window.crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, data); +} + + +function _getRandomBytes(n) { + const bytes = new Uint8Array(n); + window.crypto.getRandomValues(bytes); + return bytes; +} + +function _getZeros(n) { + const result = new Uint8Array(n); + + const value = 0; + const startIndex = 0; + const endExclusive = n; + result.fill(value, startIndex, endExclusive); + + return result; +} + +function _getFirstBytes(data, n) { + const source = new Uint8Array(data); + return source.subarray(0, n); +} + +function _getBytes(data, start, n) { + const source = new Uint8Array(data); + return source.subarray(start, start + n); +} + +function _concatData(elements) { + const length = elements.reduce( + (total, element) => total + element.byteLength, + 0 + ); + + const result = new Uint8Array(length); + let position = 0; + + for (let i = 0, max = elements.length; i < max; i += 1) { + const element = new Uint8Array(elements[i]); + result.set(element, position); + position += element.byteLength; + } + if (position !== result.length) { + throw new Error('problem concatenating!'); + } + + return result; +} diff --git a/preload.js b/preload.js index d7c2a333a..cd701ad38 100644 --- a/preload.js +++ b/preload.js @@ -107,6 +107,8 @@ window.Signal.Logs = require('./js/modules/logs'); window.Signal.OS = require('./js/modules/os'); window.Signal.Backup = require('./js/modules/backup'); + window.Signal.Crypto = require('./js/modules/crypto'); + window.Signal.Migrations = window.Signal.Migrations || {}; window.Signal.Migrations.V17 = require('./js/modules/migrations/17'); diff --git a/test/backup_test.js b/test/backup_test.js index e1fc49266..1b789466c 100644 --- a/test/backup_test.js +++ b/test/backup_test.js @@ -1,77 +1,157 @@ 'use strict'; describe('Backup', function() { - describe('sanitizeFileName', function() { + describe('_sanitizeFileName', function() { it('leaves a basic string alone', function() { var initial = 'Hello, how are you #5 (\'fine\' + great).jpg'; var expected = initial; - assert.strictEqual(Signal.Backup.sanitizeFileName(initial), expected); + assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected); }); it('replaces all unknown characters', function() { var initial = '!@$%^&*='; var expected = '________'; - assert.strictEqual(Signal.Backup.sanitizeFileName(initial), expected); + assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected); }); }); - describe('trimFileName', function() { + describe('_trimFileName', function() { it('handles a file with no extension', function() { var initial = '0123456789012345678901234567890123456789'; var expected = '012345678901234567890123456789'; - assert.strictEqual(Signal.Backup.trimFileName(initial), expected); + assert.strictEqual(Signal.Backup._trimFileName(initial), expected); }); it('handles a file with a long extension', function() { var initial = '0123456789012345678901234567890123456789.01234567890123456789'; var expected = '012345678901234567890123456789'; - assert.strictEqual(Signal.Backup.trimFileName(initial), expected); + assert.strictEqual(Signal.Backup._trimFileName(initial), expected); }); it('handles a file with a normal extension', function() { var initial = '01234567890123456789012345678901234567890123456789.jpg'; var expected = '012345678901234567890123.jpg'; - assert.strictEqual(Signal.Backup.trimFileName(initial), expected); + assert.strictEqual(Signal.Backup._trimFileName(initial), expected); }); }); - describe('getExportAttachmentFileName', function() { + describe('_getExportAttachmentFileName', function() { it('uses original filename if attachment has one', function() { + var message = { + body: 'something', + }; + var index = 0; var attachment = { fileName: 'blah.jpg' }; var expected = 'blah.jpg'; - assert.strictEqual(Signal.Backup.getExportAttachmentFileName(attachment), expected); + + var actual = Signal.Backup._getExportAttachmentFileName( + message, + index, + attachment + ); + assert.strictEqual(actual, expected); }); it('uses attachment id if no filename', function() { + var message = { + body: 'something', + }; + var index = 0; var attachment = { id: '123' }; var expected = '123'; - assert.strictEqual(Signal.Backup.getExportAttachmentFileName(attachment), expected); + + var actual = Signal.Backup._getExportAttachmentFileName( + message, + index, + attachment + ); + assert.strictEqual(actual, expected); }); it('uses filename and contentType if available', function() { + var message = { + body: 'something', + }; + var index = 0; var attachment = { id: '123', contentType: 'image/jpeg' }; var expected = '123.jpeg'; - assert.strictEqual(Signal.Backup.getExportAttachmentFileName(attachment), expected); + + var actual = Signal.Backup._getExportAttachmentFileName( + message, + index, + attachment + ); + assert.strictEqual(actual, expected); }); it('handles strange contentType', function() { + var message = { + body: 'something', + }; + var index = 0; var attachment = { id: '123', contentType: 'something' }; var expected = '123.something'; - assert.strictEqual(Signal.Backup.getExportAttachmentFileName(attachment), expected); + + var actual = Signal.Backup._getExportAttachmentFileName( + message, + index, + attachment + ); + assert.strictEqual(actual, expected); + }); + }); + + describe('_getAnonymousAttachmentFileName', function() { + it('uses message id', function() { + var message = { + id: 'id-45', + body: 'something', + }; + var index = 0; + var attachment = { + fileName: 'blah.jpg' + }; + var expected = 'id-45'; + + var actual = Signal.Backup._getAnonymousAttachmentFileName( + message, + index, + attachment + ); + assert.strictEqual(actual, expected); + }); + + it('appends index if it is above zero', function() { + var message = { + id: 'id-45', + body: 'something', + }; + var index = 1; + var attachment = { + fileName: 'blah.jpg' + }; + var expected = 'id-45-1'; + + var actual = Signal.Backup._getAnonymousAttachmentFileName( + message, + index, + attachment + ); + assert.strictEqual(actual, expected); }); }); - describe('getConversationDirName', function() { + describe('_getConversationDirName', function() { it('uses name if available', function() { var conversation = { active_at: 123, @@ -79,7 +159,7 @@ describe('Backup', function() { id: 'id' }; var expected = '123 (012345678901234567890123456789 id)'; - assert.strictEqual(Signal.Backup.getConversationDirName(conversation), expected); + assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected); }); it('uses just id if name is not available', function() { @@ -88,20 +168,20 @@ describe('Backup', function() { id: 'id' }; var expected = '123 (id)'; - assert.strictEqual(Signal.Backup.getConversationDirName(conversation), expected); + assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected); }); - it('uses never for missing active_at', function() { + it('uses inactive for missing active_at', function() { var conversation = { name: 'name', id: 'id' }; - var expected = 'never (name id)'; - assert.strictEqual(Signal.Backup.getConversationDirName(conversation), expected); + var expected = 'inactive (name id)'; + assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected); }); }); - describe('getConversationLoggingName', function() { + describe('_getConversationLoggingName', function() { it('uses plain id if conversation is private', function() { var conversation = { active_at: 123, @@ -109,7 +189,7 @@ describe('Backup', function() { type: 'private' }; var expected = '123 (id)'; - assert.strictEqual(Signal.Backup.getConversationLoggingName(conversation), expected); + assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected); }); it('uses just id if name is not available', function() { @@ -119,16 +199,16 @@ describe('Backup', function() { type: 'group' }; var expected = '123 ([REDACTED_GROUP]pId)'; - assert.strictEqual(Signal.Backup.getConversationLoggingName(conversation), expected); + assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected); }); - it('uses never for missing active_at', function() { + it('uses inactive for missing active_at', function() { var conversation = { id: 'id', type: 'private' }; - var expected = 'never (id)'; - assert.strictEqual(Signal.Backup.getConversationLoggingName(conversation), expected); + var expected = 'inactive (id)'; + assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected); }); }); }); diff --git a/test/crypto_test.js b/test/crypto_test.js new file mode 100644 index 000000000..2f728206d --- /dev/null +++ b/test/crypto_test.js @@ -0,0 +1,83 @@ +'use strict'; + +describe('Crypto', function() { + it('roundtrip symmetric encryption succeeds', async function() { + var message = 'this is my message'; + var plaintext = new dcodeIO.ByteBuffer.wrap(message, 'binary').toArrayBuffer(); + var key = textsecure.crypto.getRandomBytes(32); + + var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); + var decrypted = await Signal.Crypto.decryptSymmetric(key, encrypted); + + var equal = Signal.Crypto.constantTimeEqual(plaintext, decrypted); + if (!equal) { + throw new Error('The output and input did not match!'); + } + }); + + it('roundtrip fails if nonce is modified', async function() { + var message = 'this is my message'; + var plaintext = new dcodeIO.ByteBuffer.wrap(message, 'binary').toArrayBuffer(); + var key = textsecure.crypto.getRandomBytes(32); + + var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); + var uintArray = new Uint8Array(encrypted); + uintArray[2] = 9; + + try { + var decrypted = await Signal.Crypto.decryptSymmetric(key, uintArray.buffer); + } catch (error) { + assert.strictEqual( + error.message, + 'decryptSymmetric: Failed to decrypt; MAC verification failed' + ); + return; + } + + throw new Error('Expected error to be thrown'); + }); + + it('fails if mac is modified', async function() { + var message = 'this is my message'; + var plaintext = new dcodeIO.ByteBuffer.wrap(message, 'binary').toArrayBuffer(); + var key = textsecure.crypto.getRandomBytes(32); + + var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); + var uintArray = new Uint8Array(encrypted); + uintArray[uintArray.length - 3] = 9; + + try { + var decrypted = await Signal.Crypto.decryptSymmetric(key, uintArray.buffer); + } catch (error) { + assert.strictEqual( + error.message, + 'decryptSymmetric: Failed to decrypt; MAC verification failed' + ); + return; + } + + throw new Error('Expected error to be thrown'); + }); + + it('fails if encrypted contents are modified', async function() { + var message = 'this is my message'; + var plaintext = new dcodeIO.ByteBuffer.wrap(message, 'binary').toArrayBuffer(); + var key = textsecure.crypto.getRandomBytes(32); + + var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); + var uintArray = new Uint8Array(encrypted); + uintArray[35] = 9; + + try { + var decrypted = await Signal.Crypto.decryptSymmetric(key, uintArray.buffer); + } catch (error) { + assert.strictEqual( + error.message, + 'decryptSymmetric: Failed to decrypt; MAC verification failed' + ); + return; + } + + throw new Error('Expected error to be thrown'); + }); +}); diff --git a/test/index.html b/test/index.html index 2c6c101e7..6801c6756 100644 --- a/test/index.html +++ b/test/index.html @@ -640,6 +640,7 @@ +