diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index a432a1e84..9682401c3 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -25207,7 +25207,8 @@ "prismjs": "^1.29.0", "qclone": "^1.2.0", "rfdc": "^1.3.0", - "spark-md5": "^3.0.2" + "spark-md5": "^3.0.2", + "sqlite-better-trigram": "^0.0.1" }, "devDependencies": { "@notesnook/crypto": "file:../crypto", @@ -25222,7 +25223,7 @@ "@types/ws": "^8.5.5", "@vitest/coverage-v8": "^1.0.1", "abortcontroller-polyfill": "^1.7.3", - "better-sqlite3-multiple-ciphers": "^9.4.0", + "better-sqlite3-multiple-ciphers": "^11.5.0", "bson-objectid": "^2.0.4", "dotenv": "^16.0.1", "event-source-polyfill": "^1.0.31", @@ -35614,7 +35615,7 @@ }, "../desktop": { "name": "@notesnook/desktop", - "version": "3.0.18", + "version": "3.0.20", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { @@ -35622,10 +35623,11 @@ "@notesnook/intl": "file:../../packages/intl", "@trpc/client": "10.45.2", "@trpc/server": "10.45.2", - "better-sqlite3-multiple-ciphers": "11.2.1", + "better-sqlite3-multiple-ciphers": "11.5.0", "electron-trpc": "0.6.1", "electron-updater": "^6.3.4", "icojs": "^0.19.4", + "sqlite-better-trigram": "^0.0.2", "typed-emitter": "^2.1.0", "yargs": "^17.7.2", "zod": "^3.23.8" @@ -35635,14 +35637,17 @@ "@types/node": "22.7.0", "@types/yargs": "^17.0.33", "chokidar": "^4.0.1", - "electron": "^30.5.1", + "electron": "^31.7.4", "electron-builder": "^25.1.8", "esbuild": "^0.24.0", "node-abi": "^3.68.0", "node-gyp-build": "^4.8.2", + "playwright": "^1.48.2", "prebuildify": "^6.0.1", + "slugify": "^1.6.6", "tree-kill": "^1.2.2", - "undici": "^6.19.8" + "undici": "^6.19.8", + "vitest": "^2.1.5" }, "optionalDependencies": { "dmg-license": "^1.0.11" diff --git a/apps/web/src/dialogs/settings/app-lock-settings.ts b/apps/web/src/dialogs/settings/app-lock-settings.ts index 79806d49b..87a6963a9 100644 --- a/apps/web/src/dialogs/settings/app-lock-settings.ts +++ b/apps/web/src/dialogs/settings/app-lock-settings.ts @@ -25,6 +25,7 @@ import { CredentialType, CredentialWithSecret, CredentialWithoutSecret, + DEFAULT_ITERATIONS, useKeyStore, wrongCredentialError } from "../../interfaces/key-store"; @@ -53,17 +54,16 @@ export const AppLockSettings: SettingsGroup[] = [ type: "toggle", toggle: async () => { const { credentials } = useKeyStore.getState(); - if (credentials.length <= 0) { + const defaultCredential = credentials + .filter((c) => c.active) + .at(0); + + if (!defaultCredential) { 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); } }, @@ -309,7 +309,8 @@ async function registerCredential(type: CredentialType) { await register({ type, id: "password", - salt: window.crypto.getRandomValues(new Uint8Array(16)) + salt: window.crypto.getRandomValues(new Uint8Array(16)), + iterations: DEFAULT_ITERATIONS }).then(() => activate({ type, diff --git a/apps/web/src/interfaces/key-store.ts b/apps/web/src/interfaces/key-store.ts index 98114a8b9..7f658a5e6 100644 --- a/apps/web/src/interfaces/key-store.ts +++ b/apps/web/src/interfaces/key-store.ts @@ -41,6 +41,7 @@ type BaseCredential = { type: T; id: string }; type PasswordCredential = BaseCredential<"password"> & { password: string; salt: Uint8Array; + iterations: number | undefined; }; type SecurityKeyCredential = BaseCredential<"securityKey"> & { @@ -58,7 +59,7 @@ export type SerializableCredential = CredentialWithoutSecret & { active: boolean; }; export type CredentialWithSecret = - | Omit + | Omit | Omit; export type CredentialQuery = BaseCredential & { active?: boolean; @@ -82,6 +83,8 @@ const defaultSecrets = { }; type Secrets = typeof defaultSecrets; +export const DEFAULT_ITERATIONS = 100000; +const FALLBACK_ITERATIONS = 650000; const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -169,12 +172,13 @@ class KeyStore extends BaseStore { const index = credentials.findIndex( (c) => c.type === credential.type && c.id === credential.id ); - if (index > -1) return; + if (index > -1) return credentials[index]; this.set((store) => store.credentials.push(serializeCredential(credential, false)) ); await this.#metadataStore.set("credentials", this.get().credentials); + return credential; }; unregister = async ( @@ -257,10 +261,14 @@ class KeyStore extends BaseStore { if (!encryptedKey) throw new Error("Could not find credential's encrypted key."); - const key = await unwrapKey( - encryptedKey, - await getWrappingKey(deserializeCredential(cred, credential)) - ); + const key = await unwrapKey(encryptedKey, [ + await getWrappingKey(deserializeCredential(cred, credential)), + await getWrappingKey(fallbackCredential(cred, credential)) + ]).catch((e) => { + if (e instanceof Error && e.message === "Could not unwrap key.") + throw new Error(wrongCredentialError(credential)); + throw e; + }); if (options?.permanent) { await this.resetCredentials(); await this.storeKey(key); @@ -295,14 +303,15 @@ class KeyStore extends BaseStore { ); if (!encryptedKey) return; - const decryptedKey = await unwrapKey( - encryptedKey, - await getWrappingKey(deserializeCredential(cred, oldCredential)) - ); + const decryptedKey = await unwrapKey(encryptedKey, [ + await getWrappingKey(deserializeCredential(cred, oldCredential)), + await getWrappingKey(fallbackCredential(cred, oldCredential)) + ]); + const newCred = deserializeCredential(cred, newCredential); const reencryptedKey = await wrapKey( decryptedKey, - await getWrappingKey(deserializeCredential(cred, newCredential)) + await getWrappingKey(newCred) ); if (!reencryptedKey) throw new Error(wrongCredentialError(newCredential)); @@ -310,6 +319,10 @@ class KeyStore extends BaseStore { this.getCredentialKey(newCredential), reencryptedKey ); + await this.update(oldCredential, (c) => { + if (c.type === "password" && newCred.type === "password") + c.iterations = newCred.iterations; + }); }; verifyCredential = async (credential: CredentialWithSecret) => { @@ -322,10 +335,10 @@ class KeyStore extends BaseStore { ); if (!encryptedKey) return false; - const decryptedKey = await unwrapKey( - encryptedKey, - await getWrappingKey(deserializeCredential(cred, credential)) - ); + const decryptedKey = await unwrapKey(encryptedKey, [ + await getWrappingKey(deserializeCredential(cred, credential)), + await getWrappingKey(fallbackCredential(cred, credential)) + ]); return !!decryptedKey; } catch { return false; @@ -414,7 +427,7 @@ class KeyStore extends BaseStore { ["encrypt", "decrypt"] ); } else if (wrappingKey) { - return unwrapKey(wrappedKey, wrappingKey); + return unwrapKey(wrappedKey, [wrappingKey]); } else throw new Error("Could not decrypt key."); }; @@ -479,7 +492,8 @@ function serializeCredential( type: "password", id: credential.id, active, - salt: credential.salt + salt: credential.salt, + iterations: credential.iterations }; case "securityKey": return { @@ -500,7 +514,8 @@ function deserializeCredential( type: "password", id: credential.id, salt: credential.salt, - password: secret.password + password: secret.password, + iterations: credential.iterations || DEFAULT_ITERATIONS }; } else if (secret.type === "securityKey" && credential.type === "securityKey") return { @@ -513,6 +528,21 @@ function deserializeCredential( throw new Error("Credentials are of different types."); } +function fallbackCredential( + credential: SerializableCredential, + secret: CredentialWithSecret +): Credential | undefined { + if (secret.type === "password" && credential.type === "password") { + return { + type: "password", + id: credential.id, + salt: credential.salt, + password: secret.password, + iterations: FALLBACK_ITERATIONS + }; + } +} + export function wrongCredentialError(query: CredentialQuery): string { switch (query.type) { case "password": @@ -524,17 +554,23 @@ export function wrongCredentialError(query: CredentialQuery): string { async function unwrapKey( wrappedKey: ArrayBuffer, - wrappingKey: CryptoKey + wrappingKeys: CryptoKey[] ): Promise { - return await window.crypto.subtle.unwrapKey( - "raw", - wrappedKey, - wrappingKey, - "AES-KW", - { name: "AES-GCM", length: 256 }, - true, - ["encrypt", "decrypt"] - ); + for (const key of wrappingKeys) { + const unwrapped = await window.crypto.subtle + .unwrapKey( + "raw", + wrappedKey, + key, + "AES-KW", + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + .catch(() => undefined); + if (unwrapped) return unwrapped; + } + throw new Error("Could not unwrap key."); } async function wrapKey(key: CryptoKey, wrappingKey: CryptoKey) { @@ -555,7 +591,7 @@ async function getWrappingKey(credential?: Credential): Promise { { name: "PBKDF2", salt: credential.salt, - iterations: 100000, + iterations: credential.iterations || DEFAULT_ITERATIONS, hash: "SHA-512" }, await window.crypto.subtle.importKey( diff --git a/apps/web/src/views/app-lock.tsx b/apps/web/src/views/app-lock.tsx index c1018bf37..d154958da 100644 --- a/apps/web/src/views/app-lock.tsx +++ b/apps/web/src/views/app-lock.tsx @@ -75,7 +75,8 @@ export default function AppLock(props: PropsWithChildren) { typeof e === "string" ? e : "message" in e && typeof e.message === "string" - ? e.message === "ciphertext cannot be decrypted using that key" + ? e.message === "ciphertext cannot be decrypted using that key" || + e.message === "Could not unwrap key." ? "Wrong password." : e.message || "Wrong password." : JSON.stringify(e)