web: reimplement app lock & web key store

This commit is contained in:
Abdullah Atta
2024-02-16 15:12:29 +05:00
parent 9d58dd9bab
commit d3a7ec6b8c
32 changed files with 1164 additions and 709 deletions

View File

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

View File

@@ -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<T extends object>(Store: IStore<T>) {
export function createStore<T>(
getStore: (set: SetState<T>, get: GetState<T>) => 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;
})

View File

@@ -57,7 +57,9 @@ class EditorContext extends BaseStore<EditorContext> {
};
}
const [useEditorContext] = createStore(EditorContext);
const [useEditorContext] = createStore<EditorContext>(
(set, get) => new EditorContext(set, get)
);
export function useEditorInstance() {
const editor = useEditorContext((store) => store.subState.editor);

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<unknown>
) {
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)
));
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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"
}
]
}
]
}
];

View File

@@ -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<unknown>();
useEffect(() => {
onRender?.();
}, [onRender]);
useEffect(() => {
const unsubscribe = onStateChange?.(setState);
return () => {
unsubscribe?.();
};
}, [onStateChange]);
if (item.isHidden?.()) return null;
return (
<Flex
@@ -385,7 +400,10 @@ function SettingItem(props: { item: Setting }) {
useEffect(() => {
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 (
<select
style={{
backgroundColor: "var(--background-secondary)",
outline: "none",
border: "1px solid var(--border-secondary)",
borderRadius: "5px",
color: "var(--paragraph)",
padding: "5px"
}}
value={component.selectedOption()}
onChange={(e) =>
component.onSelectionChanged(
(e.target as HTMLSelectElement).value
)
}
>
{component.options.map((option) => (
<option
disabled={option.premium && !isUserPremium}
key={option.value}
value={option.value}
>
{option.title}
</option>
))}
</select>
<SelectComponent
{...component}
isUserPremium={isUserPremium}
/>
);
case "input":
return component.inputType === "number" ? (
@@ -539,6 +535,15 @@ function SettingItem(props: { item: Setting }) {
)}
/>
);
case "icon":
return (
<component.icon
size={component.size}
color={component.color}
/>
);
default:
return null;
}
})}
</Flex>
@@ -551,3 +556,39 @@ function SettingItem(props: { item: Setting }) {
</Flex>
);
}
function SelectComponent(
props: DropdownSettingComponent & { isUserPremium: boolean }
) {
const { onSelectionChanged, options, isUserPremium } = props;
const selectedOption = usePromise(() => props.selectedOption(), [props]);
return (
<select
style={{
backgroundColor: "var(--background-secondary)",
outline: "none",
border: "1px solid var(--border-secondary)",
borderRadius: "5px",
color: "var(--paragraph)",
padding: "5px"
}}
value={
selectedOption.status === "fulfilled" ? selectedOption.value : undefined
}
onChange={(e) =>
onSelectionChanged((e.target as HTMLSelectElement).value)
}
>
{options.map((option) => (
<option
disabled={option.premium && !isUserPremium}
key={option.value}
value={option.value}
>
{option.title}
</option>
))}
</select>
);
}

View File

@@ -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<void>;
};
@@ -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<unknown>;
};
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<string | number>;
onSelectionChanged: (value: string) => void | Promise<void>;
};

View File

@@ -41,7 +41,9 @@ class AutoUpdateStore extends BaseStore<AutoUpdateStore> {
};
}
const [useAutoUpdateStore] = createStore(AutoUpdateStore);
const [useAutoUpdateStore] = createStore<AutoUpdateStore>(
(set, get) => new AutoUpdateStore(set, get)
);
let checkingForUpdateTimeout = 0;
export function useAutoUpdater() {

View File

@@ -60,5 +60,7 @@ class SpellCheckerStore extends BaseStore<SpellCheckerStore> {
};
}
const [useSpellChecker] = createStore(SpellCheckerStore);
const [useSpellChecker] = createStore<SpellCheckerStore>(
(set, get) => new SpellCheckerStore(set, get)
);
export { useSpellChecker };

View File

@@ -17,36 +17,71 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { 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<T extends CredentialType> = { 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<Credential, "key" | "password">;
type Credential = PasswordCredential | SecurityKeyCredential;
export type CredentialWithoutSecret =
| Omit<PasswordCredential, "password">
| Omit<SecurityKeyCredential, "key">;
export type SerializableCredential = CredentialWithoutSecret & {
active: boolean;
};
export type CredentialWithSecret =
| Omit<PasswordCredential, "salt">
| Omit<SecurityKeyCredential, "config">;
export type CredentialQuery = BaseCredential<CredentialType> & {
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<KeyStore> {
#secretStore: IKVStore;
#metadataStore: IKVStore;
#keyId = "key";
#wrappingKeyId = "wrappingKey";
#key?: CryptoKey;
credentials: SerializableCredential[] = [];
secrets: Record<string, EncryptedData> = {};
isLocked = false;
constructor(
dbName: string,
setState: SetState<KeyStore>,
get: GetState<KeyStore>
) {
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<Cipher<"base64">>(
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<Cipher<"base64">>(
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<EncryptedData>()
);
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<SerializableCredential[]>("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<ArrayBuffer>(
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<ArrayBuffer>(
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<ArrayBuffer>(
this.getCredentialKey(credential)
);
if (!encryptedKey) return false;
const decryptedKey = await unwrapKey(
encryptedKey,
await getWrappingKey(deserializeCredential(cred, credential))
);
return !!decryptedKey;
} catch {
return false;
}
};
setValue = async <T extends keyof Secrets>(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<string | undefined> {
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<EncryptedData>(name);
getValue = async <T extends keyof Secrets>(
name: T
): Promise<Secrets[T] | undefined> => {
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<void> {
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<Uint8Array | undefined> {
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<ArrayBuffer>(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<Uint8Array | CryptoKey>(
this.keyId
const wrappingKey = await this.#metadataStore.get<CryptoKey>(
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<SerializableCredential[]>(
"credentials"
)) || []
);
};
return <EncryptedData>{
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<CryptoKey> {
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<CryptoKey> {
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 <EncryptedData>{
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<KeyStore>((set, get) => new KeyStore(name, set, get));
const [useKeyStore] = createKeyStore("KeyChain");
export { useKeyStore };
export type IKeyStore = typeof KeyStore.prototype;

View File

@@ -41,7 +41,7 @@ export interface IKVStore {
*
* @param entries Array of entries, where each entry is an array of `[key, value]`.
*/
setMany<T>(entries: [string, T][]): Promise<void>;
setMany(entries: [string, unknown][]): Promise<void>;
/**
* Get multiple values by their keys

View File

@@ -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<T>(key: string): Promise<T | undefined> {
@@ -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<string | undefined> {
if (!this.keyStore) throw new Error("No key store found!");
return this.keyStore.get("userEncryptionKey");
return this.keyStore()?.getValue("userEncryptionKey");
}
async generateCryptoKey(

View File

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

View File

@@ -362,5 +362,7 @@ class AppStore extends BaseStore<AppStore> {
};
}
const [useStore, store] = createStore(AppStore);
const [useStore, store] = createStore<AppStore>(
(set, get) => new AppStore(set, get)
);
export { useStore, store };

View File

@@ -152,5 +152,7 @@ class AttachmentStore extends BaseStore<AttachmentStore> {
};
}
const [useStore, store] = createStore(AttachmentStore);
const [useStore, store] = createStore<AttachmentStore>(
(set, get) => new AttachmentStore(set, get)
);
export { useStore, store };

View File

@@ -378,5 +378,7 @@ class EditorStore extends BaseStore<EditorStore> {
// };
}
const [useStore, store] = createStore(EditorStore);
const [useStore, store] = createStore<EditorStore>(
(set, get) => new EditorStore(set, get)
);
export { useStore, store, SESSION_STATES };

View File

@@ -24,8 +24,8 @@ type NNStoreCreator<T> = StateCreator<
[["zustand/subscribeWithSelector", never], ["zustand/immer", never]]
>;
type GetState<T> = Parameters<NNStoreCreator<T>>[1];
type SetState<T> = Parameters<NNStoreCreator<T>>[0];
export type GetState<T> = Parameters<NNStoreCreator<T>>[1];
export type SetState<T> = Parameters<NNStoreCreator<T>>[0];
export interface IStore<T extends object> {
new (set: SetState<T>, get: GetState<T>): T;

View File

@@ -48,5 +48,7 @@ class MonographStore extends BaseStore<MonographStore> {
};
}
const [useStore, store] = createStore(MonographStore);
const [useStore, store] = createStore<MonographStore>(
(set, get) => new MonographStore(set, get)
);
export { useStore, store };

View File

@@ -156,7 +156,9 @@ class NoteStore extends BaseStore<NoteStore> {
};
}
const [useStore, store] = createStore(NoteStore);
const [useStore, store] = createStore<NoteStore>(
(set, get) => new NoteStore(set, get)
);
export { useStore, store };
export function notesFromContext(context: Context) {

View File

@@ -55,5 +55,7 @@ class NotebookStore extends BaseStore<NotebookStore> {
};
}
const [useStore, store] = createStore(NotebookStore);
const [useStore, store] = createStore<NotebookStore>(
(set, get) => new NotebookStore(set, get)
);
export { useStore, store };

View File

@@ -51,7 +51,9 @@ class ReminderStore extends BaseStore<ReminderStore> {
};
}
const [useStore, store] = createStore(ReminderStore);
const [useStore, store] = createStore<ReminderStore>(
(set, get) => new ReminderStore(set, get)
);
export { useStore, store };
async function resetReminders(reminders: FilteredSelector<Reminder>) {

View File

@@ -39,5 +39,7 @@ class SearchStore extends BaseStore<SearchStore> {
// };
}
const [useStore, store] = createStore(SearchStore);
const [useStore, store] = createStore<SearchStore>(
(set, get) => new SearchStore(set, get)
);
export { useStore, store };

View File

@@ -70,5 +70,7 @@ class SelectionStore extends BaseStore<SelectionStore> {
};
}
const [useStore, store] = createStore(SelectionStore);
const [useStore, store] = createStore<SelectionStore>(
(set, get) => new SelectionStore(set, get)
);
export { useStore, store };

View File

@@ -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<SettingStore> {
encryptBackups = Config.get("encryptBackups", false);
@@ -62,11 +55,6 @@ class SettingStore extends BaseStore<SettingStore> {
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<SettingStore> {
this.set({ notificationsSettings: Config.get("notifications") });
};
setAppLockSettings = (settings: Partial<AppLockSettings>) => {
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<SettingStore> {
};
}
const [useStore, store] = createStore(SettingStore);
const [useStore, store] = createStore<SettingStore>(
(set, get) => new SettingStore(set, get)
);
export { useStore, store };

View File

@@ -33,5 +33,7 @@ class TagStore extends BaseStore<TagStore> {
};
}
const [useStore, store] = createStore(TagStore);
const [useStore, store] = createStore<TagStore>(
(set, get) => new TagStore(set, get)
);
export { useStore, store };

View File

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

View File

@@ -54,5 +54,7 @@ class TrashStore extends BaseStore<TrashStore> {
};
}
const [useStore, store] = createStore(TrashStore);
const [useStore, store] = createStore<TrashStore>(
(set, get) => new TrashStore(set, get)
);
export { useStore, store };

View File

@@ -151,5 +151,7 @@ class UserStore extends BaseStore<UserStore> {
};
}
const [useStore, store] = createStore(UserStore);
const [useStore, store] = createStore<UserStore>(
(set, get) => new UserStore(set, get)
);
export { useStore, store };

View File

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

View File

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

View File

@@ -20,9 +20,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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 };

View File

@@ -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<unknown>) {
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<string>();
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<HTMLInputElement>(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 (
<Flex
as="form"
sx={{
alignItems: "center",
justifyContent: "center",
@@ -96,41 +132,6 @@ export default function AppLock(props: PropsWithChildren<unknown>) {
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);
});
}}
>
<Flex
sx={{
@@ -175,12 +176,13 @@ export default function AppLock(props: PropsWithChildren<unknown>) {
gap: 2
}}
>
{keychain.value.credentials.map((credential) => {
{credentials.map((credential) => {
switch (credential.type) {
case "password":
return (
<>
<Field
inputRef={passwordRef}
id="password"
name="password"
data-test-id="app-lock-password"
@@ -189,50 +191,36 @@ export default function AppLock(props: PropsWithChildren<unknown>) {
sx={{ width: ["95%", "95%", "25%"] }}
placeholder="Enter password"
type="password"
onKeyUp={async (e) => {
if (e.key === "Enter")
await unlockWithPassword(credential);
}}
/>
<Button
type="submit"
variant="accent"
data-test-id="unlock-note-submit"
disabled={isUnlocking}
sx={{ borderRadius: 100, px: 30 }}
onClick={() => unlockWithPassword(credential)}
>
Continue
</Button>
</>
);
case "key":
case "securityKey":
return (
<Button
key={credential.id}
variant="secondary"
type="button"
onClick={async () => {
const { securityKey } =
useSettingStore.getState().appLockSettings;
if (!securityKey) return;
setError(undefined);
setIsUnlocking(true);
try {
const { encryptionKey } =
await WebAuthn.getEncryptionKey({
firstSalt: Buffer.from(
securityKey.firstSalt,
"base64"
),
label: securityKey.label,
rawId: Buffer.from(securityKey.rawId, "base64"),
transports: securityKey.transports
});
await KeyChain.unlock({
type: "key",
id: credential.id,
key: encryptionKey
});
keychain.refresh();
await WebAuthn.getEncryptionKey(credential.config);
await useKeyStore
.getState()
.unlock({ ...credential, key: encryptionKey });
} catch (e) {
setError((e as Error).message);
} finally {
@@ -240,11 +228,7 @@ export default function AppLock(props: PropsWithChildren<unknown>) {
}
}}
>
Unlock with{" "}
{credential.type === "key" &&
credential.id === "securityKey"
? "security key"
: "key"}
Unlock with security key
</Button>
);
}
@@ -255,5 +239,5 @@ export default function AppLock(props: PropsWithChildren<unknown>) {
</Flex>
);
return null;
return <>{props.children}</>;
}