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.
This commit is contained in:
Daniel Salazar committed 2026-10-10 19:45:27 -04:00
1 parent 6f0dbfd46c
commit 0d6b8859b2
3 files changed
+90 -12

No files matched your search

+8 -12
View File
@@ -17,9 +17,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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'),
);
}
/**
+46
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
});
+36
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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));
}