Compare operator key in constant time

resolveIdentity and verifyOperatorKey compared the shared
relay_cloud_operator_key with ===/!==, which short-circuits on the first
differing byte — a timing oracle on a high-value key. Use a
timingSafeEqual-based constantTimeEqual, matching admin-auth.js.
This commit is contained in:
Keysat
2026-06-13 18:22:00 -05:00
parent cbd9748a79
commit da1bba2e6b
+17 -2
View File
@@ -23,10 +23,25 @@
// A route bills against `creditKey`; for tier it uses the stored row tier
// (cloud) or `license.tier` (license) — see identityTier().
import { timingSafeEqual } from "crypto";
import { getConfigSnapshot } from "./config.js";
import { resolveLicense } from "./keysat-client.js";
import { getCreditKey } from "./credits.js";
// Constant-time string compare for the shared operator key, so a token
// guess can't be tuned byte-by-byte off response timing. Mirrors the
// helper in admin-auth.js. A length mismatch returns false early —
// length isn't the secret, and timingSafeEqual requires equal length.
function constantTimeEqual(a, b) {
if (typeof a !== "string" || typeof b !== "string") return false;
if (a.length !== b.length) return false;
try {
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
} catch {
return false;
}
}
// user-ids come from Recaps (base64url / hex account ids). Constrain the
// charset so a header can't smuggle a path-ish or oversized key into the
// ledger.
@@ -40,7 +55,7 @@ export async function resolveIdentity(req) {
const cfg = await getConfigSnapshot();
const expected = (cfg.relay_cloud_operator_key || "").trim();
const presented = (req.header("X-Recap-Operator-Key") || "").trim();
if (!expected || !presented || presented !== expected) {
if (!expected || !presented || !constantTimeEqual(presented, expected)) {
const e = new Error(
"X-Recap-User-Id requires a valid X-Recap-Operator-Key"
);
@@ -81,7 +96,7 @@ export async function verifyOperatorKey(req) {
const cfg = await getConfigSnapshot();
const expected = (cfg.relay_cloud_operator_key || "").trim();
const presented = (req.header("X-Recap-Operator-Key") || "").trim();
return !!expected && !!presented && presented === expected;
return !!expected && !!presented && constantTimeEqual(presented, expected);
}
// The tier to bill/quota at for a resolved identity.