You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +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:
		| @@ -738,6 +738,7 @@ packages/app-mobile/services/AlarmServiceDriver.ios.js | ||||
| packages/app-mobile/services/AlarmServiceDriver.web.js | ||||
| packages/app-mobile/services/BackButtonService.js | ||||
| packages/app-mobile/services/e2ee/RSA.react-native.js | ||||
| packages/app-mobile/services/e2ee/crypto.js | ||||
| packages/app-mobile/services/plugins/PlatformImplementation.js | ||||
| packages/app-mobile/services/profiles/index.js | ||||
| packages/app-mobile/services/voiceTyping/VoiceTyping.js | ||||
| @@ -1109,6 +1110,10 @@ packages/lib/services/debug/populateDatabase.js | ||||
| packages/lib/services/e2ee/EncryptionService.test.js | ||||
| packages/lib/services/e2ee/EncryptionService.js | ||||
| packages/lib/services/e2ee/RSA.node.js | ||||
| packages/lib/services/e2ee/crypto.test.js | ||||
| packages/lib/services/e2ee/crypto.js | ||||
| packages/lib/services/e2ee/cryptoShared.js | ||||
| packages/lib/services/e2ee/cryptoTestUtils.js | ||||
| packages/lib/services/e2ee/ppk.test.js | ||||
| packages/lib/services/e2ee/ppk.js | ||||
| packages/lib/services/e2ee/ppkTestUtils.js | ||||
|   | ||||
| @@ -287,6 +287,14 @@ module.exports = { | ||||
| 							'match': true, | ||||
| 						}, | ||||
| 					}, | ||||
| 					{ | ||||
| 						selector: 'enumMember', | ||||
| 						format: null, | ||||
| 						'filter': { | ||||
| 							'regex': '^(sha1|sha256|sha384|sha512|AES_128_GCM|AES_192_GCM|AES_256_GCM)$', | ||||
| 							'match': true, | ||||
| 						}, | ||||
| 					}, | ||||
|  | ||||
| 					// ----------------------------------- | ||||
| 					// INTERFACE | ||||
|   | ||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -715,6 +715,7 @@ packages/app-mobile/services/AlarmServiceDriver.ios.js | ||||
| packages/app-mobile/services/AlarmServiceDriver.web.js | ||||
| packages/app-mobile/services/BackButtonService.js | ||||
| packages/app-mobile/services/e2ee/RSA.react-native.js | ||||
| packages/app-mobile/services/e2ee/crypto.js | ||||
| packages/app-mobile/services/plugins/PlatformImplementation.js | ||||
| packages/app-mobile/services/profiles/index.js | ||||
| packages/app-mobile/services/voiceTyping/VoiceTyping.js | ||||
| @@ -1086,6 +1087,10 @@ packages/lib/services/debug/populateDatabase.js | ||||
| packages/lib/services/e2ee/EncryptionService.test.js | ||||
| packages/lib/services/e2ee/EncryptionService.js | ||||
| packages/lib/services/e2ee/RSA.node.js | ||||
| packages/lib/services/e2ee/crypto.test.js | ||||
| packages/lib/services/e2ee/crypto.js | ||||
| packages/lib/services/e2ee/cryptoShared.js | ||||
| packages/lib/services/e2ee/cryptoTestUtils.js | ||||
| packages/lib/services/e2ee/ppk.test.js | ||||
| packages/lib/services/e2ee/ppk.js | ||||
| packages/lib/services/e2ee/ppkTestUtils.js | ||||
|   | ||||
| @@ -62,6 +62,7 @@ | ||||
|     "react-native-paper": "5.12.3", | ||||
|     "react-native-popup-menu": "0.16.1", | ||||
|     "react-native-quick-actions": "0.3.13", | ||||
|     "react-native-quick-crypto": "0.7.1", | ||||
|     "react-native-rsa-native": "2.0.5", | ||||
|     "react-native-safe-area-context": "4.10.8", | ||||
|     "react-native-securerandom": "1.0.1", | ||||
|   | ||||
| @@ -110,6 +110,7 @@ import { loadMasterKeysFromSettings, migrateMasterPassword } from '@joplin/lib/s | ||||
| import { setRSA } from '@joplin/lib/services/e2ee/ppk'; | ||||
| import RSA from './services/e2ee/RSA.react-native'; | ||||
| import { runIntegrationTests as runRsaIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils'; | ||||
| import { runIntegrationTests as runCryptoIntegrationTests } from '@joplin/lib/services/e2ee/cryptoTestUtils'; | ||||
| import { Theme, ThemeAppearance } from '@joplin/lib/themes/type'; | ||||
| import ProfileSwitcher from './components/ProfileSwitcher/ProfileSwitcher'; | ||||
| import ProfileEditor from './components/ProfileSwitcher/ProfileEditor'; | ||||
| @@ -818,8 +819,9 @@ async function initialize(dispatch: Dispatch) { | ||||
| 		if (Platform.OS !== 'web') { | ||||
| 			await runRsaIntegrationTests(); | ||||
| 		} else { | ||||
| 			logger.info('Skipping RSA tests -- not supported on mobile.'); | ||||
| 			logger.info('Skipping encryption tests -- not supported on web.'); | ||||
| 		} | ||||
| 		await runCryptoIntegrationTests(); | ||||
| 		await runOnDeviceFsDriverTests(); | ||||
| 	} | ||||
|  | ||||
|   | ||||
							
								
								
									
										123
									
								
								packages/app-mobile/services/e2ee/crypto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								packages/app-mobile/services/e2ee/crypto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | ||||
| import { Crypto, CryptoBuffer, Digest, CipherAlgorithm, EncryptionResult, EncryptionParameters } from '@joplin/lib/services/e2ee/types'; | ||||
| import QuickCrypto from 'react-native-quick-crypto'; | ||||
| import { HashAlgorithm } from 'react-native-quick-crypto/lib/typescript/keys'; | ||||
| import type { CipherGCMOptions, CipherGCM, DecipherGCM } from 'crypto'; | ||||
| import { | ||||
| 	generateNonce as generateNonceShared, | ||||
| 	increaseNonce as increaseNonceShared, | ||||
| 	setRandomBytesImplementation, | ||||
| } from '@joplin/lib/services/e2ee/cryptoShared'; | ||||
|  | ||||
| type DigestNameMap = Record<Digest, string>; | ||||
| const digestNameMap: DigestNameMap = { | ||||
| 	[Digest.sha1]: 'sha1', | ||||
| 	[Digest.sha256]: 'sha256', | ||||
| 	[Digest.sha384]: 'sha384', | ||||
| 	[Digest.sha512]: 'sha512', | ||||
| }; | ||||
|  | ||||
| const pbkdf2Raw = (password: string, salt: CryptoBuffer, iterations: number, keylen: number, digest: Digest): Promise<CryptoBuffer> => { | ||||
| 	return new Promise((resolve, reject) => { | ||||
| 		QuickCrypto.pbkdf2(password, salt, iterations, keylen, digest as HashAlgorithm, (error, result) => { | ||||
| 			if (error) { | ||||
| 				reject(error); | ||||
| 			} else { | ||||
| 				resolve(result); | ||||
| 			} | ||||
| 		}); | ||||
| 	}); | ||||
| }; | ||||
|  | ||||
| const encryptRaw = (data: CryptoBuffer, algorithm: CipherAlgorithm, key: CryptoBuffer, iv: CryptoBuffer, authTagLength: number, associatedData: CryptoBuffer) => { | ||||
|  | ||||
| 	const cipher = QuickCrypto.createCipheriv(algorithm, key, iv, { authTagLength: authTagLength } as CipherGCMOptions) as CipherGCM; | ||||
|  | ||||
| 	cipher.setAAD(associatedData, { plaintextLength: Buffer.byteLength(data) }); | ||||
|  | ||||
| 	const encryptedData = [cipher.update(data), cipher.final()]; | ||||
| 	const authTag = cipher.getAuthTag(); | ||||
|  | ||||
| 	return Buffer.concat([encryptedData[0], encryptedData[1], authTag]); | ||||
| }; | ||||
|  | ||||
| const decryptRaw = (data: CryptoBuffer, algorithm: CipherAlgorithm, key: CryptoBuffer, iv: CryptoBuffer, authTagLength: number, associatedData: CryptoBuffer) => { | ||||
|  | ||||
| 	const decipher = QuickCrypto.createDecipheriv(algorithm, key, iv, { authTagLength: authTagLength } as CipherGCMOptions) as DecipherGCM; | ||||
|  | ||||
| 	const authTag = data.subarray(-authTagLength); | ||||
| 	const encryptedData = data.subarray(0, data.byteLength - authTag.byteLength); | ||||
| 	decipher.setAuthTag(authTag); | ||||
| 	decipher.setAAD(associatedData, { plaintextLength: Buffer.byteLength(data) }); | ||||
|  | ||||
| 	try { | ||||
| 		return Buffer.concat([decipher.update(encryptedData), decipher.final()]); | ||||
| 	} catch (error) { | ||||
| 		throw new Error(`Authentication failed! ${error}`); | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| const crypto: Crypto = { | ||||
|  | ||||
| 	randomBytes: async (size: number) => { | ||||
| 		return new Promise((resolve, reject) => { | ||||
| 			QuickCrypto.randomBytes(size, (error, result) => { | ||||
| 				if (error) { | ||||
| 					reject(error); | ||||
| 				} else { | ||||
| 					resolve(result); | ||||
| 				} | ||||
| 			}); | ||||
| 		}); | ||||
| 	}, | ||||
|  | ||||
| 	digest: async (algorithm: Digest, data: Uint8Array) => { | ||||
| 		const hash = QuickCrypto.createHash(digestNameMap[algorithm]); | ||||
| 		hash.update(data); | ||||
| 		return hash.digest(); | ||||
| 	}, | ||||
|  | ||||
| 	encrypt: async (password: string, salt: CryptoBuffer, data: CryptoBuffer, encryptionParameters: EncryptionParameters) => { | ||||
|  | ||||
| 		// Parameters in EncryptionParameters won't appear in result | ||||
| 		const result: EncryptionResult = { | ||||
| 			salt: salt.toString('base64'), | ||||
| 			iv: '', | ||||
| 			ct: '', // cipherText | ||||
| 		}; | ||||
|  | ||||
| 		// 96 bits IV | ||||
| 		// "For IVs, it is recommended that implementations restrict support to the length of 96 bits, to promote interoperability, efficiency, and simplicity of design." - NIST SP 800-38D | ||||
| 		const iv = await crypto.randomBytes(12); | ||||
|  | ||||
| 		const key = await pbkdf2Raw(password, salt, encryptionParameters.iterationCount, encryptionParameters.keyLength, encryptionParameters.digestAlgorithm); | ||||
| 		const encrypted = encryptRaw(data, encryptionParameters.cipherAlgorithm, key, iv, encryptionParameters.authTagLength, encryptionParameters.associatedData); | ||||
|  | ||||
| 		result.iv = iv.toString('base64'); | ||||
| 		result.ct = encrypted.toString('base64'); | ||||
|  | ||||
| 		return result; | ||||
| 	}, | ||||
|  | ||||
| 	decrypt: async (password: string, data: EncryptionResult, encryptionParameters: EncryptionParameters) => { | ||||
|  | ||||
| 		const salt = Buffer.from(data.salt, 'base64'); | ||||
| 		const iv = Buffer.from(data.iv, 'base64'); | ||||
|  | ||||
| 		const key = await pbkdf2Raw(password, salt, encryptionParameters.iterationCount, encryptionParameters.keyLength, encryptionParameters.digestAlgorithm); | ||||
| 		const decrypted = decryptRaw(Buffer.from(data.ct, 'base64'), encryptionParameters.cipherAlgorithm, key, iv, encryptionParameters.authTagLength, encryptionParameters.associatedData); | ||||
|  | ||||
| 		return decrypted; | ||||
| 	}, | ||||
|  | ||||
| 	encryptString: async (password: string, salt: CryptoBuffer, data: string, encoding: BufferEncoding, encryptionParameters: EncryptionParameters) => { | ||||
| 		return crypto.encrypt(password, salt, Buffer.from(data, encoding), encryptionParameters); | ||||
| 	}, | ||||
|  | ||||
| 	generateNonce: generateNonceShared, | ||||
|  | ||||
| 	increaseNonce: increaseNonceShared, | ||||
| }; | ||||
|  | ||||
| setRandomBytesImplementation(crypto.randomBytes); | ||||
|  | ||||
| export default crypto; | ||||
| @@ -6,6 +6,7 @@ import RNFetchBlob from 'rn-fetch-blob'; | ||||
| import { generateSecureRandom } from 'react-native-securerandom'; | ||||
| import FsDriverRN from '../fs-driver/fs-driver-rn'; | ||||
| import { Linking, Platform } from 'react-native'; | ||||
| import crypto from '../../services/e2ee/crypto'; | ||||
| const RNExitApp = require('react-native-exit-app').default; | ||||
|  | ||||
| export default function shimInit() { | ||||
| @@ -18,6 +19,8 @@ export default function shimInit() { | ||||
| 		return shim.fsDriver_; | ||||
| 	}; | ||||
|  | ||||
| 	shim.crypto = crypto; | ||||
|  | ||||
| 	shim.randomBytes = async (count: number) => { | ||||
| 		const randomBytes = await generateSecureRandom(count); | ||||
| 		const temp = []; | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import shimInitShared from './shimInitShared'; | ||||
| import FsDriverWeb from '../fs-driver/fs-driver-rn.web'; | ||||
| import { FetchBlobOptions } from '@joplin/lib/types'; | ||||
| import JoplinError from '@joplin/lib/JoplinError'; | ||||
| import joplinCrypto from '@joplin/lib/services/e2ee/crypto'; | ||||
|  | ||||
| const shimInit = () => { | ||||
| 	type GetLocationOptions = { timeout?: number }; | ||||
| @@ -41,6 +42,7 @@ const shimInit = () => { | ||||
| 		return fsDriver_; | ||||
| 	}; | ||||
| 	shim.fsDriver = fsDriver; | ||||
| 	shim.crypto = joplinCrypto; | ||||
|  | ||||
| 	shim.randomBytes = async (count: number) => { | ||||
| 		const buffer = new Uint8Array(count); | ||||
|   | ||||
							
								
								
									
										2
									
								
								packages/app-mobile/web/mocks/nodeCrypto.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								packages/app-mobile/web/mocks/nodeCrypto.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
|  | ||||
| exports.webcrypto = crypto; | ||||
| @@ -61,6 +61,7 @@ module.exports = { | ||||
| 	resolve: { | ||||
| 		alias: { | ||||
| 			'react-native$': 'react-native-web', | ||||
| 			'crypto': path.resolve(__dirname, 'mocks/nodeCrypto.js'), | ||||
|  | ||||
| 			// Map some modules that don't work on web to the empty dictionary. | ||||
| 			'react-native-fingerprint-scanner': emptyLibraryMock, | ||||
|   | ||||
| @@ -1603,6 +1603,17 @@ const builtInMetadata = (Setting: typeof SettingType) => { | ||||
| 		// 	storage: SettingStorage.File, | ||||
| 		// }, | ||||
|  | ||||
| 		'featureFlag.useBetaEncryptionMethod': { | ||||
| 			value: false, | ||||
| 			type: SettingItemType.Bool, | ||||
| 			public: true, | ||||
| 			storage: SettingStorage.File, | ||||
| 			label: () => 'Use beta encryption', | ||||
| 			description: () => 'Set beta encryption methods as the default methods. This applies to all clients and takes effect after restarting the app.', | ||||
| 			section: 'sync', | ||||
| 			isGlobal: true, | ||||
| 		}, | ||||
|  | ||||
| 		'sync.allowUnsupportedProviders': { | ||||
| 			value: -1, | ||||
| 			type: SettingItemType.Int, | ||||
|   | ||||
| @@ -95,9 +95,12 @@ describe('services_EncryptionService', () => { | ||||
| 		expect(!!masterKey.content).toBe(true); | ||||
| 	})); | ||||
|  | ||||
| 	it('should not require a checksum for new master keys', (async () => { | ||||
| 	it.each([ | ||||
| 		EncryptionMethod.SJCL4, | ||||
| 		EncryptionMethod.KeyV1, | ||||
| 	])('should not require a checksum for new master keys', (async (masterKeyEncryptionMethod) => { | ||||
| 		const masterKey = await service.generateMasterKey('123456', { | ||||
| 			encryptionMethod: EncryptionMethod.SJCL4, | ||||
| 			encryptionMethod: masterKeyEncryptionMethod, | ||||
| 		}); | ||||
|  | ||||
| 		expect(!masterKey.checksum).toBe(true); | ||||
| @@ -107,9 +110,12 @@ describe('services_EncryptionService', () => { | ||||
| 		expect(decryptedMasterKey.length).toBe(512); | ||||
| 	})); | ||||
|  | ||||
| 	it('should throw an error if master key decryption fails', (async () => { | ||||
| 	it.each([ | ||||
| 		EncryptionMethod.SJCL4, | ||||
| 		EncryptionMethod.KeyV1, | ||||
| 	])('should throw an error if master key decryption fails', (async (masterKeyEncryptionMethod) => { | ||||
| 		const masterKey = await service.generateMasterKey('123456', { | ||||
| 			encryptionMethod: EncryptionMethod.SJCL4, | ||||
| 			encryptionMethod: masterKeyEncryptionMethod, | ||||
| 		}); | ||||
|  | ||||
| 		const hasThrown = await checkThrowAsync(async () => await service.decryptMasterKeyContent(masterKey, 'wrong')); | ||||
| @@ -148,7 +154,7 @@ describe('services_EncryptionService', () => { | ||||
| 		// 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); | ||||
| 		for (let i = 0; i < service.chunkSize(service.defaultEncryptionMethod()) * 3; i++) veryLongSecret += Math.floor(Math.random() * 9); | ||||
|  | ||||
| 		const cipherText2 = await service.encryptString(veryLongSecret); | ||||
| 		const plainText2 = await service.decryptString(cipherText2); | ||||
| @@ -212,6 +218,56 @@ describe('services_EncryptionService', () => { | ||||
| 		expect(hasThrown).toBe(true); | ||||
| 	})); | ||||
|  | ||||
| 	it.each([ | ||||
| 		EncryptionMethod.SJCL1a, | ||||
| 		EncryptionMethod.SJCL1b, | ||||
| 		EncryptionMethod.SJCL4, | ||||
| 		EncryptionMethod.KeyV1, | ||||
| 		EncryptionMethod.FileV1, | ||||
| 		EncryptionMethod.StringV1, | ||||
| 	])('should fail to decrypt if ciphertext is not a valid JSON string', (async (jsonCipherTextMethod) => { | ||||
| 		const masterKey = await service.generateMasterKey('123456'); | ||||
| 		const masterKeyContent = await service.decryptMasterKeyContent(masterKey, '123456'); | ||||
|  | ||||
| 		const cipherTextString = await service.encrypt(jsonCipherTextMethod, masterKeyContent, 'e21de21d'); // 'e21de21d' is a valid base64/hex string | ||||
|  | ||||
| 		// Check if decryption is working | ||||
| 		const plainText = await service.decrypt(jsonCipherTextMethod, masterKeyContent, cipherTextString); | ||||
| 		expect(plainText).toBe('e21de21d'); | ||||
|  | ||||
| 		// Make invalid JSON | ||||
| 		const invalidCipherText = cipherTextString.replace('{', '{,'); | ||||
| 		const hasThrown = await checkThrowAsync(async () => await service.decrypt(jsonCipherTextMethod, masterKeyContent, invalidCipherText)); | ||||
| 		expect(hasThrown).toBe(true); | ||||
| 	})); | ||||
|  | ||||
| 	it.each([ | ||||
| 		EncryptionMethod.SJCL1a, | ||||
| 		EncryptionMethod.SJCL1b, | ||||
| 		EncryptionMethod.SJCL4, | ||||
| 		EncryptionMethod.KeyV1, | ||||
| 		EncryptionMethod.FileV1, | ||||
| 		EncryptionMethod.StringV1, | ||||
| 	])('should fail to decrypt if ciphertext authentication failed', (async (authenticatedEncryptionMethod) => { | ||||
| 		const masterKey = await service.generateMasterKey('123456'); | ||||
| 		const masterKeyContent = await service.decryptMasterKeyContent(masterKey, '123456'); | ||||
|  | ||||
| 		const cipherTextObject = JSON.parse(await service.encrypt(authenticatedEncryptionMethod, masterKeyContent, 'e21de21d')); // 'e21de21d' is a valid base64/hex string | ||||
| 		expect(cipherTextObject).toHaveProperty('ct'); | ||||
| 		const ct = Buffer.from(cipherTextObject['ct'], 'base64'); | ||||
|  | ||||
| 		// Should not fail if the binary data of ct is not modified | ||||
| 		const oldCipherTextObject = { ...cipherTextObject, ct: ct.toString('base64') }; | ||||
| 		const plainText = await service.decrypt(authenticatedEncryptionMethod, masterKeyContent, JSON.stringify(oldCipherTextObject)); | ||||
| 		expect(plainText).toBe('e21de21d'); | ||||
|  | ||||
| 		// The encrypted data part is changed so it doesn't match the authentication tag. Decryption should fail. | ||||
| 		ct[0] ^= 0x55; | ||||
| 		const newCipherTextObject = { ...cipherTextObject, ct: ct.toString('base64') }; | ||||
| 		const hasThrown = await checkThrowAsync(async () => service.decrypt(authenticatedEncryptionMethod, masterKeyContent, JSON.stringify(newCipherTextObject))); | ||||
| 		expect(hasThrown).toBe(true); | ||||
| 	})); | ||||
|  | ||||
| 	it('should encrypt and decrypt notes and folders', (async () => { | ||||
| 		let masterKey = await service.generateMasterKey('123456'); | ||||
| 		masterKey = await MasterKey.save(masterKey); | ||||
| @@ -243,7 +299,12 @@ describe('services_EncryptionService', () => { | ||||
| 		expect(decryptedNote.parent_id).toBe(note.parent_id); | ||||
| 	})); | ||||
|  | ||||
| 	it('should encrypt and decrypt files', (async () => { | ||||
| 	it.each([ | ||||
| 		EncryptionMethod.SJCL1a, | ||||
| 		EncryptionMethod.SJCL1b, | ||||
| 		EncryptionMethod.FileV1, | ||||
| 		EncryptionMethod.StringV1, | ||||
| 	])('should encrypt and decrypt files', (async (fileEncryptionMethod) => { | ||||
| 		let masterKey = await service.generateMasterKey('123456'); | ||||
| 		masterKey = await MasterKey.save(masterKey); | ||||
| 		await service.loadMasterKey(masterKey, '123456', true); | ||||
| @@ -252,6 +313,7 @@ describe('services_EncryptionService', () => { | ||||
| 		const encryptedPath = `${Setting.value('tempDir')}/photo.crypted`; | ||||
| 		const decryptedPath = `${Setting.value('tempDir')}/photo.jpg`; | ||||
|  | ||||
| 		service.defaultFileEncryptionMethod_ = fileEncryptionMethod; | ||||
| 		await service.encryptFile(sourcePath, encryptedPath); | ||||
| 		await service.decryptFile(encryptedPath, decryptedPath); | ||||
|  | ||||
| @@ -259,7 +321,11 @@ describe('services_EncryptionService', () => { | ||||
| 		expect(fileContentEqual(sourcePath, decryptedPath)).toBe(true); | ||||
| 	})); | ||||
|  | ||||
| 	it('should encrypt invalid UTF-8 data', (async () => { | ||||
| 	it.each([ | ||||
| 		EncryptionMethod.SJCL1a, | ||||
| 		EncryptionMethod.SJCL1b, | ||||
| 		EncryptionMethod.StringV1, | ||||
| 	])('should encrypt invalid UTF-8 data', (async (stringEncryptionMethod) => { | ||||
| 		let masterKey = await service.generateMasterKey('123456'); | ||||
| 		masterKey = await MasterKey.save(masterKey); | ||||
|  | ||||
| @@ -271,7 +337,7 @@ describe('services_EncryptionService', () => { | ||||
| 		expect(hasThrown).toBe(true); | ||||
|  | ||||
| 		// Now check that the new one fixes the problem | ||||
| 		service.defaultEncryptionMethod_ = EncryptionMethod.SJCL1a; | ||||
| 		service.defaultEncryptionMethod_ = stringEncryptionMethod; | ||||
| 		const cipherText = await service.encryptString('🐶🐶🐶'.substr(0, 5)); | ||||
| 		const plainText = await service.decryptString(cipherText); | ||||
| 		expect(plainText).toBe('🐶🐶🐶'.substr(0, 5)); | ||||
|   | ||||
| @@ -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; | ||||
| 	} | ||||
|  | ||||
|   | ||||
							
								
								
									
										103
									
								
								packages/lib/services/e2ee/crypto.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								packages/lib/services/e2ee/crypto.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | ||||
| import { afterAllCleanUp, expectNotThrow, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils'; | ||||
| import { runIntegrationTests } from './cryptoTestUtils'; | ||||
| import crypto from './crypto'; | ||||
|  | ||||
| describe('e2ee/crypto', () => { | ||||
|  | ||||
| 	beforeEach(async () => { | ||||
| 		jest.useRealTimers(); | ||||
| 		await setupDatabaseAndSynchronizer(1); | ||||
| 		await switchClient(1); | ||||
| 	}); | ||||
|  | ||||
| 	afterAll(async () => { | ||||
| 		await afterAllCleanUp(); | ||||
| 	}); | ||||
|  | ||||
| 	it('should encrypt and decrypt data from different devices', (async () => { | ||||
| 		await expectNotThrow(async () => runIntegrationTests(true)); | ||||
| 	})); | ||||
|  | ||||
| 	it('should not generate new nonce if counter does not overflow', (async () => { | ||||
| 		jest.useFakeTimers(); | ||||
|  | ||||
| 		const nonce = await crypto.generateNonce(new Uint8Array(36)); | ||||
| 		expect(nonce.subarray(-8)).toEqual(new Uint8Array(8)); | ||||
| 		const nonCounterPart = nonce.slice(0, 28); | ||||
|  | ||||
| 		jest.advanceTimersByTime(1); | ||||
| 		await crypto.increaseNonce(nonce); | ||||
| 		// Counter should have expected value | ||||
| 		expect(nonce.subarray(-8)).toEqual(new Uint8Array([0, 0, 0, 0, 0, 0, 0, 1])); | ||||
| 		// Non-counter part should stay the same | ||||
| 		expect(nonce.subarray(0, 28)).toEqual(nonCounterPart); | ||||
|  | ||||
| 		jest.advanceTimersByTime(1); | ||||
| 		await crypto.increaseNonce(nonce); | ||||
| 		// Counter should have expected value | ||||
| 		expect(nonce.subarray(-8)).toEqual(new Uint8Array([0, 0, 0, 0, 0, 0, 0, 2])); | ||||
| 		// Non-counter part should stay the same | ||||
| 		expect(nonce.subarray(0, 28)).toEqual(nonCounterPart); | ||||
|  | ||||
| 		jest.advanceTimersByTime(1); | ||||
| 		nonce.set(new Uint8Array([248, 249, 250, 251, 252, 253, 254, 255]), 28); | ||||
| 		await crypto.increaseNonce(nonce); | ||||
| 		// Counter should have expected value | ||||
| 		expect(nonce.subarray(-8)).toEqual(new Uint8Array([248, 249, 250, 251, 252, 253, 255, 0])); | ||||
| 		// Non-counter part should stay the same | ||||
| 		expect(nonce.subarray(0, 28)).toEqual(nonCounterPart); | ||||
|  | ||||
| 		jest.advanceTimersByTime(1); | ||||
| 		nonce.set(new Uint8Array([249, 250, 251, 252, 253, 254, 255, 255]), 28); | ||||
| 		await crypto.increaseNonce(nonce); | ||||
| 		// Counter should have expected value | ||||
| 		expect(nonce.subarray(-8)).toEqual(new Uint8Array([249, 250, 251, 252, 253, 255, 0, 0])); | ||||
| 		// Non-counter part should stay the same | ||||
| 		expect(nonce.subarray(0, 28)).toEqual(nonCounterPart); | ||||
|  | ||||
| 		jest.advanceTimersByTime(1); | ||||
| 		nonce.set(new Uint8Array([253, 254, 255, 255, 255, 255, 255, 255]), 28); | ||||
| 		await crypto.increaseNonce(nonce); | ||||
| 		// Counter should have expected value | ||||
| 		expect(nonce.subarray(-8)).toEqual(new Uint8Array([253, 255, 0, 0, 0, 0, 0, 0])); | ||||
| 		// Non-counter part should stay the same | ||||
| 		expect(nonce.subarray(0, 28)).toEqual(nonCounterPart); | ||||
|  | ||||
| 		jest.advanceTimersByTime(1); | ||||
| 		nonce.set(new Uint8Array([254, 255, 255, 255, 255, 255, 255, 255]), 28); | ||||
| 		await crypto.increaseNonce(nonce); | ||||
| 		// Counter should have expected value | ||||
| 		expect(nonce.subarray(-8)).toEqual(new Uint8Array([255, 0, 0, 0, 0, 0, 0, 0])); | ||||
| 		// Non-counter part should stay the same | ||||
| 		expect(nonce.subarray(0, 28)).toEqual(nonCounterPart); | ||||
| 	})); | ||||
|  | ||||
| 	it('should generate new nonce if counter overflow', (async () => { | ||||
| 		jest.useFakeTimers(); | ||||
|  | ||||
| 		const nonce = await crypto.generateNonce(new Uint8Array(36)); | ||||
| 		expect(nonce.subarray(-8)).toEqual(new Uint8Array(8)); | ||||
| 		const nonCounterPart = nonce.slice(0, 28); | ||||
| 		const randomPart = nonce.slice(0, 21); | ||||
| 		const timestampPart = nonce.slice(21, 28); | ||||
|  | ||||
| 		jest.advanceTimersByTime(1); | ||||
| 		await crypto.increaseNonce(nonce); | ||||
| 		// Counter should have expected value | ||||
| 		expect(nonce.subarray(-8)).toEqual(new Uint8Array([0, 0, 0, 0, 0, 0, 0, 1])); | ||||
| 		// Non-counter part should stay the same | ||||
| 		expect(nonce.subarray(0, 28)).toEqual(nonCounterPart); | ||||
|  | ||||
| 		jest.advanceTimersByTime(1); | ||||
| 		nonce.set(new Uint8Array([255, 255, 255, 255, 255, 255, 255, 255]), 28); | ||||
| 		await crypto.increaseNonce(nonce); | ||||
| 		// Counter should have expected value | ||||
| 		expect(nonce.subarray(-8)).toEqual(new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0])); | ||||
| 		// Random part should be changed | ||||
| 		expect(nonce.subarray(0, 21)).not.toEqual(randomPart); | ||||
| 		// Timestamp part should have expected value | ||||
| 		expect(nonce[21]).toBe(timestampPart[0] + 2); | ||||
| 		expect(nonce.subarray(22, 28)).toEqual(timestampPart.subarray(1)); | ||||
| 	})); | ||||
|  | ||||
| }); | ||||
							
								
								
									
										113
									
								
								packages/lib/services/e2ee/crypto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								packages/lib/services/e2ee/crypto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | ||||
| import { Crypto, CryptoBuffer, Digest, EncryptionResult, EncryptionParameters } from './types'; | ||||
| import { webcrypto } from 'crypto'; | ||||
| import { Buffer } from 'buffer'; | ||||
| import { | ||||
| 	generateNonce as generateNonceShared, | ||||
| 	increaseNonce as increaseNonceShared, | ||||
| 	setRandomBytesImplementation, | ||||
| } from './cryptoShared'; | ||||
|  | ||||
| const pbkdf2Raw = async (password: string, salt: Uint8Array, iterations: number, keylenBytes: number, digest: Digest) => { | ||||
| 	const encoder = new TextEncoder(); | ||||
| 	const key = await webcrypto.subtle.importKey( | ||||
| 		'raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveKey'], | ||||
| 	); | ||||
| 	return webcrypto.subtle.deriveKey( | ||||
| 		{ name: 'PBKDF2', salt, iterations, hash: digest }, key, { name: 'AES-GCM', length: keylenBytes * 8 }, false, ['encrypt', 'decrypt'], | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| const encryptRaw = async (data: Uint8Array, key: webcrypto.CryptoKey, iv: Uint8Array, authTagLengthBytes: number, additionalData: Uint8Array) => { | ||||
| 	return Buffer.from(await webcrypto.subtle.encrypt({ | ||||
| 		name: 'AES-GCM', | ||||
| 		iv, | ||||
| 		additionalData, | ||||
| 		tagLength: authTagLengthBytes * 8, | ||||
| 	}, key, data)); | ||||
| }; | ||||
|  | ||||
| const decryptRaw = async (data: Uint8Array, key: webcrypto.CryptoKey, iv: Uint8Array, authTagLengthBytes: number, associatedData: Uint8Array) => { | ||||
| 	return Buffer.from(await webcrypto.subtle.decrypt({ | ||||
| 		name: 'AES-GCM', | ||||
| 		iv, | ||||
| 		additionalData: associatedData, | ||||
| 		tagLength: authTagLengthBytes * 8, | ||||
| 	}, key, data)); | ||||
| }; | ||||
|  | ||||
| const crypto: Crypto = { | ||||
|  | ||||
| 	randomBytes: async (size: number) => { | ||||
| 		// .getRandomValues has a maximum output size | ||||
| 		const maxChunkSize = 65536; | ||||
| 		const result = new Uint8Array(size); | ||||
|  | ||||
| 		if (size <= maxChunkSize) { | ||||
| 			webcrypto.getRandomValues(result); | ||||
| 		} else { | ||||
| 			const fullSizeChunk = new Uint8Array(maxChunkSize); | ||||
| 			const lastChunkSize = size % maxChunkSize; | ||||
| 			const maxOffset = size - lastChunkSize; | ||||
| 			let offset = 0; | ||||
| 			while (offset < maxOffset) { | ||||
| 				webcrypto.getRandomValues(fullSizeChunk); | ||||
| 				result.set(fullSizeChunk, offset); | ||||
| 				offset += maxChunkSize; | ||||
| 			} | ||||
| 			if (lastChunkSize > 0) { | ||||
| 				const lastChunk = webcrypto.getRandomValues(new Uint8Array(lastChunkSize)); | ||||
| 				result.set(lastChunk, offset); | ||||
| 			} | ||||
| 		} | ||||
| 		return Buffer.from(result); | ||||
| 	}, | ||||
|  | ||||
| 	digest: async (algorithm: Digest, data: Uint8Array) => { | ||||
| 		return Buffer.from(await webcrypto.subtle.digest(algorithm, data)); | ||||
| 	}, | ||||
|  | ||||
| 	encrypt: async (password: string, salt: CryptoBuffer, data: CryptoBuffer, encryptionParameters: EncryptionParameters) => { | ||||
|  | ||||
| 		// Parameters in EncryptionParameters won't appear in result | ||||
| 		const result: EncryptionResult = { | ||||
| 			salt: salt.toString('base64'), | ||||
| 			iv: '', | ||||
| 			ct: '', // cipherText | ||||
| 		}; | ||||
|  | ||||
| 		// 96 bits IV | ||||
| 		// "For IVs, it is recommended that implementations restrict support to the length of 96 bits, to promote interoperability, efficiency, and simplicity of design." - NIST SP 800-38D | ||||
| 		const iv = await crypto.randomBytes(12); | ||||
|  | ||||
| 		const key = await pbkdf2Raw(password, salt, encryptionParameters.iterationCount, encryptionParameters.keyLength, encryptionParameters.digestAlgorithm); | ||||
| 		const encrypted = await encryptRaw(data, key, iv, encryptionParameters.authTagLength, encryptionParameters.associatedData); | ||||
|  | ||||
| 		result.iv = iv.toString('base64'); | ||||
| 		result.ct = encrypted.toString('base64'); | ||||
|  | ||||
| 		return result; | ||||
| 	}, | ||||
|  | ||||
| 	decrypt: async (password: string, data: EncryptionResult, encryptionParameters: EncryptionParameters) => { | ||||
|  | ||||
| 		const salt = Buffer.from(data.salt, 'base64'); | ||||
| 		const iv = Buffer.from(data.iv, 'base64'); | ||||
|  | ||||
| 		const key = await pbkdf2Raw(password, salt, encryptionParameters.iterationCount, encryptionParameters.keyLength, encryptionParameters.digestAlgorithm); | ||||
| 		const decrypted = decryptRaw(Buffer.from(data.ct, 'base64'), key, iv, encryptionParameters.authTagLength, encryptionParameters.associatedData); | ||||
|  | ||||
| 		return decrypted; | ||||
| 	}, | ||||
|  | ||||
| 	encryptString: async (password: string, salt: CryptoBuffer, data: string, encoding: BufferEncoding, encryptionParameters: EncryptionParameters) => { | ||||
| 		return crypto.encrypt(password, salt, Buffer.from(data, encoding), encryptionParameters); | ||||
| 	}, | ||||
|  | ||||
| 	generateNonce: generateNonceShared, | ||||
|  | ||||
| 	increaseNonce: increaseNonceShared, | ||||
| }; | ||||
|  | ||||
| setRandomBytesImplementation(crypto.randomBytes); | ||||
|  | ||||
| export default crypto; | ||||
							
								
								
									
										52
									
								
								packages/lib/services/e2ee/cryptoShared.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								packages/lib/services/e2ee/cryptoShared.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| import { CryptoBuffer } from './types'; | ||||
|  | ||||
| const nonceCounterLength = 8; | ||||
| const nonceTimestampLength = 7; | ||||
|  | ||||
| type RandomBytesImplementation = (size: number)=> Promise<CryptoBuffer>; | ||||
|  | ||||
| let randomBytesImplementation: RandomBytesImplementation = null; | ||||
|  | ||||
| export const setRandomBytesImplementation = (implementation: RandomBytesImplementation) => { | ||||
| 	randomBytesImplementation = implementation; | ||||
| }; | ||||
|  | ||||
| export const generateNonce = async (nonce: Uint8Array) => { | ||||
| 	const randomLength = nonce.length - nonceTimestampLength - nonceCounterLength; | ||||
| 	if (randomLength < 1) { | ||||
| 		throw new Error(`Nonce length should be greater than ${(nonceTimestampLength + nonceCounterLength) * 8} bits`); | ||||
| 	} | ||||
| 	nonce.set(await randomBytesImplementation(randomLength)); | ||||
| 	const timestampArray = new Uint8Array(nonceTimestampLength); | ||||
| 	let timestamp = Date.now(); | ||||
| 	let timestampMsb = Math.floor(timestamp / 2 ** 32); | ||||
| 	const lsbCount = Math.min(4, nonceTimestampLength); | ||||
| 	for (let i = 0; i < lsbCount; i++) { | ||||
| 		timestampArray[i] = timestamp & 0xFF; | ||||
| 		timestamp >>>= 8; | ||||
| 	} | ||||
| 	// The bitwise operators in Typescript only take the 32 LSBs to calculate, so we need to extract the MSBs manually. | ||||
| 	for (let i = 4; i < nonceTimestampLength; i++) { | ||||
| 		timestampArray[i] = timestampMsb & 0xFF; | ||||
| 		timestampMsb >>>= 8; | ||||
| 	} | ||||
| 	nonce.set(timestampArray, randomLength); | ||||
| 	nonce.set(new Uint8Array(nonceCounterLength), randomLength + nonceTimestampLength); | ||||
| 	return nonce; | ||||
| }; | ||||
|  | ||||
| export const increaseNonce = async (nonce: Uint8Array) => { | ||||
| 	const carry = 1; | ||||
| 	const end = nonce.length - nonceCounterLength; | ||||
| 	let i = nonce.length; | ||||
| 	while (i-- > end) { | ||||
| 		nonce[i] += carry; | ||||
| 		if (nonce[i] !== 0 || carry !== 1) { | ||||
| 			break; | ||||
| 		} | ||||
| 	} | ||||
| 	if (i < end) { | ||||
| 		await generateNonce(nonce); | ||||
| 	} | ||||
| 	return nonce; | ||||
| }; | ||||
							
								
								
									
										285
									
								
								packages/lib/services/e2ee/cryptoTestUtils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										285
									
								
								packages/lib/services/e2ee/cryptoTestUtils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,285 @@ | ||||
| import EncryptionService, { EncryptionMethod } from './EncryptionService'; | ||||
| import BaseItem from '../../models/BaseItem'; | ||||
| import Folder from '../../models/Folder'; | ||||
| import MasterKey from '../../models/MasterKey'; | ||||
| import Note from '../../models/Note'; | ||||
| import Setting from '../../models/Setting'; | ||||
| import shim from '../..//shim'; | ||||
| import { getEncryptionEnabled, setEncryptionEnabled } from '../synchronizer/syncInfoUtils'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
|  | ||||
| interface DecryptTestData { | ||||
| 	method: EncryptionMethod; | ||||
| 	password: string; | ||||
| 	plaintext: string; | ||||
| 	ciphertext: string; | ||||
| } | ||||
|  | ||||
| let serviceInstance: EncryptionService = null; | ||||
|  | ||||
| const logger = Logger.create('Crypto Tests'); | ||||
|  | ||||
| // This is convenient to quickly generate some data to verify for example that | ||||
| // react-native-quick-crypto and node:crypto can decrypt the same data. | ||||
| export async function createDecryptTestData() { | ||||
| 	const method = EncryptionMethod.StringV1; | ||||
| 	const password = 'just testing'; | ||||
| 	const plaintext = '中文にっぽんご한국어😀\uD83D\0\r\nenglish01234567890'; | ||||
| 	const ciphertext = await serviceInstance.encrypt(method, password, plaintext); | ||||
|  | ||||
| 	return { | ||||
| 		method: method, | ||||
| 		password: password, | ||||
| 		plaintext: plaintext, | ||||
| 		ciphertext: ciphertext, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| interface CheckTestDataOptions { | ||||
| 	throwOnError?: boolean; | ||||
| 	silent?: boolean; | ||||
| 	testLabel?: string; | ||||
| } | ||||
|  | ||||
| export async function checkDecryptTestData(data: DecryptTestData, options: CheckTestDataOptions = null) { | ||||
| 	options = { | ||||
| 		throwOnError: false, | ||||
| 		silent: false, | ||||
| 		...options, | ||||
| 	}; | ||||
|  | ||||
| 	// Verify that the ciphertext decrypted on this device and producing the same plaintext. | ||||
| 	const messages: string[] = []; | ||||
| 	let hasError = false; | ||||
|  | ||||
| 	try { | ||||
| 		const decrypted = await EncryptionService.instance().decrypt(data.method, data.password, data.ciphertext); | ||||
| 		if (decrypted !== data.plaintext) { | ||||
| 			messages.push('Data could not be decrypted'); | ||||
| 			messages.push('Expected:', data.plaintext); | ||||
| 			messages.push('Got:', decrypted); | ||||
| 			hasError = true; | ||||
| 		} else { | ||||
| 			messages.push('Data could be decrypted'); | ||||
| 		} | ||||
| 	} catch (error) { | ||||
| 		hasError = true; | ||||
| 		messages.push(`Failed to decrypt data: Error: ${error}`); | ||||
| 	} | ||||
|  | ||||
| 	if (hasError && options.throwOnError) { | ||||
| 		const label = options.testLabel ? ` (test ${options.testLabel})` : ''; | ||||
| 		throw new Error(`Testing Crypto failed${label}: \n${messages.join('\n')}`); | ||||
| 	} else { | ||||
| 		for (const msg of messages) { | ||||
| 			if (hasError) { | ||||
| 				logger.warn(msg); | ||||
| 			} else { | ||||
| 				if (!options.silent) logger.info(msg); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export async function testStringPerformance(method: EncryptionMethod, dataSize: number, count: number, options: CheckTestDataOptions = null) { | ||||
| 	options = { | ||||
| 		throwOnError: false, | ||||
| 		silent: false, | ||||
| 		...options, | ||||
| 	}; | ||||
|  | ||||
| 	// Verify that the ciphertext decrypted on this device and producing the same plaintext. | ||||
| 	const messages: string[] = []; | ||||
| 	let hasError = false; | ||||
|  | ||||
| 	try { | ||||
| 		serviceInstance.defaultEncryptionMethod_ = method; | ||||
| 		let masterKey = await serviceInstance.generateMasterKey('123456'); | ||||
| 		masterKey = await MasterKey.save(masterKey); | ||||
| 		await serviceInstance.loadMasterKey(masterKey, '123456', true); | ||||
|  | ||||
| 		const crypto = shim.crypto; | ||||
|  | ||||
| 		const content = (await crypto.randomBytes(dataSize / 2)).toString('hex'); | ||||
| 		const folder = await Folder.save({ title: 'folder' }); | ||||
| 		const note = await Note.save({ title: 'encrypted note', body: content, parent_id: folder.id }); | ||||
|  | ||||
| 		let encryptTime = 0.0; | ||||
| 		let decryptTime = 0.0; | ||||
| 		for (let i = 0; i < count; i++) { | ||||
| 			const tick1 = performance.now(); | ||||
| 			const serialized = await Note.serializeForSync(note); | ||||
| 			const tick2 = performance.now(); | ||||
| 			const deserialized = await Note.unserialize(serialized); | ||||
| 			const decryptedNote = await Note.decrypt(deserialized); | ||||
| 			const tick3 = performance.now(); | ||||
| 			(decryptedNote.title === note.title); | ||||
| 			encryptTime += tick2 - tick1; | ||||
| 			decryptTime += tick3 - tick2; | ||||
| 		} | ||||
|  | ||||
| 		messages.push(`testStringPerformance(): method: ${method}, count: ${count}, dataSize: ${dataSize}, encryptTime: ${encryptTime}, decryptTime: ${decryptTime}, encryptTime/count: ${encryptTime / count}, decryptTime/count: ${decryptTime / count}.`); | ||||
|  | ||||
| 	} catch (error) { | ||||
| 		hasError = true; | ||||
| 		messages.push(`testStringPerformance() failed. Error: ${error}`); | ||||
| 	} | ||||
|  | ||||
| 	if (hasError && options.throwOnError) { | ||||
| 		const label = options.testLabel ? ` (test ${options.testLabel})` : ''; | ||||
| 		throw new Error(`Testing Crypto failed${label}: \n${messages.join('\n')}`); | ||||
| 	} else { | ||||
| 		for (const msg of messages) { | ||||
| 			if (hasError) { | ||||
| 				logger.warn(msg); | ||||
| 			} else { | ||||
| 				if (!options.silent) logger.info(msg); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export async function testFilePerformance(method: EncryptionMethod, dataSize: number, count: number, options: CheckTestDataOptions = null) { | ||||
| 	options = { | ||||
| 		throwOnError: false, | ||||
| 		silent: false, | ||||
| 		...options, | ||||
| 	}; | ||||
|  | ||||
| 	// Verify that the ciphertext decrypted on this device and producing the same plaintext. | ||||
| 	const messages: string[] = []; | ||||
| 	let hasError = false; | ||||
|  | ||||
| 	try { | ||||
| 		serviceInstance.defaultFileEncryptionMethod_ = method; | ||||
| 		let masterKey = await serviceInstance.generateMasterKey('123456'); | ||||
| 		masterKey = await MasterKey.save(masterKey); | ||||
| 		await serviceInstance.loadMasterKey(masterKey, '123456', true); | ||||
|  | ||||
| 		const fsDriver = shim.fsDriver(); | ||||
| 		const crypto = shim.crypto; | ||||
|  | ||||
| 		const sourcePath = `${Setting.value('tempDir')}/testData.txt`; | ||||
| 		const encryptedPath = `${Setting.value('tempDir')}/testData.crypted`; | ||||
| 		const decryptedPath = `${Setting.value('tempDir')}/testData.decrypted`; | ||||
| 		await fsDriver.writeFile(sourcePath, ''); | ||||
| 		await fsDriver.appendFile(sourcePath, (await crypto.randomBytes(dataSize)).toString('base64'), 'base64'); | ||||
|  | ||||
| 		let encryptTime = 0.0; | ||||
| 		let decryptTime = 0.0; | ||||
| 		for (let i = 0; i < count; i++) { | ||||
| 			const tick1 = performance.now(); | ||||
| 			await serviceInstance.encryptFile(sourcePath, encryptedPath); | ||||
| 			const tick2 = performance.now(); | ||||
| 			await serviceInstance.decryptFile(encryptedPath, decryptedPath); | ||||
| 			const tick3 = performance.now(); | ||||
| 			encryptTime += tick2 - tick1; | ||||
| 			decryptTime += tick3 - tick2; | ||||
| 		} | ||||
|  | ||||
| 		messages.push(`testFilePerformance(): method: ${method}, count: ${count}, dataSize: ${dataSize}, encryptTime: ${encryptTime}, decryptTime: ${decryptTime}, encryptTime/count: ${encryptTime / count}, decryptTime/count: ${decryptTime / count}.`); | ||||
|  | ||||
| 	} catch (error) { | ||||
| 		hasError = true; | ||||
| 		messages.push(`testFilePerformance() failed. Error: ${error}`); | ||||
| 	} | ||||
|  | ||||
| 	if (hasError && options.throwOnError) { | ||||
| 		const label = options.testLabel ? ` (test ${options.testLabel})` : ''; | ||||
| 		throw new Error(`Testing Crypto failed${label}: \n${messages.join('\n')}`); | ||||
| 	} else { | ||||
| 		for (const msg of messages) { | ||||
| 			if (hasError) { | ||||
| 				logger.warn(msg); | ||||
| 			} else { | ||||
| 				if (!options.silent) logger.info(msg); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // cSpell:disable | ||||
|  | ||||
| // Data generated on desktop, using node:crypto in packages/lib/services/e2ee/crypto.ts | ||||
| const decryptTestData: Record<string, DecryptTestData> = { | ||||
| 	shortString: { | ||||
| 		method: EncryptionMethod.StringV1, | ||||
| 		password: '4BfJl8YbM,nXx.LVgs!AzkWWA]', | ||||
| 		plaintext: '中文にっぽんご한국어😀\uD83D\0\r\nenglish01234567890', | ||||
| 		ciphertext: '{"salt":"6NKebMdcrFSGSzEWusbY8JUfG9rB98PxNZtk0QkYEFQ=","iv":"zHdXu8V5SYsO+vR/","ct":"4C3uyjOtRsNQZxCECvCeRaP+oPXMxjUMxsND67odnyiFg2A+fG6QW6O8axb6RHWU7QKHRG9/kHEs283DHL3hOAJbl4LS47R/dEDJbl8kWmGtLAsn"}', | ||||
| 	}, | ||||
| 	hexKey: { | ||||
| 		method: EncryptionMethod.KeyV1, | ||||
| 		password: '4BfJl8YbM,nXx.LVgs!AzkWWA]', | ||||
| 		plaintext: 'ea5d8dd43823a812c9a64f4eb09b57e1aa2dbfcda50bf9e416dcd1f8c569a6462f7f61861c3ccda0c7c16bf4192ed9c7ecaf3e1248517bd6f397d1080d2632fc69e1ead59a398a07e7478c8d90142745c0dce39c2105b6d34117424697de03879caa3b6410325f14dc5755b69efa944eb83f06970d04444e83fe054576af20576c0f3a5dc23e0d6dcfa3e84ec05c21139070c0809bd2bdc86a7782368c9d99eb072c858c61ec8926136e6e50dfd57b7e8e0084ad08b2d984db0436f50433cab44b3c462ccd22a8567c8ff86675fff618b11526030f09f735646a9f189f54ba5485d069ee25ac468ec0a873c1223eed34962bd219972020cc147041d4b00a3ab8', | ||||
| 		ciphertext: '{"salt":"iv5kKP+scMyXKO2jqzef3q9y9p/o/mj6zAoKVltbPx0=","iv":"Gz1PtbDzoaZzRx5m","ct":"YSS4ga1Q0MeVpFbMn2V45TaAbbuJM12vU/qYQ/VEGYPXhNQ4YchfRt7+LjhVxwpvfc/rD0znt6kpAh4ROGS2CLDy27/n5VICgTVY2VVff+YjPAma6odKn2pm4Z88fZlkoJVLi7QyU96Mvb6bYVbuNjQ16hOjFQ3iIIztLcafsHaW6v6gUFrDWYVQPi1/xovmmCe/GaM3JeMye5QQiFrmLQIxEJMNv8YiNyppMVf5b1YGFtDlOjm3XE2H9bb+wWNAd82mcwcAe+ZUedz3AH9PKlsRyBGHfGQ/rfFzeoFj+Wjm4fvPniPa1muRSMQDHU2Zw5YGWQwMNVUHt+y7lDoqPF2NQv4DvPmY1kLz2yohIzc="}', | ||||
| 	}, | ||||
| 	base64File: { | ||||
| 		method: EncryptionMethod.FileV1, | ||||
| 		password: '4BfJl8YbM,nXx.LVgs!AzkWWA]', | ||||
| 		plaintext: '5Lit5paH44Gr44Gj44G944KT44GU7ZWc6rWt7Ja08J+YgO+/vQANCmVuZ2xpc2gwMTIzNDU2Nzg5MA==', | ||||
| 		ciphertext: '{"salt":"19Zx/+hxpZ+Trc7MGBt837SsTOJjHe9aiY5UPnXP6Oo=","iv":"N4PTzsyh4wONNJWa","ct":"LxAibOnVox1q2WBtLAKxeZxIIxKOEd6xdD3NKAn5mgHhv4i60yPiyPbr8rS+MzHmeq7Z3BhHjR7540rtdeBugbmf1+b3tYuRudI="}', | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| // cSpell:enable | ||||
|  | ||||
| // This can be used to run integration tests directly on device. It will throw | ||||
| // an error if something cannot be decrypted, or else print info messages. | ||||
| export const runIntegrationTests = async (silent = false, testPerformance = false) => { | ||||
| 	const log = (s: string) => { | ||||
| 		if (silent) return; | ||||
| 		logger.info(s); | ||||
| 	}; | ||||
|  | ||||
| 	log('Running integration tests...'); | ||||
| 	const encryptionEnabled = getEncryptionEnabled(); | ||||
| 	serviceInstance = EncryptionService.instance(); | ||||
| 	BaseItem.encryptionService_ = EncryptionService.instance(); | ||||
| 	setEncryptionEnabled(true); | ||||
|  | ||||
| 	log('Decrypting using known data...'); | ||||
| 	for (const testLabel in decryptTestData) { | ||||
| 		log(`Running decrypt test data case ${testLabel}...`); | ||||
| 		await checkDecryptTestData(decryptTestData[testLabel], { silent, testLabel, throwOnError: true }); | ||||
| 	} | ||||
|  | ||||
| 	log('Decrypting using local data...'); | ||||
| 	const newData = await createDecryptTestData(); | ||||
| 	await checkDecryptTestData(newData, { silent, throwOnError: true }); | ||||
|  | ||||
| 	// The performance test is very slow so it is disabled by default. | ||||
| 	if (testPerformance) { | ||||
| 		log('Testing performance...'); | ||||
| 		if (shim.mobilePlatform() === '') { | ||||
| 			await testStringPerformance(EncryptionMethod.StringV1, 100, 1000); | ||||
| 			await testStringPerformance(EncryptionMethod.StringV1, 1000000, 10); | ||||
| 			await testStringPerformance(EncryptionMethod.StringV1, 5000000, 10); | ||||
| 			await testStringPerformance(EncryptionMethod.SJCL1a, 100, 1000); | ||||
| 			await testStringPerformance(EncryptionMethod.SJCL1a, 1000000, 10); | ||||
| 			await testStringPerformance(EncryptionMethod.SJCL1a, 5000000, 10); | ||||
| 			await testFilePerformance(EncryptionMethod.FileV1, 100, 1000); | ||||
| 			await testFilePerformance(EncryptionMethod.FileV1, 1000000, 3); | ||||
| 			await testFilePerformance(EncryptionMethod.FileV1, 5000000, 3); | ||||
| 			await testFilePerformance(EncryptionMethod.SJCL1a, 100, 1000); | ||||
| 			await testFilePerformance(EncryptionMethod.SJCL1a, 1000000, 3); | ||||
| 			await testFilePerformance(EncryptionMethod.SJCL1a, 5000000, 3); | ||||
| 		} else { | ||||
| 			await testStringPerformance(EncryptionMethod.StringV1, 100, 100); | ||||
| 			await testStringPerformance(EncryptionMethod.StringV1, 500000, 3); | ||||
| 			await testStringPerformance(EncryptionMethod.StringV1, 1000000, 3); | ||||
| 			await testStringPerformance(EncryptionMethod.SJCL1a, 100, 100); | ||||
| 			await testStringPerformance(EncryptionMethod.SJCL1a, 500000, 3); | ||||
| 			await testStringPerformance(EncryptionMethod.SJCL1a, 1000000, 3); | ||||
| 			await testFilePerformance(EncryptionMethod.FileV1, 100, 100); | ||||
| 			await testFilePerformance(EncryptionMethod.FileV1, 100000, 3); | ||||
| 			await testFilePerformance(EncryptionMethod.FileV1, 500000, 3); | ||||
| 			await testFilePerformance(EncryptionMethod.SJCL1a, 100, 100); | ||||
| 			await testFilePerformance(EncryptionMethod.SJCL1a, 100000, 3); | ||||
| 			await testFilePerformance(EncryptionMethod.SJCL1a, 500000, 3); | ||||
| 		} | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	setEncryptionEnabled(encryptionEnabled); | ||||
| }; | ||||
| @@ -25,3 +25,46 @@ export interface RSA { | ||||
| 	publicKey(rsaKeyPair: RSAKeyPair): string; | ||||
| 	privateKey(rsaKeyPair: RSAKeyPair): string; | ||||
| } | ||||
|  | ||||
| export interface Crypto { | ||||
| 	randomBytes(size: number): Promise<CryptoBuffer>; | ||||
| 	digest(algorithm: Digest, data: Uint8Array): Promise<CryptoBuffer>; | ||||
| 	generateNonce(nonce: Uint8Array): Promise<Uint8Array>; | ||||
| 	increaseNonce(nonce: Uint8Array): Promise<Uint8Array>; | ||||
| 	encrypt(password: string, salt: CryptoBuffer, data: CryptoBuffer, options: EncryptionParameters): Promise<EncryptionResult>; | ||||
| 	decrypt(password: string, data: EncryptionResult, options: EncryptionParameters): Promise<Buffer>; | ||||
| 	encryptString(password: string, salt: CryptoBuffer, data: string, encoding: BufferEncoding, options: EncryptionParameters): Promise<EncryptionResult>; | ||||
| } | ||||
|  | ||||
| export interface CryptoBuffer extends Uint8Array { | ||||
| 	toString(encoding?: BufferEncoding, start?: number, end?: number): string; | ||||
| } | ||||
|  | ||||
| // A subset of react-native-quick-crypto.HashAlgorithm, supported by Web Crypto API | ||||
| export enum Digest { | ||||
| 	sha1 = 'SHA-1', | ||||
| 	sha256 = 'SHA-256', | ||||
| 	sha384 = 'SHA-384', | ||||
| 	sha512 = 'SHA-512', | ||||
| } | ||||
|  | ||||
| export enum CipherAlgorithm { | ||||
| 	AES_128_GCM = 'aes-128-gcm', | ||||
| 	AES_192_GCM = 'aes-192-gcm', | ||||
| 	AES_256_GCM = 'aes-256-gcm', | ||||
| } | ||||
|  | ||||
| export interface EncryptionResult { | ||||
| 	salt: string; // base64 encoded | ||||
| 	iv: string; // base64 encoded | ||||
| 	ct: string; // cipherText, base64 encoded | ||||
| } | ||||
|  | ||||
| export interface EncryptionParameters { | ||||
| 	cipherAlgorithm: CipherAlgorithm; | ||||
| 	authTagLength: number; // in bytes | ||||
| 	digestAlgorithm: Digest; | ||||
| 	keyLength: number; // in bytes | ||||
| 	associatedData: Uint8Array; | ||||
| 	iterationCount: number; | ||||
| } | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import Synchronizer from '../../Synchronizer'; | ||||
| import { fetchSyncInfo, getEncryptionEnabled, localSyncInfo, setEncryptionEnabled } from '../synchronizer/syncInfoUtils'; | ||||
| import { loadMasterKeysFromSettings, setupAndDisableEncryption, setupAndEnableEncryption } from '../e2ee/utils'; | ||||
| import { remoteNotesAndFolders } from '../../testing/test-utils-synchronizer'; | ||||
| import { EncryptionMethod } from '../e2ee/EncryptionService'; | ||||
|  | ||||
| let insideBeforeEach = false; | ||||
|  | ||||
| @@ -31,8 +32,12 @@ describe('Synchronizer.e2ee', () => { | ||||
| 		insideBeforeEach = false; | ||||
| 	}); | ||||
|  | ||||
| 	it('notes and folders should get encrypted when encryption is enabled', (async () => { | ||||
| 	it.each([ | ||||
| 		EncryptionMethod.SJCL1a, | ||||
| 		EncryptionMethod.StringV1, | ||||
| 	])('notes and folders should get encrypted when encryption is enabled', (async (encryptionMethod) => { | ||||
| 		setEncryptionEnabled(true); | ||||
| 		encryptionService().defaultEncryptionMethod_ = encryptionMethod; | ||||
| 		const masterKey = await loadEncryptionMasterKey(); | ||||
| 		const folder1 = await Folder.save({ title: 'folder1' }); | ||||
| 		let note1 = await Note.save({ title: 'un', body: 'to be encrypted', parent_id: folder1.id }); | ||||
| @@ -255,8 +260,13 @@ describe('Synchronizer.e2ee', () => { | ||||
| 		expect(allEncrypted).toBe(false); | ||||
| 	})); | ||||
|  | ||||
| 	it('should set the resource file size after decryption', (async () => { | ||||
| 	it.each([ | ||||
| 		[EncryptionMethod.SJCL1a, EncryptionMethod.SJCL1a], | ||||
| 		[EncryptionMethod.StringV1, EncryptionMethod.FileV1], | ||||
| 	])('should set the resource file size after decryption', (async (stringEncryptionMethod, fileEncryptionMethod) => { | ||||
| 		setEncryptionEnabled(true); | ||||
| 		encryptionService().defaultEncryptionMethod_ = stringEncryptionMethod; | ||||
| 		encryptionService().defaultFileEncryptionMethod_ = fileEncryptionMethod; | ||||
| 		const masterKey = await loadEncryptionMasterKey(); | ||||
|  | ||||
| 		const folder1 = await Folder.save({ title: 'folder1' }); | ||||
| @@ -282,9 +292,15 @@ describe('Synchronizer.e2ee', () => { | ||||
| 		expect(resource1_2.size).toBe(2720); | ||||
| 	})); | ||||
|  | ||||
| 	it('should encrypt remote resources after encryption has been enabled', (async () => { | ||||
| 	it.each([ | ||||
| 		[EncryptionMethod.SJCL1a, EncryptionMethod.SJCL1a], | ||||
| 		[EncryptionMethod.StringV1, EncryptionMethod.FileV1], | ||||
| 	])('should encrypt remote resources after encryption has been enabled', (async (stringEncryptionMethod, fileEncryptionMethod) => { | ||||
| 		while (insideBeforeEach) await time.msleep(100); | ||||
|  | ||||
| 		encryptionService().defaultEncryptionMethod_ = stringEncryptionMethod; | ||||
| 		encryptionService().defaultFileEncryptionMethod_ = fileEncryptionMethod; | ||||
|  | ||||
| 		const folder1 = await Folder.save({ title: 'folder1' }); | ||||
| 		const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id }); | ||||
| 		await shim.attachFileToNote(note1, `${supportDir}/photo.jpg`); | ||||
| @@ -350,9 +366,13 @@ describe('Synchronizer.e2ee', () => { | ||||
| 		expect(!!resource.encryption_blob_encrypted).toBe(false); | ||||
| 	})); | ||||
|  | ||||
| 	it('should stop trying to decrypt item after a few attempts', (async () => { | ||||
| 	it.each([ | ||||
| 		EncryptionMethod.SJCL1a, | ||||
| 		EncryptionMethod.StringV1, | ||||
| 	])('should stop trying to decrypt item after a few attempts', (async (encryptionMethod) => { | ||||
| 		let hasThrown; | ||||
|  | ||||
| 		encryptionService().defaultEncryptionMethod_ = encryptionMethod; | ||||
| 		const note = await Note.save({ title: 'ma note' }); | ||||
| 		const masterKey = await loadEncryptionMasterKey(); | ||||
| 		await setupAndEnableEncryption(encryptionService(), masterKey, '123456'); | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import { ResourceEntity } from './services/database/types'; | ||||
| import { TextItem } from 'pdfjs-dist/types/src/display/api'; | ||||
| import replaceUnsupportedCharacters from './utils/replaceUnsupportedCharacters'; | ||||
| import { FetchBlobOptions } from './types'; | ||||
| import crypto from './services/e2ee/crypto'; | ||||
|  | ||||
| import FileApiDriverLocal from './file-api-driver-local'; | ||||
| import * as mimeUtils from './mime-utils'; | ||||
| @@ -135,6 +136,7 @@ function shimInit(options: ShimInitOptions = null) { | ||||
| 	shim.Geolocation = GeolocationNode; | ||||
| 	shim.FormData = require('form-data'); | ||||
| 	shim.sjclModule = require('./vendor/sjcl.js'); | ||||
| 	shim.crypto = crypto; | ||||
| 	shim.electronBridge_ = options.electronBridge; | ||||
|  | ||||
| 	shim.fsDriver = () => { | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import * as React from 'react'; | ||||
| import { NoteEntity, ResourceEntity } from './services/database/types'; | ||||
| import type FsDriverBase from './fs-driver-base'; | ||||
| import type FileApiDriverLocal from './file-api-driver-local'; | ||||
| import { Crypto } from './services/e2ee/types'; | ||||
|  | ||||
| export interface CreateResourceFromPathOptions { | ||||
| 	resizeLargeImages?: 'always' | 'never' | 'ask'; | ||||
| @@ -292,6 +293,8 @@ const shim = { | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	sjclModule: null as any, | ||||
|  | ||||
| 	crypto: null as Crypto, | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	randomBytes: async (_count: number): Promise<any> => { | ||||
| 		throw new Error('Not implemented: randomBytes'); | ||||
|   | ||||
| @@ -119,7 +119,6 @@ Credentialless | ||||
| coepdegrade | ||||
| COEP | ||||
| Stormlikes | ||||
| Stormlikes | ||||
| BYTV | ||||
| keyval | ||||
| traineddata | ||||
| @@ -130,6 +129,8 @@ Zocial | ||||
| agplv | ||||
| Famegear | ||||
| rcompare | ||||
| rnqc | ||||
| owasp | ||||
| tabindex | ||||
| Backblaze | ||||
| onnx | ||||
|   | ||||
							
								
								
									
										62
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										62
									
								
								yarn.lock
									
									
									
									
									
								
							| @@ -5160,6 +5160,16 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@craftzdog/react-native-buffer@npm:^6.0.5": | ||||
|   version: 6.0.5 | ||||
|   resolution: "@craftzdog/react-native-buffer@npm:6.0.5" | ||||
|   dependencies: | ||||
|     ieee754: ^1.2.1 | ||||
|     react-native-quick-base64: ^2.0.5 | ||||
|   checksum: 921b8bc7f84778e355e81e475792399276d611a346a7e51b6266a45cf4aa82194beb3a8106af796ed143d958c8476070c59e3720c0eec0a3c31e368fbb08b350 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@cronvel/get-pixels@npm:^3.4.0": | ||||
|   version: 3.4.0 | ||||
|   resolution: "@cronvel/get-pixels@npm:3.4.0" | ||||
| @@ -7540,6 +7550,7 @@ __metadata: | ||||
|     react-native-paper: 5.12.3 | ||||
|     react-native-popup-menu: 0.16.1 | ||||
|     react-native-quick-actions: 0.3.13 | ||||
|     react-native-quick-crypto: 0.7.1 | ||||
|     react-native-rsa-native: 2.0.5 | ||||
|     react-native-safe-area-context: 4.10.8 | ||||
|     react-native-securerandom: 1.0.1 | ||||
| @@ -12228,7 +12239,7 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@types/node@npm:18.19.42": | ||||
| "@types/node@npm:18.19.42, @types/node@npm:^18.0.0": | ||||
|   version: 18.19.42 | ||||
|   resolution: "@types/node@npm:18.19.42" | ||||
|   dependencies: | ||||
| @@ -12244,15 +12255,6 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@types/node@npm:^18.0.0": | ||||
|   version: 18.19.33 | ||||
|   resolution: "@types/node@npm:18.19.33" | ||||
|   dependencies: | ||||
|     undici-types: ~5.26.4 | ||||
|   checksum: b6db87d095bc541d64a410fa323a35c22c6113220b71b608bbe810b2397932d0f0a51c3c0f3ef90c20d8180a1502d950a7c5314b907e182d9cc10b36efd2a44e | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@types/node@npm:^20.10.6": | ||||
|   version: 20.11.16 | ||||
|   resolution: "@types/node@npm:20.11.16" | ||||
| @@ -37900,6 +37902,31 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "react-native-quick-base64@npm:^2.0.5": | ||||
|   version: 2.1.2 | ||||
|   resolution: "react-native-quick-base64@npm:2.1.2" | ||||
|   dependencies: | ||||
|     base64-js: ^1.5.1 | ||||
|   peerDependencies: | ||||
|     react: "*" | ||||
|     react-native: "*" | ||||
|   checksum: 46f3b26f48b26978686b0c043336220d681e6a02af5abcf3eb4ab7b9216251d1eb2fac5c559e984d963e93f54bd9f323651daac09762196815558abbd551729b | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "react-native-quick-crypto@npm:0.7.1": | ||||
|   version: 0.7.1 | ||||
|   resolution: "react-native-quick-crypto@npm:0.7.1" | ||||
|   dependencies: | ||||
|     "@craftzdog/react-native-buffer": ^6.0.5 | ||||
|     events: ^3.3.0 | ||||
|     readable-stream: ^4.5.2 | ||||
|     string_decoder: ^1.3.0 | ||||
|     util: ^0.12.5 | ||||
|   checksum: 9a7a1fb1456410db30f062078f570bc566cac36fbc165e7d8ee8677bec09fcc96923de3cf7a0464804142af242a822bf66ea22460951399b9247e4a03fcfe059 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "react-native-rsa-native@npm:2.0.5": | ||||
|   version: 2.0.5 | ||||
|   resolution: "react-native-rsa-native@npm:2.0.5" | ||||
| @@ -38654,6 +38681,19 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "readable-stream@npm:^4.5.2": | ||||
|   version: 4.5.2 | ||||
|   resolution: "readable-stream@npm:4.5.2" | ||||
|   dependencies: | ||||
|     abort-controller: ^3.0.0 | ||||
|     buffer: ^6.0.3 | ||||
|     events: ^3.3.0 | ||||
|     process: ^0.11.10 | ||||
|     string_decoder: ^1.3.0 | ||||
|   checksum: c4030ccff010b83e4f33289c535f7830190773e274b3fcb6e2541475070bdfd69c98001c3b0cb78763fc00c8b62f514d96c2b10a8bd35d5ce45203a25fa1d33a | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "readable-stream@npm:~2.0.0": | ||||
|   version: 2.0.6 | ||||
|   resolution: "readable-stream@npm:2.0.6" | ||||
| @@ -45383,7 +45423,7 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "util@npm:^0.12.4": | ||||
| "util@npm:^0.12.4, util@npm:^0.12.5": | ||||
|   version: 0.12.5 | ||||
|   resolution: "util@npm:0.12.5" | ||||
|   dependencies: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user