summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2026-07-10 04:58:05 +0200
committerRory& <root@rory.gay>2026-07-10 08:10:37 +0200
commit0183a475c3778085788efc58b66ebca1a2f8cdf8 (patch)
treedd7d738d20865bdf71b89103907e3565db6b9676
parentClean up tests integration (diff)
downloadserver-ts-0183a475c3778085788efc58b66ebca1a2f8cdf8.tar.xz
Introduce JwtKeypairManager, JwtKeypair classes
-rw-r--r--src/extensions/SingletonCache.ts2
-rw-r--r--src/util/util/Token.ts132
-rw-r--r--src/util/util/WebAuthn.ts23
3 files changed, 94 insertions, 63 deletions
diff --git a/src/extensions/SingletonCache.ts b/src/extensions/SingletonCache.ts

index 7f9f7895..b2253f73 100644 --- a/src/extensions/SingletonCache.ts +++ b/src/extensions/SingletonCache.ts
@@ -44,7 +44,7 @@ export class SingletonCache<T> { this.cachedValue = result; return result; } catch (e) { - console.log(`[SingletonCache] Factory method failed, returning stale value:`, e); + console.error(`[SingletonCache] Factory method failed, returning stale value:`, e); return this.cachedValue; } finally { this.isLocked = false; diff --git a/src/util/util/Token.ts b/src/util/util/Token.ts
index 3f654495..d0dd7a81 100644 --- a/src/util/util/Token.ts +++ b/src/util/util/Token.ts
@@ -16,16 +16,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import crypto from "node:crypto"; +import crypto, { KeyObject } from "node:crypto"; import { existsSync } from "node:fs"; import path from "node:path"; import fs from "node:fs/promises"; import jwt from "jsonwebtoken"; import { HTTPError } from "lambert-server/HTTPError"; import { InstanceBan, Session, User } from "@spacebar/database"; -import { Random, TimeSpan } from "@spacebar/extensions"; +import { Random, sleep, Stopwatch, TimeSpan } from "@spacebar/extensions"; import { Config } from "./Config"; import { OrmUtils } from "@spacebar/util"; +import { clearInterval, setInterval } from "node:timers"; +import { ProcessLifecycle } from "@spacebar/util/util/ProcessLifecycle"; /// Change history: /// 1 - Initial version with HS256 @@ -148,15 +150,13 @@ export const checkToken = ( legacyVersion = 1; jwt.verify(token, Config.get().security.jwtSecret!, { algorithms: ["HS256"] }, validateUser); } else if (dec.header.alg == "ES512") { - loadOrGenerateKeypair().then((keyPair) => { - jwt.verify(token, keyPair.publicKey, { algorithms: ["ES512"] }, validateUser); - }); + jwt.verify(token, JwtKeypairManager.keypair.publicKey, { algorithms: ["ES512"] }, validateUser); } else return void rejectAndLog(reject, 400, "Unsupported token algorithm: " + dec.header.alg); }); export async function generateToken(id: string, isAdminSession: boolean = false): Promise<string | undefined> { const iat = Math.floor(Date.now() / 1000); - const keyPair = await loadOrGenerateKeypair(); + const keyPair = JwtKeypairManager.keypair; let newSession; do { @@ -188,58 +188,92 @@ export async function generateToken(id: string, isAdminSession: boolean = false) }); } -let lastFsCheck: number; -let cachedKeypair: { - privateKey: crypto.KeyObject; - publicKey: crypto.KeyObject; - fingerprint: string; -}; +export class JwtKeypairManager { + private static isLocked = false; + static #keypair: JwtKeypair; + static #filesystemCheckInterval: NodeJS.Timeout; -// Get ECDSA keypair from file or generate it -export async function loadOrGenerateKeypair() { - if (cachedKeypair) { - // check for file deletion every minute - if (Date.now() - lastFsCheck > 60000) { - if (!existsSync("jwt.key") || !existsSync("jwt.key.pub")) { - console.log("[JWT] Keypair files disappeared... Saving them again."); - await Promise.all([ - fs.writeFile("jwt.key", cachedKeypair.privateKey.export({ format: "pem", type: "sec1" })), - fs.writeFile("jwt.key.pub", cachedKeypair.publicKey.export({ format: "pem", type: "spki" })), - ]); + public static get keypair() { + return this.#keypair; + } + + // Get ECDSA keypair from file or generate it + public static async init() { + if (this.isLocked) { + const lockSw = Stopwatch.startNew(); + while (this.isLocked) { + await sleep(50); + if (lockSw.elapsed().totalSeconds > 10) throw new Error("[JwtKeypairManager] Initialization was locked for >10 seconds!"); } - lastFsCheck = Date.now(); } - return cachedKeypair; - } + this.isLocked = true; + try { + let privateKey: crypto.KeyObject; + let publicKey: crypto.KeyObject; - let privateKey: crypto.KeyObject; - let publicKey: crypto.KeyObject; + if (existsSync("jwt.key") && existsSync("jwt.key.pub")) { + const [loadedPrivateKey, loadedPublicKey] = await Promise.all([fs.readFile("jwt.key"), fs.readFile("jwt.key.pub")]); - if (existsSync("jwt.key") && existsSync("jwt.key.pub")) { - const [loadedPrivateKey, loadedPublicKey] = await Promise.all([fs.readFile("jwt.key"), fs.readFile("jwt.key.pub")]); + privateKey = crypto.createPrivateKey(loadedPrivateKey); + publicKey = crypto.createPublicKey(loadedPublicKey); + } else { + console.log("[JWT] Generating new keypair:", path.resolve("jwt.key"), "- PWD:", process.cwd()); + const res = crypto.generateKeyPairSync("ec", { + namedCurve: "secp521r1", + }); + privateKey = res.privateKey; + publicKey = res.publicKey; - privateKey = crypto.createPrivateKey(loadedPrivateKey); - publicKey = crypto.createPublicKey(loadedPublicKey); - } else { - console.log("[JWT] Generating new keypair:", path.resolve("jwt.key"), "- PWD:", process.cwd()); - const res = crypto.generateKeyPairSync("ec", { - namedCurve: "secp521r1", - }); - privateKey = res.privateKey; - publicKey = res.publicKey; + await Promise.all([ + fs.writeFile("jwt.key", privateKey.export({ format: "pem", type: "sec1" })), + fs.writeFile("jwt.key.pub", publicKey.export({ format: "pem", type: "spki" })), + ]); + } + + const fingerprint = crypto + .createHash("sha256") + .update(publicKey.export({ format: "pem", type: "spki" })) + .digest("hex"); - await Promise.all([ - fs.writeFile("jwt.key", privateKey.export({ format: "pem", type: "sec1" })), - fs.writeFile("jwt.key.pub", publicKey.export({ format: "pem", type: "spki" })), - ]); + this.#keypair = new JwtKeypair(privateKey, publicKey, fingerprint); + + // set up interval to check if key was accidentally deleted + this.#filesystemCheckInterval = setInterval(async () => this.runDeletionCheck(), 60_000); + ProcessLifecycle.eventEmitter.on("stopped", async () => { + clearInterval(this.#filesystemCheckInterval); + await this.runDeletionCheck(); + }); + } catch (e) { + console.error(`[JwtKeypairManager] Initialization failed:`, e); + } finally { + this.isLocked = false; + } } - const fingerprint = crypto - .createHash("sha256") - .update(publicKey.export({ format: "pem", type: "spki" })) - .digest("hex"); + private static async runDeletionCheck() { + try { + if (!existsSync("jwt.key") || !existsSync("jwt.key.pub")) { + console.log("[JWT] Keypair files disappeared... Saving them again."); + await Promise.all([ + fs.writeFile("jwt.key", this.#keypair.privateKey.export({ format: "pem", type: "sec1" })), + fs.writeFile("jwt.key.pub", this.#keypair.publicKey.export({ format: "pem", type: "spki" })), + ]); + } + } catch (e) { + console.error("[JwtKeypairManager] Failed to check if keypair was accidentally deleted:", e); + } + } +} + +class JwtKeypair { + public readonly privateKey: KeyObject; + public readonly publicKey: KeyObject; + public readonly fingerprint: string; - lastFsCheck = Date.now(); - return (cachedKeypair = { privateKey, publicKey, fingerprint }); + constructor(privateKey: KeyObject, publicKey: KeyObject, fingerprint: string) { + this.privateKey = privateKey; + this.publicKey = publicKey; + this.fingerprint = fingerprint; + } } diff --git a/src/util/util/WebAuthn.ts b/src/util/util/WebAuthn.ts
index f6c5dbc4..84627951 100644 --- a/src/util/util/WebAuthn.ts +++ b/src/util/util/WebAuthn.ts
@@ -18,12 +18,13 @@ import { Fido2Lib } from "fido2-lib"; import jwt from "jsonwebtoken"; -import { loadOrGenerateKeypair } from "./Token"; +import { JwtKeypairManager } from "./Token"; const jwtSignOptions: jwt.SignOptions = { algorithm: "ES512", expiresIn: "5m", }; + const jwtVerifyOptions: jwt.VerifyOptions = { algorithms: ["ES512"], }; @@ -42,22 +43,18 @@ export const WebAuthn: { export async function generateWebAuthnTicket(challenge: string): Promise<string> { return new Promise((res, rej) => { - loadOrGenerateKeypair().then((kp) => - jwt.sign({ challenge }, kp.privateKey, jwtSignOptions, (err, token) => { - if (err || !token) return rej(err || "no token"); - return res(token); - }), - ); + jwt.sign({ challenge }, JwtKeypairManager.keypair.privateKey, jwtSignOptions, (err, token) => { + if (err || !token) return rej(err || "no token"); + return res(token); + }); }); } export async function verifyWebAuthnToken(token: string) { return new Promise((res, rej) => { - loadOrGenerateKeypair().then((kp) => - jwt.verify(token, kp.publicKey, jwtVerifyOptions, (err, decoded) => { - if (err) return rej(err); - return res(decoded); - }), - ); + jwt.verify(token, JwtKeypairManager.keypair.publicKey, jwtVerifyOptions, (err, decoded) => { + if (err) return rej(err); + return res(decoded); + }); }); }