From d3a7ec6b8c4af025b550acd8bbe3cefedf454e2f Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Fri, 16 Feb 2024 15:12:29 +0500 Subject: [PATCH] web: reimplement app lock & web key store --- apps/web/src/common/db.ts | 18 +- apps/web/src/common/store.ts | 8 +- apps/web/src/components/editor/context.ts | 4 +- .../src/dialogs/settings/app-lock-settings.ts | 418 ++++++++++ .../dialogs/settings/app-lock-settings.tsx | 260 ------ apps/web/src/dialogs/settings/index.tsx | 101 ++- apps/web/src/dialogs/settings/types.ts | 17 +- apps/web/src/hooks/use-auto-updater.ts | 4 +- apps/web/src/hooks/use-spell-checker.ts | 4 +- apps/web/src/interfaces/key-store.ts | 751 ++++++++++++------ apps/web/src/interfaces/key-value.ts | 2 +- apps/web/src/interfaces/storage.ts | 8 +- apps/web/src/stores/announcement-store.js | 4 +- apps/web/src/stores/app-store.ts | 4 +- apps/web/src/stores/attachment-store.ts | 4 +- apps/web/src/stores/editor-store.ts | 4 +- apps/web/src/stores/index.ts | 4 +- apps/web/src/stores/monograph-store.ts | 4 +- apps/web/src/stores/note-store.ts | 4 +- apps/web/src/stores/notebook-store.ts | 4 +- apps/web/src/stores/reminder-store.ts | 4 +- apps/web/src/stores/search-store.ts | 4 +- apps/web/src/stores/selection-store.ts | 4 +- apps/web/src/stores/setting-store.ts | 22 +- apps/web/src/stores/tag-store.ts | 4 +- apps/web/src/stores/theme-store.js | 2 +- apps/web/src/stores/trash-store.ts | 4 +- apps/web/src/stores/user-store.ts | 4 +- apps/web/src/utils/dom.ts | 5 + apps/web/src/utils/logger.ts | 2 +- apps/web/src/utils/webauthn.ts | 9 +- apps/web/src/views/app-lock.tsx | 182 ++--- 32 files changed, 1164 insertions(+), 709 deletions(-) create mode 100644 apps/web/src/dialogs/settings/app-lock-settings.ts delete mode 100644 apps/web/src/dialogs/settings/app-lock-settings.tsx diff --git a/apps/web/src/common/db.ts b/apps/web/src/common/db.ts index e00bd05ed..fac7590c6 100644 --- a/apps/web/src/common/db.ts +++ b/apps/web/src/common/db.ts @@ -24,6 +24,8 @@ import { showMigrationDialog } from "./dialog-controller"; import { database } from "@notesnook/common"; import { createDialect } from "./sqlite"; import { isFeatureSupported } from "../utils/feature-check"; +import { generatePassword } from "../utils/password-generator"; +import { deriveKey } from "../interfaces/key-store"; const db = database; async function initializeDatabase(persistence: DatabasePersistence) { @@ -31,9 +33,13 @@ async function initializeDatabase(persistence: DatabasePersistence) { const { FileStorage } = await import("../interfaces/fs"); const { Compressor } = await import("../utils/compressor"); - const { KeyChain } = await import("../interfaces/key-store"); + const { useKeyStore } = await import("../interfaces/key-store"); - const databaseKey = await KeyChain.extractKey(); + let databaseKey = await useKeyStore.getState().getValue("databaseKey"); + if (!databaseKey) { + databaseKey = await deriveKey(generatePassword()); + await useKeyStore.getState().setValue("databaseKey", databaseKey); + } db.host({ API_HOST: "https://api.notesnook.com", @@ -43,7 +49,11 @@ async function initializeDatabase(persistence: DatabasePersistence) { SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co" }); - const storage = new NNStorage("Notesnook", KeyChain, persistence); + const storage = new NNStorage( + "Notesnook", + () => useKeyStore.getState(), + persistence + ); await storage.migrate(); database.setup({ @@ -59,7 +69,7 @@ async function initializeDatabase(persistence: DatabasePersistence) { synchronous: "normal", pageSize: 8192, cacheSize: -32000, - password: databaseKey + password: Buffer.from(databaseKey).toString("hex") }, storage: storage, eventsource: EventSource, diff --git a/apps/web/src/common/store.ts b/apps/web/src/common/store.ts index 92fbe91ab..9826916db 100644 --- a/apps/web/src/common/store.ts +++ b/apps/web/src/common/store.ts @@ -20,17 +20,19 @@ import { immerable, setAutoFreeze } from "immer"; import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; -import { IStore } from "../stores"; +import { GetState, SetState } from "../stores"; setAutoFreeze(false); -export function createStore(Store: IStore) { +export function createStore( + getStore: (set: SetState, get: GetState) => T +) { const store = create< T, [["zustand/subscribeWithSelector", never], ["zustand/immer", never]] >( subscribeWithSelector( immer((set, get) => { - const store = new Store(set, get); + const store = getStore(set, get); (store as any)[immerable] = true; return store; }) diff --git a/apps/web/src/components/editor/context.ts b/apps/web/src/components/editor/context.ts index 6244f39c7..95fa6f294 100644 --- a/apps/web/src/components/editor/context.ts +++ b/apps/web/src/components/editor/context.ts @@ -57,7 +57,9 @@ class EditorContext extends BaseStore { }; } -const [useEditorContext] = createStore(EditorContext); +const [useEditorContext] = createStore( + (set, get) => new EditorContext(set, get) +); export function useEditorInstance() { const editor = useEditorContext((store) => store.subState.editor); diff --git a/apps/web/src/dialogs/settings/app-lock-settings.ts b/apps/web/src/dialogs/settings/app-lock-settings.ts new file mode 100644 index 000000000..9705b2826 --- /dev/null +++ b/apps/web/src/dialogs/settings/app-lock-settings.ts @@ -0,0 +1,418 @@ +/* +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 { SettingComponent, SettingsGroup } from "./types"; +import { useStore as useUserStore } from "../../stores/user-store"; +import { + showPasswordDialog, + showPromptDialog +} from "../../common/dialog-controller"; +import { + CredentialType, + CredentialWithSecret, + CredentialWithoutSecret, + useKeyStore +} from "../../interfaces/key-store"; +import { showToast } from "../../utils/toast"; +import { WebAuthn } from "../../utils/webauthn"; +import { generatePassword } from "../../utils/password-generator"; +import { verifyAccount } from "../../common"; +import { Checkmark } from "../../components/icons"; + +export const AppLockSettings: SettingsGroup[] = [ + { + key: "app-lock", + section: "app-lock", + header: "App lock", + onStateChange: (listener) => + useKeyStore.subscribe((s) => s.credentials, listener), + settings: [ + { + key: "enable-app-lock", + title: "Enable app lock", + onStateChange: (listener) => + useKeyStore.subscribe((s) => s.credentials, listener), + components: [ + { + type: "toggle", + toggle: async () => { + const { credentials } = useKeyStore.getState(); + if (credentials.length <= 0) { + const verified = await verifyAccount(); + if (!verified) return; + + await registerCredential("password"); + } else { + const { credentials } = useKeyStore.getState(); + const defaultCredential = credentials + .filter((c) => c.active) + .at(0); + if (!defaultCredential) return; + await unlockAppLock(defaultCredential); + } + }, + isToggled: () => + useKeyStore.getState().credentials.some((c) => c.active) + } + ] + }, + { + key: "lock-app-after", + title: "Lock app after", + description: + "How long should the app wait to lock itself after going into the background or going idle?", + isHidden: () => useKeyStore.getState().activeCredentials().length <= 0, + onStateChange: (listener) => + useKeyStore.subscribe((s) => s.secrets.lockAfter, listener), + components: [ + { + type: "dropdown", + options: [ + { title: "Immediately", value: 0 }, + { title: "1 minute", value: 1 }, + { title: "5 minutes", value: 5 }, + { title: "10 minutes", value: 10 }, + { title: "15 minutes", value: 15 }, + { title: "30 minutes", value: 30 }, + { title: "45 minutes", value: 45 }, + { title: "1 hour", value: 60 }, + { title: "Never", value: -1 } + ], + onSelectionChanged: async (value) => { + if (!(await authenticateAppLock())) { + showToast("error", "Failed to authenticate."); + return; + } + useKeyStore.getState().setValue("lockAfter", parseInt(value)); + }, + selectedOption: async () => { + return (await useKeyStore.getState().getValue("lockAfter")) || 0; + } + } + ] + } + ] + }, + { + key: "app-lock-credentials", + section: "app-lock", + header: "Credentials", + isHidden: () => { + return useKeyStore.getState().activeCredentials().length <= 0; + }, + onStateChange: (listener) => + useKeyStore.subscribe((s) => s.credentials, listener), + settings: [ + { + key: "password-pin", + title: "Password/pin", + description: "The password/pin for unlocking the app.", + components: () => { + const credential = useKeyStore + .getState() + .findCredential({ type: "password", id: "password" }); + const isEnabled = credential?.active; + + const inputs: SettingComponent[] = []; + if (isEnabled) { + inputs.push({ + type: "button", + title: "Change", + action: async () => { + const result = await showPasswordDialog({ + title: "Change app lock password", + inputs: { + oldPassword: { + label: "Old password", + autoComplete: "current-password" + }, + newPassword: { + label: "New password", + autoComplete: "new-password" + } + }, + validate({ newPassword, oldPassword }) { + return useKeyStore + .getState() + .changeCredential( + { + type: "password", + id: "password", + password: oldPassword + }, + { + type: "password", + id: "password", + password: newPassword + } + ) + .then(() => true) + .catch(() => false); + } + }); + if (result) showToast("success", "App lock password changed!"); + }, + variant: "secondary" + }); + } + + if ( + !isEnabled || + useKeyStore.getState().activeCredentials().length > 1 + ) + inputs.push({ + type: "button", + title: isEnabled ? "Disable" : "Enable", + action: async () => { + if (credential?.active) await deactivateCredential(credential); + else if (credential) + await useKeyStore.getState().activate(credential); + else { + if (!(await authenticateAppLock())) { + showToast("error", "Failed to authenticate."); + return; + } + await registerCredential("password"); + } + }, + variant: "secondary" + }); + + return inputs; + } + }, + { + key: "security-key", + title: "Security key", + description: "Use security key (e.g. YubiKey) for unlocking the app.", + onStateChange: (listener) => + useKeyStore.subscribe((s) => s.credentials, listener), + components: () => { + const { findCredential } = useKeyStore.getState(); + const credential = findCredential({ + type: "securityKey", + id: "securityKey" + }); + const isEnabled = credential?.active; + const hasActiveCredentials = + useKeyStore.getState().activeCredentials().length > 1; + + const inputs: SettingComponent[] = []; + if (credential && hasActiveCredentials) { + inputs.push({ + type: "button", + title: "Unregister", + action: async () => { + if (await useKeyStore.getState().credentialHasKey(credential)) { + await verifyCredential(credential, (c) => + useKeyStore.getState().unregister(c) + ); + } else { + useKeyStore.getState().unregister(credential); + } + }, + variant: "secondary" + }); + } + + if (!credential) { + inputs.push({ + type: "button", + title: "Register", + variant: "secondary", + async action() { + if (!(await authenticateAppLock())) { + showToast("error", "Failed to authenticate."); + return; + } + await registerCredential("securityKey"); + } + }); + } else if (!isEnabled || hasActiveCredentials) { + inputs.push({ + type: "button", + title: isEnabled ? "Disable" : "Enable", + action: async () => { + const hasKey = await useKeyStore.getState().credentialHasKey({ + type: "securityKey", + id: "securityKey" + }); + if (!hasKey && !credential?.active) + await verifyCredential(credential, (c) => + useKeyStore.getState().activate(c) + ); + else if (credential?.active) + await deactivateCredential(credential); + else if (credential) + await useKeyStore.getState().activate(credential); + }, + variant: "secondary" + }); + } + + if (inputs.length === 0) + inputs.push({ + type: "icon", + icon: Checkmark, + color: "accent", + size: 24 + }); + + return inputs; + } + } + ] + } +]; + +async function registerCredential(type: CredentialType) { + if (type === "password") { + await showPasswordDialog({ + title: "App lock", + subtitle: `Enter pin or password to enable app lock.`, + inputs: { + password: { + label: "Password", + autoComplete: "new-password" + }, + confirmPassword: { + label: "Confirm password", + autoComplete: "new-password" + } + }, + async validate({ confirmPassword, password }) { + if (confirmPassword !== password) return false; + const { register, activate } = useKeyStore.getState(); + await register({ + type, + id: "password", + salt: window.crypto.getRandomValues(new Uint8Array(16)) + }).then(() => + activate({ + type, + id: "password", + password + }) + ); + return true; + } + }); + } else if (type === "securityKey") { + const user = useUserStore.getState().user; + const username = + user?.email || + (await showPromptDialog({ + title: "Enter your username", + description: + "This username will be used to distinguish between different credentials in your security key. Make sure it is unique." + })); + if (!username) return; + + const userId = user + ? Buffer.from(user.id, "hex") + : // fixed id for unregistered users to avoid creating duplicate credentials + new Uint8Array([0x61, 0xd1, 0x20, 0x82]); + + try { + const { firstSalt, rawId, transports } = + await WebAuthn.registerSecurityKey(userId, username); + + await useKeyStore.getState().register({ + type, + id: "securityKey", + config: { + firstSalt, + label: generatePassword(), + rawId, + transports + } + }); + + showToast("success", "Security key successfully registered."); + } catch (e) { + showToast("error", (e as Error).message); + } + } +} + +async function unlockAppLock(credential: CredentialWithoutSecret) { + await verifyCredential(credential, (cred) => + useKeyStore.getState().unlock(cred, { permanent: true }) + ); +} + +async function deactivateCredential(credential: CredentialWithoutSecret) { + await verifyCredential(credential, (cred) => + useKeyStore.getState().deactivate(cred) + ); +} + +async function verifyCredential( + credential: CredentialWithoutSecret, + action: (credential: CredentialWithSecret) => Promise +) { + try { + if (credential.type === "password") { + return await showPasswordDialog({ + title: "App lock", + subtitle: `Enter app lock pin or password to continue.`, + inputs: { + password: { + label: "Password", + autoComplete: "new-password" + } + }, + async validate({ password }) { + await action({ + ...credential, + password + }); + return true; + } + }); + } else if (credential.type === "securityKey") { + const config = credential.config; + const { encryptionKey } = await WebAuthn.getEncryptionKey(config); + + return await action({ + ...credential, + key: encryptionKey + }); + } + } catch (e) { + console.error(e); + if (!(e instanceof Error)) return showToast("error", JSON.stringify(e)); + if (e.message.includes("The operation either timed out or was not allowed")) + return false; + showToast("error", e.message); + } +} + +async function authenticateAppLock() { + const defaultCredential = useKeyStore + .getState() + .credentials.filter((c) => c.active) + .at(0); + if (!defaultCredential) { + return verifyAccount(); + } + return !!(await verifyCredential(defaultCredential, (c) => + useKeyStore.getState().verifyCredential(c) + )); +} diff --git a/apps/web/src/dialogs/settings/app-lock-settings.tsx b/apps/web/src/dialogs/settings/app-lock-settings.tsx deleted file mode 100644 index 42eaa4bfa..000000000 --- a/apps/web/src/dialogs/settings/app-lock-settings.tsx +++ /dev/null @@ -1,260 +0,0 @@ -/* -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 { SettingsGroup } from "./types"; -import { useStore as useSettingStore } from "../../stores/setting-store"; -import { useStore as useUserStore } from "../../stores/user-store"; -import { - showPasswordDialog, - showPromptDialog -} from "../../common/dialog-controller"; -import { KeyChain } from "../../interfaces/key-store"; -import { showToast } from "../../utils/toast"; -import { WebAuthn } from "../../utils/webauthn"; -import { generatePassword } from "../../utils/password-generator"; - -export const AppLockSettings: SettingsGroup[] = [ - { - key: "vault", - section: "app-lock", - header: "App lock", - settings: [ - { - key: "enable-app-lock", - title: "Enable app lock", - onStateChange: (listener) => - useSettingStore.subscribe((s) => s.appLockSettings, listener), - components: [ - { - type: "toggle", - toggle: async () => { - const isEnabled = - useSettingStore.getState().appLockSettings.enabled; - const result = await showPasswordDialog({ - title: "App lock", - subtitle: `Enter pin or password to ${ - isEnabled ? "disable" : "enable" - } app lock.`, - inputs: { - password: { - label: "Password", - autoComplete: "new-password" - } - }, - async validate({ password }) { - if (isEnabled) - await KeyChain.unlock( - { - type: "password", - id: "primary", - password - }, - { permanent: true } - ); - else - await KeyChain.lock({ - type: "password", - id: "primary", - password - }); - return true; - } - }); - if (result) - useSettingStore.getState().setAppLockSettings( - isEnabled - ? { - enabled: false, - securityKey: undefined - } - : { enabled: true } - ); - }, - isToggled: () => useSettingStore.getState().appLockSettings.enabled - } - ] - }, - { - key: "lock-app-after", - title: "Lock app after", - description: - "How long should the app wait to lock itself after going into the background or going idle?", - isHidden: () => !useSettingStore.getState().appLockSettings.enabled, - onStateChange: (listener) => - useSettingStore.subscribe((s) => s.appLockSettings, listener), - components: [ - { - type: "dropdown", - options: [ - { title: "Immediately", value: "0" }, - { title: "1 minute", value: "1" }, - { title: "5 minutes", value: "5" }, - { title: "10 minutes", value: "10" }, - { title: "15 minutes", value: "15" }, - { title: "30 minutes", value: "30" }, - { title: "45 minutes", value: "45" }, - { title: "1 hour", value: "60" }, - { title: "Never", value: "-1" } - ], - onSelectionChanged: (value) => - useSettingStore - .getState() - .setAppLockSettings({ lockAfter: parseInt(value) }), - selectedOption: () => - useSettingStore.getState().appLockSettings.lockAfter.toString() - } - ] - }, - { - key: "password-pin", - title: "Password/pin", - description: "The password/pin for unlocking the app.", - isHidden: () => !useSettingStore.getState().appLockSettings.enabled, - onStateChange: (listener) => - useSettingStore.subscribe((s) => s.appLockSettings, listener), - components: [ - { - type: "button", - title: "Change", - action: async () => { - const result = await showPasswordDialog({ - title: "Change app lock password", - inputs: { - oldPassword: { - label: "Old password", - autoComplete: "current-password" - }, - newPassword: { - label: "New password", - autoComplete: "new-password" - } - }, - validate({ newPassword, oldPassword }) { - return KeyChain.changeCredential( - { - type: "password", - id: "primary", - password: oldPassword - }, - { - type: "password", - id: "primary", - password: newPassword - } - ) - .then(() => true) - .catch(() => false); - } - }); - if (result) showToast("success", "App lock password changed!"); - }, - variant: "secondary" - } - ] - }, - { - key: "security-key", - title: "Use security key", - description: "Use security key (e.g. YubiKey) for unlocking the app.", - isHidden: () => !useSettingStore.getState().appLockSettings.enabled, - onStateChange: (listener) => - useSettingStore.subscribe((s) => s.appLockSettings, listener), - components: () => [ - useSettingStore.getState().appLockSettings.securityKey - ? { - type: "button", - title: "Unregister", - async action() { - await KeyChain.removeCredential({ - type: "key", - id: "securityKey" - }); - useSettingStore - .getState() - .setAppLockSettings({ securityKey: undefined }); - }, - variant: "secondary" - } - : { - type: "button", - title: "Register", - action: async () => { - const user = useUserStore.getState().user; - const username = - user?.email || - (await showPromptDialog({ - title: "Enter your username", - description: - "This username will be used to distinguish between different credentials in your security key. Make sure it is unique." - })); - if (!username) - return showToast("error", "Username is required."); - - const userId = user - ? Buffer.from(user.id, "hex") - : // fixed id for unregistered users to avoid creating duplicate credentials - new Uint8Array([0x61, 0xd1, 0x20, 0x82]); - - try { - const { firstSalt, rawId, transports } = - await WebAuthn.registerSecurityKey(userId, username); - - showToast( - "success", - "Security key registered. Generating encryption key..." - ); - - const label = generatePassword(); - const { encryptionKey } = await WebAuthn.getEncryptionKey({ - firstSalt, - label, - rawId, - transports - }); - - await KeyChain.lock({ - type: "key", - key: encryptionKey, - id: "securityKey" - }); - - useSettingStore.getState().setAppLockSettings({ - securityKey: { - firstSalt: Buffer.from(firstSalt).toString("base64"), - label, - rawId: Buffer.from(rawId).toString("base64"), - transports - } - }); - - showToast( - "success", - "Security key successfully registered." - ); - } catch (e) { - showToast("error", (e as Error).message); - } - }, - variant: "secondary" - } - ] - } - ] - } -]; diff --git a/apps/web/src/dialogs/settings/index.tsx b/apps/web/src/dialogs/settings/index.tsx index f1fd1274e..e5b5fa113 100644 --- a/apps/web/src/dialogs/settings/index.tsx +++ b/apps/web/src/dialogs/settings/index.tsx @@ -44,7 +44,13 @@ import { Perform } from "../../common/dialog-controller"; import NavigationItem from "../../components/navigation-menu/navigation-item"; import { FlexScrollContainer } from "../../components/scroll-container"; import { useCallback, useEffect, useState } from "react"; -import { SectionGroup, SectionKeys, Setting, SettingsGroup } from "./types"; +import { + DropdownSettingComponent, + SectionGroup, + SectionKeys, + Setting, + SettingsGroup +} from "./types"; import { ProfileSettings } from "./profile-settings"; import { AuthenticationSettings } from "./auth-settings"; import { useIsUserPremium } from "../../hooks/use-is-user-premium"; @@ -65,7 +71,7 @@ import { SupportSettings } from "./other-settings"; import { AppearanceSettings } from "./appearance-settings"; -import { debounce } from "@notesnook/common"; +import { debounce, usePromise } from "@notesnook/common"; import { SubscriptionSettings } from "./subscription-settings"; import { ScopedThemeProvider } from "../../components/theme-provider"; import { AppLockSettings } from "./app-lock-settings"; @@ -339,12 +345,21 @@ function SettingsSideBar(props: SettingsSideBarProps) { function SettingsGroupComponent(props: { item: SettingsGroup }) { const { item } = props; - const { onRender } = item; + const { onRender, onStateChange } = item; + + const [_, setState] = useState(); useEffect(() => { onRender?.(); }, [onRender]); + useEffect(() => { + const unsubscribe = onStateChange?.(setState); + return () => { + unsubscribe?.(); + }; + }, [onStateChange]); + if (item.isHidden?.()) return null; return ( { if (!item.onStateChange) return; - item.onStateChange(setState); + const unsubscribe = item.onStateChange(setState); + return () => { + unsubscribe?.(); + }; }, [item]); const workWithLoading = useCallback( @@ -482,32 +500,10 @@ function SettingItem(props: { item: Setting }) { ); case "dropdown": return ( - + ); case "input": return component.inputType === "number" ? ( @@ -539,6 +535,15 @@ function SettingItem(props: { item: Setting }) { )} /> ); + case "icon": + return ( + + ); + default: + return null; } })} @@ -551,3 +556,39 @@ function SettingItem(props: { item: Setting }) { ); } + +function SelectComponent( + props: DropdownSettingComponent & { isUserPremium: boolean } +) { + const { onSelectionChanged, options, isUserPremium } = props; + const selectedOption = usePromise(() => props.selectedOption(), [props]); + + return ( + + ); +} diff --git a/apps/web/src/dialogs/settings/types.ts b/apps/web/src/dialogs/settings/types.ts index b87fd68ad..37f48f048 100644 --- a/apps/web/src/dialogs/settings/types.ts +++ b/apps/web/src/dialogs/settings/types.ts @@ -66,6 +66,9 @@ export type SettingsGroup = { settings: Setting[]; header: string | ((props: any) => JSX.Element | null); isHidden?: () => boolean; + onStateChange?: ( + listener: (state: unknown, prevState: unknown) => void + ) => () => void; onRender?: () => void | Promise; }; @@ -78,7 +81,7 @@ export type Setting = { isHidden?: (state?: unknown) => boolean; onStateChange?: ( listener: (state: unknown, prevState: unknown) => void - ) => void; + ) => () => void; }; export type SettingComponentType = @@ -86,11 +89,13 @@ export type SettingComponentType = | "dropdown" | "button" | "input" + | "icon" | "custom"; export type SettingComponent = | ButtonSettingComponent | ToggleSettingComponent + | IconSettingComponent | DropdownSettingComponent | InputSettingComponent | CustomSettingComponent; @@ -105,14 +110,20 @@ export type ButtonSettingComponent = BaseSettingComponent<"button"> & { variant: "primary" | "secondary" | "error" | "errorSecondary"; }; +export type IconSettingComponent = BaseSettingComponent<"icon"> & { + icon: Icon; + size: number; + color: string; +}; + export type ToggleSettingComponent = BaseSettingComponent<"toggle"> & { isToggled: () => boolean; toggle: () => void | Promise; }; export type DropdownSettingComponent = BaseSettingComponent<"dropdown"> & { - options: { value: string; title: string; premium?: boolean }[]; - selectedOption: () => string; + options: { value: string | number; title: string; premium?: boolean }[]; + selectedOption: () => string | number | Promise; onSelectionChanged: (value: string) => void | Promise; }; diff --git a/apps/web/src/hooks/use-auto-updater.ts b/apps/web/src/hooks/use-auto-updater.ts index b1088a089..3973eef0b 100644 --- a/apps/web/src/hooks/use-auto-updater.ts +++ b/apps/web/src/hooks/use-auto-updater.ts @@ -41,7 +41,9 @@ class AutoUpdateStore extends BaseStore { }; } -const [useAutoUpdateStore] = createStore(AutoUpdateStore); +const [useAutoUpdateStore] = createStore( + (set, get) => new AutoUpdateStore(set, get) +); let checkingForUpdateTimeout = 0; export function useAutoUpdater() { diff --git a/apps/web/src/hooks/use-spell-checker.ts b/apps/web/src/hooks/use-spell-checker.ts index bc4868a14..8f7f836a8 100644 --- a/apps/web/src/hooks/use-spell-checker.ts +++ b/apps/web/src/hooks/use-spell-checker.ts @@ -60,5 +60,7 @@ class SpellCheckerStore extends BaseStore { }; } -const [useSpellChecker] = createStore(SpellCheckerStore); +const [useSpellChecker] = createStore( + (set, get) => new SpellCheckerStore(set, get) +); export { useSpellChecker }; diff --git a/apps/web/src/interfaces/key-store.ts b/apps/web/src/interfaces/key-store.ts index c24da5252..09990db2e 100644 --- a/apps/web/src/interfaces/key-store.ts +++ b/apps/web/src/interfaces/key-store.ts @@ -17,36 +17,71 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { Cipher } from "@notesnook/crypto"; import { IKVStore, IndexedDBKVStore, MemoryKVStore } from "./key-value"; -import { NNCrypto } from "./nncrypto"; import { isFeatureSupported } from "../utils/feature-check"; -import { isCipher } from "@notesnook/core/dist/database/crypto"; import { desktop } from "../common/desktop-bridge"; +import { SecurityKeyConfig } from "../utils/webauthn"; +import BaseStore, { GetState, SetState } from "../stores"; +import createStore from "../common/store"; -type BaseCredential = { id: string }; -type PasswordCredential = BaseCredential & { - type: "password"; +// Key chain credentials: +/** + * 1. Security key + * - changeable: false + * 2. Password/pin + * - changeable: true + * 3. Private/public key pair + * - changeable: true + * 4. QR Code Scan from another app + * - changeable: true + */ + +export type CredentialType = "password" | "securityKey"; +type BaseCredential = { type: T; id: string }; +type PasswordCredential = BaseCredential<"password"> & { password: string; + salt: Uint8Array; }; -type KeyCredential = BaseCredential & { - type: "key"; +type SecurityKeyCredential = BaseCredential<"securityKey"> & { key: CryptoKey; + config: SecurityKeyConfig; }; -type Credential = PasswordCredential | KeyCredential; -export type SerializableCredential = Omit; +type Credential = PasswordCredential | SecurityKeyCredential; + +export type CredentialWithoutSecret = + | Omit + | Omit; + +export type SerializableCredential = CredentialWithoutSecret & { + active: boolean; +}; +export type CredentialWithSecret = + | Omit + | Omit; +export type CredentialQuery = BaseCredential & { + active?: boolean; +}; type EncryptedData = { iv: Uint8Array; cipher: ArrayBuffer; }; -function isEncryptedData(data: any): data is EncryptedData { - return data.iv instanceof Uint8Array && typeof data.cipher !== "string"; +function isCredentialWithSecret( + c: CredentialWithSecret | CredentialWithoutSecret +): c is CredentialWithSecret { + return "key" in c || "password" in c; } +const defaultSecrets = { + databaseKey: new ArrayBuffer(0), + lockAfter: 0, + userEncryptionKey: "" +}; +type Secrets = typeof defaultSecrets; + const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -86,254 +121,278 @@ const decoder = new TextDecoder(); * system level keystore. The compromise here is that the database won't be * encrypted if the user doesn't turn on app lock. */ -class KeyStore { - private secretStore: IKVStore; - private metadataStore: IKVStore; - private keyId = "key"; - private key?: CryptoKey; - constructor(dbName: string) { - this.metadataStore = isFeatureSupported("indexedDB") +class KeyStore extends BaseStore { + #secretStore: IKVStore; + #metadataStore: IKVStore; + #keyId = "key"; + #wrappingKeyId = "wrappingKey"; + #key?: CryptoKey; + + credentials: SerializableCredential[] = []; + secrets: Record = {}; + isLocked = false; + + constructor( + dbName: string, + setState: SetState, + get: GetState + ) { + super(setState, get); + + this.#metadataStore = isFeatureSupported("indexedDB") ? new IndexedDBKVStore(`${dbName}-metadata`, "metadata") : new MemoryKVStore(); - this.secretStore = isFeatureSupported("indexedDB") + this.#secretStore = isFeatureSupported("indexedDB") ? new IndexedDBKVStore(`${dbName}-secrets`, "secrets") : new MemoryKVStore(); } - public async lock(credential: Credential) { - const originalKey = await this.getKey(); - const key = new Uint8Array( - await window.crypto.subtle.exportKey("raw", originalKey) - ); + activeCredentials = () => this.get().credentials.filter((c) => c.active); - await this.metadataStore.set( - this.getCredentialKey(credential), - await this.encryptKey(credential, key) - ); - - await this.metadataStore.delete(this.keyId); - await this.setCredential({ id: credential.id, type: credential.type }); - - this.key = originalKey; - return this; - } - - public async unlock( - credential: Credential, - options?: { - permanent?: boolean; - } - ) { - if (!(await this.hasCredential(credential))) return; - - const encryptedKey = await this.metadataStore.get< - Cipher<"base64"> | EncryptedData - >(this.getCredentialKey(credential)); - if (!encryptedKey) return this; - - const decryptedKey = await this.decryptKey(encryptedKey, credential); - if (!decryptedKey) throw new Error("Could not decrypt key."); - - const key = await window.crypto.subtle.importKey( - "raw", - decryptedKey, - { name: "AES-GCM", length: 256 }, - true, - ["encrypt", "decrypt"] - ); - - if (options?.permanent) { - await this.resetCredentials(); - await this.storeKey(key); - this.key = undefined; - } else this.key = key; - - return this; - } - - public relock() { - this.key = undefined; - } - - public async changeCredential( - oldCredential: Credential, - newCredential: Credential - ) { - const encryptedKey = await this.metadataStore.get>( - this.getCredentialKey(oldCredential) - ); - if (!encryptedKey) return this; - - const decryptedKey = await this.decryptKey(encryptedKey, oldCredential); - if (!decryptedKey) throw new Error("Could not decrypt key."); - - const reencryptedKey = await this.encryptKey(newCredential, decryptedKey); - if (!reencryptedKey) throw new Error("Could not reencrypt key."); - - await this.metadataStore.set( - this.getCredentialKey(newCredential), - reencryptedKey - ); - } - - public async verifyCredential(credential: Credential) { - try { - const encryptedKey = await this.metadataStore.get>( - this.getCredentialKey(credential) - ); - if (!encryptedKey) return false; - - const decryptedKey = await this.decryptKey(encryptedKey, credential); - return !!decryptedKey; - } catch { - return false; - } - } - - public async removeCredential(credential: SerializableCredential) { - await this.metadataStore.delete(this.getCredentialKey(credential)); + init = async () => { const credentials = await this.getCredentials(); - const index = credentials.findIndex( - (c) => c.type === credential.type && c.id === credential.id + const secrets = Object.fromEntries( + await this.#secretStore.entries() ); - credentials.splice(index, 1); - this.metadataStore.set("credentials", credentials); - } + this.set({ + credentials, + secrets, + isLocked: credentials.some((c) => c.active) + }); + }; - public async extractKey() { - if (await this.isLocked()) - throw new Error("Please unlock the key store to extract its key."); - - const key = await window.crypto.subtle.exportKey( - "raw", - await this.getKey() - ); - return Buffer.from(key).toString("base64"); - } - - async isLocked() { - return (await this.getCredentials()).length > 0 && !this.key; - } - - public async getCredentials() { - return ( - (await this.metadataStore.get("credentials")) || - [] - ); - } - - public async hasCredential(credential: SerializableCredential) { - const credentials = await this.getCredentials(); - const index = credentials.findIndex( - (c) => c.type === credential.type && c.id === credential.id - ); - return index > -1; - } - - public async resetCredentials() { - for (const credential of await this.getCredentials()) { - await this.metadataStore.delete(this.getCredentialKey(credential)); - } - await this.metadataStore.delete("credentials"); - } - - public async setCredential(credential: SerializableCredential) { - const credentials = await this.getCredentials(); + register = async (credential: CredentialWithoutSecret) => { + const { credentials } = this.get(); const index = credentials.findIndex( (c) => c.type === credential.type && c.id === credential.id ); if (index > -1) return; - credentials.push(credential); - await this.metadataStore.set("credentials", credentials); - } - public async set(name: string, value: string) { - if (await this.isLocked()) + this.set((store) => + store.credentials.push(serializeCredential(credential, false)) + ); + await this.#metadataStore.set("credentials", this.get().credentials); + }; + + unregister = async ( + credential: CredentialWithSecret | CredentialWithoutSecret + ) => { + const { credentials } = this.get(); + const index = credentials.findIndex((c) => matchCredential(c, credential)); + if (index <= -1) throw new Error("No such credential."); + + if ( + isCredentialWithSecret(credential) && + (await this.credentialHasKey(credential)) && + !(await this.verifyCredential(credential)) + ) + throw new Error(wrongCredentialError(credential)); + + this.set((store) => store.credentials.splice(index, 1)); + + await this.#metadataStore.delete(this.getCredentialKey(credential)); + await this.#metadataStore.set("credentials", this.get().credentials); + }; + + activate = async ( + credential: CredentialWithSecret | CredentialWithoutSecret + ) => { + const cred = this.findCredential(credential); + if (!cred) + throw new Error(`No credential with id "${credential.id}" registered.`); + + if (await this.credentialHasKey(credential)) { + await this.update(credential, (c) => (c.active = true)); + return; + } + + if (!isCredentialWithSecret(credential)) + throw new Error("Invalid credential."); + + const originalKey = await this.getKey(); + await this.#metadataStore.set( + this.getCredentialKey(credential), + await wrapKey( + originalKey, + await getWrappingKey(deserializeCredential(cred, credential)) + ) + ); + await this.#metadataStore.deleteMany([this.#keyId, this.#wrappingKeyId]); + this.#key = originalKey; + this.set({ isLocked: false }); + await this.update(credential, (c) => (c.active = true)); + }; + + credentialHasKey = async (credential: CredentialQuery) => { + return !!(await this.#metadataStore.get(this.getCredentialKey(credential))); + }; + + deactivate = async (credential: CredentialWithSecret) => { + if (!(await this.verifyCredential(credential))) + throw new Error(wrongCredentialError(credential)); + + const cred = this.findCredential(credential); + if (!cred) + throw new Error(`No credential with id "${credential.id}" registered.`); + + await this.update(credential, (c) => (c.active = false)); + this.set({ isLocked: false }); + }; + + unlock = async ( + credential: CredentialWithSecret, + options?: { + permanent?: boolean; + } + ) => { + const cred = this.findCredential(credential); + if (!cred) throw new Error("Could not find a valid credential."); + + const encryptedKey = await this.#metadataStore.get( + this.getCredentialKey(credential) + ); + if (!encryptedKey) + throw new Error("Could not find credential's encrypted key."); + + const key = await unwrapKey( + encryptedKey, + await getWrappingKey(deserializeCredential(cred, credential)) + ); + if (options?.permanent) { + await this.resetCredentials(); + await this.storeKey(key); + this.#key = undefined; + } else this.#key = key; + + this.set({ isLocked: false }); + }; + + relock = () => { + this.#key = undefined; + this.set({ isLocked: true }); + }; + + findCredential = (credential: CredentialQuery) => { + return this.get().credentials.find((c) => matchCredential(c, credential)); + }; + + hasCredential = (credential: CredentialQuery) => { + return !!this.findCredential(credential); + }; + + changeCredential = async ( + oldCredential: CredentialWithSecret, + newCredential: CredentialWithSecret + ) => { + const cred = this.findCredential(oldCredential); + if (!cred) throw new Error("Could not find a valid credential."); + + const encryptedKey = await this.#metadataStore.get( + this.getCredentialKey(oldCredential) + ); + if (!encryptedKey) return; + + const decryptedKey = await unwrapKey( + encryptedKey, + await getWrappingKey(deserializeCredential(cred, oldCredential)) + ); + + const reencryptedKey = await wrapKey( + decryptedKey, + await getWrappingKey(deserializeCredential(cred, newCredential)) + ); + if (!reencryptedKey) throw new Error(wrongCredentialError(newCredential)); + + await this.#metadataStore.set( + this.getCredentialKey(newCredential), + reencryptedKey + ); + }; + + verifyCredential = async (credential: CredentialWithSecret) => { + try { + const cred = this.findCredential(credential); + if (!cred) return false; + + const encryptedKey = await this.#metadataStore.get( + this.getCredentialKey(credential) + ); + if (!encryptedKey) return false; + + const decryptedKey = await unwrapKey( + encryptedKey, + await getWrappingKey(deserializeCredential(cred, credential)) + ); + return !!decryptedKey; + } catch { + return false; + } + }; + + setValue = async (name: T, value: Secrets[T]) => { + if (this.get().isLocked) throw new Error("Please unlock the key store to set values."); - return this.secretStore.set( - name, - await this.encrypt(value, await this.getKey()) + const encryptedValue = await encrypt(value, await this.getKey()); + await this.#secretStore.set(name, encryptedValue).then(() => + this.set((store) => { + store.secrets = { ...store.secrets, [name]: encryptedValue }; + }) ); - } + }; - public async get(name: string): Promise { - if (await this.isLocked()) - throw new Error("Please unlock the key store to get values."); - console.log("GETTING", name); - const blob = await this.secretStore.get(name); + getValue = async ( + name: T + ): Promise => { + const { isLocked, secrets } = this.get(); + if (isLocked) throw new Error("Please unlock the key store to get values."); + const blob = secrets[name]; if (!blob) return; - return this.decrypt(blob, await this.getKey()); - } + const decryptedBlob = await decrypt(blob, await this.getKey()); + if (defaultSecrets[name] instanceof ArrayBuffer) + return decryptedBlob as Secrets[T]; + else return JSON.parse(decoder.decode(decryptedBlob)).value as Secrets[T]; + }; - public async clear(): Promise { - await this.secretStore.clear(); - await this.metadataStore.clear(); - this.key = undefined; - } + private update = async ( + query: CredentialQuery, + patch: (c: SerializableCredential) => void + ) => { + const { credentials } = this.get(); + const index = credentials.findIndex((c) => matchCredential(c, query)); + if (index <= -1) return; + this.set((s) => patch(s.credentials[index])); + await this.#metadataStore.set("credentials", this.get().credentials); + }; - private async decryptKey( - encryptedKey: Cipher<"base64"> | EncryptedData, - credential: PasswordCredential | KeyCredential - ): Promise { - if (credential.type === "password" && isCipher(encryptedKey)) { - return await NNCrypto.decrypt( - { password: credential.password }, - encryptedKey, - "uint8array" - ); - } else if (credential.type === "key" && isEncryptedData(encryptedKey)) { - return new Uint8Array( - await window.crypto.subtle.decrypt( - { - name: "AES-GCM", - iv: encryptedKey.iv - }, - credential.key, - encryptedKey.cipher - ) - ); + private resetCredentials = async () => { + for (const credential of this.get().credentials) { + await this.#metadataStore.delete(this.getCredentialKey(credential)); } - } + await this.#metadataStore.delete("credentials"); + this.set({ credentials: [] }); + }; - private async encryptKey( - credential: PasswordCredential | KeyCredential, - key: Uint8Array - ) { - if (credential.type === "password") { - return await NNCrypto.encrypt( - { password: credential.password }, - key, - "uint8array", - "base64" - ); - } else if (credential.type === "key") { - if (!credential.key?.usages.includes("encrypt")) - throw new Error("Cannot use this key to encrypt."); + private getKey = async () => { + if (this.#key) return this.#key; + if (this.get().isLocked) throw new Error("Key store is locked."); - return await this.encrypt(key, credential.key); - } - } + const wrappedKey = await this.#metadataStore.get(this.#keyId); + if (!wrappedKey) return this.storeKey(); - private async getKey() { - if (this.key) return this.key; - if ((await this.getCredentials()).length > 0) - throw new Error("Key store is locked."); - - const key = await this.metadataStore.get( - this.keyId + const wrappingKey = await this.#metadataStore.get( + this.#wrappingKeyId ); - if (key instanceof Uint8Array) { - if (!desktop) - throw new Error("Cannot decrypt key: no safe storage found."); + if (desktop && !wrappingKey) { const decrypted = Buffer.from( await desktop.safeStorage.decryptString.query( - Buffer.from(key).toString("base64") + Buffer.from(wrappedKey).toString("base64") ), "base64" ); + return window.crypto.subtle.importKey( "raw", decrypted, @@ -341,11 +400,12 @@ class KeyStore { true, ["encrypt", "decrypt"] ); - } else if (key instanceof CryptoKey) return key; - else return this.storeKey(); - } + } else if (wrappingKey) { + return unwrapKey(wrappedKey, wrappingKey); + } else throw new Error("Could not decrypt key."); + }; - private async storeKey(key?: CryptoKey) { + private storeKey = async (key?: CryptoKey) => { key = key || (await window.crypto.subtle.generateKey( @@ -366,45 +426,212 @@ class KeyStore { ), "base64" ); - await this.metadataStore.set(this.keyId, encrypted); - } else await this.metadataStore.set(this.keyId, key); + await this.#metadataStore.set(this.#keyId, encrypted.buffer); + } else { + const wrappingKey = await getWrappingKey(); + const wrappedKey = await wrapKey(key, wrappingKey); + await this.#metadataStore.setMany([ + [this.#wrappingKeyId, wrappingKey], + [this.#keyId, wrappedKey] + ]); + } return key; - } + }; - private async encrypt(data: string | ArrayBuffer, key: CryptoKey) { - const iv = window.crypto.getRandomValues(new Uint8Array(12)); - const cipher = await window.crypto.subtle.encrypt( - { - name: "AES-GCM", - iv: iv - }, - key, - typeof data === "string" ? encoder.encode(data) : data + private getCredentials = async () => { + return ( + (await this.#metadataStore.get( + "credentials" + )) || [] ); + }; - return { - iv, - cipher - }; - } + private getCredentialKey = (credential: CredentialQuery) => { + return `${this.#keyId}-${credential.type}-${credential.id}`; + }; +} - private async decrypt(data: EncryptedData, key: CryptoKey) { - const plainText = await window.crypto.subtle.decrypt( - { - name: "AES-GCM", - iv: data.iv - }, - key, - data.cipher - ); - return decoder.decode(plainText); - } - - private getCredentialKey(credential: SerializableCredential) { - return `${this.keyId}-${credential.type}-${credential.id}`; +function serializeCredential( + credential: Credential | CredentialWithoutSecret, + active: boolean +): SerializableCredential { + switch (credential.type) { + case "password": + return { + type: "password", + id: credential.id, + active, + salt: credential.salt + }; + case "securityKey": + return { + type: "securityKey", + id: credential.id, + config: credential.config, + active + }; } } -export const KeyChain = new KeyStore("KeyChain"); +function deserializeCredential( + credential: SerializableCredential, + secret: CredentialWithSecret +): Credential { + if (secret.type === "password" && credential.type === "password") { + return { + type: "password", + id: credential.id, + salt: credential.salt, + password: secret.password + }; + } else if (secret.type === "securityKey" && credential.type === "securityKey") + return { + type: "securityKey", + id: credential.id, + config: credential.config, + key: secret.key + }; + + throw new Error("Credentials are of different types."); +} + +function wrongCredentialError(query: CredentialQuery): string { + switch (query.type) { + case "password": + return "Wrong password"; + case "securityKey": + return "Wrong security key."; + } +} + +async function unwrapKey( + wrappedKey: ArrayBuffer, + wrappingKey: CryptoKey +): Promise { + return await window.crypto.subtle.unwrapKey( + "raw", + wrappedKey, + wrappingKey, + "AES-KW", + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); +} + +async function wrapKey(key: CryptoKey, wrappingKey: CryptoKey) { + return await window.crypto.subtle.wrapKey("raw", key, wrappingKey, "AES-KW"); +} + +async function getWrappingKey(credential?: Credential): Promise { + let wrappingKey: CryptoKey | undefined; + + if (!credential) + wrappingKey = await window.crypto.subtle.generateKey( + { name: "AES-KW", length: 256 }, + false, + ["wrapKey", "unwrapKey"] + ); + else if (credential.type === "password") { + wrappingKey = await window.crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: credential.salt, + iterations: 650000, + hash: "SHA-512" + }, + await window.crypto.subtle.importKey( + "raw", + encoder.encode(credential.password), + { name: "PBKDF2" }, + false, + ["deriveKey"] + ), + { name: "AES-KW", length: 256 }, + false, + ["wrapKey", "unwrapKey"] + ); + } else if (credential.type === "securityKey") wrappingKey = credential.key; + if ( + !wrappingKey || + !wrappingKey.usages.includes("wrapKey") || + !wrappingKey.usages.includes("unwrapKey") + ) + throw new Error("Could not generate a valid wrapping key."); + return wrappingKey; +} + +async function encrypt( + data: string | number | boolean | ArrayBuffer, + key: CryptoKey +) { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const cipher = await window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv + }, + key, + data instanceof ArrayBuffer + ? data + : encoder.encode( + JSON.stringify({ + value: data + }) + ) + ); + + return { + iv, + cipher + }; +} + +async function decrypt(encrypted: EncryptedData, key: CryptoKey) { + return await window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: encrypted.iv + }, + key, + encrypted.cipher + ); +} + +function matchCredential(cred: SerializableCredential, query: CredentialQuery) { + return ( + cred.type === query.type && + cred.id === query.id && + (query.active === undefined || cred.active === query.active) + ); +} + +export async function deriveKey(password: string) { + const passwordBuffer = encoder.encode(password); + const importedKey = await crypto.subtle.importKey( + "raw", + passwordBuffer, + "PBKDF2", + false, + ["deriveBits"] + ); + const salt = window.crypto.getRandomValues(new Uint8Array(16)); + return await crypto.subtle.deriveBits( + { + name: "PBKDF2", + hash: "SHA-512", + salt, + iterations: 650000 + }, + importedKey, + 32 * 8 + ); +} + +const createKeyStore = (name: string) => + createStore((set, get) => new KeyStore(name, set, get)); + +const [useKeyStore] = createKeyStore("KeyChain"); +export { useKeyStore }; export type IKeyStore = typeof KeyStore.prototype; diff --git a/apps/web/src/interfaces/key-value.ts b/apps/web/src/interfaces/key-value.ts index 59fb3f792..8e75013a0 100644 --- a/apps/web/src/interfaces/key-value.ts +++ b/apps/web/src/interfaces/key-value.ts @@ -41,7 +41,7 @@ export interface IKVStore { * * @param entries Array of entries, where each entry is an array of `[key, value]`. */ - setMany(entries: [string, T][]): Promise; + setMany(entries: [string, unknown][]): Promise; /** * Get multiple values by their keys diff --git a/apps/web/src/interfaces/storage.ts b/apps/web/src/interfaces/storage.ts index 7a2c7c006..f9b03b1ba 100644 --- a/apps/web/src/interfaces/storage.ts +++ b/apps/web/src/interfaces/storage.ts @@ -40,7 +40,7 @@ export class NNStorage implements IStorage { constructor( name: string, - private readonly keyStore: IKeyStore | null, + private readonly keyStore: () => IKeyStore | null = () => null, persistence: DatabasePersistence = "db" ) { this.database = @@ -63,7 +63,7 @@ export class NNStorage implements IStorage { `_uk_@${user.email}`, `_uk_@${user.email}@_k` ]); - await this.keyStore.set("userEncryptionKey", key); + await this.keyStore()?.setValue("userEncryptionKey", key); } read(key: string): Promise { @@ -109,13 +109,13 @@ export class NNStorage implements IStorage { const keyData = await NNCrypto.exportKey(password, salt); if (!keyData.key) throw new Error("Invalid key."); - await this.keyStore.set("userEncryptionKey", keyData.key); + await this.keyStore()?.setValue("userEncryptionKey", keyData.key); } async getCryptoKey(): Promise { if (!this.keyStore) throw new Error("No key store found!"); - return this.keyStore.get("userEncryptionKey"); + return this.keyStore()?.getValue("userEncryptionKey"); } async generateCryptoKey( diff --git a/apps/web/src/stores/announcement-store.js b/apps/web/src/stores/announcement-store.js index 791d96e48..25c07ae43 100644 --- a/apps/web/src/stores/announcement-store.js +++ b/apps/web/src/stores/announcement-store.js @@ -71,7 +71,9 @@ class AnnouncementStore extends BaseStore { }; } -const [useStore, store] = createStore(AnnouncementStore); +const [useStore, store] = createStore( + (set, get) => new AnnouncementStore(set, get) +); export { useStore, store }; export const allowedPlatforms = [ diff --git a/apps/web/src/stores/app-store.ts b/apps/web/src/stores/app-store.ts index 90ac00e38..ddb50a1e7 100644 --- a/apps/web/src/stores/app-store.ts +++ b/apps/web/src/stores/app-store.ts @@ -362,5 +362,7 @@ class AppStore extends BaseStore { }; } -const [useStore, store] = createStore(AppStore); +const [useStore, store] = createStore( + (set, get) => new AppStore(set, get) +); export { useStore, store }; diff --git a/apps/web/src/stores/attachment-store.ts b/apps/web/src/stores/attachment-store.ts index 1bd6ff048..cd30ace6a 100644 --- a/apps/web/src/stores/attachment-store.ts +++ b/apps/web/src/stores/attachment-store.ts @@ -152,5 +152,7 @@ class AttachmentStore extends BaseStore { }; } -const [useStore, store] = createStore(AttachmentStore); +const [useStore, store] = createStore( + (set, get) => new AttachmentStore(set, get) +); export { useStore, store }; diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index d9d110db4..a0809b00d 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -378,5 +378,7 @@ class EditorStore extends BaseStore { // }; } -const [useStore, store] = createStore(EditorStore); +const [useStore, store] = createStore( + (set, get) => new EditorStore(set, get) +); export { useStore, store, SESSION_STATES }; diff --git a/apps/web/src/stores/index.ts b/apps/web/src/stores/index.ts index bf745c6b0..25174b579 100644 --- a/apps/web/src/stores/index.ts +++ b/apps/web/src/stores/index.ts @@ -24,8 +24,8 @@ type NNStoreCreator = StateCreator< [["zustand/subscribeWithSelector", never], ["zustand/immer", never]] >; -type GetState = Parameters>[1]; -type SetState = Parameters>[0]; +export type GetState = Parameters>[1]; +export type SetState = Parameters>[0]; export interface IStore { new (set: SetState, get: GetState): T; diff --git a/apps/web/src/stores/monograph-store.ts b/apps/web/src/stores/monograph-store.ts index 3e90ced7a..29049c16a 100644 --- a/apps/web/src/stores/monograph-store.ts +++ b/apps/web/src/stores/monograph-store.ts @@ -48,5 +48,7 @@ class MonographStore extends BaseStore { }; } -const [useStore, store] = createStore(MonographStore); +const [useStore, store] = createStore( + (set, get) => new MonographStore(set, get) +); export { useStore, store }; diff --git a/apps/web/src/stores/note-store.ts b/apps/web/src/stores/note-store.ts index 8bf2025ef..68ff4570f 100644 --- a/apps/web/src/stores/note-store.ts +++ b/apps/web/src/stores/note-store.ts @@ -156,7 +156,9 @@ class NoteStore extends BaseStore { }; } -const [useStore, store] = createStore(NoteStore); +const [useStore, store] = createStore( + (set, get) => new NoteStore(set, get) +); export { useStore, store }; export function notesFromContext(context: Context) { diff --git a/apps/web/src/stores/notebook-store.ts b/apps/web/src/stores/notebook-store.ts index 9fd337b44..8aedb3519 100644 --- a/apps/web/src/stores/notebook-store.ts +++ b/apps/web/src/stores/notebook-store.ts @@ -55,5 +55,7 @@ class NotebookStore extends BaseStore { }; } -const [useStore, store] = createStore(NotebookStore); +const [useStore, store] = createStore( + (set, get) => new NotebookStore(set, get) +); export { useStore, store }; diff --git a/apps/web/src/stores/reminder-store.ts b/apps/web/src/stores/reminder-store.ts index 0d5bf319b..39859bc2a 100644 --- a/apps/web/src/stores/reminder-store.ts +++ b/apps/web/src/stores/reminder-store.ts @@ -51,7 +51,9 @@ class ReminderStore extends BaseStore { }; } -const [useStore, store] = createStore(ReminderStore); +const [useStore, store] = createStore( + (set, get) => new ReminderStore(set, get) +); export { useStore, store }; async function resetReminders(reminders: FilteredSelector) { diff --git a/apps/web/src/stores/search-store.ts b/apps/web/src/stores/search-store.ts index f7c18a4f5..e154a3150 100644 --- a/apps/web/src/stores/search-store.ts +++ b/apps/web/src/stores/search-store.ts @@ -39,5 +39,7 @@ class SearchStore extends BaseStore { // }; } -const [useStore, store] = createStore(SearchStore); +const [useStore, store] = createStore( + (set, get) => new SearchStore(set, get) +); export { useStore, store }; diff --git a/apps/web/src/stores/selection-store.ts b/apps/web/src/stores/selection-store.ts index d15ad78bc..f4c5dd94e 100644 --- a/apps/web/src/stores/selection-store.ts +++ b/apps/web/src/stores/selection-store.ts @@ -70,5 +70,7 @@ class SelectionStore extends BaseStore { }; } -const [useStore, store] = createStore(SelectionStore); +const [useStore, store] = createStore( + (set, get) => new SelectionStore(set, get) +); export { useStore, store }; diff --git a/apps/web/src/stores/setting-store.ts b/apps/web/src/stores/setting-store.ts index 7b6438dca..95a3ae4c6 100644 --- a/apps/web/src/stores/setting-store.ts +++ b/apps/web/src/stores/setting-store.ts @@ -28,13 +28,6 @@ import { isTelemetryEnabled, setTelemetry } from "../utils/telemetry"; import { setDocumentTitle } from "../utils/dom"; import { TimeFormat } from "@notesnook/core/dist/utils/date"; import { TrashCleanupInterval } from "@notesnook/core"; -import { SecurityKeyConfig } from "../utils/webauthn"; - -type AppLockSettings = { - enabled: boolean; - lockAfter: number; - securityKey?: SecurityKeyConfig; -}; class SettingStore extends BaseStore { encryptBackups = Config.get("encryptBackups", false); @@ -62,11 +55,6 @@ class SettingStore extends BaseStore { isFlatpak = false; proxyRules?: string; - appLockSettings: AppLockSettings = Config.get("appLockSettings", { - enabled: false, - lockAfter: 0 - }); - refresh = async () => { this.set({ dateFormat: db.settings.getDateFormat(), @@ -145,12 +133,6 @@ class SettingStore extends BaseStore { this.set({ notificationsSettings: Config.get("notifications") }); }; - setAppLockSettings = (settings: Partial) => { - const { appLockSettings } = this.get(); - Config.set("appLockSettings", { ...appLockSettings, ...settings }); - this.set({ appLockSettings: Config.get("appLockSettings") }); - }; - toggleEncryptBackups = () => { const encryptBackups = this.get().encryptBackups; this.setEncryptBackups(!encryptBackups); @@ -210,5 +192,7 @@ class SettingStore extends BaseStore { }; } -const [useStore, store] = createStore(SettingStore); +const [useStore, store] = createStore( + (set, get) => new SettingStore(set, get) +); export { useStore, store }; diff --git a/apps/web/src/stores/tag-store.ts b/apps/web/src/stores/tag-store.ts index aa3b01a25..5b9ed9eb9 100644 --- a/apps/web/src/stores/tag-store.ts +++ b/apps/web/src/stores/tag-store.ts @@ -33,5 +33,7 @@ class TagStore extends BaseStore { }; } -const [useStore, store] = createStore(TagStore); +const [useStore, store] = createStore( + (set, get) => new TagStore(set, get) +); export { useStore, store }; diff --git a/apps/web/src/stores/theme-store.js b/apps/web/src/stores/theme-store.js index 34b36bc25..e58f15244 100644 --- a/apps/web/src/stores/theme-store.js +++ b/apps/web/src/stores/theme-store.js @@ -99,7 +99,7 @@ class ThemeStore extends BaseStore { }; } -const [useStore, store] = createStore(ThemeStore); +const [useStore, store] = createStore((set, get) => new ThemeStore(set, get)); export { useStore, store }; function getKey(theme) { diff --git a/apps/web/src/stores/trash-store.ts b/apps/web/src/stores/trash-store.ts index 608a315de..da3afbfd3 100644 --- a/apps/web/src/stores/trash-store.ts +++ b/apps/web/src/stores/trash-store.ts @@ -54,5 +54,7 @@ class TrashStore extends BaseStore { }; } -const [useStore, store] = createStore(TrashStore); +const [useStore, store] = createStore( + (set, get) => new TrashStore(set, get) +); export { useStore, store }; diff --git a/apps/web/src/stores/user-store.ts b/apps/web/src/stores/user-store.ts index c8f8ce89e..53fa6d599 100644 --- a/apps/web/src/stores/user-store.ts +++ b/apps/web/src/stores/user-store.ts @@ -151,5 +151,7 @@ class UserStore extends BaseStore { }; } -const [useStore, store] = createStore(UserStore); +const [useStore, store] = createStore( + (set, get) => new UserStore(set, get) +); export { useStore, store }; diff --git a/apps/web/src/utils/dom.ts b/apps/web/src/utils/dom.ts index 3e31276c6..36b732658 100644 --- a/apps/web/src/utils/dom.ts +++ b/apps/web/src/utils/dom.ts @@ -21,3 +21,8 @@ export const setDocumentTitle = (title?: string) => { if (!title) document.title = APP_TITLE; else document.title = `${title} - ${APP_TITLE}`; }; + +export const getDocumentTitle = () => { + if (document.title === APP_TITLE) return ""; + return document.title.replace(` - ${APP_TITLE}`, ""); +}; diff --git a/apps/web/src/utils/logger.ts b/apps/web/src/utils/logger.ts index 19c12988e..19cbf690c 100644 --- a/apps/web/src/utils/logger.ts +++ b/apps/web/src/utils/logger.ts @@ -30,7 +30,7 @@ import { sanitizeFilename } from "@notesnook/common"; let logger: typeof _logger; async function initializeLogger(persistence: DatabasePersistence = "db") { - initialize(new NNStorage("Logs", null, persistence), false); + initialize(new NNStorage("Logs", () => null, persistence), false); logger = _logger.scope("notesnook-web"); } diff --git a/apps/web/src/utils/webauthn.ts b/apps/web/src/utils/webauthn.ts index e1439dbda..11835cad3 100644 --- a/apps/web/src/utils/webauthn.ts +++ b/apps/web/src/utils/webauthn.ts @@ -20,9 +20,9 @@ along with this program. If not, see . import { getFormattedDate } from "@notesnook/common"; export type SecurityKeyConfig = { - firstSalt: string; + firstSalt: Uint8Array; label: string; - rawId: string; + rawId: ArrayBuffer; transports: AuthenticatorTransport[]; }; @@ -71,6 +71,7 @@ async function registerSecurityKey(userId: BufferSource, username: string) { ); return { + id: result.id, firstSalt, transports: ( result.response as AuthenticatorAttestationResponse @@ -137,9 +138,9 @@ async function getEncryptionKey(config: { const encryptionKey = await crypto.subtle.deriveKey( { name: "HKDF", info, salt, hash: "SHA-256" }, keyDerivationKey, - { name: "AES-GCM", length: 256 }, + { name: "AES-KW", length: 256 }, false, - ["encrypt", "decrypt"] + ["wrapKey", "unwrapKey"] ); return { encryptionKey }; diff --git a/apps/web/src/views/app-lock.tsx b/apps/web/src/views/app-lock.tsx index 3c0645759..2cd8f2c63 100644 --- a/apps/web/src/views/app-lock.tsx +++ b/apps/web/src/views/app-lock.tsx @@ -24,9 +24,8 @@ import { useRef, useState } from "react"; -import { useStore as useSettingStore } from "../stores/setting-store"; import { usePromise } from "@notesnook/common"; -import { KeyChain } from "../interfaces/key-store"; +// import { KeyChain } from "../interfaces/key-store"; import { Button, Flex, Text } from "@theme-ui/components"; import { Loading, Lock } from "../components/icons"; import { ErrorText } from "../components/error-text"; @@ -35,60 +34,97 @@ import { startIdleDetection } from "../utils/idle-detection"; import { onPageVisibilityChanged } from "../utils/page-visibility"; import { closeOpenedDialog } from "../common/dialog-controller"; import { WebAuthn } from "../utils/webauthn"; -import { setDocumentTitle } from "../utils/dom"; +import { getDocumentTitle, setDocumentTitle } from "../utils/dom"; +import { CredentialWithoutSecret, useKeyStore } from "../interfaces/key-store"; export default function AppLock(props: PropsWithChildren) { - const keychain = usePromise(async () => ({ - isLocked: await KeyChain.isLocked(), - credentials: await KeyChain.getCredentials() - })); + const init = usePromise(() => useKeyStore.getState().init()); + const credentials = useKeyStore((store) => store.activeCredentials()); + const isLocked = useKeyStore((store) => store.isLocked); + const _lockAfter = useKeyStore((store) => store.secrets.lockAfter); + const lockAfter = usePromise(async () => { + if (isLocked) return null; + return (await useKeyStore.getState().getValue("lockAfter")) || 0; + }, [isLocked, _lockAfter]); + const [error, setError] = useState(); const [isUnlocking, setIsUnlocking] = useState(false); - const appLockSettings = useSettingStore((store) => store.appLockSettings); - const windowTitle = useRef(document.title); - const lockApp = useCallback(() => { - if (keychain.status !== "fulfilled" || keychain.value.isLocked) return; + const passwordRef = useRef(null); + const windowTitle = useRef(getDocumentTitle()); - windowTitle.current = document.title; - KeyChain.relock(); - closeOpenedDialog(); - setDocumentTitle(); - keychain.refresh(); - }, [keychain]); + const unlockWithPassword = useCallback( + async (credential: CredentialWithoutSecret) => { + if (credential.type !== "password") return; + + setError(undefined); + setIsUnlocking(true); + + const password = passwordRef.current?.value; + if (!password || typeof password !== "string") { + setIsUnlocking(false); + setError("Password is required."); + return; + } + + await useKeyStore + .getState() + .unlock({ ...credential, password }) + .catch((e) => { + setError( + typeof e === "string" + ? e + : "message" in e && typeof e.message === "string" + ? e.message === "ciphertext cannot be decrypted using that key" + ? "Wrong password." + : e.message || "Wrong password." + : JSON.stringify(e) + ); + }) + .finally(() => { + setIsUnlocking(false); + }); + }, + [] + ); + + useEffect(() => { + if (isLocked) { + windowTitle.current = getDocumentTitle(); + closeOpenedDialog(); + document.title = `Notesnook 🔒`; + } else { + setDocumentTitle(windowTitle.current); + } + }, [isLocked]); useEffect(() => { - const { lockAfter, enabled } = appLockSettings; if ( - !enabled || - lockAfter === -1 || - keychain.status !== "fulfilled" || - keychain.value.isLocked + lockAfter.status !== "fulfilled" || + lockAfter.value === null || + credentials.length <= 0 ) return; - if (lockAfter > 0) { - const stop = startIdleDetection( - appLockSettings.lockAfter * 60 * 1000, - lockApp + if (lockAfter.value > 0) { + const stop = startIdleDetection(lockAfter.value * 60 * 1000, () => + useKeyStore.getState().relock() ); return () => stop(); - } else if (lockAfter === 0) { + } else if (lockAfter.value === 0) { const stop = onPageVisibilityChanged((_, hidden) => { - if (hidden) lockApp(); + if (hidden) useKeyStore.getState().relock(); }); return () => stop(); } - }, [appLockSettings, lockApp, keychain]); + }, [lockAfter, credentials]); - if (keychain.status === "fulfilled" && !keychain.value.isLocked) - return <>{props.children}; + if (init.status !== "fulfilled") return null; - if (keychain.status === "fulfilled" && keychain.value.isLocked) + if (isLocked) return ( ) { flexDirection: "column", overflowY: "auto" }} - onSubmit={async (e) => { - e.preventDefault(); - - setError(undefined); - setIsUnlocking(true); - - const data = new FormData(e.target as HTMLFormElement); - const password = data.get("password"); - if (!password || typeof password !== "string") { - setIsUnlocking(false); - setError("Password is required."); - return; - } - - await KeyChain.unlock({ type: "password", id: "primary", password }) - .then(() => { - setDocumentTitle(windowTitle.current); - keychain.refresh(); - }) - .catch((e) => { - setError( - typeof e === "string" - ? e - : "message" in e && typeof e.message === "string" - ? e.message === - "ciphertext cannot be decrypted using that key" - ? "Wrong password." - : e.message - : JSON.stringify(e) - ); - }) - .finally(() => { - setIsUnlocking(false); - }); - }} > ) { gap: 2 }} > - {keychain.value.credentials.map((credential) => { + {credentials.map((credential) => { switch (credential.type) { case "password": return ( <> ) { sx={{ width: ["95%", "95%", "25%"] }} placeholder="Enter password" type="password" + onKeyUp={async (e) => { + if (e.key === "Enter") + await unlockWithPassword(credential); + }} /> ); - case "key": + case "securityKey": return ( ); } @@ -255,5 +239,5 @@ export default function AppLock(props: PropsWithChildren) { ); - return null; + return <>{props.children}; }