Compare commits

...

3 Commits

8 changed files with 66 additions and 20 deletions

View File

@@ -108,7 +108,7 @@ function loginSync(req: Request) {
const givenHash = req.body.hash; 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." }]; return [400, { message: "Sync login credentials are incorrect. It looks like you're trying to sync two different initialized documents which is not possible." }];
} }

View File

@@ -1,3 +1,4 @@
import crypto from "crypto";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import optionService from "../services/options.js"; import optionService from "../services/options.js";
import myScryptService from "../services/encryption/my_scrypt.js"; import myScryptService from "../services/encryption/my_scrypt.js";
@@ -160,7 +161,11 @@ function verifyPassword(submittedPassword: string) {
const guess_hashed = myScryptService.getVerificationHash(submittedPassword); const guess_hashed = myScryptService.getVerificationHash(submittedPassword);
return guess_hashed.equals(hashed_password); // 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') { function sendLoginError(req: Request, res: Response, errorType: 'password' | 'totp' = 'password') {

View File

@@ -1,5 +1,5 @@
import myScryptService from "./my_scrypt.js"; import myScryptService from "./my_scrypt.js";
import utils from "../utils.js"; import utils, { constantTimeCompare } from "../utils.js";
import dataEncryptionService from "./data_encryption.js"; import dataEncryptionService from "./data_encryption.js";
import sql from "../sql.js"; import sql from "../sql.js";
import sqlInit from "../sql_init.js"; import sqlInit from "../sql_init.js";
@@ -87,8 +87,7 @@ function verifyOpenIDSubjectIdentifier(subjectIdentifier: string) {
return undefined; return undefined;
} }
console.log("Matches: " + givenHash === savedHash); return constantTimeCompare(givenHash, savedHash as string);
return givenHash === savedHash;
} }
function setDataKey( function setDataKey(

View File

@@ -1,6 +1,6 @@
import optionService from "../options.js"; import optionService from "../options.js";
import myScryptService from "./my_scrypt.js"; import myScryptService from "./my_scrypt.js";
import { toBase64 } from "../utils.js"; import { toBase64, constantTimeCompare } from "../utils.js";
import dataEncryptionService from "./data_encryption.js"; import dataEncryptionService from "./data_encryption.js";
function verifyPassword(password: string) { function verifyPassword(password: string) {
@@ -12,7 +12,7 @@ function verifyPassword(password: string) {
return false; return false;
} }
return givenPasswordHash === dbPasswordHash; return constantTimeCompare(givenPasswordHash, dbPasswordHash);
} }
function setDataKey(password: string, plainTextDataKey: string | Buffer) { function setDataKey(password: string, plainTextDataKey: string | Buffer) {

View File

@@ -1,6 +1,7 @@
import crypto from 'crypto'; import crypto from 'crypto';
import optionService from '../options.js'; import optionService from '../options.js';
import sql from '../sql.js'; import sql from '../sql.js';
import { constantTimeCompare } from '../utils.js';
function isRecoveryCodeSet() { function isRecoveryCodeSet() {
return optionService.getOptionBool('encryptedRecoveryCodes'); return optionService.getOptionBool('encryptedRecoveryCodes');
@@ -55,13 +56,22 @@ function verifyRecoveryCode(recoveryCodeGuess: string) {
const recoveryCodes = getRecoveryCodes(); const recoveryCodes = getRecoveryCodes();
let loginSuccess = false; let loginSuccess = false;
recoveryCodes.forEach((recoveryCode) => { let matchedCode: string | null = null;
if (recoveryCodeGuess === recoveryCode) {
removeRecoveryCode(recoveryCode); // Check ALL codes to prevent timing attacks - do not short-circuit
for (const recoveryCode of recoveryCodes) {
if (constantTimeCompare(recoveryCodeGuess, recoveryCode)) {
matchedCode = recoveryCode;
loginSuccess = true; loginSuccess = true;
return; // Continue checking all codes to maintain constant time
} }
}); }
// Remove the matched code only after checking all codes
if (matchedCode) {
removeRecoveryCode(matchedCode);
}
return loginSuccess; return loginSuccess;
} }

View File

@@ -1,6 +1,6 @@
import optionService from "../options.js"; import optionService from "../options.js";
import myScryptService from "./my_scrypt.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 dataEncryptionService from "./data_encryption.js";
import type { OptionNames } from "@triliumnext/commons"; import type { OptionNames } from "@triliumnext/commons";
@@ -18,7 +18,7 @@ function verifyTotpSecret(secret: string): boolean {
return false; return false;
} }
return givenSecretHash === dbSecretHash; return constantTimeCompare(givenSecretHash, dbSecretHash);
} }
function setTotpSecret(secret: string) { function setTotpSecret(secret: string) {

View File

@@ -1,5 +1,5 @@
import becca from "../becca/becca.js"; 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 BEtapiToken from "../becca/entities/betapi_token.js";
import crypto from "crypto"; import crypto from "crypto";
@@ -83,15 +83,16 @@ function isValidAuthHeader(auth: string | undefined) {
return false; return false;
} }
return etapiToken.tokenHash === authTokenHash; return constantTimeCompare(etapiToken.tokenHash, authTokenHash);
} else { } else {
// Check ALL tokens to prevent timing attacks - do not short-circuit
let isValid = false;
for (const etapiToken of becca.getEtapiTokens()) { for (const etapiToken of becca.getEtapiTokens()) {
if (etapiToken.tokenHash === authTokenHash) { if (constantTimeCompare(etapiToken.tokenHash, authTokenHash)) {
return true; isValid = true;
} }
} }
return isValid;
return false;
} }
} }

View File

@@ -74,6 +74,36 @@ export function hmac(secret: any, value: any) {
return hmac.digest("base64"); 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
* @note Returns false for null/undefined/non-string inputs. Empty strings are considered equal.
*/
export function constantTimeCompare(a: string | null | undefined, b: string | null | undefined): boolean {
// Handle null/undefined/non-string cases safely
if (typeof a !== "string" || typeof b !== "string") {
return false;
}
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) { export function hash(text: string) {
text = text.normalize(); text = text.normalize();
@@ -486,6 +516,7 @@ function slugify(text: string) {
export default { export default {
compareVersions, compareVersions,
constantTimeCompare,
crash, crash,
envToBoolean, envToBoolean,
escapeHtml, escapeHtml,