diff --git a/apps/mobile/app/app.tsx b/apps/mobile/app/app.tsx index 70cb21005..1c5bfbc5f 100644 --- a/apps/mobile/app/app.tsx +++ b/apps/mobile/app/app.tsx @@ -43,6 +43,18 @@ I18nManager.allowRTL(false); I18nManager.forceRTL(false); I18nManager.swapLeftAndRightInRTL(false); +// How app lock works +// 1. User goes to settings and setup app lock with a Pin/Password. +// 2. The Pin/Password is used to encrypt a random value or user's encryption key. +// 3. The encrypted value is stored in MMKV +// 4. When the app launches, the same value is decrypted with user provided key, if it works, we launch the app otherwise it remains locked. +// 5. If Biometrics are enabled, the app lock pin/password is stored in keychain. the value can be accessed if fingerprint auth works ONLY. +// 6. User can manually enter the pin if biometrics fails. +// 7. There is no way to enter the app if user forgets the PIN. The only way is to reset app data and start fresh again. + +// How to handle app lock for existing users... +// 1. + const App = () => { const init = useAppEvents(); useEffect(() => { diff --git a/apps/mobile/app/common/database/encryption.js b/apps/mobile/app/common/database/encryption.js index 10204fbd6..159d4d0e2 100644 --- a/apps/mobile/app/common/database/encryption.js +++ b/apps/mobile/app/common/database/encryption.js @@ -17,16 +17,30 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +import Sodium from "@ammarahmed/react-native-sodium"; import { Platform } from "react-native"; import "react-native-get-random-values"; import * as Keychain from "react-native-keychain"; import { generateSecureRandom } from "react-native-securerandom"; -import Sodium from "@ammarahmed/react-native-sodium"; import { MMKV } from "./mmkv"; +import { ProcessingModes, MMKVLoader } from "react-native-mmkv-storage"; + +// Database key cipher is persisted across different user sessions hence it has +// it's independent storage which we will never clear. This is only used when application has +// app lock with password enabled. +export const CipherStorage = new MMKVLoader() + .withInstanceID("cipher_storage") + .setProcessingMode( + Platform.OS === "ios" + ? ProcessingModes.MULTI_PROCESS + : ProcessingModes.SINGLE_PROCESS + ) + .disableIndexing() + .initialize(); const IOS_KEYCHAIN_ACCESS_GROUP = "group.org.streetwriters.notesnook"; const IOS_KEYCHAIN_SERVICE_NAME = "org.streetwriters.notesnook"; -const IOS_KEYCHAIN_UPGRAGE_KEY = "keychain-ios:upgraded"; +const KEYCHAIN_SERVER_DBKEY = "notesnook:db"; const KEYSTORE_CONFIG = Platform.select({ ios: { @@ -37,50 +51,194 @@ const KEYSTORE_CONFIG = Platform.select({ android: {} }); +function generatePassword() { + const length = 80; + const crypto = window.crypto || window.msCrypto; + if (typeof crypto === "undefined") { + throw new Error( + "Crypto API is not supported. Please upgrade your web browser" + ); + } + const charset = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&+_{}[]():<>/?;"; + const indexes = crypto.getRandomValues(new Uint32Array(length)); + let secret = ""; + for (const index of indexes) { + secret += charset[index % charset.length]; + } + return secret; +} + +export async function encryptDatabaseKeyWithPassword(appLockPassword) { + const key = getDatabaseKey(); + const appLockCredentials = await Sodium.deriveKey( + appLockPassword, + "notesnook_applock_key" + ); + const databaseKeyCipher = await encrypt(appLockCredentials, key); + MMKV.setMap("databaseKeyCipher", databaseKeyCipher); + // We reset the database key from keychain once app lock password is set. + await Keychain.resetInternetCredentials("notesnook:db"); + return true; +} + +export async function restoreDatabaseKeyToKeyChain(appLockPassword) { + const databaseKeyCipher = CipherStorage.getMap("databaseKeyCipher"); + const databaseKey = await decrypt( + { + password: appLockPassword + }, + databaseKeyCipher + ); + + await Keychain.setInternetCredentials( + KEYCHAIN_SERVER_DBKEY, + "notesnook", + databaseKey, + KEYSTORE_CONFIG + ); + MMKV.removeItem("databaseKeyCipher"); + return true; +} + +export async function setAppLockVerificationCipher(appLockPassword) { + try { + console.log("key", appLockPassword); + const appLockCredentials = await Sodium.deriveKey( + appLockPassword, + "notesnook_applock_key_salt" + ); + const encrypted = await encrypt(appLockCredentials, "applock_password"); + + CipherStorage.setMap("appLockCipher", encrypted); + } catch (e) { + console.log(e); + } +} + +export async function clearAppLockVerificationCipher() { + CipherStorage.removeItem("appLockCipher"); +} + +export async function validateAppLockPassword(appLockPassword) { + try { + const appLockCipher = CipherStorage.getMap("appLockCipher"); + if (!appLockCipher) return true; + const decrypted = await decrypt( + { + password: appLockPassword + }, + appLockCipher + ); + return decrypted === "applock_password"; + } catch (e) { + console.error(e); + return false; + } +} + +let DB_KEY; +export function clearDatabaseKey() { + DB_KEY = undefined; +} + +export async function getDatabaseKey(appLockPassword) { + if (DB_KEY) return DB_KEY; + try { + if (appLockPassword) { + const databaseKeyCipher = CipherStorage.getMap("databaseKeyCipher"); + const databaseKey = await decrypt( + { + password: appLockPassword + }, + databaseKeyCipher + ); + console.log("Getting database key from cipher"); + DB_KEY = databaseKey; + return databaseKey; + } + + const hasKey = await Keychain.hasInternetCredentials(KEYCHAIN_SERVER_DBKEY); + if (hasKey) { + let credentials = await Keychain.getInternetCredentials( + KEYCHAIN_SERVER_DBKEY, + KEYSTORE_CONFIG + ); + console.log("Getting database key from Keychain"); + DB_KEY = credentials.password; + return credentials.password; + } + console.log("Generating new database key"); + const password = generatePassword(); + const derivedDatabaseKey = await Sodium.deriveKey( + password, + "notesnook_database_key" + ); + await Keychain.setInternetCredentials( + KEYCHAIN_SERVER_DBKEY, + "notesnook", + derivedDatabaseKey.key, + KEYSTORE_CONFIG + ); + + const userKeyCredentials = await Keychain.getInternetCredentials( + "notesnook", + KEYSTORE_CONFIG + ); + + if (userKeyCredentials) { + const userKeyCipher = await encrypt( + { + key: derivedDatabaseKey.key + }, + userKeyCredentials.password + ); + // Store encrypted user key in MMKV + MMKV.setMap("userKeyCipher", userKeyCipher); + await Keychain.resetInternetCredentials("notesnook"); + console.log("Migrated user credentials to cipher"); + } + + DB_KEY = derivedDatabaseKey.key; + + return derivedDatabaseKey.key; + } catch (e) { + console.log(e); + return null; + } +} + export async function deriveCryptoKey(name, data) { try { let credentials = await Sodium.deriveKey(data.password, data.salt); - await Keychain.setInternetCredentials( - "notesnook", - name, - credentials.key, - KEYSTORE_CONFIG + + const userKeyCipher = await encrypt( + { + key: await getDatabaseKey() + }, + credentials.key ); - MMKV.setBool(IOS_KEYCHAIN_UPGRAGE_KEY, true); + // Store encrypted user key in MMKV + MMKV.setMap("userKeyCipher", userKeyCipher); return credentials.key; } catch (e) { console.error(e); } } -async function upgradeIOSKeychain(username, password) { - if (Platform.OS !== "ios") return; - if (!MMKV.getBool(IOS_KEYCHAIN_UPGRAGE_KEY)) { - await Keychain.setInternetCredentials( - "notesnook", - username, - password, - KEYSTORE_CONFIG - ); - console.log("IOS KEYCHAIN MIGRATION COMPLETED!"); - MMKV.setBool(IOS_KEYCHAIN_UPGRAGE_KEY, true); - } -} - export async function getCryptoKey(_name) { try { - if (await Keychain.hasInternetCredentials("notesnook")) { - let credentials = await Keychain.getInternetCredentials( - "notesnook", - KEYSTORE_CONFIG - ); - // upgrades ios keychain to use accessGroups - // so we have access to keychain in share extension. - await upgradeIOSKeychain(credentials.username, credentials.password); - return credentials.password; - } else { - return null; - } + const keyCipher = MMKV.getMap("userKeyCipher"); + if (!key) return null; + + const key = decrypt( + { + key: await getDatabaseKey() + }, + keyCipher + ); + + return key; } catch (e) { console.error(e); } @@ -88,8 +246,9 @@ export async function getCryptoKey(_name) { export async function removeCryptoKey(_name) { try { - let result = await Keychain.resetInternetCredentials("notesnook"); - return result; + MMKV.removeItem("userKeyCipher"); + await Keychain.resetInternetCredentials("notesnook"); + return true; } catch (e) { console.error(e); } diff --git a/apps/mobile/app/common/database/index.js b/apps/mobile/app/common/database/index.js index 2816f9f67..16dadee2e 100644 --- a/apps/mobile/app/common/database/index.js +++ b/apps/mobile/app/common/database/index.js @@ -27,6 +27,7 @@ import { SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "kysely"; import filesystem from "../filesystem"; import Storage from "./storage"; import { RNSqliteDriver } from "./sqlite.kysely"; +import { getDatabaseKey } from "./encryption"; database.host( __DEV__ @@ -51,27 +52,35 @@ database.host( } ); -database.setup({ - storage: Storage, - eventsource: Platform.OS === "ios" ? EventSource : AndroidEventSource, - fs: filesystem, - compressor: { - compress: Gzip.deflate, - decompress: Gzip.inflate - }, - batchSize: 100, - sqliteOptions: { - dialect: (name) => ({ - createDriver: () => { - return new RNSqliteDriver({ async: true, dbName: name }); - }, - createAdapter: () => new SqliteAdapter(), - createIntrospector: (db) => new SqliteIntrospector(db), - createQueryCompiler: () => new SqliteQueryCompiler() - }), - tempStore: "memory" - } -}); +export async function setupDatabase(password) { + const key = await getDatabaseKey(password); + if (!key) + throw new Error("Database setup failed, could not get database key"); + + console.log("Opening database with key:", key); + database.setup({ + storage: Storage, + eventsource: Platform.OS === "ios" ? EventSource : AndroidEventSource, + fs: filesystem, + compressor: { + compress: Gzip.deflate, + decompress: Gzip.inflate + }, + batchSize: 100, + sqliteOptions: { + dialect: (name) => ({ + createDriver: () => { + return new RNSqliteDriver({ async: true, dbName: name }); + }, + createAdapter: () => new SqliteAdapter(), + createIntrospector: (db) => new SqliteIntrospector(db), + createQueryCompiler: () => new SqliteQueryCompiler() + }), + tempStore: "memory", + password: key + } + }); +} export const db = database; export const DatabaseLogger = dbLogger; diff --git a/apps/mobile/app/components/app-lock-overlay/index.tsx b/apps/mobile/app/components/app-lock-overlay/index.tsx index f6a90fdc6..f84d00d01 100644 --- a/apps/mobile/app/components/app-lock-overlay/index.tsx +++ b/apps/mobile/app/components/app-lock-overlay/index.tsx @@ -35,6 +35,7 @@ import Input from "../ui/input"; import Seperator from "../ui/seperator"; import Heading from "../ui/typography/heading"; import Paragraph from "../ui/typography/paragraph"; +import { validateAppLockPassword } from "../../common/database/encryption"; const AppLockedOverlay = () => { const { colors } = useThemeColors(); @@ -45,11 +46,24 @@ const AppLockedOverlay = () => { const passwordInputRef = useRef(null); const password = useRef(); const appState = useAppState(); - const biometricUnlockAwaitingUserInput = useRef(false); + const appLockHasPasswordSecurity = useSettingStore( + (state) => state.settings.appLockHasPasswordSecurity + ); + const biometricsAuthEnabled = useSettingStore( + (state) => + state.settings.biometricsAuthEnabled === true || + (state.settings.biometricsAuthEnabled === undefined && + !state.settings.appLockHasPasswordSecurity) + ); const onUnlockAppRequested = useCallback(async () => { - if (!(await BiometricService.isBiometryAvailable())) return; + if ( + !biometricsAuthEnabled || + !(await BiometricService.isBiometryAvailable()) + ) + return; + if (Platform.OS === "android") { const activityName = await NotesnookModule.getActivityName(); if (activityName !== "MainActivity") return; @@ -69,12 +83,15 @@ const AppLockedOverlay = () => { biometricUnlockAwaitingUserInput.current = false; useSettingStore.getState().setRequestBiometrics(false); }, 1); - }, [lockApp]); + }, [biometricsAuthEnabled, lockApp]); const onSubmit = async () => { if (!password.current) return; try { - const unlocked = await db.user.verifyPassword(password.current); + const unlocked = appLockHasPasswordSecurity + ? validateAppLockPassword(password.current) + : await db.user.verifyPassword(password.current); + if (unlocked) { lockApp(false); enabled(false); @@ -97,109 +114,116 @@ const AppLockedOverlay = () => { } }, [appState, onUnlockAppRequested, appLocked]); - return ( - appLocked && ( + return appLocked ? ( + - + - - - Unlock your notes - + Unlock your notes + + + + {"Please verify it's you"} + + + + {user || appLockHasPasswordSecurity ? ( + <> + (password.current = v)} + onSubmit={() => { + onSubmit(); + }} + /> + + ) : null} - - {"Please verify it's you"} - - - {user ? ( + {user || appLockHasPasswordSecurity ? ( <> - (password.current = v)} - onSubmit={() => { - onSubmit(); +