mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: refuse sessionless tokens and keep the secrets file owner only
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user