From d01b9248540e0854f846537b176c7b6fee2d7b06 Mon Sep 17 00:00:00 2001 From: murdle Date: Sat, 20 Dec 2025 19:27:31 +0200 Subject: [PATCH] use discord token format --- package-lock.json | Bin 364063 -> 359312 bytes package.json | 1 - src/util/util/Token.ts | 259 ++++++++++++++--------------------------- 3 files changed, 87 insertions(+), 173 deletions(-) diff --git a/package-lock.json b/package-lock.json index 909f510c3ddf7760ab9a440b365fa37e27b32eee..31ed56df85d773aea94996dcacae985b4e0f4c8b 100644 GIT binary patch delta 286 zcmbO~M{L4((G6;h(@)Q46lu<2+@8V6nB22nYYL+@rZVzPzqFrOeS67N zMm05-g4EQa>4l7pD$@^KVr1LCeK+G0A-JIObaQuB!R<}w7=6Xzf-2KDJYp1@KJPDM z#rA?{j1{U78OBH^sqJ66nYOInerX3&FdstwcAMi&mDjd!na6xjZu{^3%s+Lve|pEP z&avI#7qfpnT%GdvsgW%C@(9zWXVkFpY?rNMX_DXmY9`Ak!N~#zjJ>&%dI5ulN delta 2725 zcmaJ@NsJrE6-6~kEsjRkHZyV{X%>0Lkr=X9Hc2@KL=N|TYiq$ax zQDkd8hzbFmI0k|&Fr0V*3B34V>*Dwj$3l?6$Yv?YAHYMQ|-2@GFe6q=d)q>{0 zx7KdnMV5+Gj;6a@e687MfR2%g8fPnaPKfj1;Zs1|x-# zL%7h$1qCnO=*P)sw8e!)k5Lj)xn1mxe9-|s|7GN9coDJ8*Xz1AJsM`7j9mWWWBbkD z@(xy^FUW>N=tzbkYce6SiZ3 zgiDt#&KX%S+LGMbn#ADye*vqDJ3Br^gRX6}ZhjoTe*!GPt0s}NpXQ3fLfE!C{l10} z@RhJ_#(W1)fz!ouRuH4CzffeF`AA!etDGECD%3SGTQ4&JY4sKQ=k8AIl()9@ml;&}+!e5DF_Q)=81!NGIm#%I(nxxPE-H|2eR-3J<*kPENjZ1APC;#yfA#!5>k!!xPWX zKyrRMa{~V1E|`Je`!hH+`NezS_6l75$TBnexedAg@9jr+iY;0dk}Y_?V(S}_1)!8w1C$0qLv)+YzZym zp-v_ijK-3wa4^{rFve@dP_J9d2yRz063^sR$&Gpn>X=T!TYt39!9yPaD|GUfxyi*% zq;PP0ocWD=wmtC7hv3v?@pXjxHB#5%`tL0B@CT3C@R_!ml_t>X!-2s|*Q}=*CLtPP zhOP<0u7_|%D1UGm$%-ifDdoH@CH$!y@ehAQsan17HsYBbv?m^1Kb=*1+Pd2QiV@I41Ce3=VOQR3j z;q7<83WJM1G8*pK*(N>Yo5gsF4|-IeKSS#j+M(4H6CZYhqAQV34E0os_xk*Hw1LJc z$(M4wNzx;r<)I`N_L!h+T0s4&1X@TI41=sYX$WE)l4a^2~hWEx$MIH zzpG6_;_!}QTPg=+trsKWSPx&ThYMM*UbaWdo%CQ>jS+fcs9?=%oC%f~A;_dk(PS!J z&&Jc;ad^yCI;n23*3Y;zYC`H)M`B*qBSX1BW19HPGw}Aa;0u%N4a-Lkxc(Y44+wbc zNd%uU%czOV{CAjz6t2GwmYJeb_o{NSgy+~mtDB04n0|ez`f5hhBXc!RI@VUJ-oZdA zk4u@L-q-!O+~*mI?pCx&w3WpxsF5^}fj20b8|Sg2-&-EFAifto0Wba-%uQl{vYgpx zF^ez+vtSmUe;$xW?^oq1l6kJek`Ulc`c+S(SWPM z#d3DT>m3ieS+Ca}Pj-e_$wl~vBc_#(CPpp$I9wfTZpNFWF@L$6FGT9C&RRyCwxBwE z@fLX8b^$k=3cUJT>j`icUiyz^Zr@g^p-eV5|7m#&zW%Y*3cpJri+k@QgO`43qTcf+ zIKJ0hN?=YGyfXnb{0(WF*>m5(o&}3c%ri`k-71j|_1aZ}8-)vrAYQ1|s$5f%yY3L1 zqQ`tJrHMXXi`fI!aRcpza&$#2)TlMPTxMJS9`DIgcCFUvMoaBVw#J0uoj0ewC}~}p zD1W!)z6zC7*7?cbVpe%!yC(IU)@K(ie76JHnAs;5ZvyhT#>%FlHZla=Wca}de(fe$ zW%`viDW%JHYAw!`dc&~fWm*kMpzFF?R`cFakf^c}M;WP5fJhP9EL%<|Lp3w+sca_@ zS;Jy!+K*Qi3Xfq4rGRyFw1@4$Hx3~#_%Vt+j$Cj-~zn*ph@-7R1sW5;97zP*MV)}bGxn?@J<$)wV2loxOCS_oZ5B& zn8MyQG;^GU+23228A11lGdSKW36W7hJWvA#T(9E_<%-x_HFw7qpuB~sKQgG4%Yrfv zn6y)@=Ea5Rz@PQB2BlKklT20X(pay$*ZO|3QuKy. -*/ - -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