mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
All: Support encrypting notes and notebooks
This commit is contained in:
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/"
|
||||
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)
|
163
CliClient/tests/encryption.js
Normal file
163
CliClient/tests/encryption.js
Normal file
@ -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);
|
||||
|
||||
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 { 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 MasterKey = require('lib/models/MasterKey');
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { BaseModel } = require('lib/base-model.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 });
|
||||
const noteId = note1.id;
|
||||
await synchronizer().start();
|
||||
let disabledItems = await BaseItem.syncDisabledItems();
|
||||
let disabledItems = await BaseItem.syncDisabledItems(syncTargetId());
|
||||
expect(disabledItems.length).toBe(0);
|
||||
await Note.save({ id: noteId, title: "un mod", });
|
||||
synchronizer().debugFlags_ = ['cannotSync'];
|
||||
@ -651,10 +652,57 @@ describe('Synchronizer', function() {
|
||||
|
||||
await switchClient(1);
|
||||
|
||||
disabledItems = await BaseItem.syncDisabledItems();
|
||||
disabledItems = await BaseItem.syncDisabledItems(syncTargetId());
|
||||
expect(disabledItems.length).toBe(1);
|
||||
|
||||
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 { Logger } = require('lib/logger.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const MasterKey = require('lib/models/MasterKey');
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { Synchronizer } = require('lib/synchronizer.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 { FsDriverNode } = require('lib/fs-driver-node.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { shimInit } = require('lib/shim-init-node.js');
|
||||
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
||||
const SyncTargetMemory = require('lib/SyncTargetMemory.js');
|
||||
const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js');
|
||||
const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js');
|
||||
const EncryptionService = require('lib/services/EncryptionService.js');
|
||||
|
||||
let databases_ = [];
|
||||
let synchronizers_ = [];
|
||||
let encryptionServices_ = [];
|
||||
let fileApi_ = null;
|
||||
let currentClient_ = 1;
|
||||
|
||||
shimInit();
|
||||
|
||||
const fsDriver = new FsDriverNode();
|
||||
Logger.fsDriver_ = fsDriver;
|
||||
Resource.fsDriver_ = fsDriver;
|
||||
@ -52,6 +58,7 @@ BaseItem.loadClass('Folder', Folder);
|
||||
BaseItem.loadClass('Resource', Resource);
|
||||
BaseItem.loadClass('Tag', Tag);
|
||||
BaseItem.loadClass('NoteTag', NoteTag);
|
||||
BaseItem.loadClass('MasterKey', MasterKey);
|
||||
|
||||
Setting.setConstant('appId', 'net.cozic.joplin-cli');
|
||||
Setting.setConstant('appType', 'cli');
|
||||
@ -79,6 +86,8 @@ async function switchClient(id) {
|
||||
BaseItem.db_ = databases_[id];
|
||||
Setting.db_ = databases_[id];
|
||||
|
||||
BaseItem.encryptionService_ = encryptionServices_[id];
|
||||
|
||||
return Setting.load();
|
||||
}
|
||||
|
||||
@ -91,6 +100,7 @@ function clearDatabase(id = null) {
|
||||
'DELETE FROM resources',
|
||||
'DELETE FROM tags',
|
||||
'DELETE FROM note_tags',
|
||||
'DELETE FROM master_keys',
|
||||
|
||||
'DELETE FROM deleted_items',
|
||||
'DELETE FROM sync_items',
|
||||
@ -135,6 +145,10 @@ async function setupDatabaseAndSynchronizer(id = null) {
|
||||
synchronizers_[id] = await syncTarget.synchronizer();
|
||||
}
|
||||
|
||||
if (!encryptionServices_[id]) {
|
||||
encryptionServices_[id] = new EncryptionService();
|
||||
}
|
||||
|
||||
if (syncTargetId_ == SyncTargetRegistry.nameToId('filesystem')) {
|
||||
fs.removeSync(syncDir)
|
||||
fs.mkdirpSync(syncDir, 0o755);
|
||||
@ -153,6 +167,22 @@ function synchronizer(id = null) {
|
||||
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() {
|
||||
if (fileApi_) return fileApi_;
|
||||
|
||||
@ -185,4 +215,23 @@ function 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
|
||||
// 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);
|
||||
// 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 ""');
|
||||
}
|
||||
|
||||
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] });
|
||||
await this.transactionExecBatch(queries);
|
||||
|
||||
|
@ -11,6 +11,16 @@ class MasterKey extends BaseItem {
|
||||
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;
|
@ -11,6 +11,10 @@ class BaseItem extends BaseModel {
|
||||
return true;
|
||||
}
|
||||
|
||||
static encryptionSupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static loadClass(className, classRef) {
|
||||
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
|
||||
if (BaseItem.syncItemDefinitions_[i].className == className) {
|
||||
@ -245,6 +249,48 @@ class BaseItem extends BaseModel {
|
||||
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) {
|
||||
let lines = content.split("\n");
|
||||
let output = {};
|
||||
@ -447,6 +493,8 @@ class BaseItem extends BaseModel {
|
||||
|
||||
}
|
||||
|
||||
BaseItem.encryptionService_ = null;
|
||||
|
||||
// Also update:
|
||||
// - itemsThatNeedSync()
|
||||
// - syncedItems()
|
||||
|
@ -1,8 +1,53 @@
|
||||
const { padLeft } = require('lib/string-utils.js');
|
||||
const { shim } = require('lib/shim.js');
|
||||
|
||||
function hexPad(s, length) {
|
||||
return padLeft(s, length, '0');
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!EncryptionService.fsDriver_) throw new Error('EncryptionService.fsDriver_ not set!');
|
||||
return EncryptionService.fsDriver_;
|
||||
@ -14,12 +59,24 @@ class EncryptionService {
|
||||
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) {
|
||||
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 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();
|
||||
|
||||
return {
|
||||
@ -32,13 +89,13 @@ class EncryptionService {
|
||||
}
|
||||
|
||||
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);
|
||||
if (checksum !== model.checksum) throw new Error('Could not decrypt master key (checksum failed)');
|
||||
return plainText;
|
||||
}
|
||||
|
||||
async encrypt(method, key, plainText) {
|
||||
async encrypt_(method, key, plainText) {
|
||||
const sjcl = shim.sjclModule;
|
||||
|
||||
if (method === EncryptionService.METHOD_SJCL) {
|
||||
@ -69,7 +126,7 @@ class EncryptionService {
|
||||
throw new Error('Unknown encryption method: ' + method);
|
||||
}
|
||||
|
||||
async decrypt(method, key, cipherText) {
|
||||
async decrypt_(method, key, cipherText) {
|
||||
const sjcl = shim.sjclModule;
|
||||
|
||||
if (method === EncryptionService.METHOD_SJCL || method === EncryptionService.METHOD_SJCL_2) {
|
||||
@ -79,6 +136,61 @@ class EncryptionService {
|
||||
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) {
|
||||
const fsDriver = this.fsDriver();
|
||||
|
||||
@ -89,10 +201,6 @@ class EncryptionService {
|
||||
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 {
|
||||
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
|
||||
|
||||
while (true) {
|
||||
const plainText = await fsDriver.readFileChunk(handle, chunkSize, 'base64');
|
||||
const plainText = await fsDriver.readFileChunk(handle, this.chunkSize_, 'base64');
|
||||
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, cipherText, 'ascii'); // Data - Data
|
||||
@ -132,7 +240,7 @@ class EncryptionService {
|
||||
await fsDriver.unlink(destPath);
|
||||
|
||||
const headerHexaBytes = await fsDriver.readFileChunk(handle, 4, 'ascii');
|
||||
const header = this.parseFileHeader_(headerHexaBytes);
|
||||
const header = this.decodeHeader_(headerHexaBytes);
|
||||
|
||||
while (true) {
|
||||
const lengthHex = await fsDriver.readFileChunk(handle, 6, 'ascii');
|
||||
@ -143,7 +251,7 @@ class EncryptionService {
|
||||
const cipherText = await fsDriver.readFileChunk(handle, length, 'ascii');
|
||||
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');
|
||||
}
|
||||
@ -156,11 +264,54 @@ class EncryptionService {
|
||||
cleanUp();
|
||||
}
|
||||
|
||||
parseFileHeader_(headerHexaBytes) {
|
||||
return {
|
||||
version: parseInt(headerHexaBytes.substr(0,2), 16),
|
||||
encryptionMethod: parseInt(headerHexaBytes.substr(2,2), 16),
|
||||
encodeHeader_(header) {
|
||||
// Sanity check
|
||||
if (header.masterKeyId.length !== 32) throw new Error('Invalid master key ID size: ' + header.masterKeyId);
|
||||
|
||||
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));
|
||||
|
||||
let remote = await this.api().stat(path);
|
||||
let content = await ItemClass.serialize(local);
|
||||
let content = await ItemClass.serializeForSync(local);
|
||||
let action = null;
|
||||
let updateSyncTimeOnly = true;
|
||||
let reason = '';
|
||||
|
Loading…
Reference in New Issue
Block a user