From 4b4ef35272af4983ce9b7e4f7ae455e775efb7b3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 12 Apr 2026 19:22:37 +0300 Subject: [PATCH] feat(standalone): start introducing crypto --- CLAUDE.md | 11 ++ apps/client-standalone/package.json | 1 + .../src/lightweight/crypto_provider.ts | 46 +++++-- apps/server/src/crypto_provider.ts | 27 +++- apps/server/src/routes/api/login.ts | 6 +- apps/server/src/routes/api/password.ts | 7 +- apps/server/src/routes/login.ts | 26 +--- apps/server/src/routes/routes.ts | 8 +- .../src/services/encryption/my_scrypt.ts | 63 +++++---- .../src/services/encryption/password.ts | 90 +------------ .../encryption/password_encryption.ts | 46 +------ .../services/encryption/totp_encryption.ts | 30 ++++- packages/trilium-core/src/index.ts | 7 +- .../src/services/encryption/crypto.ts | 33 ++++- .../services/encryption/data_encryption.ts | 104 ++++++++++++++- .../src/services/encryption/password.ts | 120 ++++++++++++++++++ .../encryption/password_encryption.ts | 51 ++++++++ .../src/services/encryption/scrypt.ts | 41 ++++++ pnpm-lock.yaml | 8 ++ 19 files changed, 521 insertions(+), 204 deletions(-) create mode 100644 packages/trilium-core/src/services/encryption/password.ts create mode 100644 packages/trilium-core/src/services/encryption/password_encryption.ts create mode 100644 packages/trilium-core/src/services/encryption/scrypt.ts diff --git a/CLAUDE.md b/CLAUDE.md index ebed3e69a6..a9050ad089 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,17 @@ Fluent builder pattern: `.child()`, `.class()`, `.css()` chaining with position- - **Barrel import caution** — `import { x } from "@triliumnext/core"` loads ALL core exports. Early-loading modules like `config.ts` should import specific subpaths (e.g. `@triliumnext/core/src/services/utils/index`) to avoid circular dependencies or initialization ordering issues - **Electron IPC** — In desktop mode, client API calls use Electron IPC (not HTTP). The IPC handler in `apps/server/src/routes/electron.ts` must be registered via `utils.isElectron` from the **server's** utils (which correctly checks `process.versions["electron"]`), not from core's utils +### Binary Utilities + +Use utilities from `packages/trilium-core/src/services/utils/binary.ts` for string/buffer conversions instead of manual `TextEncoder`/`TextDecoder` or `Buffer.from()` calls: + +- **`wrapStringOrBuffer(input)`** — Converts `string` to `Uint8Array`, returns `Uint8Array` unchanged. Use when a function expects `Uint8Array` but receives `string | Uint8Array`. +- **`unwrapStringOrBuffer(input)`** — Converts `Uint8Array` to `string`, returns `string` unchanged. Use when a function expects `string` but receives `string | Uint8Array`. +- **`encodeBase64(input)`** / **`decodeBase64(input)`** — Base64 encoding/decoding that works in both Node.js and browser. +- **`encodeUtf8(string)`** / **`decodeUtf8(buffer)`** — UTF-8 encoding/decoding. + +Import via `import { binary_utils } from "@triliumnext/core"` or directly from the module. + ### Database SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/services/sql/` with `DatabaseProvider` interface, prepared statement caching, and transaction support. diff --git a/apps/client-standalone/package.json b/apps/client-standalone/package.json index 216bb524fd..726cf138ce 100644 --- a/apps/client-standalone/package.json +++ b/apps/client-standalone/package.json @@ -51,6 +51,7 @@ "js-sha1": "0.7.0", "js-sha256": "0.11.1", "js-sha512": "0.9.0", + "scrypt-js": "3.0.1", "jsplumb": "2.15.6", "katex": "0.16.45", "knockout": "3.5.1", diff --git a/apps/client-standalone/src/lightweight/crypto_provider.ts b/apps/client-standalone/src/lightweight/crypto_provider.ts index 0d1a6d8066..2c5f96339e 100644 --- a/apps/client-standalone/src/lightweight/crypto_provider.ts +++ b/apps/client-standalone/src/lightweight/crypto_provider.ts @@ -1,13 +1,10 @@ -import type { CryptoProvider } from "@triliumnext/core"; +import type { Cipher, CryptoProvider, ScryptOptions } from "@triliumnext/core"; +import { binary_utils } from "@triliumnext/core"; import { sha1 } from "js-sha1"; import { sha256 } from "js-sha256"; import { sha512 } from "js-sha512"; import { md5 } from "js-md5"; - -interface Cipher { - update(data: Uint8Array): Uint8Array; - final(): Uint8Array; -} +import { scrypt } from "scrypt-js"; const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; @@ -17,8 +14,7 @@ const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; export default class BrowserCryptoProvider implements CryptoProvider { createHash(algorithm: "md5" | "sha1" | "sha512", content: string | Uint8Array): Uint8Array { - const data = typeof content === "string" ? content : - new TextDecoder().decode(content); + const data = binary_utils.unwrapStringOrBuffer(content); let hexHash: string; if (algorithm === "md5") { @@ -63,8 +59,8 @@ export default class BrowserCryptoProvider implements CryptoProvider { } hmac(secret: string | Uint8Array, value: string | Uint8Array): string { - const secretStr = typeof secret === "string" ? secret : new TextDecoder().decode(secret); - const valueStr = typeof value === "string" ? value : new TextDecoder().decode(value); + const secretStr = binary_utils.unwrapStringOrBuffer(secret); + const valueStr = binary_utils.unwrapStringOrBuffer(value); // sha256.hmac returns hex, convert to base64 to match Node's behavior const hexHash = sha256.hmac(secretStr, valueStr); const bytes = new Uint8Array(hexHash.length / 2); @@ -73,6 +69,36 @@ export default class BrowserCryptoProvider implements CryptoProvider { } return btoa(String.fromCharCode(...bytes)); } + + async scrypt( + password: Uint8Array | string, + salt: Uint8Array | string, + keyLength: number, + options: ScryptOptions = {} + ): Promise { + const { N = 16384, r = 8, p = 1 } = options; + const passwordBytes = binary_utils.wrapStringOrBuffer(password); + const saltBytes = binary_utils.wrapStringOrBuffer(salt); + + return scrypt(passwordBytes, saltBytes, N, r, p, keyLength); + } + + constantTimeCompare(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) { + // Maintain constant time by comparing a to itself + let dummy = 0; + for (let i = 0; i < a.length; i++) { + dummy |= a[i] ^ a[i]; + } + return false; + } + + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a[i] ^ b[i]; + } + return result === 0; + } } /** diff --git a/apps/server/src/crypto_provider.ts b/apps/server/src/crypto_provider.ts index 5643b9ecf4..b2030babc9 100644 --- a/apps/server/src/crypto_provider.ts +++ b/apps/server/src/crypto_provider.ts @@ -1,4 +1,5 @@ -import { CryptoProvider } from "@triliumnext/core"; +import type { CryptoProvider, ScryptOptions } from "@triliumnext/core"; +import { binary_utils } from "@triliumnext/core"; import crypto from "crypto"; import { generator } from "rand-token"; @@ -32,4 +33,28 @@ export default class NodejsCryptoProvider implements CryptoProvider { return hmac.digest("base64"); } + async scrypt( + password: Uint8Array | string, + salt: Uint8Array | string, + keyLength: number, + options: ScryptOptions = {} + ): Promise { + const { N = 16384, r = 8, p = 1 } = options; + const passwordBytes = binary_utils.wrapStringOrBuffer(password); + const saltBytes = binary_utils.wrapStringOrBuffer(salt); + return crypto.scryptSync(passwordBytes, saltBytes, keyLength, { N, r, p }); + } + + constantTimeCompare(a: Uint8Array, b: Uint8Array): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + + if (bufA.length !== bufB.length) { + // Compare bufA against itself to maintain constant time behavior + crypto.timingSafeEqual(bufA, bufA); + return false; + } + + return crypto.timingSafeEqual(bufA, bufB); + } } diff --git a/apps/server/src/routes/api/login.ts b/apps/server/src/routes/api/login.ts index 6106fc52d2..feea6d81c2 100644 --- a/apps/server/src/routes/api/login.ts +++ b/apps/server/src/routes/api/login.ts @@ -118,17 +118,17 @@ function loginSync(req: Request) { }; } -function loginToProtectedSession(req: Request) { +async function loginToProtectedSession(req: Request) { const password = req.body.password; - if (!passwordEncryptionService.verifyPassword(password)) { + if (!(await passwordEncryptionService.verifyPassword(password))) { return { success: false, message: "Given current password doesn't match hash" }; } - const decryptedDataKey = passwordEncryptionService.getDataKey(password); + const decryptedDataKey = await passwordEncryptionService.getDataKey(password); if (!decryptedDataKey) { return { success: false, diff --git a/apps/server/src/routes/api/password.ts b/apps/server/src/routes/api/password.ts index 959357893a..2c2ebd8f17 100644 --- a/apps/server/src/routes/api/password.ts +++ b/apps/server/src/routes/api/password.ts @@ -4,12 +4,11 @@ import type { Request } from "express"; import passwordService from "../../services/encryption/password.js"; -function changePassword(req: Request): ChangePasswordResponse { +async function changePassword(req: Request): Promise { if (passwordService.isPasswordSet()) { - return passwordService.changePassword(req.body.current_password, req.body.new_password); + return await passwordService.changePassword(req.body.current_password, req.body.new_password); } - return passwordService.setPassword(req.body.new_password); - + return await passwordService.setPassword(req.body.new_password); } function resetPassword(req: Request) { diff --git a/apps/server/src/routes/login.ts b/apps/server/src/routes/login.ts index 06bf37948c..84644340b3 100644 --- a/apps/server/src/routes/login.ts +++ b/apps/server/src/routes/login.ts @@ -1,19 +1,15 @@ -import { ValidationError } from "@triliumnext/core"; +import { ValidationError, password_encryption } from "@triliumnext/core"; import { i18n } from "@triliumnext/core"; -import crypto from "crypto"; import type { Request, Response } from 'express'; import appPath from "../services/app_path.js"; import assetPath, { assetUrlFragment } from "../services/asset_path.js"; -import myScryptService from "../services/encryption/my_scrypt.js"; import openIDEncryption from '../services/encryption/open_id_encryption.js'; import passwordService from "../services/encryption/password.js"; import recoveryCodeService from '../services/encryption/recovery_codes.js'; import log from "../services/log.js"; import openID from '../services/open_id.js'; -import optionService from "../services/options.js"; import totp from '../services/totp.js'; -import utils from "../services/utils.js"; function loginPage(req: Request, res: Response) { // Login page is triggered twice. Once here, and another time (see sendLoginError) if the password is failed. @@ -40,7 +36,7 @@ function setPasswordPage(req: Request, res: Response) { }); } -function setPassword(req: Request, res: Response) { +async function setPassword(req: Request, res: Response) { if (passwordService.isPasswordSet()) { throw new ValidationError("Password has been already set"); } @@ -67,7 +63,7 @@ function setPassword(req: Request, res: Response) { return; } - passwordService.setPassword(password1); + await passwordService.setPassword(password1); res.redirect("login"); } @@ -102,7 +98,7 @@ function setPassword(req: Request, res: Response) { * '401': * description: Password / TOTP mismatch */ -function login(req: Request, res: Response) { +async function login(req: Request, res: Response) { if (openID.isOpenIDEnabled()) { res.oidc.login({ returnTo: '/', @@ -124,7 +120,7 @@ function login(req: Request, res: Response) { } } - if (!verifyPassword(submittedPassword)) { + if (!(await password_encryption.verifyPassword(submittedPassword))) { sendLoginError(req, res, 'password'); return; } @@ -157,18 +153,6 @@ function verifyTOTP(submittedTotpToken: string) { return recoveryCodeValidates; } -function verifyPassword(submittedPassword: string) { - const hashed_password = utils.fromBase64(optionService.getOption("passwordVerificationHash")); - - const guess_hashed = myScryptService.getVerificationHash(submittedPassword); - - // Use constant-time comparison to prevent timing attacks - if (hashed_password.length !== guess_hashed.length) { - return false; - } - return crypto.timingSafeEqual(guess_hashed, hashed_password); -} - function sendLoginError(req: Request, res: Response, errorType: 'password' | 'totp' = 'password') { // note that logged IP address is usually meaningless since the traffic should come from a reverse proxy if (totp.isTotpEnabled()) { diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index 24716b1098..6510b03cff 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -58,9 +58,9 @@ function register(app: express.Application) { }); route(GET, "/bootstrap", [ auth.checkAuth ], indexRoute.bootstrap); - route(PST, "/login", [loginRateLimiter], loginRoute.login); + asyncRoute(PST, "/login", [loginRateLimiter], loginRoute.login, null); route(PST, "/logout", [csrfMiddleware, auth.checkAuth], loginRoute.logout); - route(PST, "/set-password", [auth.checkAppInitialized, auth.checkPasswordNotSet], loginRoute.setPassword); + asyncRoute(PST, "/set-password", [auth.checkAppInitialized, auth.checkPasswordNotSet], loginRoute.setPassword, null); route(GET, "/setup", [], setupRoute.setupPage); @@ -124,7 +124,7 @@ function register(app: express.Application) { // TODO: Re-enable once we support route() // route(GET, "/api/revisions/:revisionId/download", [auth.checkApiAuthOrElectron], revisionsApiRoute.downloadRevision); - apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword); + asyncApiRoute(PST, "/api/password/change", passwordApiRoute.changePassword); apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword); apiRoute(GET, "/api/metrics", metricsRoute.getMetrics); @@ -135,7 +135,7 @@ function register(app: express.Application) { route(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler); // this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username) - apiRoute(PST, "/api/login/protected", loginApiRoute.loginToProtectedSession); + asyncApiRoute(PST, "/api/login/protected", loginApiRoute.loginToProtectedSession); apiRoute(PST, "/api/login/protected/touch", loginApiRoute.touchProtectedSession); apiRoute(PST, "/api/logout/protected", loginApiRoute.logoutFromProtectedSession); diff --git a/apps/server/src/services/encryption/my_scrypt.ts b/apps/server/src/services/encryption/my_scrypt.ts index d1bd9a5361..8db65d2810 100644 --- a/apps/server/src/services/encryption/my_scrypt.ts +++ b/apps/server/src/services/encryption/my_scrypt.ts @@ -1,63 +1,74 @@ -import optionService from "../options.js"; +/** + * Server-side scrypt service. + * + * Password-related functions (getVerificationHash, getPasswordDerivedKey, getScryptHash) + * have been moved to @triliumnext/core. Import them from there: + * + * import { scrypt } from "@triliumnext/core"; + * await scrypt.getVerificationHash(password); + * + * This file only contains OpenID-specific functions that use synchronous crypto + * and access the user_data table directly. + */ import crypto from "crypto"; import sql from "../sql.js"; -function getVerificationHash(password: crypto.BinaryLike) { - const salt = optionService.getOption("passwordVerificationSalt"); +const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1 }; - return getScryptHash(password, salt); -} - -function getPasswordDerivedKey(password: crypto.BinaryLike) { - const salt = optionService.getOption("passwordDerivedKeySalt"); - - return getScryptHash(password, salt); -} - -function getScryptHash(password: crypto.BinaryLike, salt: crypto.BinaryLike) { - const hashed = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); - - return hashed; +/** + * Sync scrypt hash for OpenID functions (server-only). + */ +function getScryptHashSync(password: crypto.BinaryLike, salt: crypto.BinaryLike): Buffer { + return crypto.scryptSync(password, salt, 32, SCRYPT_OPTIONS); } +/** + * Gets the verification hash for an OpenID subject identifier. + * Uses the salt from user_data table if not provided. + */ function getSubjectIdentifierVerificationHash( guessedUserId: string | crypto.BinaryLike, salt?: string -) { - if (salt != null) return getScryptHash(guessedUserId, salt); +): Buffer | undefined { + if (salt != null) return getScryptHashSync(guessedUserId, salt); const savedSalt = sql.getValue("SELECT salt FROM user_data;"); if (!savedSalt) { console.error("User salt undefined!"); return undefined; } - return getScryptHash(guessedUserId, savedSalt.toString()); + return getScryptHashSync(guessedUserId, savedSalt.toString()); } +/** + * Gets the derived key for an OpenID subject identifier. + * Uses the salt from user_data table if not provided. + */ function getSubjectIdentifierDerivedKey( subjectIdentifer: crypto.BinaryLike, givenSalt?: string -) { +): Buffer | undefined { if (givenSalt !== undefined) { - return getScryptHash(subjectIdentifer, givenSalt.toString()); + return getScryptHashSync(subjectIdentifer, givenSalt.toString()); } const salt = sql.getValue("SELECT salt FROM user_data;"); if (!salt) return undefined; - return getScryptHash(subjectIdentifer, salt.toString()); + return getScryptHashSync(subjectIdentifer, salt.toString()); } +/** + * Creates a derived key for an OpenID subject identifier with the given salt. + */ function createSubjectIdentifierDerivedKey( subjectIdentifer: string | crypto.BinaryLike, salt: string | crypto.BinaryLike -) { - return getScryptHash(subjectIdentifer, salt); +): Buffer { + return getScryptHashSync(subjectIdentifer, salt); } export default { - getVerificationHash, - getPasswordDerivedKey, getSubjectIdentifierVerificationHash, getSubjectIdentifierDerivedKey, createSubjectIdentifierDerivedKey diff --git a/apps/server/src/services/encryption/password.ts b/apps/server/src/services/encryption/password.ts index 7885a2a2b6..26f1b08e09 100644 --- a/apps/server/src/services/encryption/password.ts +++ b/apps/server/src/services/encryption/password.ts @@ -1,85 +1,5 @@ -import sql from "../sql.js"; -import optionService from "../options.js"; -import myScryptService from "./my_scrypt.js"; -import { randomSecureToken, toBase64 } from "../utils.js"; -import passwordEncryptionService from "./password_encryption.js"; -import { ChangePasswordResponse } from "@triliumnext/commons"; - -function isPasswordSet() { - return !!sql.getValue("SELECT value FROM options WHERE name = 'passwordVerificationHash'"); -} - -function changePassword(currentPassword: string, newPassword: string): ChangePasswordResponse { - if (!isPasswordSet()) { - throw new Error("Password has not been set yet, so it cannot be changed. Use 'setPassword' instead."); - } - - if (!passwordEncryptionService.verifyPassword(currentPassword)) { - return { - success: false, - message: "Given current password doesn't match hash" - }; - } - - sql.transactional(() => { - const decryptedDataKey = passwordEncryptionService.getDataKey(currentPassword); - - optionService.setOption("passwordVerificationSalt", randomSecureToken(32)); - optionService.setOption("passwordDerivedKeySalt", randomSecureToken(32)); - - const newPasswordVerificationKey = toBase64(myScryptService.getVerificationHash(newPassword)); - - if (decryptedDataKey) { - // TODO: what should happen if the decrypted data key is null? - passwordEncryptionService.setDataKey(newPassword, decryptedDataKey); - } - - optionService.setOption("passwordVerificationHash", newPasswordVerificationKey); - }); - - return { - success: true - }; -} - -function setPassword(password: string): ChangePasswordResponse { - if (isPasswordSet()) { - throw new Error("Password is set already. Either change it or perform 'reset password' first."); - } - - optionService.createOption("passwordVerificationSalt", randomSecureToken(32), true); - optionService.createOption("passwordDerivedKeySalt", randomSecureToken(32), true); - - const passwordVerificationKey = toBase64(myScryptService.getVerificationHash(password)); - optionService.createOption("passwordVerificationHash", passwordVerificationKey, true); - - // passwordEncryptionService expects these options to already exist - optionService.createOption("encryptedDataKey", "", true); - - passwordEncryptionService.setDataKey(password, randomSecureToken(16)); - - return { - success: true - }; -} - -function resetPassword() { - // user forgot the password, - sql.transactional(() => { - optionService.setOption("passwordVerificationSalt", ""); - optionService.setOption("passwordDerivedKeySalt", ""); - optionService.setOption("encryptedDataKey", ""); - optionService.setOption("passwordVerificationHash", ""); - }); - - return { - success: true - }; -} - -export default { - isPasswordSet, - changePassword, - setPassword, - resetPassword -}; +/** + * Re-exports the password service from core. + * changePassword and setPassword are now async - callers must use await. + */ +export { default } from "@triliumnext/core/src/services/encryption/password.js"; diff --git a/apps/server/src/services/encryption/password_encryption.ts b/apps/server/src/services/encryption/password_encryption.ts index 7c126f3260..3c2a1312bc 100644 --- a/apps/server/src/services/encryption/password_encryption.ts +++ b/apps/server/src/services/encryption/password_encryption.ts @@ -1,41 +1,5 @@ -import { data_encryption } from "@triliumnext/core"; - -import optionService from "../options.js"; -import { constantTimeCompare,toBase64 } from "../utils.js"; -import myScryptService from "./my_scrypt.js"; - -function verifyPassword(password: string) { - const givenPasswordHash = toBase64(myScryptService.getVerificationHash(password)); - - const dbPasswordHash = optionService.getOptionOrNull("passwordVerificationHash"); - - if (!dbPasswordHash) { - return false; - } - - return constantTimeCompare(givenPasswordHash, dbPasswordHash); -} - -function setDataKey(password: string, plainTextDataKey: string | Buffer | Uint8Array) { - const passwordDerivedKey = myScryptService.getPasswordDerivedKey(password); - - const newEncryptedDataKey = data_encryption.encrypt(passwordDerivedKey, plainTextDataKey); - - optionService.setOption("encryptedDataKey", newEncryptedDataKey); -} - -function getDataKey(password: string) { - const passwordDerivedKey = myScryptService.getPasswordDerivedKey(password); - - const encryptedDataKey = optionService.getOption("encryptedDataKey"); - - const decryptedDataKey = data_encryption.decrypt(passwordDerivedKey, encryptedDataKey); - - return decryptedDataKey; -} - -export default { - verifyPassword, - getDataKey, - setDataKey -}; +/** + * Re-exports the password encryption service from core. + * All functions are now async - callers must use await. + */ +export { default } from "@triliumnext/core/src/services/encryption/password_encryption.js"; diff --git a/apps/server/src/services/encryption/totp_encryption.ts b/apps/server/src/services/encryption/totp_encryption.ts index 1d1297dc52..dd78d98c59 100644 --- a/apps/server/src/services/encryption/totp_encryption.ts +++ b/apps/server/src/services/encryption/totp_encryption.ts @@ -1,9 +1,20 @@ +/** + * Server-side TOTP (Time-based One-Time Password) encryption service. + * + * This service handles encryption/decryption of TOTP secrets and remains + * server-only because: + * - TOTP/2FA is not supported in standalone mode + * - Uses synchronous Node.js crypto.scryptSync for performance + * + * The TOTP secret is encrypted using AES and stored in options. + * Verification uses scrypt-based hashing with constant-time comparison. + */ import type { OptionNames } from "@triliumnext/commons"; import { data_encryption } from "@triliumnext/core"; +import crypto from "crypto"; import optionService from "../options.js"; -import { constantTimeCompare,randomSecureToken, toBase64 } from "../utils.js"; -import myScryptService from "./my_scrypt.js"; +import { constantTimeCompare, randomSecureToken, toBase64 } from "../utils.js"; const TOTP_OPTIONS: Record = { SALT: "totpEncryptionSalt", @@ -11,8 +22,19 @@ const TOTP_OPTIONS: Record = { VERIFICATION_HASH: "totpVerificationHash" }; +const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1 }; + +/** + * Gets verification hash for TOTP secret using the password verification salt. + * This is server-only and uses sync scrypt. + */ +function getTotpVerificationHash(secret: string): Buffer { + const salt = optionService.getOption("passwordVerificationSalt"); + return crypto.scryptSync(secret, salt, 32, SCRYPT_OPTIONS); +} + function verifyTotpSecret(secret: string): boolean { - const givenSecretHash = toBase64(myScryptService.getVerificationHash(secret)); + const givenSecretHash = toBase64(getTotpVerificationHash(secret)); const dbSecretHash = optionService.getOptionOrNull(TOTP_OPTIONS.VERIFICATION_HASH); if (!dbSecretHash) { @@ -30,7 +52,7 @@ function setTotpSecret(secret: string) { const encryptionSalt = randomSecureToken(32); optionService.setOption(TOTP_OPTIONS.SALT, encryptionSalt); - const verificationHash = toBase64(myScryptService.getVerificationHash(secret)); + const verificationHash = toBase64(getTotpVerificationHash(secret)); optionService.setOption(TOTP_OPTIONS.VERIFICATION_HASH, verificationHash); const encryptedSecret = data_encryption.encrypt( diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 4520c5998a..e33f0484ec 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -22,7 +22,10 @@ export type * from "./services/sql/types"; export * from "./services/sql/index"; export { default as sql_init } from "./services/sql_init"; export * as protected_session from "./services/protected_session"; -export { default as data_encryption } from "./services/encryption/data_encryption" +export { default as data_encryption } from "./services/encryption/data_encryption"; +export { default as scrypt } from "./services/encryption/scrypt"; +export { default as password_encryption } from "./services/encryption/password_encryption"; +export { default as password } from "./services/encryption/password"; export * as binary_utils from "./services/utils/binary"; export * as utils from "./services/utils/index"; export * from "./services/build"; @@ -41,7 +44,7 @@ export * as cls from "./services/context"; export * as i18n from "./services/i18n"; export * from "./errors"; export { default as getInstanceId } from "./services/instance_id"; -export type { CryptoProvider } from "./services/encryption/crypto"; +export type { CryptoProvider, ScryptOptions, Cipher } from "./services/encryption/crypto"; export { default as note_types } from "./services/note_types"; export { default as tree } from "./services/tree"; export { default as cloning } from "./services/cloning"; diff --git a/packages/trilium-core/src/services/encryption/crypto.ts b/packages/trilium-core/src/services/encryption/crypto.ts index 0f3c54dddb..150eafedc3 100644 --- a/packages/trilium-core/src/services/encryption/crypto.ts +++ b/packages/trilium-core/src/services/encryption/crypto.ts @@ -1,10 +1,20 @@ -interface Cipher { +export interface Cipher { update(data: Uint8Array): Uint8Array; final(): Uint8Array; + /** Async finalization for browser environments where Web Crypto API is async-only */ + finalizeAsync?(): Promise; +} + +export interface ScryptOptions { + /** CPU/memory cost parameter (default: 16384) */ + N?: number; + /** Block size (default: 8) */ + r?: number; + /** Parallelization (default: 1) */ + p?: number; } export interface CryptoProvider { - createHash(algorithm: "md5" | "sha1" | "sha512", content: string | Uint8Array): Uint8Array; randomBytes(size: number): Uint8Array; randomString(length: number): string; @@ -12,6 +22,25 @@ export interface CryptoProvider { createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher; hmac(secret: string | Uint8Array, value: string | Uint8Array): string; + /** + * Derives a key from a password using the scrypt algorithm. + * @param password - The password to derive from + * @param salt - The salt to use + * @param keyLength - The length of the derived key in bytes + * @param options - Scrypt parameters (N, r, p) + */ + scrypt( + password: Uint8Array | string, + salt: Uint8Array | string, + keyLength: number, + options?: ScryptOptions + ): Promise; + + /** + * Constant-time comparison of two byte arrays to prevent timing attacks. + * @returns true if arrays are equal, false otherwise + */ + constantTimeCompare(a: Uint8Array, b: Uint8Array): boolean; } let crypto: CryptoProvider | null = null; diff --git a/packages/trilium-core/src/services/encryption/data_encryption.ts b/packages/trilium-core/src/services/encryption/data_encryption.ts index ffae2c1e9d..ff0274731d 100644 --- a/packages/trilium-core/src/services/encryption/data_encryption.ts +++ b/packages/trilium-core/src/services/encryption/data_encryption.ts @@ -108,8 +108,110 @@ function decryptString(dataKey: Uint8Array, cipherText: string) { return decodeUtf8(buffer); } +/** + * Async version of encrypt that works in both Node.js and browser environments. + * Uses finalizeAsync() for browser compatibility when available. + */ +async function encryptAsync(key: Uint8Array, plainText: Uint8Array | string): Promise { + if (!key) { + throw new Error("No data key!"); + } + + const plainTextUint8Array = ArrayBuffer.isView(plainText) ? plainText : encodeUtf8(plainText); + + const iv = getCrypto().randomBytes(16); + const cipher = getCrypto().createCipheriv("aes-128-cbc", pad(key), pad(iv)); + + const digest = shaArray(plainTextUint8Array).slice(0, 4); + const digestWithPayload = concat2(digest, plainTextUint8Array); + + cipher.update(digestWithPayload); + + // Use async finalization if available (browser), otherwise sync (Node.js) + const encryptedData = cipher.finalizeAsync + ? await cipher.finalizeAsync() + : concat2(cipher.update(digestWithPayload), cipher.final()); + + const encryptedDataWithIv = concat2(iv, encryptedData); + + return encodeBase64(encryptedDataWithIv); +} + +/** + * Async version of decrypt that works in both Node.js and browser environments. + * Uses finalizeAsync() for browser compatibility when available. + */ +async function decryptAsync(key: Uint8Array, cipherText: string | Uint8Array): Promise { + if (cipherText === null) { + return null; + } + + if (!key) { + return encodeUtf8("[protected]"); + } + + try { + const cipherTextStr = typeof cipherText === "string" ? cipherText : decodeUtf8(cipherText); + const cipherTextUint8ArrayWithIv = decodeBase64(cipherTextStr); + + // old encrypted data can have IV of length 13, see some details here: https://github.com/zadam/trilium/issues/3017 + const ivLength = cipherTextUint8ArrayWithIv.length % 16 === 0 ? 16 : 13; + + const iv = cipherTextUint8ArrayWithIv.slice(0, ivLength); + const cipherTextUint8Array = cipherTextUint8ArrayWithIv.slice(ivLength); + + const decipher = getCrypto().createDecipheriv("aes-128-cbc", pad(key), pad(iv)); + + decipher.update(cipherTextUint8Array); + + // Use async finalization if available (browser), otherwise sync (Node.js) + const decryptedBytes = decipher.finalizeAsync + ? await decipher.finalizeAsync() + : concat2(decipher.update(cipherTextUint8Array), decipher.final()); + + const digest = decryptedBytes.slice(0, 4); + const payload = decryptedBytes.slice(4); + + const computedDigest = shaArray(payload).slice(0, 4); + + if (!arraysIdentical(digest, computedDigest)) { + return false; + } + + return payload; + } catch (e: any) { + // recovery from https://github.com/zadam/trilium/issues/510 + if (e.message?.includes("WRONG_FINAL_BLOCK_LENGTH") || e.message?.includes("wrong final block length")) { + getLog().info("Caught WRONG_FINAL_BLOCK_LENGTH, returning cipherText instead"); + + return (ArrayBuffer.isView(cipherText) ? cipherText : Uint8Array.from(cipherText)); + } + throw e; + } +} + +/** + * Async version of decryptString that works in both Node.js and browser environments. + */ +async function decryptStringAsync(dataKey: Uint8Array, cipherText: string): Promise { + const buffer = await decryptAsync(dataKey, cipherText); + + if (buffer === null) { + return null; + } else if (buffer === false) { + getLog().error(`Could not decrypt string. Uint8Array: ${buffer}`); + + throw new Error("Could not decrypt string."); + } + + return decodeUtf8(buffer); +} + export default { encrypt, decrypt, - decryptString + decryptString, + encryptAsync, + decryptAsync, + decryptStringAsync }; diff --git a/packages/trilium-core/src/services/encryption/password.ts b/packages/trilium-core/src/services/encryption/password.ts new file mode 100644 index 0000000000..97d19518f7 --- /dev/null +++ b/packages/trilium-core/src/services/encryption/password.ts @@ -0,0 +1,120 @@ +import type { ChangePasswordResponse } from "@triliumnext/commons"; +import options from "../options.js"; +import { getSql } from "../sql/index.js"; +import scryptService from "./scrypt.js"; +import passwordEncryptionService from "./password_encryption.js"; +import { encodeBase64 } from "../utils/binary.js"; +import { getCrypto } from "./crypto.js"; + +/** + * Generates a random secure token encoded as base64. + * @param bytes - Number of random bytes to generate + */ +function randomSecureToken(bytes: number): string { + return encodeBase64(getCrypto().randomBytes(bytes)); +} + +/** + * Checks if a password has been set. + */ +export function isPasswordSet(): boolean { + const sql = getSql(); + return !!sql.getValue("SELECT value FROM options WHERE name = 'passwordVerificationHash'"); +} + +/** + * Changes the password from currentPassword to newPassword. + * Re-encrypts the data key with the new password. + */ +export async function changePassword( + currentPassword: string, + newPassword: string +): Promise { + if (!isPasswordSet()) { + throw new Error("Password has not been set yet, so it cannot be changed. Use 'setPassword' instead."); + } + + if (!(await passwordEncryptionService.verifyPassword(currentPassword))) { + return { + success: false, + message: "Given current password doesn't match hash" + }; + } + + const sql = getSql(); + const decryptedDataKey = await passwordEncryptionService.getDataKey(currentPassword); + + sql.transactional(() => { + options.setOption("passwordVerificationSalt", randomSecureToken(32)); + options.setOption("passwordDerivedKeySalt", randomSecureToken(32)); + }); + + const newPasswordVerificationKey = encodeBase64( + await scryptService.getVerificationHash(newPassword) + ); + + if (decryptedDataKey) { + await passwordEncryptionService.setDataKey(newPassword, decryptedDataKey); + } + + options.setOption("passwordVerificationHash", newPasswordVerificationKey); + + return { + success: true + }; +} + +/** + * Sets the initial password for a new installation. + * Creates all necessary password-related options. + */ +export async function setPassword(password: string): Promise { + if (isPasswordSet()) { + throw new Error("Password is set already. Either change it or perform 'reset password' first."); + } + + options.createOption("passwordVerificationSalt", randomSecureToken(32), true); + options.createOption("passwordDerivedKeySalt", randomSecureToken(32), true); + + const passwordVerificationKey = encodeBase64( + await scryptService.getVerificationHash(password) + ); + options.createOption("passwordVerificationHash", passwordVerificationKey, true); + + // passwordEncryptionService expects these options to already exist + options.createOption("encryptedDataKey", "", true); + + // Generate a random 16-byte data key and encrypt it with the password + const randomDataKey = getCrypto().randomBytes(16); + await passwordEncryptionService.setDataKey(password, randomDataKey); + + return { + success: true + }; +} + +/** + * Resets the password by clearing all password-related options. + * This should be used when the user has forgotten their password. + * WARNING: This will make all protected notes inaccessible. + */ +export function resetPassword(): ChangePasswordResponse { + const sql = getSql(); + sql.transactional(() => { + options.setOption("passwordVerificationSalt", ""); + options.setOption("passwordDerivedKeySalt", ""); + options.setOption("encryptedDataKey", ""); + options.setOption("passwordVerificationHash", ""); + }); + + return { + success: true + }; +} + +export default { + isPasswordSet, + changePassword, + setPassword, + resetPassword +}; diff --git a/packages/trilium-core/src/services/encryption/password_encryption.ts b/packages/trilium-core/src/services/encryption/password_encryption.ts new file mode 100644 index 0000000000..f027ea494d --- /dev/null +++ b/packages/trilium-core/src/services/encryption/password_encryption.ts @@ -0,0 +1,51 @@ +import data_encryption from "./data_encryption.js"; +import scryptService from "./scrypt.js"; +import options from "../options.js"; +import { getCrypto } from "./crypto.js"; +import { encodeBase64 } from "../utils/binary.js"; + +/** + * Verifies a password against the stored hash. + * Uses constant-time comparison to prevent timing attacks. + */ +export async function verifyPassword(password: string): Promise { + const givenPasswordHash = encodeBase64(await scryptService.getVerificationHash(password)); + const dbPasswordHash = options.getOptionOrNull("passwordVerificationHash"); + + if (!dbPasswordHash) { + return false; + } + + // Use constant-time comparison to prevent timing attacks + const givenBytes = new TextEncoder().encode(givenPasswordHash); + const dbBytes = new TextEncoder().encode(dbPasswordHash); + + return getCrypto().constantTimeCompare(givenBytes, dbBytes); +} + +/** + * Encrypts and stores the data key using the password-derived key. + */ +export async function setDataKey( + password: string, + plainTextDataKey: string | Uint8Array +): Promise { + const passwordDerivedKey = await scryptService.getPasswordDerivedKey(password); + const newEncryptedDataKey = await data_encryption.encryptAsync(passwordDerivedKey, plainTextDataKey); + options.setOption("encryptedDataKey", newEncryptedDataKey); +} + +/** + * Decrypts and returns the data key using the password-derived key. + */ +export async function getDataKey(password: string): Promise { + const passwordDerivedKey = await scryptService.getPasswordDerivedKey(password); + const encryptedDataKey = options.getOption("encryptedDataKey"); + return data_encryption.decryptAsync(passwordDerivedKey, encryptedDataKey); +} + +export default { + verifyPassword, + getDataKey, + setDataKey +}; diff --git a/packages/trilium-core/src/services/encryption/scrypt.ts b/packages/trilium-core/src/services/encryption/scrypt.ts new file mode 100644 index 0000000000..127840bb11 --- /dev/null +++ b/packages/trilium-core/src/services/encryption/scrypt.ts @@ -0,0 +1,41 @@ +import options from "../options.js"; +import { getCrypto } from "./crypto.js"; + +const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1 }; + +/** + * Gets the password verification hash using scrypt. + * Uses the passwordVerificationSalt option as salt. + */ +export async function getVerificationHash(password: string): Promise { + const salt = options.getOption("passwordVerificationSalt"); + return getScryptHash(password, salt); +} + +/** + * Gets the password-derived encryption key using scrypt. + * Uses the passwordDerivedKeySalt option as salt. + */ +export async function getPasswordDerivedKey(password: string): Promise { + const salt = options.getOption("passwordDerivedKeySalt"); + return getScryptHash(password, salt); +} + +/** + * Computes a scrypt hash with standard parameters. + * @param password - The password to hash + * @param salt - The salt to use + * @returns 32-byte derived key + */ +export async function getScryptHash( + password: string, + salt: string +): Promise { + return getCrypto().scrypt(password, salt, 32, SCRYPT_OPTIONS); +} + +export default { + getVerificationHash, + getPasswordDerivedKey, + getScryptHash +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abe72e5681..bc2db6486b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -590,6 +590,9 @@ importers: reveal.js: specifier: 6.0.0 version: 6.0.0 + scrypt-js: + specifier: 3.0.1 + version: 3.0.1 svg-pan-zoom: specifier: 3.6.2 version: 3.6.2 @@ -12591,6 +12594,9 @@ packages: script-loader@0.7.2: resolution: {integrity: sha512-UMNLEvgOAQuzK8ji8qIscM3GIrRCWN6MmMXGD4SD5l6cSycgGsCo0tX5xRnfQcoghqct0tjHjcykgI1PyBE2aA==} + scrypt-js@3.0.1: + resolution: {integrity: sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==} + scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} @@ -28434,6 +28440,8 @@ snapshots: dependencies: raw-loader: 0.5.1 + scrypt-js@3.0.1: {} + scule@1.3.0: {} secure-compare@3.0.1: {}