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", () => ({
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<string, string> = {}) {
}
function token(userId: string) {
return jwt.sign({ userId }, jwtSecret, { expiresIn: "1h" });
return jwt.sign({ userId, sessionId: `s-${userId}` }, jwtSecret, {
expiresIn: "1h",
});
}
beforeEach(() => {
@@ -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<string, string> {
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);
});
});
+5 -1
View File
@@ -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) {
+14 -3
View File
@@ -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<void> {
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) {