diff --git a/src/admin/web/manage.ts b/src/admin/web/manage.ts index dd031a7..62421ec 100644 --- a/src/admin/web/manage.ts +++ b/src/admin/web/manage.ts @@ -17,7 +17,7 @@ import { } from "../../shared/users/schema"; import { getLastNImages } from "../../shared/file-storage/image-history"; import { blacklists, parseCidrs, whitelists } from "../../shared/cidr"; -import { invalidatePowHmacKey } from "../../user/web/pow-captcha"; +import { invalidatePowChallenges } from "../../user/web/pow-captcha"; const router = Router(); @@ -323,7 +323,7 @@ router.post("/maintenance", (req, res) => { user.disabledReason = "Admin forced expiration."; userStore.upsertUser(user); }); - invalidatePowHmacKey(); + invalidatePowChallenges(); flash.type = "success"; flash.message = `${temps.length} temporary users marked for expiration.`; break; @@ -348,6 +348,7 @@ router.post("/maintenance", (req, res) => { throw new HttpError(400, "Invalid difficulty" + selected); } config.powDifficultyLevel = selected; + invalidatePowChallenges(); break; } case "generateTempIpReport": { diff --git a/src/config.ts b/src/config.ts index d4e4899..ad59f1d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -519,7 +519,7 @@ function generateSigningKey() { } const signingKey = generateSigningKey(); -export const COOKIE_SECRET = signingKey; +export const SECRET_SIGNING_KEY = signingKey; export async function assertConfigIsValid() { if (process.env.MODEL_RATE_LIMIT !== undefined) { diff --git a/src/shared/hmac-signing.ts b/src/shared/hmac-signing.ts new file mode 100644 index 0000000..6216857 --- /dev/null +++ b/src/shared/hmac-signing.ts @@ -0,0 +1,18 @@ +/** Module for generating and verifying HMAC signatures. */ + +import crypto from "crypto"; +import { SECRET_SIGNING_KEY } from "../config"; + +/** + * Generates a HMAC signature for the given message. Optionally salts the + * key with a provided string. + */ +export function signMessage(msg: any, salt: string = ""): string { + const hmac = crypto.createHmac("sha256", SECRET_SIGNING_KEY + salt); + if (typeof msg === "object") { + hmac.update(JSON.stringify(msg)); + } else { + hmac.update(msg); + } + return hmac.digest("hex"); +} diff --git a/src/shared/inject-csrf.ts b/src/shared/inject-csrf.ts index b438740..24cb051 100644 --- a/src/shared/inject-csrf.ts +++ b/src/shared/inject-csrf.ts @@ -1,9 +1,9 @@ import { doubleCsrf } from "csrf-csrf"; import express from "express"; -import { config, COOKIE_SECRET } from "../config"; +import { config, SECRET_SIGNING_KEY } from "../config"; const { generateToken, doubleCsrfProtection } = doubleCsrf({ - getSecret: () => COOKIE_SECRET, + getSecret: () => SECRET_SIGNING_KEY, cookieName: "csrf", cookieOptions: { sameSite: "strict", diff --git a/src/shared/with-session.ts b/src/shared/with-session.ts index 25448f2..89693ff 100644 --- a/src/shared/with-session.ts +++ b/src/shared/with-session.ts @@ -1,14 +1,14 @@ import cookieParser from "cookie-parser"; import expressSession from "express-session"; import MemoryStore from "memorystore"; -import { config, COOKIE_SECRET } from "../config"; +import { config, SECRET_SIGNING_KEY } from "../config"; const ONE_WEEK = 1000 * 60 * 60 * 24 * 7; -const cookieParserMiddleware = cookieParser(COOKIE_SECRET); +const cookieParserMiddleware = cookieParser(SECRET_SIGNING_KEY); const sessionMiddleware = expressSession({ - secret: COOKIE_SECRET, + secret: SECRET_SIGNING_KEY, resave: false, saveUninitialized: false, store: new (MemoryStore(expressSession))({ checkPeriod: ONE_WEEK }), diff --git a/src/user/web/pow-captcha.ts b/src/user/web/pow-captcha.ts index 8e7e9fc..b57b393 100644 --- a/src/user/web/pow-captcha.ts +++ b/src/user/web/pow-captcha.ts @@ -2,6 +2,7 @@ import crypto from "crypto"; import express from "express"; import argon2 from "@node-rs/argon2"; import { z } from "zod"; +import { signMessage } from "../../shared/hmac-signing"; import { authenticate, createUser, @@ -13,15 +14,13 @@ import { config } from "../../config"; /** Lockout time after verification in milliseconds */ const LOCKOUT_TIME = 1000 * 60; // 60 seconds -/** HMAC key for signing challenges; regenerated on startup */ -let hmacSecret = crypto.randomBytes(32).toString("hex"); +let powKeySalt = crypto.randomBytes(32).toString("hex"); /** - * Regenerate the HMAC key used for signing challenges. Calling this function - * will invalidate all existing challenges. + * Invalidates any outstanding unsolved challenges. */ -export function invalidatePowHmacKey() { - hmacSecret = crypto.randomBytes(32).toString("hex"); +export function invalidatePowChallenges() { + powKeySalt = crypto.randomBytes(32).toString("hex"); } const argon2Params = { @@ -141,16 +140,6 @@ function generateChallenge(clientIp?: string, token?: string): Challenge { }; } -function signMessage(msg: any): string { - const hmac = crypto.createHmac("sha256", hmacSecret); - if (typeof msg === "object") { - hmac.update(JSON.stringify(msg)); - } else { - hmac.update(msg); - } - return hmac.digest("hex"); -} - async function verifySolution( challenge: Challenge, solution: string, @@ -225,11 +214,11 @@ router.post("/challenge", (req, res) => { return; } const challenge = generateChallenge(req.ip, refreshToken); - const signature = signMessage(challenge); + const signature = signMessage(challenge, powKeySalt); res.json({ challenge, signature }); } else { const challenge = generateChallenge(req.ip); - const signature = signMessage(challenge); + const signature = signMessage(challenge, powKeySalt); res.json({ challenge, signature }); } }); @@ -253,7 +242,7 @@ router.post("/verify", async (req, res) => { } const { challenge, signature, solution } = result.data; - if (signMessage(challenge) !== signature) { + if (signMessage(challenge, powKeySalt) !== signature) { res.status(400).json({ error: "Invalid signature; server may have restarted since challenge was issued. Please request a new challenge.",