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:
+17
-2
@@ -23,10 +23,25 @@
|
|||||||
// A route bills against `creditKey`; for tier it uses the stored row tier
|
// A route bills against `creditKey`; for tier it uses the stored row tier
|
||||||
// (cloud) or `license.tier` (license) — see identityTier().
|
// (cloud) or `license.tier` (license) — see identityTier().
|
||||||
|
|
||||||
|
import { timingSafeEqual } from "crypto";
|
||||||
import { getConfigSnapshot } from "./config.js";
|
import { getConfigSnapshot } from "./config.js";
|
||||||
import { resolveLicense } from "./keysat-client.js";
|
import { resolveLicense } from "./keysat-client.js";
|
||||||
import { getCreditKey } from "./credits.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
|
// 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
|
// charset so a header can't smuggle a path-ish or oversized key into the
|
||||||
// ledger.
|
// ledger.
|
||||||
@@ -40,7 +55,7 @@ export async function resolveIdentity(req) {
|
|||||||
const cfg = await getConfigSnapshot();
|
const cfg = await getConfigSnapshot();
|
||||||
const expected = (cfg.relay_cloud_operator_key || "").trim();
|
const expected = (cfg.relay_cloud_operator_key || "").trim();
|
||||||
const presented = (req.header("X-Recap-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(
|
const e = new Error(
|
||||||
"X-Recap-User-Id requires a valid X-Recap-Operator-Key"
|
"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 cfg = await getConfigSnapshot();
|
||||||
const expected = (cfg.relay_cloud_operator_key || "").trim();
|
const expected = (cfg.relay_cloud_operator_key || "").trim();
|
||||||
const presented = (req.header("X-Recap-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.
|
// The tier to bill/quota at for a resolved identity.
|
||||||
|
|||||||
Reference in New Issue
Block a user