From 0d6b8859b22f5b89c58df9878f80c87d64abcfa6 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sat, 10 Oct 2026 18:57:19 -0400 Subject: [PATCH] refactor(util): add secretsEqual for constant-time secret comparison One HMAC-then-timingSafeEqual helper, safe for inputs of any length, so callers stop hand-rolling the length guard. File signature verification uses it. --- src/backend/util/fileSigning.ts | 20 +++++------ src/backend/util/secureCompare.test.ts | 46 ++++++++++++++++++++++++++ src/backend/util/secureCompare.ts | 36 ++++++++++++++++++++ 3 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 src/backend/util/secureCompare.test.ts create mode 100644 src/backend/util/secureCompare.ts diff --git a/src/backend/util/fileSigning.ts b/src/backend/util/fileSigning.ts index 7416c41cb..180919214 100644 --- a/src/backend/util/fileSigning.ts +++ b/src/backend/util/fileSigning.ts @@ -17,9 +17,10 @@ * along with this program. If not, see . */ -import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; +import { createHash, createHmac } from 'node:crypto'; import { HttpError } from '../core/http/HttpError.js'; import type { FSEntry } from '../stores/fs/FSEntry.js'; +import { secretsEqual } from './secureCompare.js'; /** * File URL signing. A signed URL carries `uid`, `expires` and `signature`; that @@ -91,20 +92,15 @@ function computeOwnerBoundSignature( } /** - * Constant-time equality for the hex signature strings. A plain `===` - * short-circuits on the first differing character, leaking a byte-by-byte - * timing oracle on the one value an attacker controls and submits repeatedly. - * Length mismatch (or non-hex input) returns false without a timing-variable - * compare. + * Constant-time equality for the hex signature strings; compares the decoded + * bytes, so hex case doesn't matter. */ function signaturesEqual(provided: string, expected: string): boolean { if (provided.length !== expected.length) return false; - const a = Buffer.from(provided, 'hex'); - const b = Buffer.from(expected, 'hex'); - // Malformed hex yields a shorter buffer than the hex length implies; - // timingSafeEqual requires equal lengths, so guard before comparing. - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); + return secretsEqual( + Buffer.from(provided, 'hex'), + Buffer.from(expected, 'hex'), + ); } /** diff --git a/src/backend/util/secureCompare.test.ts b/src/backend/util/secureCompare.test.ts new file mode 100644 index 000000000..0ff24e43c --- /dev/null +++ b/src/backend/util/secureCompare.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it } from 'vitest'; +import { secretsEqual } from './secureCompare.ts'; + +describe('secretsEqual', () => { + it('is true for equal strings and equal buffers', () => { + expect(secretsEqual('s3cret', 's3cret')).toBe(true); + expect( + secretsEqual(Buffer.from([1, 2, 3]), Buffer.from([1, 2, 3])), + ).toBe(true); + expect(secretsEqual('', '')).toBe(true); + }); + + it('is false for values that differ anywhere', () => { + expect(secretsEqual('s3cret', 's3creT')).toBe(false); + expect(secretsEqual('s3cret', 'X3cret')).toBe(false); + }); + + it('is false for different lengths without throwing', () => { + expect(secretsEqual('short', 'much longer secret')).toBe(false); + expect(secretsEqual('', 'x')).toBe(false); + expect(secretsEqual(Buffer.alloc(16), Buffer.alloc(32))).toBe(false); + }); + + it('compares a string with its utf8 bytes as equal', () => { + expect(secretsEqual('héllo', Buffer.from('héllo', 'utf8'))).toBe(true); + }); +}); diff --git a/src/backend/util/secureCompare.ts b/src/backend/util/secureCompare.ts new file mode 100644 index 000000000..2424ff70a --- /dev/null +++ b/src/backend/util/secureCompare.ts @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +// Only has to stay unknown to callers for the life of the process. +const COMPARE_KEY = randomBytes(32); + +const digest = (value: string | Buffer): Buffer => + createHmac('sha256', COMPARE_KEY).update(value).digest(); + +/** + * Constant-time equality for secrets, tokens and signatures. Both sides are + * HMACed to a fixed-length digest first, so neither the inputs' lengths nor + * where they first differ shows in the timing, and unequal lengths need no + * early return. + */ +export function secretsEqual(a: string | Buffer, b: string | Buffer): boolean { + return timingSafeEqual(digest(a), digest(b)); +}