da1bba2e6b
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.
125 lines
5.0 KiB
JavaScript
125 lines
5.0 KiB
JavaScript
// Request identity resolution (core-decoupling).
|
|
//
|
|
// Two kinds of caller authenticate to the relay:
|
|
//
|
|
// "cloud" — the operator's cloud Recaps server (recaps.cc) acting on
|
|
// behalf of one of its signed-in users. It authenticates
|
|
// ONCE with a shared operator key (X-Recap-Operator-Key) and
|
|
// names the user via X-Recap-User-Id. The credit pool is
|
|
// keyed by `user:<id>`, and the tier is whatever the relay
|
|
// has STORED for that user (operator-set) — NOT read from a
|
|
// license. This is the path that removes the per-user Keysat
|
|
// license from cloud requests.
|
|
//
|
|
// "license" — a license bearer (a self-hosted install using the
|
|
// operator's relay, or the operator's own single-mode app).
|
|
// Unchanged legacy behavior: tier + pool come from the
|
|
// resolved Keysat license / install id.
|
|
//
|
|
// resolveIdentity(req) returns a uniform shape the routes thread into the
|
|
// credits/job-credits/envelope helpers:
|
|
// { kind, creditKey, userId|null, installId|null, license|null }
|
|
//
|
|
// 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.
|
|
const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
|
|
export async function resolveIdentity(req) {
|
|
const userId = (req.header("X-Recap-User-Id") || "").trim();
|
|
|
|
if (userId) {
|
|
// Cloud path — must present a valid operator key.
|
|
const cfg = await getConfigSnapshot();
|
|
const expected = (cfg.relay_cloud_operator_key || "").trim();
|
|
const presented = (req.header("X-Recap-Operator-Key") || "").trim();
|
|
if (!expected || !presented || !constantTimeEqual(presented, expected)) {
|
|
const e = new Error(
|
|
"X-Recap-User-Id requires a valid X-Recap-Operator-Key"
|
|
);
|
|
e.status = 401;
|
|
throw e;
|
|
}
|
|
if (!USER_ID_RE.test(userId)) {
|
|
const e = new Error("invalid X-Recap-User-Id");
|
|
e.status = 400;
|
|
throw e;
|
|
}
|
|
return {
|
|
kind: "cloud",
|
|
creditKey: `user:${userId}`,
|
|
userId,
|
|
installId: null,
|
|
license: null,
|
|
};
|
|
}
|
|
|
|
// License / install path — unchanged legacy behavior.
|
|
const installId = req.header("X-Recap-Install-Id") || null;
|
|
const license = await resolveLicense(req.header("Authorization"));
|
|
// getCreditKey throws only when there's neither a license fingerprint
|
|
// nor an installId; routes guard "missing install id" themselves, so
|
|
// tolerate it here and leave creditKey null.
|
|
let creditKey = null;
|
|
try {
|
|
creditKey = getCreditKey({ installId, license });
|
|
} catch {}
|
|
return { kind: "license", creditKey, userId: null, installId, license };
|
|
}
|
|
|
|
// Is this request the operator's cloud server (valid operator key)?
|
|
// Used by operator-only endpoints (e.g. setting a user's tier) that
|
|
// aren't tied to a specific X-Recap-User-Id.
|
|
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 && constantTimeEqual(presented, expected);
|
|
}
|
|
|
|
// The tier to bill/quota at for a resolved identity.
|
|
// cloud → the relay's stored row tier (operator-set; default "core")
|
|
// license → the resolved license tier
|
|
export function identityTier(identity, row) {
|
|
if (identity.kind === "cloud") {
|
|
// Prepaid-period enforcement (self-serve subscriptions): once the paid
|
|
// period lapses, the user is effectively Core until they renew. An
|
|
// operator comp grant leaves subscription_expires_at null, so this is a
|
|
// no-op for those — only dated purchases expire.
|
|
if (isSubscriptionExpired(row)) return "core";
|
|
return row?.tier_snapshot || "core";
|
|
}
|
|
return identity.license?.tier || "core";
|
|
}
|
|
|
|
// True when a cloud user's paid period has a set expiry that's in the past.
|
|
// Null/absent expiry = never expires (e.g. an operator comp grant).
|
|
export function isSubscriptionExpired(row) {
|
|
const exp = row?.subscription_expires_at;
|
|
if (!exp) return false;
|
|
const t = new Date(exp).getTime();
|
|
return Number.isFinite(t) && t < Date.now();
|
|
}
|