141 lines
5.3 KiB
TypeScript
141 lines
5.3 KiB
TypeScript
import { createHmac } from "node:crypto";
|
|
import {
|
|
ICE_CONFIG_PROTOCOL_VERSION,
|
|
type IceConfigGrant,
|
|
type IceConfigRequest,
|
|
type IceConfigUnavailable,
|
|
type StunIceServer,
|
|
type TurnIceServer,
|
|
} from "../../../src/media/iceTypes.ts";
|
|
import type { IceConfig } from "../config.ts";
|
|
|
|
export type IceCredentialResult =
|
|
| { ok: true; value: IceConfigGrant }
|
|
| { ok: false; code: "unavailable" | "rate-limited"; value: IceConfigUnavailable };
|
|
|
|
export interface IceCredentialRateIdentity {
|
|
/** Used transiently to derive a keyed rate bucket; never retained. */
|
|
subject: string;
|
|
/** Fastify's trusted client address; used transiently and never retained. */
|
|
trustedIp: string;
|
|
}
|
|
|
|
export interface IceCredentialProvider {
|
|
issue(request: IceConfigRequest, rateIdentity: IceCredentialRateIdentity, authorizationExpiresAtMs: number): IceCredentialResult;
|
|
}
|
|
|
|
export interface IceCredentialProviderOptions {
|
|
now?: () => number;
|
|
}
|
|
|
|
interface RateEntry { startedAtMs: number; count: number }
|
|
const MAX_RATE_KEYS = 4_096;
|
|
|
|
export function createIceCredentialProvider(
|
|
config: IceConfig,
|
|
options: IceCredentialProviderOptions = {},
|
|
): IceCredentialProvider {
|
|
const now = options.now ?? Date.now;
|
|
const subjectRates = new Map<string, RateEntry>();
|
|
const ipRates = new Map<string, RateEntry>();
|
|
const windowMs = config.rateWindowSeconds * 1_000;
|
|
|
|
return {
|
|
issue(request, rateIdentity, authorizationExpiresAtMs) {
|
|
const at = now();
|
|
if (!config.configured) return unavailable(request.requestId, windowMs, "unavailable");
|
|
// Derive immediately and retain only keyed digests in the bounded maps.
|
|
const rateKeys = deriveIceCredentialRateKeys(config.sharedSecret, rateIdentity);
|
|
const subjectRetry = rate(subjectRates, rateKeys.subject, at);
|
|
const ipRetry = rate(ipRates, rateKeys.ip, at);
|
|
const retryAfterMs = Math.max(subjectRetry ?? 0, ipRetry ?? 0);
|
|
if (retryAfterMs > 0) return unavailable(request.requestId, retryAfterMs, "rate-limited");
|
|
|
|
const expiresAtSeconds = Math.min(
|
|
Math.floor(at / 1_000) + config.credentialTtlSeconds,
|
|
Math.floor(authorizationExpiresAtMs / 1_000),
|
|
);
|
|
if (expiresAtSeconds <= Math.floor(at / 1_000)) {
|
|
return unavailable(request.requestId, 1_000, "unavailable");
|
|
}
|
|
// Coturn applies user-quota after stripping the REST expiry prefix. A new
|
|
// random suffix per issuance would therefore evade quota. This keyed,
|
|
// domain-separated suffix is stable only for one opaque media participant
|
|
// and contains no auth subject, email, profile id, or IP address.
|
|
const opaqueParticipant = createHmac("sha256", config.sharedSecret)
|
|
.update("tera-turn-user-v1\0")
|
|
.update(request.credential.sessionId)
|
|
.update("\0")
|
|
.update(request.credential.participantId)
|
|
.digest("base64url");
|
|
const username = `${expiresAtSeconds}:${opaqueParticipant}`;
|
|
const credential = createHmac("sha1", config.sharedSecret).update(username).digest("base64");
|
|
const stunUrls = config.urls.filter((url) => url.startsWith("stun:") || url.startsWith("stuns:"));
|
|
const turnUrls = config.urls.filter((url) => url.startsWith("turn:") || url.startsWith("turns:"));
|
|
const iceServers: Array<StunIceServer | TurnIceServer> = [];
|
|
if (stunUrls.length > 0) iceServers.push({ urls: stunUrls });
|
|
iceServers.push({ urls: turnUrls, username, credential, credentialType: "password" });
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
type: "ice-config-grant",
|
|
protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
|
|
requestId: request.requestId,
|
|
issuedAtMs: at,
|
|
expiresAtMs: expiresAtSeconds * 1_000,
|
|
iceServers,
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
function rate(entries: Map<string, RateEntry>, key: string, at: number): number | null {
|
|
let entry = entries.get(key);
|
|
if (!entry && entries.size >= MAX_RATE_KEYS) {
|
|
for (const [candidate, value] of entries) {
|
|
if (at - value.startedAtMs >= windowMs) entries.delete(candidate);
|
|
}
|
|
if (entries.size >= MAX_RATE_KEYS) return windowMs;
|
|
entry = entries.get(key);
|
|
}
|
|
if (!entry || at - entry.startedAtMs >= windowMs) {
|
|
entries.set(key, { startedAtMs: at, count: 1 });
|
|
return null;
|
|
}
|
|
entry.count += 1;
|
|
return entry.count > config.rateAttempts
|
|
? Math.max(1_000, windowMs - (at - entry.startedAtMs))
|
|
: null;
|
|
}
|
|
}
|
|
|
|
/** Server-only keyed pseudonyms for the two independent limiter dimensions. */
|
|
export function deriveIceCredentialRateKeys(
|
|
sharedSecret: string,
|
|
identity: IceCredentialRateIdentity,
|
|
): { subject: string; ip: string } {
|
|
const derive = (domain: string, value: string) => createHmac("sha256", sharedSecret)
|
|
.update(domain).update("\0").update(value).digest("hex");
|
|
return {
|
|
subject: derive("tera-ice-subject-v1", identity.subject),
|
|
ip: derive("tera-ice-ip-v1", identity.trustedIp),
|
|
};
|
|
}
|
|
|
|
function unavailable(
|
|
requestId: string,
|
|
retryAfterMs: number,
|
|
code: "unavailable" | "rate-limited",
|
|
): IceCredentialResult {
|
|
return {
|
|
ok: false,
|
|
code,
|
|
value: {
|
|
type: "ice-config-unavailable",
|
|
protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
|
|
requestId,
|
|
retryAfterMs: Math.min(3_600_000, Math.max(1_000, Math.ceil(retryAfterMs))),
|
|
},
|
|
};
|
|
}
|