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();
+
>
) : null}
-
- {user ? (
- <>
-
- >
- ) : null}
-
+ {biometricsAuthEnabled ? (
+ ) : null}
- )
- );
+
+ ) : null;
};
export default AppLockedOverlay;
diff --git a/apps/mobile/app/components/dialog-provider/index.js b/apps/mobile/app/components/dialog-provider/index.js
index d1a7c018f..a3dee1020 100644
--- a/apps/mobile/app/components/dialog-provider/index.js
+++ b/apps/mobile/app/components/dialog-provider/index.js
@@ -36,6 +36,7 @@ import SheetProvider from "../sheet-provider";
import RateAppSheet from "../sheets/rate-app";
import RecoveryKeySheet from "../sheets/recovery-key";
import RestoreDataSheet from "../sheets/restore-data";
+import { AppLockPassword } from "../dialogs/applock-password";
const DialogProvider = () => {
const { colors } = useThemeColors();
@@ -60,6 +61,7 @@ const DialogProvider = () => {
+
>
);
};
diff --git a/apps/mobile/app/components/dialog/index.js b/apps/mobile/app/components/dialog/index.js
index 7f878ebe4..b5545fc23 100644
--- a/apps/mobile/app/components/dialog/index.js
+++ b/apps/mobile/app/components/dialog/index.js
@@ -65,16 +65,6 @@ export const Dialog = ({ context = "global" }) => {
}
});
- useEffect(() => {
- eSubscribeEvent(eOpenSimpleDialog, show);
- eSubscribeEvent(eCloseSimpleDialog, hide);
-
- return () => {
- eUnSubscribeEvent(eOpenSimpleDialog, show);
- eUnSubscribeEvent(eCloseSimpleDialog, hide);
- };
- }, [show]);
-
const onPressPositive = async () => {
if (dialogInfo.positivePress) {
inputRef.current?.blur();
@@ -102,6 +92,16 @@ export const Dialog = ({ context = "global" }) => {
[context]
);
+ useEffect(() => {
+ eSubscribeEvent(eOpenSimpleDialog, show);
+ eSubscribeEvent(eCloseSimpleDialog, hide);
+
+ return () => {
+ eUnSubscribeEvent(eOpenSimpleDialog, show);
+ eUnSubscribeEvent(eCloseSimpleDialog, hide);
+ };
+ }, [show]);
+
const hide = () => {
setChecked(false);
values.current.inputValue = undefined;
diff --git a/apps/mobile/app/components/dialogs/applock-password/index.tsx b/apps/mobile/app/components/dialogs/applock-password/index.tsx
new file mode 100644
index 000000000..2f44b1399
--- /dev/null
+++ b/apps/mobile/app/components/dialogs/applock-password/index.tsx
@@ -0,0 +1,286 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+import { useThemeColors } from "@notesnook/theme";
+import React, { useEffect, useRef, useState } from "react";
+import { TextInput, View } from "react-native";
+import {
+ clearAppLockVerificationCipher,
+ setAppLockVerificationCipher,
+ validateAppLockPassword
+} from "../../../common/database/encryption";
+import { DDS } from "../../../services/device-detection";
+import {
+ ToastManager,
+ eSendEvent,
+ eSubscribeEvent
+} from "../../../services/event-manager";
+import SettingsService from "../../../services/settings";
+import { getElevationStyle } from "../../../utils/elevation";
+import {
+ eCloseAppLocKPasswordDailog,
+ eOpenAppLockPasswordDialog
+} from "../../../utils/events";
+import { sleep } from "../../../utils/time";
+import BaseDialog from "../../dialog/base-dialog";
+import DialogButtons from "../../dialog/dialog-buttons";
+import DialogHeader from "../../dialog/dialog-header";
+import { Toast } from "../../toast";
+import Input from "../../ui/input";
+import Seperator from "../../ui/seperator";
+
+export const AppLockPassword = () => {
+ const { colors } = useThemeColors();
+ const [mode, setMode] = useState<"create" | "change" | "remove">("create");
+ const [visible, setVisible] = useState(false);
+ const currentPasswordInputRef = useRef(null);
+ const passwordInputRef = useRef(null);
+ const confirmPasswordInputRef = useRef(null);
+ const values = useRef<{
+ currentPassword?: string;
+ password?: string;
+ confirmPassword?: string;
+ }>({});
+
+ useEffect(() => {
+ const subs = [
+ eSubscribeEvent(
+ eOpenAppLockPasswordDialog,
+ (mode: "create" | "change" | "remove") => {
+ setMode(mode);
+ setVisible(true);
+ }
+ ),
+ eSubscribeEvent(eCloseAppLocKPasswordDailog, () => {
+ values.current = {};
+ setVisible(false);
+ })
+ ];
+ return () => {
+ subs.forEach((sub) => sub.unsubscribe());
+ };
+ }, []);
+
+ const close = () => {
+ values.current = {};
+ setVisible(false);
+ };
+
+ return (
+ {
+ await sleep(100);
+ passwordInputRef.current?.focus();
+ }}
+ statusBarTranslucent={false}
+ onRequestClose={close}
+ visible={visible}
+ >
+
+
+
+
+
+ {mode === "change" ? (
+ {
+ values.current.currentPassword = value;
+ }}
+ onSubmit={() => {
+ passwordInputRef.current?.focus();
+ }}
+ autoComplete="password"
+ returnKeyLabel="Next"
+ keyboardType="number-pad"
+ returnKeyType="next"
+ secureTextEntry
+ placeholder={"Current pin"}
+ />
+ ) : null}
+
+ {
+ values.current.password = value;
+ }}
+ onSubmit={() => {
+ confirmPasswordInputRef.current?.focus();
+ }}
+ keyboardType="number-pad"
+ autoComplete="password"
+ returnKeyLabel={mode !== "remove" ? "Next" : "Remove"}
+ returnKeyType={mode !== "remove" ? "next" : "done"}
+ secureTextEntry
+ placeholder={mode === "change" ? "New pin" : "Pin"}
+ />
+
+ {mode !== "remove" ? (
+ {
+ values.current.confirmPassword = value;
+ }}
+ onSubmit={() => {
+ confirmPasswordInputRef.current?.focus();
+ }}
+ keyboardType="number-pad"
+ customValidator={() => values.current.password || ""}
+ validationType="confirmPassword"
+ autoComplete="password"
+ returnKeyLabel="Done"
+ returnKeyType="done"
+ secureTextEntry
+ placeholder={"Confirm pin"}
+ />
+ ) : null}
+
+
+ {
+ if (mode === "create") {
+ if (!values.current.password || !values.current.confirmPassword) {
+ ToastManager.error(
+ new Error("All inputs are required"),
+ undefined,
+ "local"
+ );
+ return;
+ }
+
+ if (values.current.password !== values.current.confirmPassword) {
+ ToastManager.error(
+ new Error("Pin does not match"),
+ undefined,
+ "local"
+ );
+ return;
+ }
+ await setAppLockVerificationCipher(values.current.password);
+ SettingsService.setProperty("appLockHasPasswordSecurity", true);
+ } else if (mode === "change") {
+ if (
+ !values.current.currentPassword ||
+ !values.current.password ||
+ !values.current.confirmPassword
+ ) {
+ ToastManager.error(
+ new Error("All inputs are required"),
+ undefined,
+ "local"
+ );
+ return;
+ }
+
+ if (values.current.password !== values.current.confirmPassword) {
+ ToastManager.error(
+ new Error("Pin does not match"),
+ undefined,
+ "local"
+ );
+ return;
+ }
+
+ const isCurrentPasswordCorrect = await validateAppLockPassword(
+ values.current.currentPassword
+ );
+
+ if (!isCurrentPasswordCorrect) {
+ ToastManager.error(
+ new Error("Pin incorrect"),
+ undefined,
+ "local"
+ );
+ return;
+ }
+
+ SettingsService.setProperty("appLockHasPasswordSecurity", true);
+ await setAppLockVerificationCipher(values.current.password);
+ } else if (mode === "remove") {
+ if (!values.current.password) {
+ ToastManager.error(
+ new Error("All inputs are required"),
+ undefined,
+ "local"
+ );
+ return;
+ }
+
+ const isCurrentPasswordCorrect = await validateAppLockPassword(
+ values.current.password
+ );
+
+ if (!isCurrentPasswordCorrect) {
+ ToastManager.error(new Error("Pin incorrect"), "local");
+ return;
+ }
+ clearAppLockVerificationCipher();
+ SettingsService.setProperty("appLockHasPasswordSecurity", false);
+ }
+
+ close();
+ }}
+ positiveTitle="Save"
+ negativeTitle="Cancel"
+ positiveType="transparent"
+ loading={false}
+ doneText=""
+ />
+
+
+
+
+ );
+};
+
+AppLockPassword.present = (mode: "create" | "change" | "remove") => {
+ eSendEvent(eOpenAppLockPasswordDialog, mode);
+};
diff --git a/apps/mobile/app/hooks/use-app-events.tsx b/apps/mobile/app/hooks/use-app-events.tsx
index f2bfd1bc3..598405f4a 100644
--- a/apps/mobile/app/hooks/use-app-events.tsx
+++ b/apps/mobile/app/hooks/use-app-events.tsx
@@ -49,7 +49,7 @@ import { User } from "@notesnook/core/dist/api/user-manager";
import { EventManagerSubscription } from "@notesnook/core/dist/utils/event-manager";
//@ts-ignore
import { enabled } from "react-native-privacy-snapshot";
-import { DatabaseLogger, db } from "../common/database";
+import { DatabaseLogger, db, setupDatabase } from "../common/database";
import { MMKV } from "../common/database/mmkv";
import Migrate from "../components/sheets/migrate";
import NewFeature from "../components/sheets/new-feature";
@@ -645,34 +645,44 @@ export const useAppEvents = () => {
}
}, [loading, onUserUpdated]);
- const initializeDatabase = useCallback(async () => {
- try {
+ const initializeDatabase = useCallback(
+ async (password?: string) => {
+ if (useUserStore.getState().appLocked) return;
if (!db.isInitialized) {
RNBootSplash.hide({ fade: true });
DatabaseLogger.info("Initializing database");
- await db.init();
+ try {
+ await setupDatabase(password);
+ await db.init();
+ } catch (e) {
+ DatabaseLogger.error(e as Error);
+ ToastManager.error(
+ e as Error,
+ "Error initializing database",
+ "global"
+ );
+ }
+ }
+
+ if (db.isInitialized) {
+ useSettingStore.getState().setAppLoading(false);
}
if (IsDatabaseMigrationRequired()) return;
- setImmediate(() => {
- useSettingStore.getState().setAppLoading(false);
- });
Walkthrough.init();
- } catch (e) {
- DatabaseLogger.error(e as Error);
- ToastManager.error(e as Error, "Error initializing database", "global");
- }
- }, [IsDatabaseMigrationRequired]);
+ },
+ [IsDatabaseMigrationRequired]
+ );
useEffect(() => {
let sub: () => void;
if (appLocked) {
const sub = useUserStore.subscribe((state) => {
- if (
- !state.appLocked &&
- db.isInitialized &&
- useSettingStore.getState().isAppLoading
- ) {
- initializeDatabase();
+ if (!state.appLocked && useSettingStore.getState().isAppLoading) {
+ initializeDatabase(useSettingStore.getState().dbPassword);
+ useSettingStore.setState({
+ dbPassword: undefined
+ });
+
sub();
}
});
diff --git a/apps/mobile/app/navigation/tabs-holder.js b/apps/mobile/app/navigation/tabs-holder.js
index a1d1b5c7e..317f5a3bf 100644
--- a/apps/mobile/app/navigation/tabs-holder.js
+++ b/apps/mobile/app/navigation/tabs-holder.js
@@ -22,7 +22,7 @@ import {
deactivateKeepAwake
} from "@sayem314/react-native-keep-awake";
import React, { useCallback, useEffect, useRef, useState } from "react";
-import { Platform, StatusBar, View } from "react-native";
+import { Dimensions, Platform, StatusBar, View } from "react-native";
import changeNavigationBarColor from "react-native-navigation-bar-color";
import {
addOrientationListener,
@@ -211,6 +211,11 @@ const _TabsHolder = () => {
checkDeviceType(size);
};
+ if (!deviceMode) {
+ const size = Dimensions.get("window");
+ checkDeviceType(size);
+ }
+
function checkDeviceType(size) {
setDimensions({
width: size.width,
diff --git a/apps/mobile/app/screens/settings/app-lock.js b/apps/mobile/app/screens/settings/app-lock.js
index 3401f59e5..0930a3123 100644
--- a/apps/mobile/app/screens/settings/app-lock.js
+++ b/apps/mobile/app/screens/settings/app-lock.js
@@ -210,7 +210,8 @@ const AppLock = ({ route }) => {
if (
!(await BiometicService.isBiometryAvailable()) &&
!useUserStore.getState().user &&
- item.value !== modes[0].value
+ item.value !== modes[0].value &&
+ !SettingsService.getProperty("appLockHasPasswordSecurity")
) {
ToastManager.show({
heading: "Biometrics not enrolled",
@@ -220,6 +221,26 @@ const AppLock = ({ route }) => {
});
return;
}
+
+ if (
+ !SettingsService.getProperty(
+ "appLockHasPasswordSecurity"
+ ) &&
+ item.value !== modes[0].value
+ ) {
+ const verified = await BiometicService.validateUser(
+ "Verify it's you"
+ );
+ if (verified) {
+ SettingsService.setProperty(
+ "biometricsAuthEnabled",
+ true
+ );
+ } else {
+ return;
+ }
+ }
+
SettingsService.set({ appLockMode: item.value });
}}
customStyle={{
diff --git a/apps/mobile/app/screens/settings/settings-data.tsx b/apps/mobile/app/screens/settings/settings-data.tsx
index 4938625b9..3d6062e64 100644
--- a/apps/mobile/app/screens/settings/settings-data.tsx
+++ b/apps/mobile/app/screens/settings/settings-data.tsx
@@ -68,6 +68,7 @@ import { useDragState } from "./editor/state";
import { verifyUser } from "./functions";
import { SettingSection } from "./types";
import { getTimeLeft } from "./user-section";
+import { AppLockPassword } from "../../components/dialogs/applock-password";
type User = any;
export const settingsGroups: SettingSection[] = [
@@ -767,11 +768,84 @@ export const settingsGroups: SettingSection[] = [
{
id: "app-lock",
name: "App lock",
- description: "Change app lock mode to suit your needs",
- icon: "fingerprint",
- modifer: () => {
- AppLock.present();
- }
+ type: "screen",
+ description: "Enhanced at rest encryption with app lock",
+ icon: "lock",
+ sections: [
+ {
+ id: "app-lock-mode",
+ name: "App lock mode",
+ description:
+ "Select the mode for the desired level of app lock security.",
+ icon: "fingerprint",
+ modifer: () => {
+ AppLock.present();
+ }
+ },
+ {
+ id: "app-lock-pin",
+ name: "Setup app lock pin",
+ description: "Set up a new pin for app lock",
+ hidden: () => {
+ return !!SettingsService.getProperty(
+ "appLockHasPasswordSecurity"
+ );
+ },
+ property: "appLockHasPasswordSecurity",
+ modifer: () => {
+ AppLockPassword.present("create");
+ }
+ },
+ {
+ id: "app-lock-pin-change",
+ name: "Change app lock pin",
+ description: "Set up a new pin for the app lock",
+ hidden: () => {
+ return !SettingsService.getProperty("appLockHasPasswordSecurity");
+ },
+ property: "appLockHasPasswordSecurity",
+ modifer: () => {
+ AppLockPassword.present("change");
+ }
+ },
+ {
+ id: "app-lock-pin-remove",
+ name: "Remove app lock pin",
+ description:
+ "Remove app lock pin, app lock will fallback to using account password to unlock the app",
+ hidden: () => {
+ return !SettingsService.getProperty("appLockHasPasswordSecurity");
+ },
+ property: "appLockHasPasswordSecurity",
+ modifer: () => {
+ AppLockPassword.present("remove");
+ }
+ },
+ {
+ id: "app-lock-fingerprint",
+ name: "Unlock with biometrics",
+ description: "Allow biometric authentication to unlock the app",
+ type: "switch",
+ property: "biometricsAuthEnabled",
+ onChange: async () => {
+ if (await BiometicService.isBiometryAvailable()) {
+ const verified = await BiometicService.validateUser(
+ "Verify it's you"
+ );
+ if (!verified) {
+ SettingsService.setProperty("biometricsAuthEnabled", false);
+ }
+ } else {
+ ToastManager.error(
+ new Error(
+ "Biometric authentication is unavailable on this device."
+ )
+ );
+ SettingsService.setProperty("biometricsAuthEnabled", false);
+ }
+ }
+ }
+ ]
}
]
},
diff --git a/apps/mobile/app/services/biometrics.ts b/apps/mobile/app/services/biometrics.ts
index 11671247d..c11b99b9b 100644
--- a/apps/mobile/app/services/biometrics.ts
+++ b/apps/mobile/app/services/biometrics.ts
@@ -74,13 +74,13 @@ async function getCredentials(title?: string, description?: string) {
const options = Platform.select({
ios: {
- fallbackEnabled: true,
+ fallbackEnabled: false,
description: description
},
android: {
title: title,
description: description,
- deviceCredentialAllowed: true
+ deviceCredentialAllowed: false
}
});
await FingerprintScanner.authenticate(options as AuthenticateIOS);
@@ -125,13 +125,13 @@ async function validateUser(title: string, description?: string) {
await FingerprintScanner.authenticate(
Platform.select({
ios: {
- fallbackEnabled: true,
+ fallbackEnabled: false,
description: title
},
android: {
title: title,
description: description,
- deviceCredentialAllowed: true
+ deviceCredentialAllowed: false
}
}) as AuthenticateIOS
);
diff --git a/apps/mobile/app/stores/use-setting-store.ts b/apps/mobile/app/stores/use-setting-store.ts
index 6dcdc802d..498b307c5 100644
--- a/apps/mobile/app/stores/use-setting-store.ts
+++ b/apps/mobile/app/stores/use-setting-store.ts
@@ -75,6 +75,8 @@ export type Settings = {
lighTheme: ThemeDefinition;
darkTheme: ThemeDefinition;
markdownShortcuts?: boolean;
+ appLockHasPasswordSecurity?: boolean;
+ biometricsAuthEnabled?: boolean;
};
type DimensionsType = {
@@ -110,6 +112,8 @@ export interface SettingStore extends State {
setInsets: (insets: Insets) => void;
timeFormat: string;
dateFormat: string;
+ dbPassword?: string;
+ isOldAppLock: () => boolean;
}
const { width, height } = Dimensions.get("window");
@@ -156,10 +160,13 @@ export const defaultSettings: SettingStore["settings"] = {
colorScheme: "light",
lighTheme: ThemeLight,
darkTheme: ThemeDark,
- markdownShortcuts: true
+ markdownShortcuts: true,
+ biometricsAuthEnabled: false,
+ appLockHasPasswordSecurity: false
};
export const useSettingStore = create((set, get) => ({
+ dbPassword: undefined,
settings: { ...defaultSettings },
sheetKeyboardHandler: true,
fullscreen: false,
@@ -184,6 +191,13 @@ export const useSettingStore = create((set, get) => ({
});
},
appDidEnterBackgroundForAction: false,
+ isOldAppLock: () => {
+ return (
+ get().settings.appLockHasPasswordSecurity === undefined &&
+ get().settings.biometricsAuthEnabled === undefined &&
+ get().settings.appLockMode !== "none"
+ );
+ },
insets: initialWindowMetrics?.insets
? initialWindowMetrics.insets
: { top: 0, right: 0, left: 0, bottom: 0 }
diff --git a/apps/mobile/app/utils/events.js b/apps/mobile/app/utils/events.js
index dbd5da01b..3135a6ad5 100644
--- a/apps/mobile/app/utils/events.js
+++ b/apps/mobile/app/utils/events.js
@@ -163,3 +163,6 @@ export const eLoginSessionExpired = "609";
export const eDBItemUpdate = "610";
export const eGroupOptionsUpdated = "611";
export const eOnRefreshSearch = "612";
+
+export const eOpenAppLockPasswordDialog = "613";
+export const eCloseAppLocKPasswordDailog = "614";
diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts
index 65033c550..5df80bfb6 100644
--- a/packages/core/src/api/index.ts
+++ b/packages/core/src/api/index.ts
@@ -64,6 +64,7 @@ import {
DatabaseAccessor,
DatabaseSchema,
SQLiteOptions,
+ changeDatabasePassword,
createDatabase
} from "../database";
import { Kysely, Transaction, sql } from "kysely";
@@ -223,7 +224,8 @@ class Database {
"DELETE FROM sqlite_master",
"PRAGMA writable_schema = 0",
"VACUUM",
- "PRAGMA integrity_check"
+ "PRAGMA integrity_check",
+ "PRAGMA rekey = ''"
]) {
await sql.raw(statement).execute(this.sql());
}
@@ -234,6 +236,11 @@ class Database {
)) as unknown as Kysely;
}
+ async changePassword(password?: string) {
+ if (!this._sql) return;
+ await changeDatabasePassword(this._sql, password);
+ }
+
async init() {
if (!this.options)
throw new Error(
diff --git a/packages/core/src/database/index.ts b/packages/core/src/database/index.ts
index 95a86ab71..9d27b182a 100644
--- a/packages/core/src/database/index.ts
+++ b/packages/core/src/database/index.ts
@@ -216,12 +216,17 @@ export type SQLiteOptions = {
tempStore?: "memory" | "file" | "default";
cacheSize?: number;
pageSize?: number;
+ password?: string;
};
export async function createDatabase(name: string, options: SQLiteOptions) {
const db = new Kysely({
dialect: options.dialect(name),
plugins: [new SqliteBooleanPlugin()]
});
+ if (options.password)
+ await sql`PRAGMA key = ${sql.ref(options.password)}`
+ .execute(db)
+ .then((r) => console.log(r));
const migrator = new Migrator({
db,
@@ -275,6 +280,13 @@ export async function createDatabase(name: string, options: SQLiteOptions) {
return db;
}
+export async function changeDatabasePassword(
+ db: Kysely,
+ password?: string
+) {
+ await sql`PRAGMA rekey = "${password ? password : ""}"`.execute(db);
+}
+
export function isFalse(
column: ReferenceExpression
) {