diff --git a/package-lock.json b/package-lock.json index 909f510c..31ed56df 100644 Binary files a/package-lock.json and b/package-lock.json differ diff --git a/package.json b/package.json index 1a20cf0c..79a2416c 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,6 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.936.0", - "@spacebarchat/medooze-webrtc": "^1.0.8", "@toondepauw/node-zstd": "^1.2.0", "@types/web-push": "^3.6.4", "ajv": "^8.17.1", diff --git a/src/util/util/Token.ts b/src/util/util/Token.ts index 3c3f859f..aa239669 100644 --- a/src/util/util/Token.ts +++ b/src/util/util/Token.ts @@ -1,196 +1,111 @@ -/* - 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 . -*/ - -import jwt, { VerifyOptions } from "jsonwebtoken"; -import { Config } from "./Config"; -import { User } from "../entities"; import crypto from "node:crypto"; -import fs from "fs/promises"; -import { existsSync } from "fs"; -// TODO: dont use deprecated APIs lol +import { User } from "../entities"; +import { Config } from "./Config"; import { FindOptionsRelationByString, FindOptionsSelectByString, } from "typeorm"; -import * as console from "node:console"; -export const JWTOptions: VerifyOptions = { algorithms: ["HS256"] }; +const DISCORD_EPOCH = 1293840000; export type UserTokenData = { user: User; - decoded: { id: string; iat: number }; + decoded: { + id: string; + iat: number; + }; }; -function logAuth(text: string) { - if(process.env.LOG_AUTH !== "true") return; - console.log(`[AUTH] ${text}`); +function b64url(input: Buffer | string) { + return Buffer.from(input) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); } -function rejectAndLog(rejectFunction: (reason?: any) => void, reason: any) { - console.error(reason); - rejectFunction(reason); +function b64urlDecode(input: string) { + input = input.replace(/-/g, "+").replace(/_/g, "/"); + return Buffer.from(input, "base64").toString(); } -export const checkToken = ( +function sign(data: string) { + return crypto + .createHmac("sha256", Config.get().security.jwtSecret) + .update(data) + .digest(); +} + +export function generateToken(userId: string): string { + const unixNow = Math.floor(Date.now() / 1000); + const iat = unixNow - DISCORD_EPOCH; + + const p1 = b64url(userId); + const p2 = b64url(iat.toString()); + + const sig = b64url(sign(`${p1}.${p2}`)); + + return `${p1}.${p2}.${sig}`; +} + +export async function checkToken( token: string, opts?: { select?: FindOptionsSelectByString; relations?: FindOptionsRelationByString; }, -): Promise => - new Promise((resolve, reject) => { - token = token.replace("Bot ", ""); // there is no bot distinction in sb - token = token.replace("Bearer ", ""); // allow bearer tokens +): Promise { + token = token.replace("Bot ", "").replace("Bearer ", ""); - const validateUser: jwt.VerifyCallback = async (err, out) => { - const decoded = out as UserTokenData["decoded"]; - if (err || !decoded) { - logAuth("validateUser rejected: " + err); - return rejectAndLog(reject, "Invalid Token meow " + err); - } - - const user = await User.findOne({ - where: { id: decoded.id }, - select: [ - ...(opts?.select || []), - "id", - "bot", - "disabled", - "deleted", - "rights", - "data", - ], - relations: opts?.relations, - }); - - if (!user) { - logAuth("validateUser rejected: User not found"); - return rejectAndLog(reject, "User not found"); - } - - // we need to round it to seconds as it saved as seconds in jwt iat and valid_tokens_since is stored in milliseconds - if ( - decoded.iat * 1000 < - new Date(user.data.valid_tokens_since).setSeconds(0, 0) - ) { - logAuth("validateUser rejected: Token not yet valid"); - return rejectAndLog(reject, "Invalid Token"); - } - - if (user.disabled) { - logAuth("validateUser rejected: User disabled"); - return rejectAndLog(reject, "User disabled"); - } - if (user.deleted) { - logAuth("validateUser rejected: User deleted"); - return rejectAndLog(reject, "User not found"); - } - - logAuth("validateUser success: " + JSON.stringify({ decoded, user })); - return resolve({ decoded, user }); - }; - - const dec = jwt.decode(token, { complete: true }); - if (!dec) return reject("Could not parse token"); - logAuth("Decoded token: " + JSON.stringify(dec)); - - if (dec.header.alg == "HS256") { - jwt.verify( - token, - Config.get().security.jwtSecret, - JWTOptions, - validateUser, - ); - } else if (dec.header.alg == "ES512") { - loadOrGenerateKeypair().then((keyPair) => { - jwt.verify( - token, - keyPair.publicKey, - { algorithms: ["ES512"] }, - validateUser, - ); - }); - } else return reject("Invalid token algorithm"); - }); - -export async function generateToken(id: string) { - const iat = Math.floor(Date.now() / 1000); - const keyPair = await loadOrGenerateKeypair(); - - return new Promise((res, rej) => { - jwt.sign( - { id, iat, kid: keyPair.fingerprint }, - keyPair.privateKey, - { - algorithm: "ES512", - }, - (err, token) => { - if (err) return rej(err); - return res(token); - }, - ); - }); -} - -// Get ECDSA keypair from file or generate it -export async function loadOrGenerateKeypair() { - let privateKey: string | crypto.KeyObject; - let publicKey: string | crypto.KeyObject; - - if (existsSync("jwt.key") && existsSync("jwt.key.pub")) { - const [loadedPrivateKey, loadedPublicKey] = await Promise.all([ - fs.readFile("jwt.key", "utf-8"), - fs.readFile("jwt.key.pub", "utf-8"), - ]); - - privateKey = loadedPrivateKey; - publicKey = loadedPublicKey; - } else { - console.log("[JWT] Generating new keypair"); - const res = crypto.generateKeyPairSync("ec", { - namedCurve: "secp521r1", - publicKeyEncoding: { - type: "spki", - format: "pem" - }, - privateKeyEncoding: { - type: "pkcs8", - format: "pem" - } - }); - - await Promise.all([ - fs.writeFile("jwt.key", res.privateKey), - fs.writeFile("jwt.key.pub", res.publicKey), - ]); - - privateKey = res.privateKey; - publicKey = res.publicKey; + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("Invalid token format"); } - const publicKeyForHash = typeof publicKey === 'string' - ? crypto.createPublicKey(publicKey) - : publicKey; - - const fingerprint = crypto - .createHash("sha256") - .update(publicKeyForHash.export({ format: "pem", type: "spki" })) - .digest("hex"); + const [p1, p2, sig] = parts; + const expected = b64url(sign(`${p1}.${p2}`)); - return { privateKey, publicKey, fingerprint }; -} + if ( + !crypto.timingSafeEqual( + Buffer.from(sig), + Buffer.from(expected), + ) + ) { + throw new Error("Invalid token signature"); + } + + const id = b64urlDecode(p1); + const iat = Number(b64urlDecode(p2)); + + if (!Number.isFinite(iat)) { + throw new Error("Invalid timestamp"); + } + + const user = await User.findOne({ + where: { id }, + select: [ + ...(opts?.select || []), + "id", + "bot", + "disabled", + "deleted", + "rights", + "data", + ], + relations: opts?.relations, + }); + + if (!user) throw new Error("User not found"); + if (user.disabled) throw new Error("User disabled"); + if (user.deleted) throw new Error("User deleted"); + + const unixTime = (iat + DISCORD_EPOCH) * 1000; + if (unixTime < new Date(user.data.valid_tokens_since).setSeconds(0, 0)) { + throw new Error("Token revoked"); + } + + return { + user, + decoded: { id, iat }, + }; +} \ No newline at end of file