From c3624cdf626631bc3f83728e0b70730b7e45e811 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 7 Oct 2026 21:20:03 -0500 Subject: [PATCH] fix: refuse sessionless tokens and keep the secrets file owner only --- .../utils/auth-manager-impersonation.test.ts | 14 +++++-- .../tests/utils/auth-manager-token.test.ts | 38 ++++++++++++++++--- src/backend/utils/auth-manager.ts | 6 ++- src/backend/utils/system-crypto.ts | 17 +++++++-- 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/backend/tests/utils/auth-manager-impersonation.test.ts b/src/backend/tests/utils/auth-manager-impersonation.test.ts index e3a59d1a3..f03328da1 100644 --- a/src/backend/tests/utils/auth-manager-impersonation.test.ts +++ b/src/backend/tests/utils/auth-manager-impersonation.test.ts @@ -19,8 +19,14 @@ vi.mock("../../database/db/index.js", () => ({ vi.mock("../../database/repositories/factory.js", () => ({ createCurrentSettingsRepository: () => ({ get: async () => null }), - // No sessionId in our tokens, so the session branch is skipped. - createCurrentSessionRepository: () => ({ findById: async () => null }), + createCurrentSessionRepository: () => ({ + findById: async (id: string) => ({ + id, + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }), + touch: async () => {}, + revoke: async () => {}, + }), createCurrentUserRepository: () => ({ findById: async (userId: string) => state.users.get(userId) ?? null, }), @@ -128,7 +134,9 @@ function runMiddleware(token: string, headers: Record = {}) { } function token(userId: string) { - return jwt.sign({ userId }, jwtSecret, { expiresIn: "1h" }); + return jwt.sign({ userId, sessionId: `s-${userId}` }, jwtSecret, { + expiresIn: "1h", + }); } beforeEach(() => { diff --git a/src/backend/tests/utils/auth-manager-token.test.ts b/src/backend/tests/utils/auth-manager-token.test.ts index 82f106396..9b946963d 100644 --- a/src/backend/tests/utils/auth-manager-token.test.ts +++ b/src/backend/tests/utils/auth-manager-token.test.ts @@ -18,7 +18,9 @@ vi.mock("../../database/db/index.js", () => ({ vi.mock("../../database/repositories/factory.js", () => ({ createCurrentSettingsRepository: () => ({ get: async () => null }), - createCurrentSessionRepository: () => ({}), + createCurrentSessionRepository: () => ({ + findById: async (id: string) => ({ id }), + }), createCurrentUserRepository: () => ({}), createCurrentApiKeyRepository: () => ({}), createCurrentTrustedDeviceRepository: () => ({}), @@ -58,7 +60,7 @@ function makeLegacyDataKeyWrap( ): Record { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv); - cipher.setAAD(Buffer.from(`${userId}:`, "utf8")); + cipher.setAAD(Buffer.from(`${userId}:s1`, "utf8")); const data = Buffer.concat([cipher.update(dek), cipher.final()]); return { version: "v1", @@ -85,7 +87,11 @@ describe("AuthManager token handling", () => { it("adopts the DEK from a legacy dataKeyWrap token on verify", async () => { const dek = crypto.randomBytes(32); const token = jwt.sign( - { userId: "user-1", dataKeyWrap: makeLegacyDataKeyWrap("user-1", dek) }, + { + userId: "user-1", + sessionId: "s1", + dataKeyWrap: makeLegacyDataKeyWrap("user-1", dek), + }, jwtSecret, { expiresIn: "1h" }, ); @@ -107,6 +113,7 @@ describe("AuthManager token handling", () => { const token = jwt.sign( { userId: "user-1", + sessionId: "s1", dataKeyWrap: makeLegacyDataKeyWrap("user-1", crypto.randomBytes(32)), }, jwtSecret, @@ -123,13 +130,32 @@ describe("AuthManager token handling", () => { wrap.tag = Buffer.from( Buffer.from(wrap.tag, "base64url").map((b) => b ^ 0xff), ).toString("base64url"); - const token = jwt.sign({ userId: "user-1", dataKeyWrap: wrap }, jwtSecret, { - expiresIn: "1h", - }); + const token = jwt.sign( + { userId: "user-1", sessionId: "s1", dataKeyWrap: wrap }, + jwtSecret, + { + expiresIn: "1h", + }, + ); const payload = await authManager.verifyJWTToken(token); expect(payload?.userId).toBe("user-1"); expect(mocks.adoptRecoveredDEK).not.toHaveBeenCalled(); }); + + it("rejects a signed token with no session behind it", async () => { + const token = jwt.sign({ userId: "user-1" }, jwtSecret, { + expiresIn: "1h", + }); + expect(await authManager.verifyJWTToken(token)).toBeNull(); + }); + + it("still accepts the sessionless pending second-factor token", async () => { + const token = jwt.sign({ userId: "user-1", pendingTOTP: true }, jwtSecret, { + expiresIn: "10m", + }); + const payload = await authManager.verifyJWTToken(token); + expect(payload?.pendingTOTP).toBe(true); + }); }); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index d7a330c7c..25e065c1e 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -426,8 +426,12 @@ class AuthManager { ); return null; } - } else { + } else if (payload.pendingTOTP) { await this.migrateDataKeyFromPayload(payload); + } else { + // Every real login has a session row to revoke; a signed token + // without one did not come from this server's login. + return null; } return payload; } catch (error) { diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index 1f3a7f7fb..c17c6a074 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -3,6 +3,16 @@ import { promises as fs } from "fs"; import path from "path"; import { databaseLogger } from "./logger.js"; +/** Owner-only access for the secrets file. Not meaningful on Windows. */ +export async function restrictToOwner(filePath: string): Promise { + if (process.platform === "win32") return; + try { + await fs.chmod(filePath, 0o600); + } catch { + // best effort, new files are already written with 0600 + } +} + class SystemCrypto { private static instance: SystemCrypto; private jwtSecret: string | null = null; @@ -58,9 +68,9 @@ class SystemCrypto { const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); - try { const envContent = await fs.readFile(envPath, "utf8"); + await restrictToOwner(envPath); const jwtMatch = envContent.match(/^JWT_SECRET=(.+)$/m); if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) { this.jwtSecret = jwtMatch[1]; @@ -349,7 +359,7 @@ class SystemCrypto { const envPath = path.join(dataDir, ".env"); try { - await fs.mkdir(dataDir, { recursive: true }); + await fs.mkdir(dataDir, { recursive: true, mode: 0o700 }); let envContent = ""; @@ -370,7 +380,8 @@ class SystemCrypto { envContent += `${key}=${value}\n`; } - await fs.writeFile(envPath, envContent); + await fs.writeFile(envPath, envContent, { mode: 0o600 }); + await restrictToOwner(envPath); process.env[key] = value; } catch (error) {