Files
server/src/util/Signing.ts
2025-07-06 18:03:17 +02:00

267 lines
7.3 KiB
TypeScript

/*
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;
constructor(data: Partial<UrlSignResult>) {
for (const key in data) {
// @ts-expect-error 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;
};