fix: refuse sessionless tokens and keep the secrets file owner only

This commit is contained in:
LukeGus committed 2026-10-07 21:20:03 -05:00
1 parent 740d2adaf3
commit c3624cdf62
4 files changed
+62 -13

No files matched your search

@@ -19,8 +19,14 @@ vi.mock("../../database/db/index.js", () => ({
vi.mock("../../database/repositories/factory.js", () => ({ vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }), createCurrentSettingsRepository: () => ({ get: async () => null }),
// No sessionId in our tokens, so the session branch is skipped. createCurrentSessionRepository: () => ({
createCurrentSessionRepository: () => ({ findById: async () => null }), findById: async (id: string) => ({
id,
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
}),
touch: async () => {},
revoke: async () => {},
}),
createCurrentUserRepository: () => ({ createCurrentUserRepository: () => ({
findById: async (userId: string) => state.users.get(userId) ?? null, findById: async (userId: string) => state.users.get(userId) ?? null,
}), }),
@@ -128,7 +134,9 @@ function runMiddleware(token: string, headers: Record<string, string> = {}) {
} }
function token(userId: string) { function token(userId: string) {
return jwt.sign({ userId }, jwtSecret, { expiresIn: "1h" }); return jwt.sign({ userId, sessionId: `s-${userId}` }, jwtSecret, {
expiresIn: "1h",
});
} }
beforeEach(() => { beforeEach(() => {
@@ -18,7 +18,9 @@ vi.mock("../../database/db/index.js", () => ({
vi.mock("../../database/repositories/factory.js", () => ({ vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }), createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentSessionRepository: () => ({}), createCurrentSessionRepository: () => ({
findById: async (id: string) => ({ id }),
}),
createCurrentUserRepository: () => ({}), createCurrentUserRepository: () => ({}),
createCurrentApiKeyRepository: () => ({}), createCurrentApiKeyRepository: () => ({}),
createCurrentTrustedDeviceRepository: () => ({}), createCurrentTrustedDeviceRepository: () => ({}),
@@ -58,7 +60,7 @@ function makeLegacyDataKeyWrap(
): Record<string, string> { ): Record<string, string> {
const iv = crypto.randomBytes(12); const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv); 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()]); const data = Buffer.concat([cipher.update(dek), cipher.final()]);
return { return {
version: "v1", version: "v1",
@@ -85,7 +87,11 @@ describe("AuthManager token handling", () => {
it("adopts the DEK from a legacy dataKeyWrap token on verify", async () => { it("adopts the DEK from a legacy dataKeyWrap token on verify", async () => {
const dek = crypto.randomBytes(32); const dek = crypto.randomBytes(32);
const token = jwt.sign( const token = jwt.sign(
{ userId: "user-1", dataKeyWrap: makeLegacyDataKeyWrap("user-1", dek) }, {
userId: "user-1",
sessionId: "s1",
dataKeyWrap: makeLegacyDataKeyWrap("user-1", dek),
},
jwtSecret, jwtSecret,
{ expiresIn: "1h" }, { expiresIn: "1h" },
); );
@@ -107,6 +113,7 @@ describe("AuthManager token handling", () => {
const token = jwt.sign( const token = jwt.sign(
{ {
userId: "user-1", userId: "user-1",
sessionId: "s1",
dataKeyWrap: makeLegacyDataKeyWrap("user-1", crypto.randomBytes(32)), dataKeyWrap: makeLegacyDataKeyWrap("user-1", crypto.randomBytes(32)),
}, },
jwtSecret, jwtSecret,
@@ -123,13 +130,32 @@ describe("AuthManager token handling", () => {
wrap.tag = Buffer.from( wrap.tag = Buffer.from(
Buffer.from(wrap.tag, "base64url").map((b) => b ^ 0xff), Buffer.from(wrap.tag, "base64url").map((b) => b ^ 0xff),
).toString("base64url"); ).toString("base64url");
const token = jwt.sign({ userId: "user-1", dataKeyWrap: wrap }, jwtSecret, { const token = jwt.sign(
expiresIn: "1h", { userId: "user-1", sessionId: "s1", dataKeyWrap: wrap },
}); jwtSecret,
{
expiresIn: "1h",
},
);
const payload = await authManager.verifyJWTToken(token); const payload = await authManager.verifyJWTToken(token);
expect(payload?.userId).toBe("user-1"); expect(payload?.userId).toBe("user-1");
expect(mocks.adoptRecoveredDEK).not.toHaveBeenCalled(); 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);
});
}); });
+5 -1
View File
@@ -426,8 +426,12 @@ class AuthManager {
); );
return null; return null;
} }
} else { } else if (payload.pendingTOTP) {
await this.migrateDataKeyFromPayload(payload); 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; return payload;
} catch (error) { } catch (error) {
+14 -3
View File
@@ -3,6 +3,16 @@ import { promises as fs } from "fs";
import path from "path"; import path from "path";
import { databaseLogger } from "./logger.js"; import { databaseLogger } from "./logger.js";
/** Owner-only access for the secrets file. Not meaningful on Windows. */
export async function restrictToOwner(filePath: string): Promise<void> {
if (process.platform === "win32") return;
try {
await fs.chmod(filePath, 0o600);
} catch {
// best effort, new files are already written with 0600
}
}
class SystemCrypto { class SystemCrypto {
private static instance: SystemCrypto; private static instance: SystemCrypto;
private jwtSecret: string | null = null; private jwtSecret: string | null = null;
@@ -58,9 +68,9 @@ class SystemCrypto {
const dataDir = process.env.DATA_DIR || "./db/data"; const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env"); const envPath = path.join(dataDir, ".env");
try { try {
const envContent = await fs.readFile(envPath, "utf8"); const envContent = await fs.readFile(envPath, "utf8");
await restrictToOwner(envPath);
const jwtMatch = envContent.match(/^JWT_SECRET=(.+)$/m); const jwtMatch = envContent.match(/^JWT_SECRET=(.+)$/m);
if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) { if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) {
this.jwtSecret = jwtMatch[1]; this.jwtSecret = jwtMatch[1];
@@ -349,7 +359,7 @@ class SystemCrypto {
const envPath = path.join(dataDir, ".env"); const envPath = path.join(dataDir, ".env");
try { try {
await fs.mkdir(dataDir, { recursive: true }); await fs.mkdir(dataDir, { recursive: true, mode: 0o700 });
let envContent = ""; let envContent = "";
@@ -370,7 +380,8 @@ class SystemCrypto {
envContent += `${key}=${value}\n`; envContent += `${key}=${value}\n`;
} }
await fs.writeFile(envPath, envContent); await fs.writeFile(envPath, envContent, { mode: 0o600 });
await restrictToOwner(envPath);
process.env[key] = value; process.env[key] = value;
} catch (error) { } catch (error) {