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 @@
+