diff --git a/apps/server/src/routes/api/login.ts b/apps/server/src/routes/api/login.ts index 36600791c..22c0e6ab0 100644 --- a/apps/server/src/routes/api/login.ts +++ b/apps/server/src/routes/api/login.ts @@ -108,7 +108,7 @@ function loginSync(req: Request) { const givenHash = req.body.hash; - if (expectedHash !== givenHash) { + if (!utils.constantTimeCompare(expectedHash, givenHash)) { return [400, { message: "Sync login credentials are incorrect. It looks like you're trying to sync two different initialized documents which is not possible." }]; } diff --git a/apps/server/src/services/encryption/open_id_encryption.ts b/apps/server/src/services/encryption/open_id_encryption.ts index 5dad9c06b..5ba14ca82 100644 --- a/apps/server/src/services/encryption/open_id_encryption.ts +++ b/apps/server/src/services/encryption/open_id_encryption.ts @@ -1,5 +1,5 @@ import myScryptService from "./my_scrypt.js"; -import utils from "../utils.js"; +import utils, { constantTimeCompare } from "../utils.js"; import dataEncryptionService from "./data_encryption.js"; import sql from "../sql.js"; import sqlInit from "../sql_init.js"; @@ -87,8 +87,7 @@ function verifyOpenIDSubjectIdentifier(subjectIdentifier: string) { return undefined; } - console.log("Matches: " + givenHash === savedHash); - return givenHash === savedHash; + return constantTimeCompare(givenHash, savedHash as string); } function setDataKey( diff --git a/apps/server/src/services/encryption/password_encryption.ts b/apps/server/src/services/encryption/password_encryption.ts index 931ab9c86..61e73495c 100644 --- a/apps/server/src/services/encryption/password_encryption.ts +++ b/apps/server/src/services/encryption/password_encryption.ts @@ -1,6 +1,6 @@ import optionService from "../options.js"; import myScryptService from "./my_scrypt.js"; -import { toBase64 } from "../utils.js"; +import { toBase64, constantTimeCompare } from "../utils.js"; import dataEncryptionService from "./data_encryption.js"; function verifyPassword(password: string) { @@ -12,7 +12,7 @@ function verifyPassword(password: string) { return false; } - return givenPasswordHash === dbPasswordHash; + return constantTimeCompare(givenPasswordHash, dbPasswordHash); } function setDataKey(password: string, plainTextDataKey: string | Buffer) { diff --git a/apps/server/src/services/encryption/recovery_codes.ts b/apps/server/src/services/encryption/recovery_codes.ts index 9fb7d4d3c..898a4cbb4 100644 --- a/apps/server/src/services/encryption/recovery_codes.ts +++ b/apps/server/src/services/encryption/recovery_codes.ts @@ -1,6 +1,7 @@ import crypto from 'crypto'; import optionService from '../options.js'; import sql from '../sql.js'; +import { constantTimeCompare } from '../utils.js'; function isRecoveryCodeSet() { return optionService.getOptionBool('encryptedRecoveryCodes'); @@ -56,7 +57,7 @@ function verifyRecoveryCode(recoveryCodeGuess: string) { const recoveryCodes = getRecoveryCodes(); let loginSuccess = false; recoveryCodes.forEach((recoveryCode) => { - if (recoveryCodeGuess === recoveryCode) { + if (constantTimeCompare(recoveryCodeGuess, recoveryCode)) { removeRecoveryCode(recoveryCode); loginSuccess = true; return; diff --git a/apps/server/src/services/encryption/totp_encryption.ts b/apps/server/src/services/encryption/totp_encryption.ts index 6d6de51e8..87f4cfef1 100644 --- a/apps/server/src/services/encryption/totp_encryption.ts +++ b/apps/server/src/services/encryption/totp_encryption.ts @@ -1,6 +1,6 @@ import optionService from "../options.js"; import myScryptService from "./my_scrypt.js"; -import { randomSecureToken, toBase64 } from "../utils.js"; +import { randomSecureToken, toBase64, constantTimeCompare } from "../utils.js"; import dataEncryptionService from "./data_encryption.js"; import type { OptionNames } from "@triliumnext/commons"; @@ -18,7 +18,7 @@ function verifyTotpSecret(secret: string): boolean { return false; } - return givenSecretHash === dbSecretHash; + return constantTimeCompare(givenSecretHash, dbSecretHash); } function setTotpSecret(secret: string) { diff --git a/apps/server/src/services/etapi_tokens.ts b/apps/server/src/services/etapi_tokens.ts index 031856f2a..304691c7d 100644 --- a/apps/server/src/services/etapi_tokens.ts +++ b/apps/server/src/services/etapi_tokens.ts @@ -1,5 +1,5 @@ import becca from "../becca/becca.js"; -import { fromBase64, randomSecureToken } from "./utils.js"; +import { fromBase64, randomSecureToken, constantTimeCompare } from "./utils.js"; import BEtapiToken from "../becca/entities/betapi_token.js"; import crypto from "crypto"; @@ -83,10 +83,10 @@ function isValidAuthHeader(auth: string | undefined) { return false; } - return etapiToken.tokenHash === authTokenHash; + return constantTimeCompare(etapiToken.tokenHash, authTokenHash); } else { for (const etapiToken of becca.getEtapiTokens()) { - if (etapiToken.tokenHash === authTokenHash) { + if (constantTimeCompare(etapiToken.tokenHash, authTokenHash)) { return true; } } diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index bee14dbba..370be6724 100644 --- a/apps/server/src/services/utils.ts +++ b/apps/server/src/services/utils.ts @@ -74,6 +74,30 @@ export function hmac(secret: any, value: any) { return hmac.digest("base64"); } +/** + * Constant-time string comparison to prevent timing attacks. + * Uses crypto.timingSafeEqual to ensure comparison time is independent + * of how many characters match. + * + * @param a First string to compare + * @param b Second string to compare + * @returns true if strings are equal, false otherwise + */ +export function constantTimeCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a, "utf-8"); + const bufB = Buffer.from(b, "utf-8"); + + // If lengths differ, we still do a constant-time comparison + // to avoid leaking length information through timing + 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); +} + export function hash(text: string) { text = text.normalize(); @@ -486,6 +510,7 @@ function slugify(text: string) { export default { compareVersions, + constantTimeCompare, crash, envToBoolean, escapeHtml,