mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-02 12:14:48 +02:00
web: reimplement app lock & web key store
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
418
apps/web/src/dialogs/settings/app-lock-settings.ts
Normal file
418
apps/web/src/dialogs/settings/app-lock-settings.ts
Normal 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)
|
||||
));
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}`, "");
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user