Newer
Older
percord / src / util / Signing.ts
/*
	Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
	Copyright (C) 2023 Spacebar and Spacebar Contributors
	
	This program 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 <https://www.gnu.org/licenses/>.
*/

import { Config } from "@spacebar/util";
import { createHmac, timingSafeEqual } from "crypto";
import ms, { StringValue } from "ms";
import * as console from "node:console";

export class NewUrlUserSignatureData {
	ip?: string;
	userAgent?: string;
	constructor(data: NewUrlUserSignatureData) {
		this.ip = data.ip;
		this.userAgent = data.userAgent;
	}
}

export class NewUrlSignatureData extends NewUrlUserSignatureData {
	path?: string;
	url?: string;

	constructor(data: NewUrlSignatureData) {
		super(data);
		this.path = data.path;
		this.url = data.url;
		if (!this.path && !this.url) {
			throw new Error("Either path or url must be provided for URL signing");
		}
		if (this.path && this.url) {
			console.warn(
				"[Signing] Both path and url are provided, using path for signing",
			);
		}
		if (this.url) {
			try {
				const parsedUrl = new URL(this.url);
				this.path = parsedUrl.pathname;
			} catch (e) {
				throw new Error("Invalid URL provided for signing: " + this.url);
			}
		}
	}
}

export class UrlSignatureData extends NewUrlSignatureData {
	issuedAt: string;
	expiresAt: string;

	constructor(data: UrlSignatureData) {
		super(data);
		this.issuedAt = data.issuedAt;
		this.expiresAt = data.expiresAt;
	}
}

export class UrlSignResult {
	path: string;
	hash: string;
	issuedAt: string;
	expiresAt: string;

	/*
	 * @param data {UrlSignResult}
	 */
	constructor(data: any) {
		for (const key in data) {
			// @ts-ignore TS7053 - We dont care about string indexing a class
			this[key] = data[key];
		}
	}

	applyToUrl(url: URL | string): URL {
		if (typeof url === "string") {
			url = new URL(url);
		}
		url.searchParams.set("ex", this.expiresAt);
		url.searchParams.set("is", this.issuedAt);
		url.searchParams.set("hm", this.hash);
		return url;
	}

	static fromUrl(url: URL | string): UrlSignResult {
		if (typeof url === "string") {
			console.debug("[Signing] Parsing URL from string:", url);
			url = new URL(url);
		}
		console.debug("[Signing] Parsing URL from URL object:", url.toString());
		const ex = url.searchParams.get("ex");
		const is = url.searchParams.get("is");
		const hm = url.searchParams.get("hm");

		if (!ex || !is || !hm) {
			throw new Error("Invalid URL signature parameters");
		}

		return new UrlSignResult({
			path: url.pathname,
			issuedAt: is,
			expiresAt: ex,
			hash: hm,
		});
	}
}

export const getUrlSignature = (data: NewUrlSignatureData): UrlSignResult => {
	const { cdnSignatureKey, cdnSignatureDuration } = Config.get().security;

	// calculate the expiration time
	const now = Date.now();
	const issuedAt = now.toString(16);
	const expiresAt = (now + ms(cdnSignatureDuration as StringValue)).toString(
		16,
	);

	// hash the url with the cdnSignatureKey
	return calculateHash(
		new UrlSignatureData({
			...data,
			issuedAt,
			expiresAt,
		}),
	);
};

function calculateHash(request: UrlSignatureData): UrlSignResult {
	const { cdnSignatureKey } = Config.get().security;
	const data = createHmac("sha256", cdnSignatureKey as string)
		.update(request.path!)
		.update(request.issuedAt)
		.update(request.expiresAt);

	if (Config.get().security.cdnSignatureIncludeIp) {
		if (!request.ip)
			console.log(
				"[Signing] CDN Signature IP is enabled but we couldn't find the IP field in the request. This may cause issues with signature validation. Please report this to the Spacebar team!",
			);
		else {
			console.log(
				"[Signing] CDN Signature IP is enabled, adding IP to hash:",
				request.ip,
			);
			data.update(request.ip!);
		}
	}

	if (Config.get().security.cdnSignatureIncludeUserAgent) {
		if (!request.userAgent)
			console.log(
				"[Signing] CDN Signature User-Agent is enabled but we couldn't find the user-agent header in the request. This may cause issues with signature validation. Please report this to the Spacebar team!",
			);
		else {
			console.log(
				"[Signing] CDN Signature User-Agent is enabled, adding User-Agent to hash:",
				request.userAgent,
			);
			data.update(request.userAgent!);
		}
	}

	const hash = data.digest("hex");
	const result = new UrlSignResult({
		path: request.path,
		issuedAt: request.issuedAt,
		expiresAt: request.expiresAt,
		hash,
	});
	console.log("[Signing]", {
		path: request.path,
		validity: request.issuedAt + " .. " + request.expiresAt,
		ua: Config.get().security.cdnSignatureIncludeUserAgent ? request.userAgent : "[disabled]",
		ip: Config.get().security.cdnSignatureIncludeIp ? request.ip : "[disabled]"
	}, "->", result);
	return result;
}

export const isExpired = (data: UrlSignResult | UrlSignatureData) => {
	// convert issued at
	const issuedAt = parseInt(data.issuedAt, 16);
	const expiresAt = parseInt(data.expiresAt, 16);

	if (Number.isNaN(issuedAt) || Number.isNaN(expiresAt)) {
		console.debug("[Signing] Invalid timestamps in query");
		return true;
	}

	const currentTime = Date.now();

	const isExpired = expiresAt < currentTime;
	if (isExpired) {
		console.debug("[Signing] Signature expired");
		return true;
	}

	const isValidIssuedAt = issuedAt < currentTime;
	if (!isValidIssuedAt) {
		console.debug("[Signing] Signature issued in the future");
		return true;
	}

	return false;
};

export const hasValidSignature = (req: NewUrlUserSignatureData, sig: UrlSignResult) => {
	// if the required query parameters are not present, return false
	if (!sig.expiresAt || !sig.issuedAt || !sig.hash) {
		console.warn(
			"[Signing] Missing required query parameters for signature validation",
		);
		return false;
	}

	// check if the signature is expired
	if (isExpired(sig)) {
		console.warn("[Signing] Signature is expired");
		return false;
	}

	const calcResult = calculateHash(new UrlSignatureData({
		path: sig.path,
		issuedAt: sig.issuedAt,
		expiresAt: sig.expiresAt,
		ip: req.ip,
		userAgent: req.userAgent
	}));
	const calcd = calcResult.hash;
	const calculated = Buffer.from(calcd);
	const received = Buffer.from(sig.hash as string);

	console.assert(sig.issuedAt == calcResult.issuedAt, "[Signing] Mismatched issuedAt", {
		is: sig.issuedAt,
		calculated: calcResult.issuedAt,
	});

	console.assert(sig.expiresAt == calcResult.expiresAt, "[Signing] Mismatched expiresAt", {
		ex: sig.expiresAt,
		calculated: calcResult.expiresAt,
	});

	console.assert(calculated.length === received.length, "[Signing] Mismatched hash length", {
		calculated: calculated.length,
		received: received.length,
	});

	const isHashValid =
		calculated.length === received.length &&
		timingSafeEqual(calculated, received);

	if (!isHashValid)
		console.warn(
			`Signature validation for ${sig.path} (is=${sig.issuedAt}, ex=${sig.expiresAt}) failed: calculated: ${calcd}, received: ${sig.hash}`,
		);

	return isHashValid;
};