mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +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", () => ({
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user