1
0
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:
Self Not Found
2024-10-27 04:15:10 +08:00
committed by GitHub
parent bed5297829
commit aa6348c5c2
23 changed files with 1064 additions and 59 deletions

View File

@ -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;
}