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));
+}