diff --git a/apps/mobile/app/components/auth/change-password.tsx b/apps/mobile/app/components/auth/change-password.tsx
index 1fc7dd8163..a3ca009383 100644
--- a/apps/mobile/app/components/auth/change-password.tsx
+++ b/apps/mobile/app/components/auth/change-password.tsx
@@ -73,7 +73,15 @@ export const ChangePassword = () => {
throw new Error(strings.backupFailed() + `: ${result.error}`);
}
- await db.user.changePassword(oldPassword.current, password.current);
+ const passwordChanged = await db.user.changePassword(
+ oldPassword.current,
+ password.current
+ );
+
+ if (!passwordChanged) {
+ throw new Error("Could not change user account password.");
+ }
+
ToastManager.show({
heading: strings.passwordChangedSuccessfully(),
type: "success",
diff --git a/apps/mobile/app/components/auth/common.ts b/apps/mobile/app/components/auth/common.ts
index 0408691fbb..b2c9ee2df2 100644
--- a/apps/mobile/app/components/auth/common.ts
+++ b/apps/mobile/app/components/auth/common.ts
@@ -39,7 +39,7 @@ export function hideAuth(context?: AuthParams["context"]) {
initialAuthMode.current === AuthMode.welcomeLogin ||
context === "intro"
) {
- Navigation.replace("FluidPanelsView", {});
+ Navigation.navigate("FluidPanelsView", {});
} else {
Navigation.goBack();
}
diff --git a/apps/mobile/app/components/auth/forgot-password.tsx b/apps/mobile/app/components/auth/forgot-password.tsx
index 14e62a7ead..67f82e16e8 100644
--- a/apps/mobile/app/components/auth/forgot-password.tsx
+++ b/apps/mobile/app/components/auth/forgot-password.tsx
@@ -19,7 +19,6 @@ along with this program. If not, see .
import React, { useRef, useState } from "react";
import { TextInput, View } from "react-native";
-import ActionSheet from "react-native-actions-sheet";
import { db } from "../../common/database";
import { DDS } from "../../services/device-detection";
import { ToastManager } from "../../services/event-manager";
@@ -35,9 +34,9 @@ import Paragraph from "../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../utils/styles";
-export const ForgotPassword = () => {
+export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
const { colors } = useThemeColors("sheet");
- const email = useRef(undefined);
+ const email = useRef(userEmail);
const emailInputRef = useRef(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
@@ -87,94 +86,76 @@ export const ForgotPassword = () => {
return (
<>
- (email.current = data)}
- onClose={() => {
- setSent(false);
- setLoading(false);
- }}
- onOpen={() => {
- emailInputRef.current?.setNativeProps({
- text: email.current
- });
- }}
- indicatorStyle={{
- width: 100
- }}
- gestureEnabled
- id="forgotpassword_sheet"
- >
- {sent ? (
-
+
-
- {strings.recoveryEmailSent()}
-
- {strings.recoveryEmailSentDesc()}
-
-
- ) : (
-
+ {strings.recoveryEmailSent()}
+
-
-
+ {strings.recoveryEmailSentDesc()}
+
+
+ ) : (
+
+
+
- {
- email.current = value;
- }}
- defaultValue={email.current}
- onErrorCheck={(e) => setError(e)}
- returnKeyLabel={strings.next()}
- returnKeyType="next"
- autoComplete="email"
- validationType="email"
- autoCorrect={false}
- autoCapitalize="none"
- errorMessage={strings.emailInvalid()}
- placeholder={strings.email()}
- onSubmit={() => {}}
- />
+ {
+ email.current = value;
+ }}
+ defaultValue={email.current}
+ onErrorCheck={(e) => setError(e)}
+ returnKeyLabel={strings.next()}
+ returnKeyType="next"
+ autoComplete="email"
+ validationType="email"
+ autoCorrect={false}
+ autoCapitalize="none"
+ errorMessage={strings.emailInvalid()}
+ placeholder={strings.email()}
+ onSubmit={() => {}}
+ />
-
-
- )}
-
+
+
+ )}
>
);
};
diff --git a/apps/mobile/app/components/auth/login.tsx b/apps/mobile/app/components/auth/login.tsx
index a9f8692677..5fb5b72b82 100644
--- a/apps/mobile/app/components/auth/login.tsx
+++ b/apps/mobile/app/components/auth/login.tsx
@@ -25,7 +25,11 @@ import { TouchableOpacity, View, useWindowDimensions } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { DDS } from "../../services/device-detection";
-import { eSendEvent, ToastManager } from "../../services/event-manager";
+import {
+ eSendEvent,
+ presentSheet,
+ ToastManager
+} from "../../services/event-manager";
import Navigation from "../../services/navigation";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
@@ -110,7 +114,6 @@ export const Login = ({
return (
<>
-
{
- ToastManager.show({
- type: "info",
- message:
- "Password changing has been disabled temporarily to address some issues faced by users. It will be enabled again once the issues have resolved."
+ if (loading || !email.current) return;
+ presentSheet({
+ component:
});
- // if (loading || !email.current) return;
- // SheetManager.show("forgotpassword_sheet");
}}
textStyle={{
textDecorationLine: "underline"
diff --git a/apps/mobile/app/components/auth/session-expired.tsx b/apps/mobile/app/components/auth/session-expired.tsx
index f6df8a902d..360f8153e6 100644
--- a/apps/mobile/app/components/auth/session-expired.tsx
+++ b/apps/mobile/app/components/auth/session-expired.tsx
@@ -97,7 +97,7 @@ export const SessionExpired = () => {
if (db.tokenManager._isTokenExpired(res))
throw new Error("token expired");
- const key = await db.user.getEncryptionKey();
+ const key = await db.user.getDataEncryptionKeys();
if (!key) throw new Error("No encryption key found.");
Sync.run("global", false, "full", async (complete) => {
diff --git a/apps/mobile/app/components/paywall/index.tsx b/apps/mobile/app/components/paywall/index.tsx
index 8a7d7b6240..ee228cb819 100644
--- a/apps/mobile/app/components/paywall/index.tsx
+++ b/apps/mobile/app/components/paywall/index.tsx
@@ -144,7 +144,7 @@ const PayWall = (props: NavigationProps<"PayWall">) => {
(sub: User["subscription"]) => {
if (sub.plan === SubscriptionPlan.FREE) return;
if (routeParams.context === "signup") {
- Navigation.replace("FluidPanelsView", {});
+ Navigation.navigate("FluidPanelsView", {});
} else {
Navigation.goBack();
}
@@ -182,8 +182,9 @@ const PayWall = (props: NavigationProps<"PayWall">) => {
>
{
- Navigation.replace("FluidPanelsView", {});
+ Navigation.navigate("FluidPanelsView", {});
}}
/>
@@ -196,7 +197,7 @@ const PayWall = (props: NavigationProps<"PayWall">) => {
return;
}
if (routeParams.context === "signup") {
- Navigation.replace("FluidPanelsView", {});
+ Navigation.navigate("FluidPanelsView", {});
} else {
Navigation.goBack();
}
@@ -654,7 +655,7 @@ After trying all the privacy security oriented note taking apps, for the price a
type="accent"
onPress={() => {
if (routeParams.context === "signup") {
- Navigation.replace("FluidPanelsView", {});
+ Navigation.navigate("FluidPanelsView", {});
} else {
Navigation.goBack();
}
diff --git a/apps/mobile/app/components/sheets/recovery-key/index.jsx b/apps/mobile/app/components/sheets/recovery-key/index.jsx
index 45f5dd2b51..7b0825d0ec 100644
--- a/apps/mobile/app/components/sheets/recovery-key/index.jsx
+++ b/apps/mobile/app/components/sheets/recovery-key/index.jsx
@@ -169,7 +169,7 @@ class RecoveryKeySheet extends React.Component {
};
onOpen = async () => {
- let k = await db.user.getEncryptionKey();
+ let k = await db.user.getMasterKey();
this.user = await db.user.getUser();
if (k) {
this.setState({
diff --git a/apps/mobile/app/screens/editor/tiptap/use-editor.ts b/apps/mobile/app/screens/editor/tiptap/use-editor.ts
index aa351882a9..58d69c46d8 100644
--- a/apps/mobile/app/screens/editor/tiptap/use-editor.ts
+++ b/apps/mobile/app/screens/editor/tiptap/use-editor.ts
@@ -1034,13 +1034,6 @@ export const useEditor = (
state.current.isRestoringState = false;
}, []);
- useEffect(() => {
- eSubscribeEvent(eOnLoadNote + editorId, loadNote);
- return () => {
- eUnSubscribeEvent(eOnLoadNote + editorId, loadNote);
- };
- }, [editorId, loadNote, restoreEditorState, isDefaultEditor]);
-
const onContentChanged = (noteId?: string) => {
if (noteId) {
lastContentChangeTime.current[noteId] = Date.now();
diff --git a/apps/mobile/app/screens/settings/settings-data.tsx b/apps/mobile/app/screens/settings/settings-data.tsx
index 34a5069184..bbf2e4d705 100644
--- a/apps/mobile/app/screens/settings/settings-data.tsx
+++ b/apps/mobile/app/screens/settings/settings-data.tsx
@@ -331,17 +331,10 @@ export const settingsGroups: SettingSection[] = [
{
id: "change-password",
name: strings.changePassword(),
- // type: "screen",
+ type: "screen",
description: strings.changePasswordDesc(),
- // component: "change-password",
- icon: "form-textbox-password",
- modifer: () => {
- ToastManager.show({
- type: "info",
- message:
- "Password changing has been disabled temporarily to address some issues faced by users. It will be enabled again once the issues have resolved."
- });
- }
+ component: "change-password",
+ icon: "form-textbox-password"
},
{
id: "change-email",
diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json
index 9409d3e6a4..d84a50ee05 100644
--- a/apps/mobile/package-lock.json
+++ b/apps/mobile/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@notesnook/mobile",
- "version": "3.3.13-beta.1",
+ "version": "3.3.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/mobile",
- "version": "3.3.13-beta.1",
+ "version": "3.3.13",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
diff --git a/apps/web/src/common/db.ts b/apps/web/src/common/db.ts
index fb0e7cbdc3..37941f339c 100644
--- a/apps/web/src/common/db.ts
+++ b/apps/web/src/common/db.ts
@@ -48,15 +48,23 @@ async function initializeDatabase(persistence: DatabasePersistence) {
await useKeyStore.getState().setValue("databaseKey", databaseKey);
}
+ // db.host({
+ // API_HOST: "https://api.notesnook.com",
+ // AUTH_HOST: "https://auth.streetwriters.co",
+ // SSE_HOST: "https://events.streetwriters.co",
+ // ISSUES_HOST: "https://issues.streetwriters.co",
+ // SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
+ // MONOGRAPH_HOST: "https://monogr.ph",
+ // NOTESNOOK_HOST: "https://notesnook.com",
+ // ...Config.get("serverUrls", {})
+ // });
+ const base = `http://localhost`;
db.host({
- API_HOST: "https://api.notesnook.com",
- AUTH_HOST: "https://auth.streetwriters.co",
- SSE_HOST: "https://events.streetwriters.co",
- ISSUES_HOST: "https://issues.streetwriters.co",
- SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
- MONOGRAPH_HOST: "https://monogr.ph",
- NOTESNOOK_HOST: "https://notesnook.com",
- ...Config.get("serverUrls", {})
+ API_HOST: `${base}:5264`,
+ AUTH_HOST: `${base}:8264`,
+ SSE_HOST: `${base}:7264`,
+ ISSUES_HOST: `${base}:2624`,
+ SUBSCRIPTIONS_HOST: `${base}:9264`
});
const storage = new NNStorage(
@@ -72,7 +80,7 @@ async function initializeDatabase(persistence: DatabasePersistence) {
dialect: (name, init) =>
createDialect({
name: persistence === "memory" ? ":memory:" : name,
- encrypted: true,
+ encrypted: persistence !== "memory",
async: !isFeatureSupported("opfs"),
init,
multiTab
@@ -87,7 +95,10 @@ async function initializeDatabase(persistence: DatabasePersistence) {
synchronous: "normal",
pageSize: 8192,
cacheSize: -32000,
- password: Buffer.from(databaseKey).toString("hex"),
+ password:
+ persistence === "memory"
+ ? undefined
+ : Buffer.from(databaseKey).toString("hex"),
skipInitialization: !IS_DESKTOP_APP && multiTab
},
storage: storage,
diff --git a/apps/web/src/common/sqlite/sqlite.worker.ts b/apps/web/src/common/sqlite/sqlite.worker.ts
index 36929cf03d..f72e420d2b 100644
--- a/apps/web/src/common/sqlite/sqlite.worker.ts
+++ b/apps/web/src/common/sqlite/sqlite.worker.ts
@@ -167,6 +167,8 @@ class _SQLiteWorker {
sql: string,
parameters?: SQLiteCompatibleType[]
): Promise> {
+ if (!this.encrypted && !this.initialized) await this.initialize();
+
if (this.encrypted && !sql.startsWith("PRAGMA key")) {
await this.waitForDatabase();
}
diff --git a/apps/web/src/dialogs/recovery-key-dialog.tsx b/apps/web/src/dialogs/recovery-key-dialog.tsx
index 5298613cee..4491af84b1 100644
--- a/apps/web/src/dialogs/recovery-key-dialog.tsx
+++ b/apps/web/src/dialogs/recovery-key-dialog.tsx
@@ -38,7 +38,7 @@ type RecoveryKeyDialogProps = BaseDialogProps;
export const RecoveryKeyDialog = DialogManager.register(
function RecoveryKeyDialog(props: RecoveryKeyDialogProps) {
const key = usePromise(() =>
- db.user.getEncryptionKey().then((key) => key?.key)
+ db.user.getMasterKey().then((key) => key?.key)
);
const [copyText, setCopyText] = useState("Copy to clipboard");
diff --git a/apps/web/src/dialogs/settings/auth-settings.ts b/apps/web/src/dialogs/settings/auth-settings.ts
index 79e62c2fd1..8166be8673 100644
--- a/apps/web/src/dialogs/settings/auth-settings.ts
+++ b/apps/web/src/dialogs/settings/auth-settings.ts
@@ -27,7 +27,6 @@ import { RecoveryCodesDialog } from "../mfa/recovery-code-dialog";
import { MultifactorDialog } from "../mfa/multi-factor-dialog";
import { RecoveryKeyDialog } from "../recovery-key-dialog";
import { strings } from "@notesnook/intl";
-import { ConfirmDialog } from "../confirm";
export const AuthenticationSettings: SettingsGroup[] = [
{
@@ -46,39 +45,38 @@ export const AuthenticationSettings: SettingsGroup[] = [
title: strings.changePassword(),
variant: "secondary",
action: async () => {
- ConfirmDialog.show({
- title: "Password changing has been disabled temporarily",
- message:
- "Password changing has been disabled temporarily to address some issues faced by users. It will be enabled again once the issues have resolved.",
- positiveButtonText: "Ok"
+ const result = await showPasswordDialog({
+ title: strings.changePassword(),
+ message: strings.changePasswordDesc(),
+ inputs: {
+ oldPassword: {
+ label: strings.oldPassword(),
+ autoComplete: "current-password"
+ },
+ newPassword: {
+ label: strings.newPassword(),
+ autoComplete: "new-password"
+ }
+ },
+ validate: async ({ oldPassword, newPassword }) => {
+ try {
+ if (!(await createBackup({ noVerify: true }))) return false;
+ return (
+ (await db.user.changePassword(
+ oldPassword,
+ newPassword
+ )) || false
+ );
+ } catch (e) {
+ console.error(e);
+ return false;
+ }
+ }
});
- return;
- // const result = await showPasswordDialog({
- // title: strings.changePassword(),
- // message: strings.changePasswordDesc(),
- // inputs: {
- // oldPassword: {
- // label: strings.oldPassword(),
- // autoComplete: "current-password"
- // },
- // newPassword: {
- // label: strings.newPassword(),
- // autoComplete: "new-password"
- // }
- // },
- // validate: async ({ oldPassword, newPassword }) => {
- // if (!(await createBackup())) return false;
- // await db.user.clearSessions();
- // return (
- // (await db.user.changePassword(oldPassword, newPassword)) ||
- // false
- // );
- // }
- // });
- // if (result) {
- // showToast("success", strings.passwordChangedSuccessfully());
- // await RecoveryKeyDialog.show({});
- // }
+ if (result) {
+ showToast("success", strings.passwordChangedSuccessfully());
+ await RecoveryKeyDialog.show({});
+ }
}
}
]
diff --git a/apps/web/src/interfaces/key-store.ts b/apps/web/src/interfaces/key-store.ts
index 75b270e439..3f54f0d9ae 100644
--- a/apps/web/src/interfaces/key-store.ts
+++ b/apps/web/src/interfaces/key-store.ts
@@ -146,13 +146,19 @@ class KeyStore extends BaseStore {
activeCredentials = () => this.get().credentials.filter((c) => c.active);
- init = async () => {
+ init = async (
+ config: { persistence: "memory" | "db" } = { persistence: "db" }
+ ) => {
this.#metadataStore =
- isFeatureSupported("indexedDB") && isFeatureSupported("clonableCryptoKey")
+ isFeatureSupported("indexedDB") &&
+ isFeatureSupported("clonableCryptoKey") &&
+ config.persistence !== "memory"
? new IndexedDBKVStore(`${this.dbName}-metadata`, "metadata")
: new MemoryKVStore();
this.#secretStore =
- isFeatureSupported("indexedDB") && isFeatureSupported("clonableCryptoKey")
+ isFeatureSupported("indexedDB") &&
+ isFeatureSupported("clonableCryptoKey") &&
+ config.persistence !== "memory"
? new IndexedDBKVStore(`${this.dbName}-secrets`, "secrets")
: new MemoryKVStore();
diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx
index 5de2050da0..1f621ba728 100644
--- a/apps/web/src/root.tsx
+++ b/apps/web/src/root.tsx
@@ -54,7 +54,12 @@ export async function startApp(children?: React.ReactNode) {
try {
const { Component, props, path } = await init();
- await useKeyStore.getState().init();
+ const persistence =
+ (path !== "/sessionexpired" && path !== "/account/recovery") ||
+ Config.get("sessionExpired", false)
+ ? "db"
+ : "memory";
+ await useKeyStore.getState().init({ persistence });
root.render(
<>
@@ -70,6 +75,7 @@ export async function startApp(children?: React.ReactNode) {
Component={Component}
path={path}
routeProps={props}
+ persistence={persistence}
/>
{children}
@@ -96,9 +102,10 @@ function RouteWrapper(props: {
Component: (props: AuthProps) => JSX.Element;
path: Routes;
routeProps: AuthProps | null;
+ persistence?: "db" | "memory";
}) {
const [isMigrating, setIsMigrating] = useState(false);
- const { Component, path, routeProps } = props;
+ const { Component, path, routeProps, persistence } = props;
useEffect(() => {
EV.subscribe(EVENTS.migrationStarted, (name) =>
@@ -112,12 +119,8 @@ function RouteWrapper(props: {
const result = usePromise(async () => {
performance.mark("load:database");
- await loadDatabase(
- path !== "/sessionexpired" || Config.get("sessionExpired", false)
- ? "db"
- : "memory"
- );
- }, [path]);
+ await loadDatabase(persistence);
+ }, [path, persistence]);
if (result.status === "rejected") {
throw result.reason instanceof Error
diff --git a/apps/web/src/utils/feature-check.ts b/apps/web/src/utils/feature-check.ts
index 1164138741..dac2c487ee 100644
--- a/apps/web/src/utils/feature-check.ts
+++ b/apps/web/src/utils/feature-check.ts
@@ -34,11 +34,11 @@ export function isTransferableStreamsSupported() {
controller.close();
}
});
- window.postMessage(readable, [readable]);
+ window.postMessage(readable, window.location.origin, [readable]);
FEATURE_CHECKS.transferableStreams = true;
return true;
- } catch {
- console.log("Transferable streams not supported");
+ } catch (e) {
+ console.log("Transferable streams not supported", e);
FEATURE_CHECKS.transferableStreams = false;
return false;
}
diff --git a/apps/web/src/views/auth.tsx b/apps/web/src/views/auth.tsx
index 25ba10d2fc..a24d7730ae 100644
--- a/apps/web/src/views/auth.tsx
+++ b/apps/web/src/views/auth.tsx
@@ -40,7 +40,7 @@ import AuthContainer from "../components/auth-container";
import { useTimer } from "../hooks/use-timer";
import { ErrorText } from "../components/error-text";
import { AuthenticatorType, User } from "@notesnook/core";
-import { ConfirmDialog, showLogoutConfirmation } from "../dialogs/confirm";
+import { showLogoutConfirmation } from "../dialogs/confirm";
import { TaskManager } from "../common/task-manager";
import { strings } from "@notesnook/intl";
import { ScrollContainer } from "@notesnook/ui";
@@ -339,16 +339,7 @@ function LoginPassword(props: BaseAuthComponentProps<"login:password">) {
type="button"
mt={2}
variant="anchor"
- onClick={() => {
- ConfirmDialog.show({
- title: "Password changing has been disabled temporarily",
- message:
- "Password changing has been disabled temporarily to address some issues faced by users. It will be enabled again once the issues have resolved.",
- positiveButtonText: "Ok"
- });
- return;
- // navigate("recover", { email: formData.email })
- }}
+ onClick={() => navigate("recover", { email: formData.email })}
sx={{ color: "paragraph", alignSelf: "end" }}
>
{strings.forgotPassword()}
@@ -508,16 +499,7 @@ function SessionExpiry(props: BaseAuthComponentProps<"sessionExpiry">) {
type="button"
mt={2}
variant="anchor"
- onClick={() => {
- ConfirmDialog.show({
- title: "Password changing has been disabled temporarily",
- message:
- "Password changing has been disabled temporarily to address some issues faced by users. It will be enabled again once the issues have resolved.",
- positiveButtonText: "Ok"
- });
- return;
- // user && navigate("recover", { email: user.email })
- }}
+ onClick={() => user && navigate("recover", { email: user.email })}
sx={{ color: "paragraph", alignSelf: "end" }}
>
{strings.forgotPassword()}
diff --git a/apps/web/src/views/recovery.tsx b/apps/web/src/views/recovery.tsx
index a045df736a..c602cc8065 100644
--- a/apps/web/src/views/recovery.tsx
+++ b/apps/web/src/views/recovery.tsx
@@ -25,7 +25,6 @@ import { Loader } from "../components/loader";
import { showToast } from "../utils/toast";
import AuthContainer from "../components/auth-container";
import { AuthField, SubmitButton } from "./auth";
-import { createBackup, restoreBackupFile, selectBackupFile } from "../common";
import Config from "../utils/config";
import { ErrorText } from "../components/error-text";
import { EVENTS, User } from "@notesnook/core";
@@ -33,18 +32,14 @@ import { RecoveryKeyDialog } from "../dialogs/recovery-key-dialog";
import { strings } from "@notesnook/intl";
import { useKeyStore } from "../interfaces/key-store";
-type RecoveryMethodType = "key" | "backup" | "reset";
+type RecoveryMethodType = "key" | "reset";
type RecoveryMethodsFormData = Record;
type RecoveryKeyFormData = {
recoveryKey: string;
};
-type BackupFileFormData = {
- backupFile: File;
-};
-
-type NewPasswordFormData = BackupFileFormData & {
+type NewPasswordFormData = {
userResetRequired?: boolean;
password: string;
confirmPassword: string;
@@ -53,9 +48,7 @@ type NewPasswordFormData = BackupFileFormData & {
type RecoveryFormData = {
methods: RecoveryMethodsFormData;
"method:key": RecoveryKeyFormData;
- "method:backup": BackupFileFormData;
"method:reset": NewPasswordFormData;
- backup: RecoveryMethodsFormData;
new: NewPasswordFormData;
final: RecoveryMethodsFormData;
};
@@ -73,9 +66,7 @@ type BaseRecoveryComponentProps = {
type RecoveryRoutes =
| "methods"
| "method:key"
- | "method:backup"
| "method:reset"
- | "backup"
| "new"
| "final";
type RecoveryProps = { route: RecoveryRoutes };
@@ -92,10 +83,6 @@ function getRouteComponent(
return RecoveryMethods as RecoveryComponent;
case "method:key":
return RecoveryKeyMethod as RecoveryComponent;
- case "method:backup":
- return BackupFileMethod as RecoveryComponent;
- case "backup":
- return BackupData as RecoveryComponent;
case "method:reset":
case "new":
return NewPassword as RecoveryComponent;
@@ -108,9 +95,7 @@ function getRouteComponent(
const routePaths: Record = {
methods: "/account/recovery/methods",
"method:key": "/account/recovery/method/key",
- "method:backup": "/account/recovery/method/backup",
"method:reset": "/account/recovery/method/reset",
- backup: "/account/recovery/backup",
new: "/account/recovery/new",
final: "/account/recovery/final"
};
@@ -240,12 +225,6 @@ const recoveryMethods: RecoveryMethod[] = [
title: () => strings.recoveryKeyMethod(),
description: () => strings.recoveryKeyMethodDesc()
},
- {
- type: "backup",
- testId: "step-backup",
- title: () => strings.backupFileMethod(),
- description: () => strings.backupFileMethodDesc()
- },
{
type: "reset",
testId: "step-reset-account",
@@ -356,8 +335,7 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
await useKeyStore
.getState()
.setValue("userEncryptionKey", form.recoveryKey);
- await db.sync({ type: "fetch", force: true });
- navigate("backup");
+ navigate("new", {});
}}
>
) {
);
}
-function BackupFileMethod(props: BaseRecoveryComponentProps<"method:backup">) {
- const { navigate } = props;
- const [backupFile, setBackupFile] =
- useState();
-
- useEffect(() => {
- if (!backupFile) return;
- const backupFileInput = document.getElementById("backupFile");
- if (!(backupFileInput instanceof HTMLInputElement)) return;
- backupFileInput.value = backupFile?.name;
- }, [backupFile]);
-
- return (
-
- }
- onSubmit={async () => {
- navigate("new", { backupFile, userResetRequired: true });
- }}
- >
- {strings.browse()},
- onClick: async () => {
- setBackupFile(await selectBackupFile());
- }
- }}
- />
-
-
-
-
- );
-}
-
-function BackupData(props: BaseRecoveryComponentProps<"backup">) {
- const { navigate } = props;
-
- return (
- {
- await createBackup({ rescueMode: true, mode: "full" });
- navigate("new");
- }}
- >
-
-
- );
-}
-
function NewPassword(props: BaseRecoveryComponentProps<"new">) {
const { navigate, formData } = props;
const [progress, setProgress] = useState(0);
@@ -498,11 +396,6 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
if (!(await db.user.resetPassword(form.password)))
throw new Error("Could not reset account password.");
- if (formData?.backupFile) {
- await restoreBackupFile(formData?.backupFile);
- await db.sync({ type: "full", force: true });
- }
-
navigate("final");
}}
>
diff --git a/packages/core/src/api/key-manager.ts b/packages/core/src/api/key-manager.ts
new file mode 100644
index 0000000000..b5290b1adc
--- /dev/null
+++ b/packages/core/src/api/key-manager.ts
@@ -0,0 +1,145 @@
+/*
+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 { Cipher, SerializedKey, SerializedKeyPair } from "@notesnook/crypto";
+import Database from ".";
+import { isCipher } from "../utils/index.js";
+
+const KEY_INFO = {
+ inboxKeys: {
+ type: "asymmetric"
+ },
+ attachmentsKey: {
+ type: "symmetric"
+ },
+ monographPasswordsKey: {
+ type: "symmetric"
+ },
+ dataEncryptionKey: {
+ type: "symmetric"
+ },
+ legacyDataEncryptionKey: {
+ type: "symmetric"
+ }
+} as const;
+
+export type KeyId = keyof typeof KEY_INFO;
+
+type WrapKeyReturnType =
+ T extends SerializedKeyPair
+ ? { public: string; private: Cipher<"base64"> }
+ : Cipher<"base64">;
+
+type WrappedKey =
+ | Cipher<"base64">
+ | {
+ public: string;
+ private: Cipher<"base64">;
+ };
+
+export type UnwrapKeyReturnType = T extends {
+ public: string;
+ private: Cipher<"base64">;
+}
+ ? SerializedKeyPair
+ : SerializedKey;
+
+export type KeyTypeFromId =
+ (typeof KEY_INFO)[TId]["type"] extends "symmetric"
+ ? Cipher<"base64">
+ : {
+ public: string;
+ private: Cipher<"base64">;
+ };
+
+export class KeyManager {
+ private cache = new Map>();
+ constructor(private readonly db: Database) {}
+
+ clearCache() {
+ this.cache.clear();
+ }
+
+ async get(
+ id: TId,
+ options: {
+ useCache?: boolean;
+ refetchUser?: boolean;
+ } = { refetchUser: true, useCache: true }
+ ): Promise | undefined> {
+ if (options.useCache && this.cache.has(id)) {
+ return this.cache.get(id) as KeyTypeFromId;
+ }
+ let user = await this.db.user.getUser();
+ if ((!user || !user[id]) && options.refetchUser) {
+ user = await this.db.user.fetchUser();
+ }
+ if (!user) return;
+
+ this.cache.set(id, user[id] as KeyTypeFromId);
+ return user[id] as KeyTypeFromId;
+ }
+
+ async unwrapKey(
+ key: T,
+ wrappingKey: SerializedKey
+ ): Promise> {
+ if (isCipher(key))
+ return JSON.parse(
+ await this.db.storage().decrypt(wrappingKey, key)
+ ) as UnwrapKeyReturnType;
+ else {
+ const privateKey = await this.db
+ .storage()
+ .decrypt(wrappingKey, key.private);
+ return {
+ publicKey: key.public,
+ privateKey
+ } as UnwrapKeyReturnType;
+ }
+ }
+
+ async wrapKey(
+ key: T,
+ wrappingKey: SerializedKey
+ ): Promise> {
+ if (!("publicKey" in key)) {
+ return (await this.db
+ .storage()
+ .encrypt(wrappingKey, JSON.stringify(key))) as WrapKeyReturnType;
+ } else {
+ const encryptedPrivateKey = await this.db
+ .storage()
+ .encrypt(wrappingKey, (key as SerializedKeyPair).privateKey);
+ return {
+ public: (key as SerializedKeyPair).publicKey,
+ private: encryptedPrivateKey
+ } as WrapKeyReturnType;
+ }
+ }
+
+ async rewrapKey(
+ key: T,
+ oldWrappingKey: SerializedKey,
+ newWrappingKey: SerializedKey
+ ) {
+ const unwrappedKey = await this.unwrapKey(key, oldWrappingKey);
+ return await this.wrapKey(unwrappedKey, newWrappingKey);
+ }
+}
diff --git a/packages/core/src/api/sync/__tests__/collector.test.js b/packages/core/src/api/sync/__tests__/collector.test.js
index cadfc47267..026b18cb59 100644
--- a/packages/core/src/api/sync/__tests__/collector.test.js
+++ b/packages/core/src/api/sync/__tests__/collector.test.js
@@ -109,6 +109,223 @@ test("unlinked relation should get included in collector", () =>
expect(items[0].items[0].id).toBe("cd93df7a4c64fbd5f100361d629ac5b5");
}));
+test("collector should use latest key version for encryption", () =>
+ databaseTest().then(async (db) => {
+ await loginFakeUser(db);
+ const collector = new Collector(db);
+
+ const noteId = await db.notes.add(TEST_NOTE);
+
+ const items = await iteratorToArray(collector.collect(100, false));
+
+ // Find the note item
+ const noteItem = items.find((i) => i.type === "note");
+ expect(noteItem).toBeDefined();
+ expect(noteItem.items[0].keyVersion).toBeDefined();
+
+ // Should use the latest key version available
+ const keys = await db.user.getDataEncryptionKeys();
+ const latestKeyVersion = Math.max(...keys.map((k) => k.version));
+ expect(noteItem.items[0].keyVersion).toBe(latestKeyVersion);
+ }));
+
+test("collector should assign keyVersion to all encrypted items", () =>
+ databaseTest().then(async (db) => {
+ await loginFakeUser(db);
+ const collector = new Collector(db);
+
+ await db.notes.add(TEST_NOTE);
+ await db.notes.add({ ...TEST_NOTE, title: "Note 2" });
+ await db.notes.add({ ...TEST_NOTE, title: "Note 3" });
+
+ const items = await iteratorToArray(collector.collect(100, false));
+
+ // All items should have keyVersion set
+ for (const chunk of items) {
+ for (const item of chunk.items) {
+ expect(item.keyVersion).toBeDefined();
+ expect(typeof item.keyVersion).toBe("number");
+ }
+ }
+ }));
+
+test("sync roundtrip: items encrypted with keyVersion can be decrypted", () =>
+ databaseTest().then(async (db) => {
+ await loginFakeUser(db);
+ const { Sync } = await import("../index.ts");
+ const sync = new Sync(db);
+ const collector = new Collector(db);
+
+ const noteId = await db.notes.add({
+ ...TEST_NOTE,
+ title: "Sync Test Note"
+ });
+ const note = await db.notes.note(noteId);
+
+ const items = await iteratorToArray(collector.collect(100, false));
+ const noteChunk = items.find((i) => i.type === "note");
+
+ expect(noteChunk).toBeDefined();
+ expect(noteChunk.items[0].keyVersion).toBeDefined();
+
+ // Simulate receiving the same item back from server
+ const keys = await db.user.getDataEncryptionKeys();
+ await sync.processChunk(noteChunk, keys, { type: "fetch" });
+
+ // Verify the note is still intact
+ const syncedNote = await db.notes.note(noteId);
+ expect(syncedNote.title).toBe("Sync Test Note");
+ expect(syncedNote.id).toBe(note.id);
+ }));
+
+test("sync should handle mixed keyVersion items in same chunk", () =>
+ databaseTest().then(async (db) => {
+ await loginFakeUser(db);
+ const { Sync } = await import("../index.ts");
+ const sync = new Sync(db);
+
+ const keys = await db.user.getDataEncryptionKeys();
+
+ // Create mock items with different key versions
+ const note1 = JSON.stringify({
+ id: "note1",
+ type: "note",
+ title: "Note 1",
+ dateModified: Date.now()
+ });
+ const note2 = JSON.stringify({
+ id: "note2",
+ type: "note",
+ title: "Note 2",
+ dateModified: Date.now()
+ });
+
+ const cipher1 = await db.storage().encrypt(keys[0].key, note1);
+ const cipher2 =
+ keys.length > 1
+ ? await db.storage().encrypt(keys[1].key, note2)
+ : await db.storage().encrypt(keys[0].key, note2);
+
+ const chunk = {
+ type: "note",
+ count: 2,
+ items: [
+ { ...cipher1, id: "note1", v: 5, keyVersion: keys[0].version },
+ {
+ ...cipher2,
+ id: "note2",
+ v: 5,
+ keyVersion: keys.length > 1 ? keys[1].version : keys[0].version
+ }
+ ]
+ };
+
+ // Process the chunk with mixed key versions
+ await sync.processChunk(chunk, keys, { type: "fetch" });
+
+ // Verify both notes were decrypted correctly
+ const savedNote1 = await db.notes.note("note1");
+ const savedNote2 = await db.notes.note("note2");
+
+ expect(savedNote1).toBeDefined();
+ expect(savedNote2).toBeDefined();
+ expect(savedNote1.title).toBe("Note 1");
+ expect(savedNote2.title).toBe("Note 2");
+ }));
+
+test("sync should maintain stable ordering across decryptMulti", () =>
+ databaseTest().then(async (db) => {
+ await loginFakeUser(db);
+ const collector = new Collector(db);
+
+ // Create multiple notes with predictable order
+ const noteIds = [];
+ for (let i = 0; i < 5; i++) {
+ const id = await db.notes.add({
+ ...TEST_NOTE,
+ title: `Note ${i}`
+ });
+ noteIds.push(id);
+ }
+
+ const items = await iteratorToArray(collector.collect(100, false));
+ const noteChunk = items.find((i) => i.type === "note");
+
+ expect(noteChunk).toBeDefined();
+ expect(noteChunk.items).toHaveLength(5);
+
+ // Verify all items have IDs
+ const collectedIds = noteChunk.items.map((item) => item.id);
+ expect(collectedIds).toHaveLength(5);
+
+ // All IDs should be present
+ for (const id of noteIds) {
+ expect(collectedIds).toContain(id);
+ }
+
+ // Decrypt and verify ID mapping is preserved
+ const keys = await db.user.getDataEncryptionKeys();
+ const { Sync } = await import("../index.ts");
+ const sync = new Sync(db);
+
+ await sync.processChunk(noteChunk, keys, { type: "fetch" });
+
+ // Verify each note can be retrieved with correct content
+ for (let i = 0; i < 5; i++) {
+ const note = await db.notes.note(noteIds[i]);
+ expect(note).toBeDefined();
+ expect(note.title).toBe(`Note ${i}`);
+ }
+ }));
+
+test("sync should correctly select key based on keyVersion", () =>
+ databaseTest().then(async (db) => {
+ await loginFakeUser(db);
+ const { Sync } = await import("../index.ts");
+ const sync = new Sync(db);
+
+ const keys = await db.user.getDataEncryptionKeys();
+
+ // Create items encrypted with specific key versions
+ const testCases = keys.map((keyInfo, idx) => ({
+ id: `note${idx}`,
+ title: `Note with keyVersion ${keyInfo.version}`,
+ keyVersion: keyInfo.version,
+ key: keyInfo.key
+ }));
+
+ const chunks = [];
+ for (const testCase of testCases) {
+ const noteData = JSON.stringify({
+ id: testCase.id,
+ type: "note",
+ title: testCase.title,
+ dateModified: Date.now()
+ });
+ const cipher = await db.storage().encrypt(testCase.key, noteData);
+
+ chunks.push({
+ type: "note",
+ count: 1,
+ items: [
+ { ...cipher, id: testCase.id, v: 5, keyVersion: testCase.keyVersion }
+ ]
+ });
+ }
+
+ // Process each chunk
+ for (const chunk of chunks) {
+ await sync.processChunk(chunk, keys, { type: "fetch" });
+ }
+
+ // Verify each note was decrypted with the correct key
+ for (const testCase of testCases) {
+ const note = await db.notes.note(testCase.id);
+ expect(note).toBeDefined();
+ expect(note.title).toBe(testCase.title);
+ }
+ }));
+
async function iteratorToArray(iterator) {
let items = [];
for await (const item of iterator) {
diff --git a/packages/core/src/api/sync/collector.ts b/packages/core/src/api/sync/collector.ts
index 2fcfc9dd43..641526593b 100644
--- a/packages/core/src/api/sync/collector.ts
+++ b/packages/core/src/api/sync/collector.ts
@@ -25,7 +25,8 @@ import {
SyncItem,
SyncTransferItem,
SYNC_COLLECTIONS_MAP,
- SYNC_ITEM_TYPES
+ SYNC_ITEM_TYPES,
+ KeyVersion
} from "./types.js";
import { Item, MaybeDeletedItem } from "../../types.js";
@@ -46,12 +47,17 @@ class Collector {
chunkSize: number,
isForceSync = false
): AsyncGenerator {
- const key = await this.db.user.getEncryptionKey();
- if (!key || !key.key || !key.salt) {
+ const keys = await this.db.user.getDataEncryptionKeys();
+ if (!keys || !keys.length) {
EV.publish(EVENTS.userSessionExpired);
throw new Error("User encryption key not generated. Please relogin.");
}
+ // select the latest available key for encryption
+ const key = keys.reduce((max, current) =>
+ current.version > max.version ? current : max
+ );
+
for (const itemType of SYNC_ITEM_TYPES) {
const collectionKey = SYNC_COLLECTIONS_MAP[itemType];
const collection = this.db[collectionKey].collection;
@@ -61,8 +67,8 @@ class Collector {
if (!ids.length) continue;
const ciphers = await this.db
.storage()
- .encryptMulti(key, syncableItems);
- const items = toSyncItem(ids, ciphers);
+ .encryptMulti(key.key, syncableItems);
+ const items = toSyncItem(ids, ciphers, key.version);
if (!items.length) continue;
yield { items, type: itemType, count: items.length };
@@ -88,7 +94,11 @@ class Collector {
}
export default Collector;
-function toSyncItem(ids: string[], ciphers: Cipher<"base64">[]) {
+function toSyncItem(
+ ids: string[],
+ ciphers: Cipher<"base64">[],
+ keyVersion: KeyVersion
+) {
if (ids.length !== ciphers.length)
throw new Error("ids.length must be equal to ciphers.length");
@@ -98,6 +108,7 @@ function toSyncItem(ids: string[], ciphers: Cipher<"base64">[]) {
const cipher = ciphers[i] as SyncItem;
cipher.v = CURRENT_DATABASE_VERSION;
cipher.id = id;
+ cipher.keyVersion = keyVersion;
items.push(cipher);
}
return items;
diff --git a/packages/core/src/api/sync/index.ts b/packages/core/src/api/sync/index.ts
index 53e595cb94..db47ab229e 100644
--- a/packages/core/src/api/sync/index.ts
+++ b/packages/core/src/api/sync/index.ts
@@ -47,6 +47,8 @@ import {
Notebook
} from "../../types.js";
import {
+ KEY_VERSION,
+ KeyVersion,
SYNC_COLLECTIONS_MAP,
SyncableItemType,
SyncInboxItem,
@@ -55,7 +57,6 @@ import {
import { DownloadableFile } from "../../database/fs.js";
import { SyncDevices } from "./devices.js";
import { DefaultColors } from "../../collections/colors.js";
-import { Monographs } from "../monographs.js";
enum LogLevel {
/** Log level for very low severity diagnostic messages. */
@@ -149,7 +150,7 @@ export default class SyncManager {
}
}
-class Sync {
+export class Sync {
collector;
merger;
autoSync;
@@ -245,7 +246,7 @@ class Sync {
"RequestFetchV3 failed, falling back to RequestFetchV2"
);
await this.connection?.invoke("RequestFetchV2", deviceId);
- }
+ } else throw error;
}
if (this.conflictedNoteIds.length > 0) {
@@ -343,16 +344,51 @@ class Sync {
async processChunk(
chunk: SyncTransferItem,
- key: SerializedKey,
+ keys: {
+ version: KeyVersion;
+ key: SerializedKey;
+ }[],
options: SyncOptions
) {
const itemType = chunk.type;
- const decrypted = await this.db.storage().decryptMulti(key, chunk.items);
+ const decrypted: string[] = [];
+
+ // Pre-group items by keyVersion for O(1) lookups
+ const itemsByKeyVersion = new Map();
+ const versionMap = new Map();
+
+ for (const item of chunk.items) {
+ const keyVersion = item.keyVersion ?? KEY_VERSION.LEGACY;
+ const group = itemsByKeyVersion.get(keyVersion);
+ if (group) {
+ group.push(item);
+ } else {
+ itemsByKeyVersion.set(keyVersion, [item]);
+ }
+ versionMap.set(item.id, item.v);
+ }
+
+ for (const keyInfo of keys) {
+ const itemsToDecrypt = itemsByKeyVersion.get(keyInfo.version);
+ if (!itemsToDecrypt || itemsToDecrypt.length === 0) continue;
+
+ decrypted.push(
+ ...(await this.db.storage().decryptMulti(keyInfo.key, itemsToDecrypt))
+ );
+ }
const deserialized: MaybeDeletedItem- [] = [];
for (let i = 0; i < decrypted.length; ++i) {
- const decryptedItem = decrypted[i];
- const version = chunk.items[i].v;
+ const decryptedItem = JSON.parse(decrypted[i]) as MaybeDeletedItem
- ;
+ const version = versionMap.get(decryptedItem.id);
+ if (version === undefined) {
+ this.logger.error(
+ new Error(
+ `Version not found for item ${decryptedItem.id}. Skipping item.`
+ )
+ );
+ continue;
+ }
const item = await deserializeItem(
decryptedItem,
itemType,
@@ -476,10 +512,15 @@ class Sync {
this.connection.on("SendItems", async (chunk) => {
if (this.connection?.state !== HubConnectionState.Connected) return false;
- const key = await this.getKey();
- if (!key) return false;
-
- await this.processChunk(chunk, key, options);
+ const keys = await this.db.user.getDataEncryptionKeys();
+ if (!keys || !keys.length) {
+ this.logger.error(
+ new Error("User encryption keys not generated. Please relogin.")
+ );
+ EV.publish(EVENTS.userSessionExpired);
+ return false;
+ }
+ await this.processChunk(chunk, keys, options);
sendSyncProgressEvent(this.db.eventManager, `download`, chunk.count);
@@ -513,18 +554,6 @@ class Sync {
);
}
- private async getKey() {
- const key = await this.db.user.getEncryptionKey();
- if (!key?.key) {
- this.logger.error(
- new Error("User encryption key not generated. Please relogin.")
- );
- EV.publish(EVENTS.userSessionExpired);
- return;
- }
- return key;
- }
-
private async checkConnection() {
await this.syncConnectionMutex.runExclusive(async () => {
try {
@@ -564,12 +593,11 @@ function promiseTimeout(ms: number, promise: Promise) {
}
async function deserializeItem(
- decryptedItem: string,
+ item: MaybeDeletedItem
- ,
type: SyncableItemType,
version: number,
database: Database
): Promise | undefined> {
- const item = JSON.parse(decryptedItem) as MaybeDeletedItem
- ;
item.remote = true;
item.synced = true;
diff --git a/packages/core/src/api/sync/types.ts b/packages/core/src/api/sync/types.ts
index 994fc84519..a4b33ecef8 100644
--- a/packages/core/src/api/sync/types.ts
+++ b/packages/core/src/api/sync/types.ts
@@ -19,9 +19,17 @@ along with this program. If not, see .
import { Cipher } from "@notesnook/crypto";
+export const KEY_VERSION = {
+ LEGACY: 0,
+ DEK: 1
+} as const;
+
+export type KeyVersion = (typeof KEY_VERSION)[keyof typeof KEY_VERSION];
+
export type SyncItem = {
id: string;
v: number;
+ keyVersion?: KeyVersion;
} & Cipher<"base64">;
export type SyncableItemType = keyof typeof SYNC_COLLECTIONS_MAP;
diff --git a/packages/core/src/api/user-manager.ts b/packages/core/src/api/user-manager.ts
index 53040af2a0..f4901ac62d 100644
--- a/packages/core/src/api/user-manager.ts
+++ b/packages/core/src/api/user-manager.ts
@@ -24,8 +24,15 @@ import TokenManager from "./token-manager.js";
import { EV, EVENTS } from "../common.js";
import { HealthCheck } from "./healthcheck.js";
import Database from "./index.js";
-import { SerializedKeyPair, SerializedKey, Cipher } from "@notesnook/crypto";
+import { SerializedKeyPair, SerializedKey } from "@notesnook/crypto";
import { logger } from "../logger.js";
+import { KEY_VERSION, KeyVersion } from "./sync/types.js";
+import {
+ KeyId,
+ KeyManager,
+ KeyTypeFromId,
+ UnwrapKeyReturnType
+} from "./key-manager.js";
const ENDPOINTS = {
signup: "/users",
@@ -42,11 +49,10 @@ const ENDPOINTS = {
class UserManager {
private tokenManager: TokenManager;
- private cachedAttachmentKey?: SerializedKey;
- private cachedMonographPasswordsKey?: SerializedKey;
- private cachedInboxKeys?: SerializedKeyPair;
+ private keyManager: KeyManager;
constructor(private readonly db: Database) {
- this.tokenManager = new TokenManager(this.db.kv);
+ this.tokenManager = new TokenManager(db.kv);
+ this.keyManager = new KeyManager(db);
EV.subscribe(EVENTS.userUnauthorized, async (url: string) => {
if (url.includes("/connect/token") || !(await HealthCheck.auth())) return;
@@ -240,6 +246,17 @@ class UserManager {
await this.db.setLastSynced(0);
await this.db.syncer.devices.register();
+ // TODO: Uncomment this when we are done testing legacy password change
+ // support
+ // const masterKey = await this.getMasterKey();
+ // if (!masterKey) throw new Error("User encryption key not generated.");
+ // await this.updateUser({
+ // dataEncryptionKey: await this.keyManager.wrapKey(
+ // await this.db.crypto().generateRandomKey(),
+ // masterKey
+ // )
+ // });
+
this.db.eventManager.publish(EVENTS.userLoggedIn, user);
}
@@ -278,8 +295,7 @@ class UserManager {
} catch (e) {
logger.error(e, "Error logging out user.", { revoke, reason });
} finally {
- this.cachedAttachmentKey = undefined;
- this.cachedInboxKeys = undefined;
+ this.keyManager.clearCache();
await this.db.reset();
this.db.eventManager.publish(EVENTS.userLoggedOut, reason);
this.db.eventManager.publish(EVENTS.appRefreshRequested);
@@ -381,7 +397,7 @@ class UserManager {
}
changePassword(oldPassword: string, newPassword: string) {
- return this._updatePassword("change_password", {
+ return this._updatePassword("change", {
old_password: oldPassword,
new_password: newPassword
});
@@ -402,12 +418,46 @@ class UserManager {
}
resetPassword(newPassword: string) {
- return this._updatePassword("reset_password", {
+ return this._updatePassword("reset", {
new_password: newPassword
});
}
- async getEncryptionKey(): Promise {
+ async getDataEncryptionKeys(): Promise<
+ { version: KeyVersion; key: SerializedKey }[] | undefined
+ > {
+ const masterKey = await this.getMasterKey();
+ if (!masterKey) return;
+
+ const dataEncryptionKey = await this.keyManager.get("dataEncryptionKey");
+ if (!dataEncryptionKey)
+ return [
+ {
+ key: masterKey,
+ version: KEY_VERSION.LEGACY
+ }
+ ];
+ const keys: { version: KeyVersion; key: SerializedKey }[] = [];
+
+ const legacyDataEncryptionKey = await this.keyManager.get(
+ "legacyDataEncryptionKey"
+ );
+ if (legacyDataEncryptionKey)
+ keys.push({
+ key: await this.keyManager.unwrapKey(
+ legacyDataEncryptionKey,
+ masterKey
+ ),
+ version: KEY_VERSION.LEGACY
+ });
+ keys.push({
+ key: await this.keyManager.unwrapKey(dataEncryptionKey, masterKey),
+ version: KEY_VERSION.DEK
+ });
+ return keys;
+ }
+
+ async getMasterKey(): Promise {
const user = await this.getUser();
if (!user) return;
const key = await this.db.storage().getCryptoKey();
@@ -426,44 +476,31 @@ class UserManager {
return { key, salt: user.salt };
}
- private async getUserKey(config: {
- getCache: () => T | undefined;
- setCache: (key: T) => void;
- userProperty: keyof User;
- generateKey: () => Promise;
- errorContext: string;
- decrypt: (user: User, userEncryptionKey: SerializedKey) => Promise;
- encrypt: (
- key: T,
- userEncryptionKey: SerializedKey
- ) => Promise>;
- }): Promise {
- const cachedKey = config.getCache();
- if (cachedKey) return cachedKey;
-
+ private async getUserKey(
+ id: TId,
+ config: {
+ generateKey: () => Promise;
+ errorContext: string;
+ }
+ ): Promise> | undefined> {
try {
- let user = await this.getUser();
- if (!user) return;
+ const masterKey = await this.getMasterKey();
+ if (!masterKey) return;
- if (!user[config.userProperty]) {
- const token = await this.tokenManager.getAccessToken();
- user = await http.get(`${constants.API_HOST}${ENDPOINTS.user}`, token);
- }
- if (!user) return;
-
- const userEncryptionKey = await this.getEncryptionKey();
- if (!userEncryptionKey) return;
+ const wrappedKey = await this.keyManager.get(id);
- if (!user[config.userProperty]) {
+ if (!wrappedKey) {
const key = await config.generateKey();
- const updatePayload = await config.encrypt(key, userEncryptionKey);
- await this.updateUser(updatePayload);
- return key;
+ await this.updateUser({
+ [id]: await this.keyManager.wrapKey(key, masterKey)
+ });
+ return key as UnwrapKeyReturnType>;
}
- const decryptedKey = await config.decrypt(user, userEncryptionKey);
- config.setCache(decryptedKey);
- return decryptedKey;
+ return (await this.keyManager.unwrapKey(
+ wrappedKey,
+ masterKey
+ )) as UnwrapKeyReturnType>;
} catch (e) {
logger.error(e, `Could not get ${config.errorContext}.`);
if (e instanceof Error)
@@ -474,94 +511,27 @@ class UserManager {
}
async getAttachmentsKey() {
- return this.getUserKey({
- getCache: () => this.cachedAttachmentKey,
- setCache: (key) => {
- this.cachedAttachmentKey = key;
- },
- userProperty: "attachmentsKey",
+ return this.getUserKey("attachmentsKey", {
generateKey: () => this.db.crypto().generateRandomKey(),
- errorContext: "attachments encryption key",
- encrypt: async (key, userEncryptionKey) => {
- const encryptedKey = await this.db
- .storage()
- .encrypt(userEncryptionKey, JSON.stringify(key));
- return { attachmentsKey: encryptedKey };
- },
- decrypt: async (user, userEncryptionKey) => {
- const encryptedKey = user.attachmentsKey as Cipher<"base64">;
- const plainData = await this.db
- .storage()
- .decrypt(userEncryptionKey, encryptedKey);
- if (!plainData) throw new Error("Failed to decrypt attachments key");
- return JSON.parse(plainData) as SerializedKey;
- }
+ errorContext: "attachments encryption key"
});
}
async getMonographPasswordsKey() {
- return this.getUserKey({
- getCache: () => this.cachedMonographPasswordsKey,
- setCache: (key) => {
- this.cachedMonographPasswordsKey = key;
- },
- userProperty: "monographPasswordsKey",
+ return this.getUserKey("monographPasswordsKey", {
generateKey: () => this.db.crypto().generateRandomKey(),
- errorContext: "monographs encryption key",
- encrypt: async (key, userEncryptionKey) => {
- const encryptedKey = await this.db
- .storage()
- .encrypt(userEncryptionKey, JSON.stringify(key));
- return { monographPasswordsKey: encryptedKey };
- },
- decrypt: async (user, userEncryptionKey) => {
- const encryptedKey = user.monographPasswordsKey as Cipher<"base64">;
- const plainData = await this.db
- .storage()
- .decrypt(userEncryptionKey, encryptedKey);
- if (!plainData)
- throw new Error("Failed to decrypt monograph passwords key");
- return JSON.parse(plainData) as SerializedKey;
- }
+ errorContext: "monographs encryption key"
});
}
async getInboxKeys() {
- return this.getUserKey({
- getCache: () => this.cachedInboxKeys,
- setCache: (key) => {
- this.cachedInboxKeys = key;
- },
- userProperty: "inboxKeys",
+ return this.getUserKey("inboxKeys", {
generateKey: () => this.db.crypto().generateCryptoKeyPair(),
- errorContext: "inbox encryption keys",
- encrypt: async (keys, userEncryptionKey) => {
- const encryptedPrivateKey = await this.db
- .storage()
- .encrypt(userEncryptionKey, JSON.stringify(keys.privateKey));
- return {
- inboxKeys: {
- public: keys.publicKey,
- private: encryptedPrivateKey
- }
- };
- },
- decrypt: async (user, userEncryptionKey) => {
- if (!user.inboxKeys) throw new Error("Inbox keys not found");
- const decryptedPrivateKey = await this.db
- .storage()
- .decrypt(userEncryptionKey, user.inboxKeys.private);
- return {
- publicKey: user.inboxKeys.public,
- privateKey: JSON.parse(decryptedPrivateKey)
- };
- }
+ errorContext: "inbox encryption keys"
});
}
async hasInboxKeys() {
- if (this.cachedInboxKeys) return true;
-
const user = await this.getUser();
if (!user) return false;
@@ -569,7 +539,7 @@ class UserManager {
}
async discardInboxKeys() {
- this.cachedInboxKeys = undefined;
+ this.keyManager.clearCache();
const user = await this.getUser();
if (!user) return;
@@ -627,19 +597,20 @@ class UserManager {
async verifyPassword(password: string) {
try {
const user = await this.getUser();
- const key = await this.getEncryptionKey();
+ const key = await this.getMasterKey();
if (!user || !key) return false;
const cipher = await this.db.storage().encrypt(key, "notesnook");
const plainText = await this.db.storage().decrypt({ password }, cipher);
return plainText === "notesnook";
} catch (e) {
+ logger.error(e);
return false;
}
}
async _updatePassword(
- type: "change_password" | "reset_password",
+ type: "change" | "reset",
data: {
new_password: string;
old_password?: string;
@@ -652,98 +623,100 @@ class UserManager {
const { email, salt } = user;
- let { new_password, old_password } = data;
+ const { new_password, old_password } = data;
if (old_password && !(await this.verifyPassword(old_password)))
throw new Error("Incorrect old password.");
- if (!new_password) throw new Error("New password is required.");
-
- data.encryptionKey = data.encryptionKey || (await this.getEncryptionKey());
-
- await this.clearSessions();
-
- if (data.encryptionKey) await this.db.sync({ type: "fetch", force: true });
-
- if (old_password)
- old_password = await this.db.storage().hash(old_password, email, {
- usesFallback: await this.usesFallbackPWHash(old_password)
- });
-
- // retrieve user keys before deriving a new encryption key
- const oldUserKeys = {
- attachmentsKey: await this.getAttachmentsKey(),
- monographPasswordsKey: await this.getMonographPasswordsKey(),
- inboxKeys: (await this.hasInboxKeys())
- ? await this.getInboxKeys()
- : undefined
- } as const;
-
- await this.db.storage().deriveCryptoKey({
- password: new_password,
- salt
- });
+ const oldPassword = old_password
+ ? await this.db.storage().hash(old_password, email, {
+ usesFallback: await this.usesFallbackPWHash(old_password)
+ })
+ : null;
- if (!(await this.resetUser(false))) return;
+ if (!new_password) throw new Error("New password is required.");
- await this.db.sync({ type: "send", force: true });
+ data.encryptionKey = data.encryptionKey || (await this.getMasterKey());
- const userEncryptionKey = await this.getEncryptionKey();
- if (userEncryptionKey) {
- const updateUserPayload: Partial = {};
- if (oldUserKeys.attachmentsKey) {
- user.attachmentsKey = await this.db
- .storage()
- .encrypt(
- userEncryptionKey,
- JSON.stringify(oldUserKeys.attachmentsKey)
- );
- updateUserPayload.attachmentsKey = user.attachmentsKey;
+ const updateUserPayload: Partial = {};
+ console.log(
+ "Has encryption key",
+ !!data.encryptionKey,
+ await this.getMasterKey()
+ );
+ if (data.encryptionKey) {
+ const newMasterKey = await this.db
+ .storage()
+ .generateCryptoKey(new_password, salt);
+ if (user.attachmentsKey) {
+ updateUserPayload.attachmentsKey = await this.keyManager.rewrapKey(
+ user.attachmentsKey,
+ data.encryptionKey,
+ newMasterKey
+ );
}
- if (oldUserKeys.monographPasswordsKey) {
- user.monographPasswordsKey = await this.db
- .storage()
- .encrypt(
- userEncryptionKey,
- JSON.stringify(oldUserKeys.monographPasswordsKey)
+ if (user.monographPasswordsKey) {
+ updateUserPayload.monographPasswordsKey =
+ await this.keyManager.rewrapKey(
+ user.monographPasswordsKey,
+ data.encryptionKey,
+ newMasterKey
);
- updateUserPayload.monographPasswordsKey = user.monographPasswordsKey;
}
- if (oldUserKeys.inboxKeys) {
- user.inboxKeys = {
- public: oldUserKeys.inboxKeys.publicKey,
- private: await this.db
- .storage()
- .encrypt(
- userEncryptionKey,
- JSON.stringify(oldUserKeys.inboxKeys.privateKey)
- )
- };
- updateUserPayload.inboxKeys = user.inboxKeys;
+ if (user.inboxKeys) {
+ updateUserPayload.inboxKeys = await this.keyManager.rewrapKey(
+ user.inboxKeys,
+ data.encryptionKey,
+ newMasterKey
+ );
}
- if (Object.keys(updateUserPayload).length > 0) {
- await this.updateUser(updateUserPayload);
+
+ if (user.legacyDataEncryptionKey)
+ updateUserPayload.legacyDataEncryptionKey =
+ await this.keyManager.rewrapKey(
+ user.legacyDataEncryptionKey,
+ data.encryptionKey,
+ newMasterKey
+ );
+ if (user.dataEncryptionKey)
+ updateUserPayload.dataEncryptionKey = await this.keyManager.rewrapKey(
+ user.dataEncryptionKey,
+ data.encryptionKey,
+ newMasterKey
+ );
+ else {
+ updateUserPayload.dataEncryptionKey = await this.keyManager.wrapKey(
+ await this.db.crypto().generateRandomKey(),
+ newMasterKey
+ );
+ updateUserPayload.legacyDataEncryptionKey =
+ await this.keyManager.wrapKey(data.encryptionKey, newMasterKey);
}
}
- if (new_password)
- new_password = await this.db.storage().hash(new_password, email);
-
- await http.patch(
- `${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
+ await http.patch.json(
+ `${constants.API_HOST}/users/password/${type}`,
{
- type,
- old_password,
- new_password
+ oldPassword: oldPassword,
+ newPassword: await this.db.storage().hash(new_password, email),
+ userKeys: updateUserPayload
},
token
);
+ await this.db.storage().deriveCryptoKey({
+ password: new_password,
+ salt
+ });
+
+ this.keyManager.clearCache();
+ await this.setUser({ ...user, ...updateUserPayload });
+
return true;
}
private async usesFallbackPWHash(password: string) {
const user = await this.getUser();
- const encryptionKey = await this.getEncryptionKey();
+ const encryptionKey = await this.getMasterKey();
if (!user || !encryptionKey) return false;
const fallbackCryptoKey = await this.db
.storage()
diff --git a/packages/core/src/database/backup.ts b/packages/core/src/database/backup.ts
index d23a4ac9b5..c5215293b2 100644
--- a/packages/core/src/database/backup.ts
+++ b/packages/core/src/database/backup.ts
@@ -309,8 +309,8 @@ export default class Backup {
if (encrypt && !user)
throw new Error("Please login to create encrypted backups.");
- const key = await this.db.user.getEncryptionKey();
- if (encrypt && !key) throw new Error("No encryption key found.");
+ const key = await this.db.user.getMasterKey();
+ if (encrypt && !key) throw new Error("No master key found.");
yield {
type: "file",
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 75c6bb0a34..0252e7a358 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -599,6 +599,9 @@ export type User = {
attachmentsKey?: Cipher<"base64">;
monographPasswordsKey?: Cipher<"base64">;
inboxKeys?: { public: string; private: Cipher<"base64"> };
+ dataEncryptionKey?: Cipher<"base64">;
+ legacyDataEncryptionKey?: Cipher<"base64">;
+
marketingConsent?: boolean;
storageUsed?: number;
totalStorage?: number;