Encryption support for backup and restore
Also moved to the _ prefix in backup.js for all private methods exported for testing.
This commit is contained in:
parent
6d8f4b7b6e
commit
cea42bde7d
6 changed files with 509 additions and 85 deletions
|
@ -19,6 +19,9 @@ const archiver = require('archiver');
|
||||||
const rimraf = require('rimraf');
|
const rimraf = require('rimraf');
|
||||||
const electronRemote = require('electron').remote;
|
const electronRemote = require('electron').remote;
|
||||||
|
|
||||||
|
const crypto = require('./crypto');
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
dialog,
|
dialog,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
|
@ -31,11 +34,12 @@ module.exports = {
|
||||||
getDirectoryForImport,
|
getDirectoryForImport,
|
||||||
importFromDirectory,
|
importFromDirectory,
|
||||||
// for testing
|
// for testing
|
||||||
sanitizeFileName,
|
_sanitizeFileName,
|
||||||
trimFileName,
|
_trimFileName,
|
||||||
getExportAttachmentFileName,
|
_getExportAttachmentFileName,
|
||||||
getConversationDirName,
|
_getAnonymousAttachmentFileName,
|
||||||
getConversationLoggingName,
|
_getConversationDirName,
|
||||||
|
_getConversationLoggingName,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -136,6 +140,9 @@ function exportContactsAndGroups(db, fileWriter) {
|
||||||
stream.write('{');
|
stream.write('{');
|
||||||
|
|
||||||
_.each(storeNames, (storeName) => {
|
_.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');
|
const transaction = db.transaction(storeNames, 'readwrite');
|
||||||
transaction.onerror = () => {
|
transaction.onerror = () => {
|
||||||
Whisper.Database.handleDOMException(
|
Whisper.Database.handleDOMException(
|
||||||
|
@ -349,7 +356,7 @@ function importFromJsonString(db, jsonString, targetPath, options) {
|
||||||
|
|
||||||
function createDirectory(parent, name) {
|
function createDirectory(parent, name) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const sanitized = sanitizeFileName(name);
|
const sanitized = _sanitizeFileName(name);
|
||||||
const targetDir = path.join(parent, sanitized);
|
const targetDir = path.join(parent, sanitized);
|
||||||
if (fs.existsSync(targetDir)) {
|
if (fs.existsSync(targetDir)) {
|
||||||
resolve(targetDir);
|
resolve(targetDir);
|
||||||
|
@ -369,7 +376,7 @@ function createDirectory(parent, name) {
|
||||||
|
|
||||||
function createFileAndWriter(parent, name) {
|
function createFileAndWriter(parent, name) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const sanitized = sanitizeFileName(name);
|
const sanitized = _sanitizeFileName(name);
|
||||||
const targetPath = path.join(parent, sanitized);
|
const targetPath = path.join(parent, sanitized);
|
||||||
const options = {
|
const options = {
|
||||||
flags: 'wx',
|
flags: 'wx',
|
||||||
|
@ -406,7 +413,7 @@ function readFileAsArrayBuffer(targetPath) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimFileName(filename) {
|
function _trimFileName(filename) {
|
||||||
const components = filename.split('.');
|
const components = filename.split('.');
|
||||||
if (components.length <= 1) {
|
if (components.length <= 1) {
|
||||||
return filename.slice(0, 30);
|
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) {
|
if (attachment.fileName) {
|
||||||
return trimFileName(attachment.fileName);
|
return _trimFileName(attachment.fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = attachment.id;
|
let name = attachment.id;
|
||||||
|
@ -437,15 +444,18 @@ function getExportAttachmentFileName(message, index, attachment) {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAnonymousAttachmentFileName(message, index) {
|
function _getAnonymousAttachmentFileName(message, index) {
|
||||||
if (!index) {
|
if (!index) {
|
||||||
return message.id;
|
return message.id;
|
||||||
}
|
}
|
||||||
return `${message.id}-${index}`;
|
return `${message.id}-${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readAttachment(dir, attachment, name) {
|
async function readAttachment(dir, attachment, name, options) {
|
||||||
const anonymousName = sanitizeFileName(name);
|
options = options || {};
|
||||||
|
const { key, encrypted } = options;
|
||||||
|
|
||||||
|
const anonymousName = _sanitizeFileName(name);
|
||||||
const targetPath = path.join(dir, anonymousName);
|
const targetPath = path.join(dir, anonymousName);
|
||||||
|
|
||||||
if (!fs.existsSync(targetPath)) {
|
if (!fs.existsSync(targetPath)) {
|
||||||
|
@ -453,27 +463,51 @@ async function readAttachment(dir, attachment, name) {
|
||||||
return;
|
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) {
|
async function writeAttachment(attachment, options) {
|
||||||
const filename = getAnonymousAttachmentFileName(message, index);
|
const {
|
||||||
|
dir,
|
||||||
|
message,
|
||||||
|
index,
|
||||||
|
key,
|
||||||
|
newKey,
|
||||||
|
} = options;
|
||||||
|
const filename = _getAnonymousAttachmentFileName(message, index);
|
||||||
const target = path.join(dir, filename);
|
const target = path.join(dir, filename);
|
||||||
if (fs.existsSync(target)) {
|
if (fs.existsSync(target)) {
|
||||||
console.log(`Skipping attachment ${filename}; already exists`);
|
if (newKey) {
|
||||||
return;
|
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 writer = await createFileAndWriter(dir, filename);
|
||||||
const stream = createOutputStream(writer);
|
const stream = createOutputStream(writer);
|
||||||
stream.write(Buffer.from(attachment.data));
|
stream.write(Buffer.from(encrypted));
|
||||||
await stream.close();
|
await stream.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeAttachments(dir, name, message, attachments) {
|
async function writeAttachments(attachments, options) {
|
||||||
|
const { name } = options;
|
||||||
|
|
||||||
const promises = _.map(
|
const promises = _.map(
|
||||||
attachments,
|
attachments,
|
||||||
(attachment, index) => writeAttachment(dir, message, index, attachment)
|
(attachment, index) => writeAttachment(attachment, Object.assign({}, options, {
|
||||||
|
index,
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await Promise.all(promises);
|
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, '_');
|
return filename.toString().replace(/[^a-z0-9.,+()'#\- ]/gi, '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -498,6 +532,7 @@ async function exportConversation(db, conversation, options) {
|
||||||
name,
|
name,
|
||||||
dir,
|
dir,
|
||||||
attachmentsDir,
|
attachmentsDir,
|
||||||
|
key,
|
||||||
} = options;
|
} = options;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw new Error('Need a name!');
|
throw new Error('Need a name!');
|
||||||
|
@ -508,6 +543,9 @@ async function exportConversation(db, conversation, options) {
|
||||||
if (!attachmentsDir) {
|
if (!attachmentsDir) {
|
||||||
throw new Error('Need an attachments directory!');
|
throw new Error('Need an attachments directory!');
|
||||||
}
|
}
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('Need a key to encrypt with!');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('exporting conversation', name);
|
console.log('exporting conversation', name);
|
||||||
const writer = await createFileAndWriter(dir, 'messages.json');
|
const writer = await createFileAndWriter(dir, 'messages.json');
|
||||||
|
@ -583,8 +621,12 @@ async function exportConversation(db, conversation, options) {
|
||||||
stream.write(jsonString);
|
stream.write(jsonString);
|
||||||
|
|
||||||
if (attachments && attachments.length) {
|
if (attachments && attachments.length) {
|
||||||
const exportAttachments = () =>
|
const exportAttachments = () => writeAttachments(attachments, {
|
||||||
writeAttachments(attachmentsDir, name, message, attachments);
|
dir: attachmentsDir,
|
||||||
|
name,
|
||||||
|
message,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line more/no-then
|
// eslint-disable-next-line more/no-then
|
||||||
promiseChain = promiseChain.then(exportAttachments);
|
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)
|
// 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)
|
// 2. Sorted just like the list of conversations in the left-pan (active_at)
|
||||||
// 3. Disambiguated from other directories (active_at, truncated name, id)
|
// 3. Disambiguated from other directories (active_at, truncated name, id)
|
||||||
function getConversationDirName(conversation) {
|
function _getConversationDirName(conversation) {
|
||||||
const name = conversation.active_at || 'never';
|
const name = conversation.active_at || 'inactive';
|
||||||
if (conversation.name) {
|
if (conversation.name) {
|
||||||
return `${name} (${conversation.name.slice(0, 30)} ${conversation.id})`;
|
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
|
// 2. Adequately disambiguated to enable debugging flow of execution
|
||||||
// 3. Can be shared to the web without privacy concerns (there's no global redaction
|
// 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)
|
// logic for group ids, so we do it manually here)
|
||||||
function getConversationLoggingName(conversation) {
|
function _getConversationLoggingName(conversation) {
|
||||||
let name = conversation.active_at || 'never';
|
let name = conversation.active_at || 'inactive';
|
||||||
if (conversation.type === 'private') {
|
if (conversation.type === 'private') {
|
||||||
name += ` (${conversation.id})`;
|
name += ` (${conversation.id})`;
|
||||||
} else {
|
} else {
|
||||||
|
@ -649,6 +691,7 @@ function exportConversations(db, options) {
|
||||||
const {
|
const {
|
||||||
messagesDir,
|
messagesDir,
|
||||||
attachmentsDir,
|
attachmentsDir,
|
||||||
|
key,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (!messagesDir) {
|
if (!messagesDir) {
|
||||||
|
@ -685,8 +728,8 @@ function exportConversations(db, options) {
|
||||||
const cursor = event.target.result;
|
const cursor = event.target.result;
|
||||||
if (cursor && cursor.value) {
|
if (cursor && cursor.value) {
|
||||||
const conversation = cursor.value;
|
const conversation = cursor.value;
|
||||||
const dirName = getConversationDirName(conversation);
|
const dirName = _getConversationDirName(conversation);
|
||||||
const name = getConversationLoggingName(conversation);
|
const name = _getConversationLoggingName(conversation);
|
||||||
|
|
||||||
const process = async () => {
|
const process = async () => {
|
||||||
const dir = await createDirectory(messagesDir, dirName);
|
const dir = await createDirectory(messagesDir, dirName);
|
||||||
|
@ -694,6 +737,7 @@ function exportConversations(db, options) {
|
||||||
name,
|
name,
|
||||||
dir,
|
dir,
|
||||||
attachmentsDir,
|
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 promises = _.map(message.attachments, (attachment, index) => {
|
||||||
const name = getName(message, index, attachment);
|
const name = getName(message, index, attachment);
|
||||||
return readAttachment(dir, attachment, name);
|
return readAttachment(dir, attachment, name, options);
|
||||||
});
|
});
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
@ -830,6 +877,7 @@ async function importConversation(db, dir, options) {
|
||||||
const {
|
const {
|
||||||
messageLookup,
|
messageLookup,
|
||||||
attachmentsDir,
|
attachmentsDir,
|
||||||
|
key,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
let conversationId = 'unknown';
|
let conversationId = 'unknown';
|
||||||
|
@ -862,11 +910,15 @@ async function importConversation(db, dir, options) {
|
||||||
if (message.attachments && message.attachments.length) {
|
if (message.attachments && message.attachments.length) {
|
||||||
const importMessage = async () => {
|
const importMessage = async () => {
|
||||||
const getName = attachmentsDir
|
const getName = attachmentsDir
|
||||||
? getAnonymousAttachmentFileName
|
? _getAnonymousAttachmentFileName
|
||||||
: getExportAttachmentFileName;
|
: _getExportAttachmentFileName;
|
||||||
const parent = attachmentsDir || path.join(dir, message.received_at.toString());
|
const parentDir = attachmentsDir ||
|
||||||
|
path.join(dir, message.received_at.toString());
|
||||||
|
|
||||||
await loadAttachments(parent, message, getName);
|
await loadAttachments(parentDir, getName, {
|
||||||
|
message,
|
||||||
|
key,
|
||||||
|
});
|
||||||
return saveMessage(db, message);
|
return saveMessage(db, message);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1005,12 +1057,45 @@ function createZip(zipDir, targetDir) {
|
||||||
|
|
||||||
archive.pipe(output);
|
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.directory(targetDir, '');
|
||||||
|
|
||||||
archive.finalize();
|
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() {
|
function createTempDir() {
|
||||||
return pify(tmp.dir)();
|
return pify(tmp.dir)();
|
||||||
}
|
}
|
||||||
|
@ -1020,37 +1105,45 @@ function deleteAll(pattern) {
|
||||||
return pify(rimraf)(pattern);
|
return pify(rimraf)(pattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function backupToDirectory(directory) {
|
async function backupToDirectory(directory, options) {
|
||||||
let tempDir;
|
options = options || {};
|
||||||
|
|
||||||
|
if (!options.key) {
|
||||||
|
throw new Error('Encrypted backup requires a key to encrypt with!');
|
||||||
|
}
|
||||||
|
|
||||||
|
let stagingDir;
|
||||||
|
let encryptionDir;
|
||||||
try {
|
try {
|
||||||
tempDir = await createTempDir();
|
stagingDir = await createTempDir();
|
||||||
|
encryptionDir = await createTempDir();
|
||||||
|
|
||||||
const db = await Whisper.Database.open();
|
const db = await Whisper.Database.open();
|
||||||
const attachmentsDir = await createDirectory(directory, 'attachments');
|
const attachmentsDir = await createDirectory(directory, 'attachments');
|
||||||
|
|
||||||
await exportContactAndGroupsToFile(db, tempDir);
|
await exportContactAndGroupsToFile(db, stagingDir);
|
||||||
await exportConversations(db, {
|
await exportConversations(db, Object.assign({}, options, {
|
||||||
messagesDir: tempDir,
|
messagesDir: stagingDir,
|
||||||
attachmentsDir,
|
attachmentsDir,
|
||||||
});
|
}));
|
||||||
|
|
||||||
await createZip(directory, tempDir);
|
const zip = await createZip(encryptionDir, stagingDir);
|
||||||
|
await encryptFile(zip, path.join(directory, 'messages.zip'), options);
|
||||||
// now that we've made the zip file, we can delete the temp messages directory
|
|
||||||
await deleteAll(tempDir);
|
|
||||||
tempDir = null;
|
|
||||||
|
|
||||||
console.log('done backing up!');
|
console.log('done backing up!');
|
||||||
return directory;
|
return directory;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(
|
console.log(
|
||||||
'the backup went wrong:',
|
'The backup went wrong!',
|
||||||
error && error.stack ? error.stack : error
|
error && error.stack ? error.stack : error
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (tempDir) {
|
if (stagingDir) {
|
||||||
await deleteAll(tempDir);
|
await deleteAll(stagingDir);
|
||||||
|
}
|
||||||
|
if (encryptionDir) {
|
||||||
|
await deleteAll(encryptionDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1083,24 +1176,38 @@ async function importFromDirectory(directory, options) {
|
||||||
const zipPath = path.join(directory, 'messages.zip');
|
const zipPath = path.join(directory, 'messages.zip');
|
||||||
if (fs.existsSync(zipPath)) {
|
if (fs.existsSync(zipPath)) {
|
||||||
// we're in the world of an encrypted, zipped backup
|
// 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 {
|
try {
|
||||||
tempDir = await createTempDir();
|
stagingDir = await createTempDir();
|
||||||
|
decryptionDir = await createTempDir();
|
||||||
|
|
||||||
const attachmentsDir = path.join(directory, 'attachments');
|
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, {
|
options = Object.assign({}, options, {
|
||||||
attachmentsDir,
|
attachmentsDir,
|
||||||
});
|
});
|
||||||
const result = await importNonMessages(db, tempDir, options);
|
const result = await importNonMessages(db, stagingDir, options);
|
||||||
await importConversations(db, tempDir, options);
|
await importConversations(db, stagingDir, Object.assign({}, options, {
|
||||||
|
encrypted: true,
|
||||||
|
}));
|
||||||
|
|
||||||
console.log('done importing from backup!');
|
console.log('Done importing from backup!');
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
if (tempDir) {
|
if (stagingDir) {
|
||||||
await deleteAll(tempDir);
|
await deleteAll(stagingDir);
|
||||||
|
}
|
||||||
|
if (decryptionDir) {
|
||||||
|
await deleteAll(decryptionDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1108,11 +1215,11 @@ async function importFromDirectory(directory, options) {
|
||||||
const result = await importNonMessages(db, directory, options);
|
const result = await importNonMessages(db, directory, options);
|
||||||
await importConversations(db, directory, options);
|
await importConversations(db, directory, options);
|
||||||
|
|
||||||
console.log('done importing!');
|
console.log('Done importing!');
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(
|
console.log(
|
||||||
'the import went wrong:',
|
'The import went wrong!',
|
||||||
error && error.stack ? error.stack : error
|
error && error.stack ? error.stack : error
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
151
js/modules/crypto.js
Normal file
151
js/modules/crypto.js
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -107,6 +107,8 @@
|
||||||
window.Signal.Logs = require('./js/modules/logs');
|
window.Signal.Logs = require('./js/modules/logs');
|
||||||
window.Signal.OS = require('./js/modules/os');
|
window.Signal.OS = require('./js/modules/os');
|
||||||
window.Signal.Backup = require('./js/modules/backup');
|
window.Signal.Backup = require('./js/modules/backup');
|
||||||
|
window.Signal.Crypto = require('./js/modules/crypto');
|
||||||
|
|
||||||
window.Signal.Migrations = window.Signal.Migrations || {};
|
window.Signal.Migrations = window.Signal.Migrations || {};
|
||||||
window.Signal.Migrations.V17 = require('./js/modules/migrations/17');
|
window.Signal.Migrations.V17 = require('./js/modules/migrations/17');
|
||||||
|
|
||||||
|
|
|
@ -1,77 +1,157 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
describe('Backup', function() {
|
describe('Backup', function() {
|
||||||
describe('sanitizeFileName', function() {
|
describe('_sanitizeFileName', function() {
|
||||||
it('leaves a basic string alone', function() {
|
it('leaves a basic string alone', function() {
|
||||||
var initial = 'Hello, how are you #5 (\'fine\' + great).jpg';
|
var initial = 'Hello, how are you #5 (\'fine\' + great).jpg';
|
||||||
var expected = initial;
|
var expected = initial;
|
||||||
assert.strictEqual(Signal.Backup.sanitizeFileName(initial), expected);
|
assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces all unknown characters', function() {
|
it('replaces all unknown characters', function() {
|
||||||
var initial = '!@$%^&*=';
|
var initial = '!@$%^&*=';
|
||||||
var expected = '________';
|
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() {
|
it('handles a file with no extension', function() {
|
||||||
var initial = '0123456789012345678901234567890123456789';
|
var initial = '0123456789012345678901234567890123456789';
|
||||||
var expected = '012345678901234567890123456789';
|
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() {
|
it('handles a file with a long extension', function() {
|
||||||
var initial = '0123456789012345678901234567890123456789.01234567890123456789';
|
var initial = '0123456789012345678901234567890123456789.01234567890123456789';
|
||||||
var expected = '012345678901234567890123456789';
|
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() {
|
it('handles a file with a normal extension', function() {
|
||||||
var initial = '01234567890123456789012345678901234567890123456789.jpg';
|
var initial = '01234567890123456789012345678901234567890123456789.jpg';
|
||||||
var expected = '012345678901234567890123.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() {
|
it('uses original filename if attachment has one', function() {
|
||||||
|
var message = {
|
||||||
|
body: 'something',
|
||||||
|
};
|
||||||
|
var index = 0;
|
||||||
var attachment = {
|
var attachment = {
|
||||||
fileName: 'blah.jpg'
|
fileName: 'blah.jpg'
|
||||||
};
|
};
|
||||||
var expected = '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() {
|
it('uses attachment id if no filename', function() {
|
||||||
|
var message = {
|
||||||
|
body: 'something',
|
||||||
|
};
|
||||||
|
var index = 0;
|
||||||
var attachment = {
|
var attachment = {
|
||||||
id: '123'
|
id: '123'
|
||||||
};
|
};
|
||||||
var expected = '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() {
|
it('uses filename and contentType if available', function() {
|
||||||
|
var message = {
|
||||||
|
body: 'something',
|
||||||
|
};
|
||||||
|
var index = 0;
|
||||||
var attachment = {
|
var attachment = {
|
||||||
id: '123',
|
id: '123',
|
||||||
contentType: 'image/jpeg'
|
contentType: 'image/jpeg'
|
||||||
};
|
};
|
||||||
var expected = '123.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() {
|
it('handles strange contentType', function() {
|
||||||
|
var message = {
|
||||||
|
body: 'something',
|
||||||
|
};
|
||||||
|
var index = 0;
|
||||||
var attachment = {
|
var attachment = {
|
||||||
id: '123',
|
id: '123',
|
||||||
contentType: 'something'
|
contentType: 'something'
|
||||||
};
|
};
|
||||||
var expected = '123.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('getConversationDirName', function() {
|
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() {
|
||||||
it('uses name if available', function() {
|
it('uses name if available', function() {
|
||||||
var conversation = {
|
var conversation = {
|
||||||
active_at: 123,
|
active_at: 123,
|
||||||
|
@ -79,7 +159,7 @@ describe('Backup', function() {
|
||||||
id: 'id'
|
id: 'id'
|
||||||
};
|
};
|
||||||
var expected = '123 (012345678901234567890123456789 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() {
|
it('uses just id if name is not available', function() {
|
||||||
|
@ -88,20 +168,20 @@ describe('Backup', function() {
|
||||||
id: 'id'
|
id: 'id'
|
||||||
};
|
};
|
||||||
var expected = '123 (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 = {
|
var conversation = {
|
||||||
name: 'name',
|
name: 'name',
|
||||||
id: 'id'
|
id: 'id'
|
||||||
};
|
};
|
||||||
var expected = 'never (name id)';
|
var expected = 'inactive (name id)';
|
||||||
assert.strictEqual(Signal.Backup.getConversationDirName(conversation), expected);
|
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getConversationLoggingName', function() {
|
describe('_getConversationLoggingName', function() {
|
||||||
it('uses plain id if conversation is private', function() {
|
it('uses plain id if conversation is private', function() {
|
||||||
var conversation = {
|
var conversation = {
|
||||||
active_at: 123,
|
active_at: 123,
|
||||||
|
@ -109,7 +189,7 @@ describe('Backup', function() {
|
||||||
type: 'private'
|
type: 'private'
|
||||||
};
|
};
|
||||||
var expected = '123 (id)';
|
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() {
|
it('uses just id if name is not available', function() {
|
||||||
|
@ -119,16 +199,16 @@ describe('Backup', function() {
|
||||||
type: 'group'
|
type: 'group'
|
||||||
};
|
};
|
||||||
var expected = '123 ([REDACTED_GROUP]pId)';
|
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 = {
|
var conversation = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
type: 'private'
|
type: 'private'
|
||||||
};
|
};
|
||||||
var expected = 'never (id)';
|
var expected = 'inactive (id)';
|
||||||
assert.strictEqual(Signal.Backup.getConversationLoggingName(conversation), expected);
|
assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
83
test/crypto_test.js
Normal file
83
test/crypto_test.js
Normal file
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -640,6 +640,7 @@
|
||||||
<script type="text/javascript" src="emoji_util_test.js"></script>
|
<script type="text/javascript" src="emoji_util_test.js"></script>
|
||||||
<script type="text/javascript" src="reliable_trigger_test.js"></script>
|
<script type="text/javascript" src="reliable_trigger_test.js"></script>
|
||||||
<script type="text/javascript" src="backup_test.js"></script>
|
<script type="text/javascript" src="backup_test.js"></script>
|
||||||
|
<script type="text/javascript" src="crypto_test.js"></script>
|
||||||
<script type="text/javascript" src="database_test.js"></script>
|
<script type="text/javascript" src="database_test.js"></script>
|
||||||
<script type="text/javascript" src="i18n_test.js"></script>
|
<script type="text/javascript" src="i18n_test.js"></script>
|
||||||
<script type="text/javascript" src="spellcheck_test.js"></script>
|
<script type="text/javascript" src="spellcheck_test.js"></script>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue