Introduce JwtKeypairManager, JwtKeypair classes

This commit is contained in:
Rory&
2026-07-10 08:10:37 +02:00
parent 6d7bede37d
commit 0183a475c3
3 changed files with 97 additions and 66 deletions
+1 -1
View File
@@ -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;
+86 -52
View File
@@ -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) {
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!");
}
}
this.isLocked = true;
try {
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")]);
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");
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;
}
}
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", cachedKeypair.privateKey.export({ format: "pem", type: "sec1" })),
fs.writeFile("jwt.key.pub", cachedKeypair.publicKey.export({ format: "pem", type: "spki" })),
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" })),
]);
}
lastFsCheck = Date.now();
} catch (e) {
console.error("[JwtKeypairManager] Failed to check if keypair was accidentally deleted:", e);
}
return cachedKeypair;
}
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")]);
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");
lastFsCheck = Date.now();
return (cachedKeypair = { privateKey, publicKey, fingerprint });
}
class JwtKeypair {
public readonly privateKey: KeyObject;
public readonly publicKey: KeyObject;
public readonly fingerprint: string;
constructor(privateKey: KeyObject, publicKey: KeyObject, fingerprint: string) {
this.privateKey = privateKey;
this.publicKey = publicKey;
this.fingerprint = fingerprint;
}
}
+10 -13
View File
@@ -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);
});
});
}