You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-07-13 00:10:37 +02:00
All: Add new encryption methods based on native crypto libraries (#10696)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com> Co-authored-by: Henry Heino <personalizedrefrigerator@gmail.com>
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
import { MasterKeyEntity } from './types';
|
||||
import { CipherAlgorithm, Digest, MasterKeyEntity } from './types';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import shim from '../../shim';
|
||||
import Setting from '../../models/Setting';
|
||||
@ -10,6 +10,8 @@ const { padLeft } = require('../../string-utils.js');
|
||||
|
||||
const logger = Logger.create('EncryptionService');
|
||||
|
||||
const emptyUint8Array = new Uint8Array(0);
|
||||
|
||||
function hexPad(s: string, length: number) {
|
||||
return padLeft(s, length, '0');
|
||||
}
|
||||
@ -42,6 +44,9 @@ export enum EncryptionMethod {
|
||||
SJCL1a = 5,
|
||||
Custom = 6,
|
||||
SJCL1b = 7,
|
||||
KeyV1 = 8,
|
||||
FileV1 = 9,
|
||||
StringV1 = 10,
|
||||
}
|
||||
|
||||
export interface EncryptOptions {
|
||||
@ -65,24 +70,13 @@ export default class EncryptionService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public static fsDriver_: any = null;
|
||||
|
||||
// Note: 1 MB is very slow with Node and probably even worse on mobile.
|
||||
//
|
||||
// On mobile the time it takes to decrypt increases exponentially for some reason, so it's important
|
||||
// to have a relatively small size so as not to freeze the app. For example, on Android 7.1 simulator
|
||||
// with 4.1 GB RAM, it takes this much to decrypt a block;
|
||||
//
|
||||
// 50KB => 1000 ms
|
||||
// 25KB => 250ms
|
||||
// 10KB => 200ms
|
||||
// 5KB => 10ms
|
||||
//
|
||||
// So making the block 10 times smaller make it 100 times faster! So for now using 5KB. This can be
|
||||
// changed easily since the chunk size is incorporated into the encrypted data.
|
||||
private chunkSize_ = 5000;
|
||||
private encryptedMasterKeys_: Map<string, EncryptedMasterKey> = new Map();
|
||||
private decryptedMasterKeys_: Map<string, DecryptedMasterKey> = new Map();
|
||||
public defaultEncryptionMethod_ = EncryptionMethod.SJCL1a; // public because used in tests
|
||||
private defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
|
||||
public defaultEncryptionMethod_ = Setting.value('featureFlag.useBetaEncryptionMethod') ? EncryptionMethod.StringV1 : EncryptionMethod.SJCL1a; // public because used in tests
|
||||
public defaultFileEncryptionMethod_ = Setting.value('featureFlag.useBetaEncryptionMethod') ? EncryptionMethod.FileV1 : EncryptionMethod.SJCL1a; // public because used in tests
|
||||
private defaultMasterKeyEncryptionMethod_ = Setting.value('featureFlag.useBetaEncryptionMethod') ? EncryptionMethod.KeyV1 : EncryptionMethod.SJCL4;
|
||||
|
||||
private encryptionNonce_: Uint8Array = null;
|
||||
|
||||
private headerTemplates_ = {
|
||||
// Template version 1
|
||||
@ -92,6 +86,15 @@ export default class EncryptionService {
|
||||
},
|
||||
};
|
||||
|
||||
public constructor() {
|
||||
const crypto = shim.crypto;
|
||||
crypto.generateNonce(new Uint8Array(36))
|
||||
// eslint-disable-next-line promise/prefer-await-to-then
|
||||
.then((nonce) => this.encryptionNonce_ = nonce)
|
||||
// eslint-disable-next-line promise/prefer-await-to-then
|
||||
.catch((error) => logger.error(error));
|
||||
}
|
||||
|
||||
public static instance() {
|
||||
if (this.instance_) return this.instance_;
|
||||
this.instance_ = new EncryptionService();
|
||||
@ -106,14 +109,47 @@ export default class EncryptionService {
|
||||
return this.loadedMasterKeyIds().length;
|
||||
}
|
||||
|
||||
public chunkSize() {
|
||||
return this.chunkSize_;
|
||||
// Note for methods using SJCL:
|
||||
//
|
||||
// 1 MB is very slow with Node and probably even worse on mobile.
|
||||
//
|
||||
// On mobile the time it takes to decrypt increases exponentially for some reason, so it's important
|
||||
// to have a relatively small size so as not to freeze the app. For example, on Android 7.1 simulator
|
||||
// with 4.1 GB RAM, it takes this much to decrypt a block;
|
||||
//
|
||||
// 50KB => 1000 ms
|
||||
// 25KB => 250ms
|
||||
// 10KB => 200ms
|
||||
// 5KB => 10ms
|
||||
//
|
||||
// So making the block 10 times smaller make it 100 times faster! So for now using 5KB. This can be
|
||||
// changed easily since the chunk size is incorporated into the encrypted data.
|
||||
public chunkSize(method: EncryptionMethod) {
|
||||
type EncryptionMethodChunkSizeMap = Record<EncryptionMethod, number>;
|
||||
const encryptionMethodChunkSizeMap: EncryptionMethodChunkSizeMap = {
|
||||
[EncryptionMethod.SJCL]: 5000,
|
||||
[EncryptionMethod.SJCL1a]: 5000,
|
||||
[EncryptionMethod.SJCL1b]: 5000,
|
||||
[EncryptionMethod.SJCL2]: 5000,
|
||||
[EncryptionMethod.SJCL3]: 5000,
|
||||
[EncryptionMethod.SJCL4]: 5000,
|
||||
[EncryptionMethod.Custom]: 5000,
|
||||
[EncryptionMethod.KeyV1]: 5000, // Master key is not encrypted by chunks so this value will not be used.
|
||||
[EncryptionMethod.FileV1]: 131072, // 128k
|
||||
[EncryptionMethod.StringV1]: 65536, // 64k
|
||||
};
|
||||
|
||||
return encryptionMethodChunkSizeMap[method];
|
||||
}
|
||||
|
||||
public defaultEncryptionMethod() {
|
||||
return this.defaultEncryptionMethod_;
|
||||
}
|
||||
|
||||
public defaultFileEncryptionMethod() {
|
||||
return this.defaultFileEncryptionMethod_;
|
||||
}
|
||||
|
||||
public setActiveMasterKeyId(id: string) {
|
||||
setActiveMasterKeyId(id);
|
||||
}
|
||||
@ -322,8 +358,10 @@ export default class EncryptionService {
|
||||
if (!key) throw new Error('Encryption key is required');
|
||||
|
||||
const sjcl = shim.sjclModule;
|
||||
const crypto = shim.crypto;
|
||||
|
||||
const handlers: Record<EncryptionMethod, ()=> string> = {
|
||||
type EncryptionMethodHandler = (()=> Promise<string>);
|
||||
const handlers: Record<EncryptionMethod, EncryptionMethodHandler> = {
|
||||
// 2020-01-23: Deprecated and no longer secure due to the use og OCB2 mode - do not use.
|
||||
[EncryptionMethod.SJCL]: () => {
|
||||
try {
|
||||
@ -438,6 +476,47 @@ export default class EncryptionService {
|
||||
}
|
||||
},
|
||||
|
||||
// New encryption method powered by native crypto libraries(node:crypto/react-native-quick-crypto). Using AES-256-GCM and pbkdf2
|
||||
// The master key is not directly used. A new data key is generated from the master key and a 256 bits random salt to prevent nonce reuse problem
|
||||
// 2024-08: Set iteration count in pbkdf2 to 220000 as suggested by OWASP. https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
|
||||
[EncryptionMethod.KeyV1]: async () => {
|
||||
return JSON.stringify(await crypto.encryptString(key, await crypto.digest(Digest.sha256, this.encryptionNonce_), plainText, 'hex', {
|
||||
cipherAlgorithm: CipherAlgorithm.AES_256_GCM,
|
||||
authTagLength: 16,
|
||||
digestAlgorithm: Digest.sha512,
|
||||
keyLength: 32,
|
||||
associatedData: emptyUint8Array,
|
||||
iterationCount: 220000,
|
||||
}));
|
||||
},
|
||||
|
||||
// New encryption method powered by native crypto libraries(node:crypto/react-native-quick-crypto). Using AES-256-GCM and pbkdf2
|
||||
// The master key is not directly used. A new data key is generated from the master key and a 256 bits random salt to prevent nonce reuse problem
|
||||
// The file content is base64 encoded. Decoding it before encryption to reduce the size overhead.
|
||||
[EncryptionMethod.FileV1]: async () => {
|
||||
return JSON.stringify(await crypto.encryptString(key, await crypto.digest(Digest.sha256, this.encryptionNonce_), plainText, 'base64', {
|
||||
cipherAlgorithm: CipherAlgorithm.AES_256_GCM,
|
||||
authTagLength: 16,
|
||||
digestAlgorithm: Digest.sha512,
|
||||
keyLength: 32,
|
||||
associatedData: emptyUint8Array,
|
||||
iterationCount: 3,
|
||||
}));
|
||||
},
|
||||
|
||||
// New encryption method powered by native crypto libraries(node:crypto/react-native-quick-crypto). Using AES-256-GCM and pbkdf2
|
||||
// The master key is not directly used. A new data key is generated from the master key and a 256 bits random salt to prevent nonce reuse problem
|
||||
[EncryptionMethod.StringV1]: async () => {
|
||||
return JSON.stringify(await crypto.encryptString(key, await crypto.digest(Digest.sha256, this.encryptionNonce_), plainText, 'utf16le', {
|
||||
cipherAlgorithm: CipherAlgorithm.AES_256_GCM,
|
||||
authTagLength: 16,
|
||||
digestAlgorithm: Digest.sha512,
|
||||
keyLength: 32,
|
||||
associatedData: emptyUint8Array,
|
||||
iterationCount: 3,
|
||||
}));
|
||||
},
|
||||
|
||||
[EncryptionMethod.Custom]: () => {
|
||||
// This is handled elsewhere but as a sanity check, throw an exception
|
||||
throw new Error('Custom encryption method is not supported here');
|
||||
@ -452,19 +531,49 @@ export default class EncryptionService {
|
||||
if (!key) throw new Error('Encryption key is required');
|
||||
|
||||
const sjcl = shim.sjclModule;
|
||||
if (!this.isValidEncryptionMethod(method)) throw new Error(`Unknown decryption method: ${method}`);
|
||||
const crypto = shim.crypto;
|
||||
if (method === EncryptionMethod.KeyV1) {
|
||||
return (await crypto.decrypt(key, JSON.parse(cipherText), {
|
||||
cipherAlgorithm: CipherAlgorithm.AES_256_GCM,
|
||||
authTagLength: 16,
|
||||
digestAlgorithm: Digest.sha512,
|
||||
keyLength: 32,
|
||||
associatedData: emptyUint8Array,
|
||||
iterationCount: 220000,
|
||||
})).toString('hex');
|
||||
} else if (method === EncryptionMethod.FileV1) {
|
||||
return (await crypto.decrypt(key, JSON.parse(cipherText), {
|
||||
cipherAlgorithm: CipherAlgorithm.AES_256_GCM,
|
||||
authTagLength: 16,
|
||||
digestAlgorithm: Digest.sha512,
|
||||
keyLength: 32,
|
||||
associatedData: emptyUint8Array,
|
||||
iterationCount: 3,
|
||||
})).toString('base64');
|
||||
} else if (method === EncryptionMethod.StringV1) {
|
||||
return (await crypto.decrypt(key, JSON.parse(cipherText), {
|
||||
cipherAlgorithm: CipherAlgorithm.AES_256_GCM,
|
||||
authTagLength: 16,
|
||||
digestAlgorithm: Digest.sha512,
|
||||
keyLength: 32,
|
||||
associatedData: emptyUint8Array,
|
||||
iterationCount: 3,
|
||||
})).toString('utf16le');
|
||||
} else if (this.isValidSjclEncryptionMethod(method)) {
|
||||
try {
|
||||
const output = sjcl.json.decrypt(key, cipherText);
|
||||
|
||||
try {
|
||||
const output = sjcl.json.decrypt(key, cipherText);
|
||||
|
||||
if (method === EncryptionMethod.SJCL1a || method === EncryptionMethod.SJCL1b) {
|
||||
return unescape(output);
|
||||
} else {
|
||||
return output;
|
||||
if (method === EncryptionMethod.SJCL1a || method === EncryptionMethod.SJCL1b) {
|
||||
return unescape(output);
|
||||
} else {
|
||||
return output;
|
||||
}
|
||||
} catch (error) {
|
||||
// SJCL returns a string as error which means stack trace is missing so convert to an error object here
|
||||
throw new Error(error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
// SJCL returns a string as error which means stack trace is missing so convert to an error object here
|
||||
throw new Error(error.message);
|
||||
} else {
|
||||
throw new Error(`Unknown decryption method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -475,6 +584,8 @@ export default class EncryptionService {
|
||||
const method = options.encryptionMethod;
|
||||
const masterKeyId = options.masterKeyId ? options.masterKeyId : this.activeMasterKeyId();
|
||||
const masterKeyPlainText = (await this.loadedMasterKey(masterKeyId)).plainText;
|
||||
const chunkSize = this.chunkSize(method);
|
||||
const crypto = shim.crypto;
|
||||
|
||||
const header = {
|
||||
encryptionMethod: method,
|
||||
@ -486,10 +597,10 @@ export default class EncryptionService {
|
||||
let doneSize = 0;
|
||||
|
||||
while (true) {
|
||||
const block = await source.read(this.chunkSize_);
|
||||
const block = await source.read(chunkSize);
|
||||
if (!block) break;
|
||||
|
||||
doneSize += this.chunkSize_;
|
||||
doneSize += chunkSize;
|
||||
if (options.onProgress) options.onProgress({ doneSize: doneSize });
|
||||
|
||||
// Wait for a frame so that the app remains responsive in mobile.
|
||||
@ -497,6 +608,7 @@ export default class EncryptionService {
|
||||
await shim.waitForFrame();
|
||||
|
||||
const encrypted = await this.encrypt(method, masterKeyPlainText, block);
|
||||
await crypto.increaseNonce(this.encryptionNonce_);
|
||||
|
||||
await destination.append(padLeft(encrypted.length.toString(16), 6, '0'));
|
||||
await destination.append(encrypted);
|
||||
@ -604,6 +716,8 @@ export default class EncryptionService {
|
||||
}
|
||||
|
||||
public async encryptFile(srcPath: string, destPath: string, options: EncryptOptions = null) {
|
||||
options = { encryptionMethod: this.defaultFileEncryptionMethod(), ...options };
|
||||
|
||||
let source = await this.fileReader_(srcPath, 'base64');
|
||||
let destination = await this.fileWriter_(destPath, 'ascii');
|
||||
|
||||
@ -724,7 +838,7 @@ export default class EncryptionService {
|
||||
return output;
|
||||
}
|
||||
|
||||
public isValidEncryptionMethod(method: EncryptionMethod) {
|
||||
private isValidSjclEncryptionMethod(method: EncryptionMethod) {
|
||||
return [EncryptionMethod.SJCL, EncryptionMethod.SJCL1a, EncryptionMethod.SJCL1b, EncryptionMethod.SJCL2, EncryptionMethod.SJCL3, EncryptionMethod.SJCL4].indexOf(method) >= 0;
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user