mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 19:06:35 +02:00
feat(standalone): start introducing crypto
This commit is contained in:
11
CLAUDE.md
11
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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Uint8Array> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Uint8Array> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ChangePasswordResponse> {
|
||||
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) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, OptionNames> = {
|
||||
SALT: "totpEncryptionSalt",
|
||||
@@ -11,8 +22,19 @@ const TOTP_OPTIONS: Record<string, OptionNames> = {
|
||||
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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Uint8Array>;
|
||||
}
|
||||
|
||||
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<Uint8Array>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -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<string> {
|
||||
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<Uint8Array | false | null> {
|
||||
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<string | null> {
|
||||
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
|
||||
};
|
||||
|
||||
120
packages/trilium-core/src/services/encryption/password.ts
Normal file
120
packages/trilium-core/src/services/encryption/password.ts
Normal file
@@ -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<ChangePasswordResponse> {
|
||||
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<ChangePasswordResponse> {
|
||||
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
|
||||
};
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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<Uint8Array | false | null> {
|
||||
const passwordDerivedKey = await scryptService.getPasswordDerivedKey(password);
|
||||
const encryptedDataKey = options.getOption("encryptedDataKey");
|
||||
return data_encryption.decryptAsync(passwordDerivedKey, encryptedDataKey);
|
||||
}
|
||||
|
||||
export default {
|
||||
verifyPassword,
|
||||
getDataKey,
|
||||
setDataKey
|
||||
};
|
||||
41
packages/trilium-core/src/services/encryption/scrypt.ts
Normal file
41
packages/trilium-core/src/services/encryption/scrypt.ts
Normal file
@@ -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<Uint8Array> {
|
||||
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<Uint8Array> {
|
||||
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<Uint8Array> {
|
||||
return getCrypto().scrypt(password, salt, 32, SCRYPT_OPTIONS);
|
||||
}
|
||||
|
||||
export default {
|
||||
getVerificationHash,
|
||||
getPasswordDerivedKey,
|
||||
getScryptHash
|
||||
};
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user