mobile: at rest encryption

This commit is contained in:
Ammar Ahmed
2023-12-28 14:35:15 +05:00
committed by Abdullah Atta
parent 8722157f33
commit aec4c510f1
16 changed files with 828 additions and 190 deletions

View File

@@ -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(() => {

View File

@@ -17,16 +17,30 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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);
}

View File

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

View File

@@ -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<TextInput>(null);
const password = useRef<string>();
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 ? (
<View
style={{
backgroundColor: colors.primary.background,
width: "100%",
height: "100%",
position: "absolute",
zIndex: 999,
justifyContent: "center"
}}
>
<View
style={{
backgroundColor: colors.primary.background,
width: "100%",
height: "100%",
position: "absolute",
zIndex: 999,
justifyContent: "center"
flex: 1,
justifyContent: "center",
width:
deviceMode !== "mobile"
? "50%"
: Platform.OS == "ios"
? "95%"
: "100%",
paddingHorizontal: 12,
marginBottom: 30,
marginTop: 15,
alignSelf: "center"
}}
>
<View
<IconButton
name="fingerprint"
size={100}
customStyle={{
width: 100,
height: 100,
marginBottom: 20,
marginTop: user ? 0 : 50
}}
onPress={onUnlockAppRequested}
color={colors.primary.border}
/>
<Heading
color={colors.primary.heading}
style={{
flex: 1,
justifyContent: "center",
width:
deviceMode !== "mobile"
? "50%"
: Platform.OS == "ios"
? "95%"
: "100%",
paddingHorizontal: 12,
marginBottom: 30,
marginTop: 15,
alignSelf: "center"
alignSelf: "center",
textAlign: "center"
}}
>
<IconButton
name="fingerprint"
size={100}
customStyle={{
width: 100,
height: 100,
marginBottom: 20,
marginTop: user ? 0 : 50
}}
onPress={onUnlockAppRequested}
color={colors.primary.border}
/>
<Heading
color={colors.primary.heading}
style={{
alignSelf: "center",
textAlign: "center"
}}
>
Unlock your notes
</Heading>
Unlock your notes
</Heading>
<Paragraph
style={{
alignSelf: "center",
textAlign: "center",
maxWidth: "90%"
}}
>
{"Please verify it's you"}
</Paragraph>
<Seperator />
<View
style={{
width: "100%",
padding: 12,
backgroundColor: colors.primary.background
}}
>
{user || appLockHasPasswordSecurity ? (
<>
<Input
fwdRef={passwordInputRef}
secureTextEntry
keyboardType={
appLockHasPasswordSecurity ? "number-pad" : "default"
}
placeholder={`Enter ${
appLockHasPasswordSecurity
? `app lock pin`
: "account password"
}`}
onChangeText={(v) => (password.current = v)}
onSubmit={() => {
onSubmit();
}}
/>
</>
) : null}
<Paragraph
style={{
alignSelf: "center",
textAlign: "center",
maxWidth: "90%"
}}
>
{"Please verify it's you"}
</Paragraph>
<Seperator />
<View
style={{
width: "100%",
padding: 12,
backgroundColor: colors.primary.background
marginTop: user ? 25 : 25
}}
>
{user ? (
{user || appLockHasPasswordSecurity ? (
<>
<Input
fwdRef={passwordInputRef}
secureTextEntry
placeholder="Enter account password"
onChangeText={(v) => (password.current = v)}
onSubmit={() => {
onSubmit();
<Button
title="Continue"
type="accent"
onPress={onSubmit}
width={250}
height={45}
style={{
borderRadius: 150,
marginBottom: 10
}}
fontSize={SIZE.md}
/>
</>
) : null}
<View
style={{
marginTop: user ? 25 : 25
}}
>
{user ? (
<>
<Button
title="Continue"
type="accent"
onPress={onSubmit}
width={250}
height={45}
style={{
borderRadius: 150,
marginBottom: 10
}}
fontSize={SIZE.md}
/>
</>
) : null}
{biometricsAuthEnabled ? (
<Button
title="Unlock with Biometrics"
width={250}
@@ -207,12 +231,12 @@ const AppLockedOverlay = () => {
icon={"fingerprint"}
type="transparent"
/>
</View>
) : null}
</View>
</View>
</View>
)
);
</View>
) : null;
};
export default AppLockedOverlay;

View File

@@ -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 = () => {
<SessionExpired />
<PDFPreview />
<JumpToSectionDialog />
<AppLockPassword />
</>
);
};

View File

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

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const confirmPasswordInputRef = useRef<TextInput>(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 (
<BaseDialog
onShow={async () => {
await sleep(100);
passwordInputRef.current?.focus();
}}
statusBarTranslucent={false}
onRequestClose={close}
visible={visible}
>
<View
style={{
...getElevationStyle(10),
width: DDS.isTab ? 350 : "85%",
borderRadius: 10,
backgroundColor: colors.primary.background,
paddingTop: 12
}}
>
<DialogHeader
title={
mode === "change"
? "Change app lock pin"
: mode === "remove"
? "Remove app lock pin"
: "Set up app lock pin"
}
paragraph={
mode === "change"
? "Change app lock pin"
: mode === "remove"
? "Remove app lock pin"
: "Set up a custom app lock pin to unlock the app"
}
icon="shield"
padding={12}
/>
<Seperator half />
<View
style={{
paddingHorizontal: 12
}}
>
{mode === "change" ? (
<Input
fwdRef={currentPasswordInputRef}
autoCapitalize="none"
onChangeText={(value) => {
values.current.currentPassword = value;
}}
onSubmit={() => {
passwordInputRef.current?.focus();
}}
autoComplete="password"
returnKeyLabel="Next"
keyboardType="number-pad"
returnKeyType="next"
secureTextEntry
placeholder={"Current pin"}
/>
) : null}
<Input
fwdRef={passwordInputRef}
autoCapitalize="none"
onChangeText={(value) => {
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" ? (
<Input
fwdRef={confirmPasswordInputRef}
autoCapitalize="none"
onChangeText={(value) => {
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}
</View>
<DialogButtons
onPressNegative={close}
onPressPositive={async () => {
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=""
/>
</View>
<Toast context="local" />
</BaseDialog>
);
};
AppLockPassword.present = (mode: "create" | "change" | "remove") => {
eSendEvent(eOpenAppLockPasswordDialog, mode);
};

View File

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

View File

@@ -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,

View File

@@ -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={{

View File

@@ -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);
}
}
}
]
}
]
},

View File

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

View File

@@ -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<SettingStore>((set, get) => ({
dbPassword: undefined,
settings: { ...defaultSettings },
sheetKeyboardHandler: true,
fullscreen: false,
@@ -184,6 +191,13 @@ export const useSettingStore = create<SettingStore>((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 }

View File

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

View File

@@ -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<DatabaseSchema>;
}
async changePassword(password?: string) {
if (!this._sql) return;
await changeDatabasePassword(this._sql, password);
}
async init() {
if (!this.options)
throw new Error(

View File

@@ -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<RawDatabaseSchema>({
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<DatabaseSchema>,
password?: string
) {
await sql`PRAGMA rekey = "${password ? password : ""}"`.execute(db);
}
export function isFalse<TB extends keyof DatabaseSchema>(
column: ReferenceExpression<DatabaseSchema, TB>
) {