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:
		| @@ -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, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user