mirror of https://github.com/laurent22/joplin.git
All: Support encrypting notes and notebooks
parent
f6fbf3ba0f
commit
5951ed3f55
|
@ -7,4 +7,5 @@ rsync -a "$ROOT_DIR/../ReactNativeClient/lib/" "$BUILD_DIR/lib/"
|
||||||
rsync -a "$ROOT_DIR/build/locales/" "$BUILD_DIR/locales/"
|
rsync -a "$ROOT_DIR/build/locales/" "$BUILD_DIR/locales/"
|
||||||
mkdir -p "$BUILD_DIR/data"
|
mkdir -p "$BUILD_DIR/data"
|
||||||
|
|
||||||
(cd "$ROOT_DIR" && npm test tests-build/synchronizer.js)
|
(cd "$ROOT_DIR" && npm test tests-build/synchronizer.js)
|
||||||
|
(cd "$ROOT_DIR" && npm test tests-build/encryption.js)
|
|
@ -0,0 +1,163 @@
|
||||||
|
require('app-module-path').addPath(__dirname);
|
||||||
|
|
||||||
|
const { time } = require('lib/time-utils.js');
|
||||||
|
const { setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
|
||||||
|
const { Folder } = require('lib/models/folder.js');
|
||||||
|
const { Note } = require('lib/models/note.js');
|
||||||
|
const { Tag } = require('lib/models/tag.js');
|
||||||
|
const { Database } = require('lib/database.js');
|
||||||
|
const { Setting } = require('lib/models/setting.js');
|
||||||
|
const { BaseItem } = require('lib/models/base-item.js');
|
||||||
|
const { BaseModel } = require('lib/base-model.js');
|
||||||
|
const MasterKey = require('lib/models/MasterKey');
|
||||||
|
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
||||||
|
const EncryptionService = require('lib/services/EncryptionService.js');
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, p) => {
|
||||||
|
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; // The first test is slow because the database needs to be built
|
||||||
|
|
||||||
|
let service = null;
|
||||||
|
|
||||||
|
describe('Encryption', function() {
|
||||||
|
|
||||||
|
beforeEach(async (done) => {
|
||||||
|
await setupDatabaseAndSynchronizer(1);
|
||||||
|
//await setupDatabaseAndSynchronizer(2);
|
||||||
|
//await switchClient(1);
|
||||||
|
service = new EncryptionService();
|
||||||
|
BaseItem.encryptionService_ = service;
|
||||||
|
Setting.setValue('encryption.enabled', true);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should encode and decode header', async (done) => {
|
||||||
|
const header = {
|
||||||
|
version: 1,
|
||||||
|
encryptionMethod: EncryptionService.METHOD_SJCL,
|
||||||
|
masterKeyId: '01234568abcdefgh01234568abcdefgh',
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodedHeader = service.encodeHeader_(header);
|
||||||
|
const decodedHeader = service.decodeHeader_(encodedHeader);
|
||||||
|
delete decodedHeader.length;
|
||||||
|
|
||||||
|
expect(objectsEqual(header, decodedHeader)).toBe(true);
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate and decrypt a master key', async (done) => {
|
||||||
|
const masterKey = await service.generateMasterKey('123456');
|
||||||
|
expect(!!masterKey.checksum).toBe(true);
|
||||||
|
expect(!!masterKey.content).toBe(true);
|
||||||
|
|
||||||
|
let hasThrown = false;
|
||||||
|
try {
|
||||||
|
await service.decryptMasterKey(masterKey, 'wrongpassword');
|
||||||
|
} catch (error) {
|
||||||
|
hasThrown = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hasThrown).toBe(true);
|
||||||
|
|
||||||
|
const decryptedMasterKey = await service.decryptMasterKey(masterKey, '123456');
|
||||||
|
expect(decryptedMasterKey.length).toBe(512);
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should encrypt and decrypt with a master key', async (done) => {
|
||||||
|
let masterKey = await service.generateMasterKey('123456');
|
||||||
|
masterKey = await MasterKey.save(masterKey);
|
||||||
|
|
||||||
|
await service.loadMasterKey(masterKey, '123456', true);
|
||||||
|
|
||||||
|
const cipherText = await service.encryptString('some secret');
|
||||||
|
const plainText = await service.decryptString(cipherText);
|
||||||
|
|
||||||
|
expect(plainText).toBe('some secret');
|
||||||
|
|
||||||
|
// Test that a long string, that is going to be split into multiple chunks, encrypt
|
||||||
|
// and decrypt properly too.
|
||||||
|
let veryLongSecret = '';
|
||||||
|
for (let i = 0; i < service.chunkSize() * 3; i++) veryLongSecret += Math.floor(Math.random() * 9);
|
||||||
|
|
||||||
|
const cipherText2 = await service.encryptString(veryLongSecret);
|
||||||
|
const plainText2 = await service.decryptString(cipherText2);
|
||||||
|
|
||||||
|
expect(plainText2 === veryLongSecret).toBe(true);
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to decrypt if master key not present', async (done) => {
|
||||||
|
let masterKey = await service.generateMasterKey('123456');
|
||||||
|
masterKey = await MasterKey.save(masterKey);
|
||||||
|
|
||||||
|
await service.loadMasterKey(masterKey, '123456', true);
|
||||||
|
|
||||||
|
const cipherText = await service.encryptString('some secret');
|
||||||
|
|
||||||
|
await service.unloadMasterKey(masterKey);
|
||||||
|
|
||||||
|
let hasThrown = await checkThrowAsync(async () => await service.decryptString(cipherText));
|
||||||
|
|
||||||
|
expect(hasThrown).toBe(true);
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('should fail to decrypt if data tampered with', async (done) => {
|
||||||
|
let masterKey = await service.generateMasterKey('123456');
|
||||||
|
masterKey = await MasterKey.save(masterKey);
|
||||||
|
|
||||||
|
await service.loadMasterKey(masterKey, '123456', true);
|
||||||
|
|
||||||
|
let cipherText = await service.encryptString('some secret');
|
||||||
|
cipherText += "ABCDEFGHIJ";
|
||||||
|
|
||||||
|
let hasThrown = await checkThrowAsync(async () => await service.decryptString(cipherText));
|
||||||
|
|
||||||
|
expect(hasThrown).toBe(true);
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should encrypt and decrypt serialised data', async (done) => {
|
||||||
|
let masterKey = await service.generateMasterKey('123456');
|
||||||
|
masterKey = await MasterKey.save(masterKey);
|
||||||
|
await service.loadMasterKey(masterKey, '123456', true);
|
||||||
|
|
||||||
|
let folder = await Folder.save({ title: 'folder' });
|
||||||
|
let note = await Note.save({ title: 'encrypted note', body: 'something', parent_id: folder.id });
|
||||||
|
let serialized = await Note.serializeForSync(note);
|
||||||
|
let deserialized = Note.filter(await Note.unserialize(serialized));
|
||||||
|
|
||||||
|
// Check that required properties are not encrypted
|
||||||
|
expect(deserialized.id).toBe(note.id);
|
||||||
|
expect(deserialized.parent_id).toBe(note.parent_id);
|
||||||
|
expect(deserialized.updated_time).toBe(note.updated_time);
|
||||||
|
|
||||||
|
// Check that at least title and body are encrypted
|
||||||
|
expect(!deserialized.title).toBe(true);
|
||||||
|
expect(!deserialized.body).toBe(true);
|
||||||
|
|
||||||
|
// Check that encrypted data is there
|
||||||
|
expect(!!deserialized.encryption_cipher_text).toBe(true);
|
||||||
|
|
||||||
|
encryptedNote = await Note.save(deserialized);
|
||||||
|
decryptedNote = await Note.decrypt(encryptedNote);
|
||||||
|
|
||||||
|
expect(decryptedNote.title).toBe(note.title);
|
||||||
|
expect(decryptedNote.body).toBe(note.body);
|
||||||
|
expect(decryptedNote.id).toBe(note.id);
|
||||||
|
expect(decryptedNote.parent_id).toBe(note.parent_id);
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
|
@ -1,12 +1,13 @@
|
||||||
require('app-module-path').addPath(__dirname);
|
require('app-module-path').addPath(__dirname);
|
||||||
|
|
||||||
const { time } = require('lib/time-utils.js');
|
const { time } = require('lib/time-utils.js');
|
||||||
const { setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId } = require('test-utils.js');
|
const { setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, encryptionService, loadEncryptionMasterKey } = require('test-utils.js');
|
||||||
const { Folder } = require('lib/models/folder.js');
|
const { Folder } = require('lib/models/folder.js');
|
||||||
const { Note } = require('lib/models/note.js');
|
const { Note } = require('lib/models/note.js');
|
||||||
const { Tag } = require('lib/models/tag.js');
|
const { Tag } = require('lib/models/tag.js');
|
||||||
const { Database } = require('lib/database.js');
|
const { Database } = require('lib/database.js');
|
||||||
const { Setting } = require('lib/models/setting.js');
|
const { Setting } = require('lib/models/setting.js');
|
||||||
|
const MasterKey = require('lib/models/MasterKey');
|
||||||
const { BaseItem } = require('lib/models/base-item.js');
|
const { BaseItem } = require('lib/models/base-item.js');
|
||||||
const { BaseModel } = require('lib/base-model.js');
|
const { BaseModel } = require('lib/base-model.js');
|
||||||
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
||||||
|
@ -634,7 +635,7 @@ describe('Synchronizer', function() {
|
||||||
let note1 = await Note.save({ title: "un", is_todo: 1, parent_id: folder1.id });
|
let note1 = await Note.save({ title: "un", is_todo: 1, parent_id: folder1.id });
|
||||||
const noteId = note1.id;
|
const noteId = note1.id;
|
||||||
await synchronizer().start();
|
await synchronizer().start();
|
||||||
let disabledItems = await BaseItem.syncDisabledItems();
|
let disabledItems = await BaseItem.syncDisabledItems(syncTargetId());
|
||||||
expect(disabledItems.length).toBe(0);
|
expect(disabledItems.length).toBe(0);
|
||||||
await Note.save({ id: noteId, title: "un mod", });
|
await Note.save({ id: noteId, title: "un mod", });
|
||||||
synchronizer().debugFlags_ = ['cannotSync'];
|
synchronizer().debugFlags_ = ['cannotSync'];
|
||||||
|
@ -651,10 +652,57 @@ describe('Synchronizer', function() {
|
||||||
|
|
||||||
await switchClient(1);
|
await switchClient(1);
|
||||||
|
|
||||||
disabledItems = await BaseItem.syncDisabledItems();
|
disabledItems = await BaseItem.syncDisabledItems(syncTargetId());
|
||||||
expect(disabledItems.length).toBe(1);
|
expect(disabledItems.length).toBe(1);
|
||||||
|
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('notes and folders should get encrypted when encryption is enabled', async (done) => {
|
||||||
|
Setting.setValue('encryption.enabled', true);
|
||||||
|
const masterKey = await loadEncryptionMasterKey();
|
||||||
|
let folder1 = await Folder.save({ title: "folder1" });
|
||||||
|
let note1 = await Note.save({ title: "un", body: 'to be encrypted', parent_id: folder1.id });
|
||||||
|
await synchronizer().start();
|
||||||
|
// After synchronisation, remote items should be encrypted but local ones remain plain text
|
||||||
|
note1 = await Note.load(note1.id);
|
||||||
|
expect(note1.title).toBe('un');
|
||||||
|
|
||||||
|
await switchClient(2);
|
||||||
|
|
||||||
|
await synchronizer().start();
|
||||||
|
let folder1_2 = await Folder.load(folder1.id);
|
||||||
|
let note1_2 = await Note.load(note1.id);
|
||||||
|
let masterKey_2 = await MasterKey.load(masterKey.id);
|
||||||
|
// On this side however it should be received encrypted
|
||||||
|
expect(!note1_2.title).toBe(true);
|
||||||
|
expect(!folder1_2.title).toBe(true);
|
||||||
|
expect(!!note1_2.encryption_cipher_text).toBe(true);
|
||||||
|
expect(!!folder1_2.encryption_cipher_text).toBe(true);
|
||||||
|
// Master key is already encrypted so it does not get re-encrypted during sync
|
||||||
|
expect(masterKey_2.content).toBe(masterKey.content);
|
||||||
|
expect(masterKey_2.checksum).toBe(masterKey.checksum);
|
||||||
|
// Now load the master key we got from client 1 and try to decrypt
|
||||||
|
await encryptionService().loadMasterKey(masterKey_2, '123456', true);
|
||||||
|
// Get the decrypted items back
|
||||||
|
await Folder.decrypt(folder1_2);
|
||||||
|
await Note.decrypt(note1_2);
|
||||||
|
folder1_2 = await Folder.load(folder1.id);
|
||||||
|
note1_2 = await Note.load(note1.id);
|
||||||
|
// Check that properties match the original items. Also check
|
||||||
|
// the encryption did not affect the updated_time timestamp.
|
||||||
|
expect(note1_2.title).toBe(note1.title);
|
||||||
|
expect(note1_2.body).toBe(note1.body);
|
||||||
|
expect(note1_2.updated_time).toBe(note1.updated_time);
|
||||||
|
expect(!note1_2.encryption_cipher_text).toBe(true);
|
||||||
|
expect(folder1_2.title).toBe(folder1.title);
|
||||||
|
expect(folder1_2.updated_time).toBe(folder1.updated_time);
|
||||||
|
expect(!folder1_2.encryption_cipher_text).toBe(true);
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: test tags
|
||||||
|
// TODO: test resources
|
||||||
|
|
||||||
});
|
});
|
|
@ -9,6 +9,7 @@ const { Tag } = require('lib/models/tag.js');
|
||||||
const { NoteTag } = require('lib/models/note-tag.js');
|
const { NoteTag } = require('lib/models/note-tag.js');
|
||||||
const { Logger } = require('lib/logger.js');
|
const { Logger } = require('lib/logger.js');
|
||||||
const { Setting } = require('lib/models/setting.js');
|
const { Setting } = require('lib/models/setting.js');
|
||||||
|
const MasterKey = require('lib/models/MasterKey');
|
||||||
const { BaseItem } = require('lib/models/base-item.js');
|
const { BaseItem } = require('lib/models/base-item.js');
|
||||||
const { Synchronizer } = require('lib/synchronizer.js');
|
const { Synchronizer } = require('lib/synchronizer.js');
|
||||||
const { FileApi } = require('lib/file-api.js');
|
const { FileApi } = require('lib/file-api.js');
|
||||||
|
@ -16,16 +17,21 @@ const { FileApiDriverMemory } = require('lib/file-api-driver-memory.js');
|
||||||
const { FileApiDriverLocal } = require('lib/file-api-driver-local.js');
|
const { FileApiDriverLocal } = require('lib/file-api-driver-local.js');
|
||||||
const { FsDriverNode } = require('lib/fs-driver-node.js');
|
const { FsDriverNode } = require('lib/fs-driver-node.js');
|
||||||
const { time } = require('lib/time-utils.js');
|
const { time } = require('lib/time-utils.js');
|
||||||
|
const { shimInit } = require('lib/shim-init-node.js');
|
||||||
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
||||||
const SyncTargetMemory = require('lib/SyncTargetMemory.js');
|
const SyncTargetMemory = require('lib/SyncTargetMemory.js');
|
||||||
const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js');
|
const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js');
|
||||||
const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js');
|
const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js');
|
||||||
|
const EncryptionService = require('lib/services/EncryptionService.js');
|
||||||
|
|
||||||
let databases_ = [];
|
let databases_ = [];
|
||||||
let synchronizers_ = [];
|
let synchronizers_ = [];
|
||||||
|
let encryptionServices_ = [];
|
||||||
let fileApi_ = null;
|
let fileApi_ = null;
|
||||||
let currentClient_ = 1;
|
let currentClient_ = 1;
|
||||||
|
|
||||||
|
shimInit();
|
||||||
|
|
||||||
const fsDriver = new FsDriverNode();
|
const fsDriver = new FsDriverNode();
|
||||||
Logger.fsDriver_ = fsDriver;
|
Logger.fsDriver_ = fsDriver;
|
||||||
Resource.fsDriver_ = fsDriver;
|
Resource.fsDriver_ = fsDriver;
|
||||||
|
@ -52,6 +58,7 @@ BaseItem.loadClass('Folder', Folder);
|
||||||
BaseItem.loadClass('Resource', Resource);
|
BaseItem.loadClass('Resource', Resource);
|
||||||
BaseItem.loadClass('Tag', Tag);
|
BaseItem.loadClass('Tag', Tag);
|
||||||
BaseItem.loadClass('NoteTag', NoteTag);
|
BaseItem.loadClass('NoteTag', NoteTag);
|
||||||
|
BaseItem.loadClass('MasterKey', MasterKey);
|
||||||
|
|
||||||
Setting.setConstant('appId', 'net.cozic.joplin-cli');
|
Setting.setConstant('appId', 'net.cozic.joplin-cli');
|
||||||
Setting.setConstant('appType', 'cli');
|
Setting.setConstant('appType', 'cli');
|
||||||
|
@ -79,6 +86,8 @@ async function switchClient(id) {
|
||||||
BaseItem.db_ = databases_[id];
|
BaseItem.db_ = databases_[id];
|
||||||
Setting.db_ = databases_[id];
|
Setting.db_ = databases_[id];
|
||||||
|
|
||||||
|
BaseItem.encryptionService_ = encryptionServices_[id];
|
||||||
|
|
||||||
return Setting.load();
|
return Setting.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,6 +100,7 @@ function clearDatabase(id = null) {
|
||||||
'DELETE FROM resources',
|
'DELETE FROM resources',
|
||||||
'DELETE FROM tags',
|
'DELETE FROM tags',
|
||||||
'DELETE FROM note_tags',
|
'DELETE FROM note_tags',
|
||||||
|
'DELETE FROM master_keys',
|
||||||
|
|
||||||
'DELETE FROM deleted_items',
|
'DELETE FROM deleted_items',
|
||||||
'DELETE FROM sync_items',
|
'DELETE FROM sync_items',
|
||||||
|
@ -135,6 +145,10 @@ async function setupDatabaseAndSynchronizer(id = null) {
|
||||||
synchronizers_[id] = await syncTarget.synchronizer();
|
synchronizers_[id] = await syncTarget.synchronizer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!encryptionServices_[id]) {
|
||||||
|
encryptionServices_[id] = new EncryptionService();
|
||||||
|
}
|
||||||
|
|
||||||
if (syncTargetId_ == SyncTargetRegistry.nameToId('filesystem')) {
|
if (syncTargetId_ == SyncTargetRegistry.nameToId('filesystem')) {
|
||||||
fs.removeSync(syncDir)
|
fs.removeSync(syncDir)
|
||||||
fs.mkdirpSync(syncDir, 0o755);
|
fs.mkdirpSync(syncDir, 0o755);
|
||||||
|
@ -153,6 +167,22 @@ function synchronizer(id = null) {
|
||||||
return synchronizers_[id];
|
return synchronizers_[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encryptionService(id = null) {
|
||||||
|
if (id === null) id = currentClient_;
|
||||||
|
return encryptionServices_[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEncryptionMasterKey(id = null) {
|
||||||
|
const service = encryptionService(id);
|
||||||
|
|
||||||
|
let masterKey = await service.generateMasterKey('123456');
|
||||||
|
masterKey = await MasterKey.save(masterKey);
|
||||||
|
|
||||||
|
await service.loadMasterKey(masterKey, '123456', true);
|
||||||
|
|
||||||
|
return masterKey;
|
||||||
|
}
|
||||||
|
|
||||||
function fileApi() {
|
function fileApi() {
|
||||||
if (fileApi_) return fileApi_;
|
if (fileApi_) return fileApi_;
|
||||||
|
|
||||||
|
@ -185,4 +215,23 @@ function fileApi() {
|
||||||
return fileApi_;
|
return fileApi_;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId };
|
function objectsEqual(o1, o2) {
|
||||||
|
if (Object.getOwnPropertyNames(o1).length !== Object.getOwnPropertyNames(o2).length) return false;
|
||||||
|
for (let n in o1) {
|
||||||
|
if (!o1.hasOwnProperty(n)) continue;
|
||||||
|
if (o1[n] !== o2[n]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkThrowAsync(asyncFn) {
|
||||||
|
let hasThrown = false;
|
||||||
|
try {
|
||||||
|
await asyncFn();
|
||||||
|
} catch (error) {
|
||||||
|
hasThrown = true;
|
||||||
|
}
|
||||||
|
return hasThrown;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, encryptionService, loadEncryptionMasterKey };
|
|
@ -202,7 +202,7 @@ class JoplinDatabase extends Database {
|
||||||
// default value and thus might cause problems. In that case, the default value
|
// default value and thus might cause problems. In that case, the default value
|
||||||
// must be set in the synchronizer too.
|
// must be set in the synchronizer too.
|
||||||
|
|
||||||
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8];
|
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
|
||||||
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
|
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
|
||||||
// currentVersionIndex < 0 if for the case where an old version of Joplin used with a newer
|
// currentVersionIndex < 0 if for the case where an old version of Joplin used with a newer
|
||||||
|
@ -270,6 +270,15 @@ class JoplinDatabase extends Database {
|
||||||
queries.push('ALTER TABLE sync_items ADD COLUMN sync_disabled_reason TEXT NOT NULL DEFAULT ""');
|
queries.push('ALTER TABLE sync_items ADD COLUMN sync_disabled_reason TEXT NOT NULL DEFAULT ""');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetVersion == 9) {
|
||||||
|
queries.push('CREATE TABLE master_keys (id TEXT PRIMARY KEY, created_time INT NOT NULL, updated_time INT NOT NULL, encryption_method INT NOT NULL, checksum TEXT NOT NULL, content TEXT NOT NULL);');
|
||||||
|
queries.push('ALTER TABLE notes ADD COLUMN encryption_cipher_text TEXT NOT NULL DEFAULT ""');
|
||||||
|
queries.push('ALTER TABLE folders ADD COLUMN encryption_cipher_text TEXT NOT NULL DEFAULT ""');
|
||||||
|
queries.push('ALTER TABLE tags ADD COLUMN encryption_cipher_text TEXT NOT NULL DEFAULT ""');
|
||||||
|
queries.push('ALTER TABLE note_tags ADD COLUMN encryption_cipher_text TEXT NOT NULL DEFAULT ""');
|
||||||
|
queries.push('ALTER TABLE resources ADD COLUMN encryption_cipher_text TEXT NOT NULL DEFAULT ""');
|
||||||
|
}
|
||||||
|
|
||||||
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
|
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
|
||||||
await this.transactionExecBatch(queries);
|
await this.transactionExecBatch(queries);
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,16 @@ class MasterKey extends BaseItem {
|
||||||
return BaseModel.TYPE_MASTER_KEY;
|
return BaseModel.TYPE_MASTER_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static encryptionSupported() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async serialize(item, type = null, shownKeys = null) {
|
||||||
|
let fieldNames = this.fieldNames();
|
||||||
|
fieldNames.push('type_');
|
||||||
|
return super.serialize(item, 'master_key', fieldNames);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = MasterKey;
|
module.exports = MasterKey;
|
|
@ -11,6 +11,10 @@ class BaseItem extends BaseModel {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static encryptionSupported() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static loadClass(className, classRef) {
|
static loadClass(className, classRef) {
|
||||||
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
|
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
|
||||||
if (BaseItem.syncItemDefinitions_[i].className == className) {
|
if (BaseItem.syncItemDefinitions_[i].className == className) {
|
||||||
|
@ -245,6 +249,48 @@ class BaseItem extends BaseModel {
|
||||||
return temp.join("\n\n");
|
return temp.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async serializeForSync(item) {
|
||||||
|
const ItemClass = this.itemClass(item);
|
||||||
|
let serialized = await ItemClass.serialize(item);
|
||||||
|
if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported()) return serialized;
|
||||||
|
|
||||||
|
if (!BaseItem.encryptionService_) throw new Error('BaseItem.encryptionService_ is not set!!');
|
||||||
|
|
||||||
|
const cipherText = await BaseItem.encryptionService_.encryptString(serialized);
|
||||||
|
|
||||||
|
const reducedItem = Object.assign({}, item);
|
||||||
|
const keepKeys = ['id', 'title', 'parent_id', 'body', 'updated_time', 'type_'];
|
||||||
|
if ('title' in reducedItem) reducedItem.title = '';
|
||||||
|
if ('body' in reducedItem) reducedItem.body = '';
|
||||||
|
|
||||||
|
for (let n in reducedItem) {
|
||||||
|
if (!reducedItem.hasOwnProperty(n)) continue;
|
||||||
|
|
||||||
|
if (keepKeys.indexOf(n) >= 0) {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
delete reducedItem[n];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reducedItem.encryption_cipher_text = cipherText;
|
||||||
|
|
||||||
|
return ItemClass.serialize(reducedItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decrypt(item) {
|
||||||
|
if (!item.encryption_cipher_text) throw new Error('Item is not encrypted: ' + item.id);
|
||||||
|
|
||||||
|
const ItemClass = this.itemClass(item);
|
||||||
|
const plainText = await BaseItem.encryptionService_.decryptString(item.encryption_cipher_text);
|
||||||
|
|
||||||
|
// Note: decryption does not count has a change, so don't update any timestamp
|
||||||
|
const plainItem = await ItemClass.unserialize(plainText);
|
||||||
|
plainItem.updated_time = item.updated_time;
|
||||||
|
plainItem.encryption_cipher_text = '';
|
||||||
|
return ItemClass.save(plainItem, { autoTimestamp: false });
|
||||||
|
}
|
||||||
|
|
||||||
static async unserialize(content) {
|
static async unserialize(content) {
|
||||||
let lines = content.split("\n");
|
let lines = content.split("\n");
|
||||||
let output = {};
|
let output = {};
|
||||||
|
@ -447,6 +493,8 @@ class BaseItem extends BaseModel {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BaseItem.encryptionService_ = null;
|
||||||
|
|
||||||
// Also update:
|
// Also update:
|
||||||
// - itemsThatNeedSync()
|
// - itemsThatNeedSync()
|
||||||
// - syncedItems()
|
// - syncedItems()
|
||||||
|
|
|
@ -1,8 +1,53 @@
|
||||||
const { padLeft } = require('lib/string-utils.js');
|
const { padLeft } = require('lib/string-utils.js');
|
||||||
const { shim } = require('lib/shim.js');
|
const { shim } = require('lib/shim.js');
|
||||||
|
|
||||||
|
function hexPad(s, length) {
|
||||||
|
return padLeft(s, length, '0');
|
||||||
|
}
|
||||||
|
|
||||||
class EncryptionService {
|
class EncryptionService {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Note: 1 MB is very slow with Node and probably even worse on mobile. 50 KB seems to work well
|
||||||
|
// and doesn't produce too much overhead in terms of headers.
|
||||||
|
this.chunkSize_ = 50000;
|
||||||
|
this.loadedMasterKeys_ = {};
|
||||||
|
this.activeMasterKeyId_ = null;
|
||||||
|
this.defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkSize() {
|
||||||
|
return this.chunkSize_;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultEncryptionMethod() {
|
||||||
|
return this.defaultEncryptionMethod_;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveMasterKeyId(id) {
|
||||||
|
this.activeMasterKeyId_ = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeMasterKeyId() {
|
||||||
|
if (!this.activeMasterKeyId_) throw new Error('No master key is defined as active');
|
||||||
|
return this.activeMasterKeyId_;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMasterKey(model, password, makeActive = false) {
|
||||||
|
if (!model.id) throw new Error('Master key does not have an ID - save it first');
|
||||||
|
this.loadedMasterKeys_[model.id] = await this.decryptMasterKey(model, password);
|
||||||
|
if (makeActive) this.setActiveMasterKeyId(model.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
unloadMasterKey(model) {
|
||||||
|
delete this.loadedMasterKeys_[model.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedMasterKey(id) {
|
||||||
|
if (!this.loadedMasterKeys_[id]) throw new Error('Master key is not loaded: ' + id);
|
||||||
|
return this.loadedMasterKeys_[id];
|
||||||
|
}
|
||||||
|
|
||||||
fsDriver() {
|
fsDriver() {
|
||||||
if (!EncryptionService.fsDriver_) throw new Error('EncryptionService.fsDriver_ not set!');
|
if (!EncryptionService.fsDriver_) throw new Error('EncryptionService.fsDriver_ not set!');
|
||||||
return EncryptionService.fsDriver_;
|
return EncryptionService.fsDriver_;
|
||||||
|
@ -14,12 +59,24 @@ class EncryptionService {
|
||||||
return sjcl.codec.hex.fromBits(bitArray);
|
return sjcl.codec.hex.fromBits(bitArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async seedSjcl() {
|
||||||
|
throw new Error('NOT TESTED');
|
||||||
|
|
||||||
|
// Just putting this here in case it becomes needed
|
||||||
|
|
||||||
|
const sjcl = shim.sjclModule;
|
||||||
|
const randomBytes = await shim.randomBytes(1024/8);
|
||||||
|
const hexBytes = randomBytes.map((a) => { return a.toString(16) });
|
||||||
|
const hexSeed = sjcl.codec.hex.toBits(hexBytes.join(''));
|
||||||
|
sjcl.random.addEntropy(hexSeed, 1024, 'shim.randomBytes');
|
||||||
|
}
|
||||||
|
|
||||||
async generateMasterKey(password) {
|
async generateMasterKey(password) {
|
||||||
const bytes = await shim.randomBytes(256);
|
const bytes = await shim.randomBytes(256);
|
||||||
const hexaBytes = bytes.map((a) => { return a.toString(16); }).join('');
|
const hexaBytes = bytes.map((a) => { return hexPad(a.toString(16), 2); }).join('');
|
||||||
const checksum = this.sha256(hexaBytes);
|
const checksum = this.sha256(hexaBytes);
|
||||||
const encryptionMethod = EncryptionService.METHOD_SJCL_2;
|
const encryptionMethod = EncryptionService.METHOD_SJCL_2;
|
||||||
const cipherText = await this.encrypt(encryptionMethod, password, hexaBytes);
|
const cipherText = await this.encrypt_(encryptionMethod, password, hexaBytes);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -32,13 +89,13 @@ class EncryptionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async decryptMasterKey(model, password) {
|
async decryptMasterKey(model, password) {
|
||||||
const plainText = await this.decrypt(model.encryption_method, password, model.content);
|
const plainText = await this.decrypt_(model.encryption_method, password, model.content);
|
||||||
const checksum = this.sha256(plainText);
|
const checksum = this.sha256(plainText);
|
||||||
if (checksum !== model.checksum) throw new Error('Could not decrypt master key (checksum failed)');
|
if (checksum !== model.checksum) throw new Error('Could not decrypt master key (checksum failed)');
|
||||||
return plainText;
|
return plainText;
|
||||||
}
|
}
|
||||||
|
|
||||||
async encrypt(method, key, plainText) {
|
async encrypt_(method, key, plainText) {
|
||||||
const sjcl = shim.sjclModule;
|
const sjcl = shim.sjclModule;
|
||||||
|
|
||||||
if (method === EncryptionService.METHOD_SJCL) {
|
if (method === EncryptionService.METHOD_SJCL) {
|
||||||
|
@ -69,7 +126,7 @@ class EncryptionService {
|
||||||
throw new Error('Unknown encryption method: ' + method);
|
throw new Error('Unknown encryption method: ' + method);
|
||||||
}
|
}
|
||||||
|
|
||||||
async decrypt(method, key, cipherText) {
|
async decrypt_(method, key, cipherText) {
|
||||||
const sjcl = shim.sjclModule;
|
const sjcl = shim.sjclModule;
|
||||||
|
|
||||||
if (method === EncryptionService.METHOD_SJCL || method === EncryptionService.METHOD_SJCL_2) {
|
if (method === EncryptionService.METHOD_SJCL || method === EncryptionService.METHOD_SJCL_2) {
|
||||||
|
@ -79,6 +136,61 @@ class EncryptionService {
|
||||||
throw new Error('Unknown decryption method: ' + method);
|
throw new Error('Unknown decryption method: ' + method);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async encryptString(plainText) {
|
||||||
|
const method = this.defaultEncryptionMethod();
|
||||||
|
const masterKeyId = this.activeMasterKeyId();
|
||||||
|
const masterKeyPlainText = this.loadedMasterKey(masterKeyId);
|
||||||
|
|
||||||
|
const header = {
|
||||||
|
version: 1,
|
||||||
|
encryptionMethod: method,
|
||||||
|
masterKeyId: masterKeyId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cipherText = [];
|
||||||
|
|
||||||
|
cipherText.push(this.encodeHeader_(header));
|
||||||
|
|
||||||
|
let fromIndex = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const block = plainText.substr(fromIndex, this.chunkSize_);
|
||||||
|
if (!block) break;
|
||||||
|
|
||||||
|
fromIndex += block.length;
|
||||||
|
|
||||||
|
const encrypted = await this.encrypt_(method, masterKeyPlainText, block);
|
||||||
|
|
||||||
|
cipherText.push(padLeft(encrypted.length.toString(16), 6, '0'));
|
||||||
|
cipherText.push(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cipherText.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptString(cipherText) {
|
||||||
|
const header = this.decodeHeader_(cipherText);
|
||||||
|
|
||||||
|
const masterKeyPlainText = this.loadedMasterKey(header.masterKeyId);
|
||||||
|
|
||||||
|
let index = header.length;
|
||||||
|
|
||||||
|
let output = [];
|
||||||
|
|
||||||
|
while (index < cipherText.length) {
|
||||||
|
const length = parseInt(cipherText.substr(index, 6), 16);
|
||||||
|
index += 6;
|
||||||
|
if (!length) continue; // Weird but could be not completely invalid (block of size 0) so continue decrypting
|
||||||
|
const block = cipherText.substr(index, length);
|
||||||
|
index += length;
|
||||||
|
|
||||||
|
const plainText = await this.decrypt_(header.encryptionMethod, masterKeyPlainText, block);
|
||||||
|
output.push(plainText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join('');
|
||||||
|
}
|
||||||
|
|
||||||
async encryptFile(method, key, srcPath, destPath) {
|
async encryptFile(method, key, srcPath, destPath) {
|
||||||
const fsDriver = this.fsDriver();
|
const fsDriver = this.fsDriver();
|
||||||
|
|
||||||
|
@ -89,10 +201,6 @@ class EncryptionService {
|
||||||
handle = null;
|
handle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: 1 MB is very slow with Node and probably even worse on mobile. 50 KB seems to work well
|
|
||||||
// and doesn't produce too much overhead in terms of headers.
|
|
||||||
const chunkSize = 50000;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsDriver.unlink(destPath);
|
await fsDriver.unlink(destPath);
|
||||||
|
|
||||||
|
@ -101,10 +209,10 @@ class EncryptionService {
|
||||||
await fsDriver.appendFile(destPath, padLeft(EncryptionService.METHOD_SJCL.toString(16), 2, '0'), 'ascii'); // Encryption method
|
await fsDriver.appendFile(destPath, padLeft(EncryptionService.METHOD_SJCL.toString(16), 2, '0'), 'ascii'); // Encryption method
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const plainText = await fsDriver.readFileChunk(handle, chunkSize, 'base64');
|
const plainText = await fsDriver.readFileChunk(handle, this.chunkSize_, 'base64');
|
||||||
if (!plainText) break;
|
if (!plainText) break;
|
||||||
|
|
||||||
const cipherText = await this.encrypt(method, key, plainText);
|
const cipherText = await this.encrypt_(method, key, plainText);
|
||||||
|
|
||||||
await fsDriver.appendFile(destPath, padLeft(cipherText.length.toString(16), 6, '0'), 'ascii'); // Data - Length
|
await fsDriver.appendFile(destPath, padLeft(cipherText.length.toString(16), 6, '0'), 'ascii'); // Data - Length
|
||||||
await fsDriver.appendFile(destPath, cipherText, 'ascii'); // Data - Data
|
await fsDriver.appendFile(destPath, cipherText, 'ascii'); // Data - Data
|
||||||
|
@ -132,7 +240,7 @@ class EncryptionService {
|
||||||
await fsDriver.unlink(destPath);
|
await fsDriver.unlink(destPath);
|
||||||
|
|
||||||
const headerHexaBytes = await fsDriver.readFileChunk(handle, 4, 'ascii');
|
const headerHexaBytes = await fsDriver.readFileChunk(handle, 4, 'ascii');
|
||||||
const header = this.parseFileHeader_(headerHexaBytes);
|
const header = this.decodeHeader_(headerHexaBytes);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const lengthHex = await fsDriver.readFileChunk(handle, 6, 'ascii');
|
const lengthHex = await fsDriver.readFileChunk(handle, 6, 'ascii');
|
||||||
|
@ -143,7 +251,7 @@ class EncryptionService {
|
||||||
const cipherText = await fsDriver.readFileChunk(handle, length, 'ascii');
|
const cipherText = await fsDriver.readFileChunk(handle, length, 'ascii');
|
||||||
if (!cipherText) break;
|
if (!cipherText) break;
|
||||||
|
|
||||||
const plainText = await this.decrypt(header.encryptionMethod, key, cipherText);
|
const plainText = await this.decrypt_(header.encryptionMethod, key, cipherText);
|
||||||
|
|
||||||
await fsDriver.appendFile(destPath, plainText, 'base64');
|
await fsDriver.appendFile(destPath, plainText, 'base64');
|
||||||
}
|
}
|
||||||
|
@ -156,11 +264,54 @@ class EncryptionService {
|
||||||
cleanUp();
|
cleanUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
parseFileHeader_(headerHexaBytes) {
|
encodeHeader_(header) {
|
||||||
return {
|
// Sanity check
|
||||||
version: parseInt(headerHexaBytes.substr(0,2), 16),
|
if (header.masterKeyId.length !== 32) throw new Error('Invalid master key ID size: ' + header.masterKeyId);
|
||||||
encryptionMethod: parseInt(headerHexaBytes.substr(2,2), 16),
|
|
||||||
|
const output = [];
|
||||||
|
output.push(padLeft(header.version.toString(16), 2, '0'));
|
||||||
|
output.push(padLeft(header.encryptionMethod.toString(16), 2, '0'));
|
||||||
|
output.push(header.masterKeyId);
|
||||||
|
return output.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
decodeHeader_(headerHexaBytes) {
|
||||||
|
const headerTemplates = {
|
||||||
|
1: [
|
||||||
|
[ 'encryptionMethod', 2, 'int' ],
|
||||||
|
[ 'masterKeyId', 32, 'hex' ],
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const output = {};
|
||||||
|
const version = parseInt(headerHexaBytes.substr(0, 2), 16);
|
||||||
|
const template = headerTemplates[version];
|
||||||
|
|
||||||
|
if (!template) throw new Error('Invalid header version: ' + version);
|
||||||
|
|
||||||
|
output.version = version;
|
||||||
|
|
||||||
|
let index = 2;
|
||||||
|
for (let i = 0; i < template.length; i++) {
|
||||||
|
const m = template[i];
|
||||||
|
const type = m[2];
|
||||||
|
let v = headerHexaBytes.substr(index, m[1]);
|
||||||
|
|
||||||
|
if (type === 'int') {
|
||||||
|
v = parseInt(v, 16);
|
||||||
|
} else if (type === 'hex') {
|
||||||
|
// Already in hexa
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid type: ' + type);
|
||||||
|
}
|
||||||
|
|
||||||
|
index += m[1];
|
||||||
|
output[m[0]] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.length = index;
|
||||||
|
|
||||||
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -204,7 +204,7 @@ class Synchronizer {
|
||||||
if (donePaths.indexOf(path) > 0) throw new Error(sprintf('Processing a path that has already been done: %s. sync_time was not updated?', path));
|
if (donePaths.indexOf(path) > 0) throw new Error(sprintf('Processing a path that has already been done: %s. sync_time was not updated?', path));
|
||||||
|
|
||||||
let remote = await this.api().stat(path);
|
let remote = await this.api().stat(path);
|
||||||
let content = await ItemClass.serialize(local);
|
let content = await ItemClass.serializeForSync(local);
|
||||||
let action = null;
|
let action = null;
|
||||||
let updateSyncTimeOnly = true;
|
let updateSyncTimeOnly = true;
|
||||||
let reason = '';
|
let reason = '';
|
||||||
|
|
Loading…
Reference in New Issue