diff --git a/src/app/api/cli-tools/antigravity-mitm/route.ts b/src/app/api/cli-tools/antigravity-mitm/route.ts index 6d4e0b442f..283bc65de4 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.ts +++ b/src/app/api/cli-tools/antigravity-mitm/route.ts @@ -7,6 +7,7 @@ import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { resolveApiKey } from "@/shared/services/apiKeyResolver"; +import { isRoot } from "@/mitm/systemCommands"; // GET - Check MITM status export async function GET(request) { @@ -60,9 +61,10 @@ export async function POST(request) { const apiKey = await resolveApiKey(apiKeyId, rawApiKey); const { startMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager"); const isWin = process.platform === "win32"; + const isRootUser = !isWin && isRoot(); const pwd = sudoPassword || getCachedPassword() || ""; - if (!apiKey || (!isWin && !pwd)) { + if (!apiKey || (!isWin && !pwd && !isRootUser)) { return NextResponse.json( { error: isWin ? "Missing apiKey" : "Missing apiKey or sudoPassword" }, { status: 400 } @@ -114,9 +116,10 @@ export async function DELETE(request) { const { sudoPassword } = validation.data; const { stopMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager"); const isWin = process.platform === "win32"; + const isRootUser = !isWin && isRoot(); const pwd = sudoPassword || getCachedPassword() || ""; - if (!isWin && !pwd) { + if (!isWin && !pwd && !isRootUser) { return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 }); } diff --git a/src/mitm/systemCommands.ts b/src/mitm/systemCommands.ts index a25ac95f61..c7f9b434cc 100644 --- a/src/mitm/systemCommands.ts +++ b/src/mitm/systemCommands.ts @@ -4,6 +4,14 @@ export function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +export function isRoot(): boolean { + try { + return !!(process.getuid && process.getuid() === 0); + } catch { + return false; + } +} + export function execFileText(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { execFile(command, args, { encoding: "utf8" }, (error, stdout, stderr) => { @@ -22,8 +30,22 @@ export function execFileWithPassword( password: string, stdinAfterPassword = "" ): Promise { + // When running as root, skip sudo -S and run the target command directly + const root = isRoot(); + const needsPassword = !root || command !== "sudo"; + let finalCommand = command; + let finalArgs = args; + + if (root && command === "sudo") { + const realCmdIndex = args.findIndex((arg) => !arg.startsWith("-")); + if (realCmdIndex !== -1) { + finalCommand = args[realCmdIndex]; + finalArgs = args.slice(realCmdIndex + 1); + } + } + return new Promise((resolve, reject) => { - const child = spawn(command, args, { + const child = spawn(finalCommand, finalArgs, { stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; @@ -57,7 +79,12 @@ export function execFileWithPassword( settle(new Error(`Command failed with code ${code}\n${stderr}`)); }); - child.stdin?.write(`${password}\n${stdinAfterPassword}`); + const stdinInput = needsPassword + ? `${password}\n${stdinAfterPassword}` + : stdinAfterPassword || ""; + if (stdinInput) { + child.stdin?.write(stdinInput); + } child.stdin?.end(); }); } diff --git a/tests/unit/encryption.spec.ts b/tests/unit/encryption.spec.ts new file mode 100644 index 0000000000..00da12c739 --- /dev/null +++ b/tests/unit/encryption.spec.ts @@ -0,0 +1,557 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createCipheriv, randomBytes, scryptSync, createHash } from "crypto"; + +// Test helper to manually create legacy-encrypted values +function createLegacyEncrypted(plaintext: string, secret: string): string { + const ALGORITHM = "aes-256-gcm"; + const IV_LENGTH = 16; + const KEY_LENGTH = 32; + const PREFIX = "enc:v1:"; + + // OLD dynamic salt derivation (the bug) + const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16); + const legacyKey = scryptSync(secret, dynamicSalt, KEY_LENGTH); + + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, legacyKey, iv); + + let encrypted = cipher.update(plaintext, "utf8", "hex"); + encrypted += cipher.final("hex"); + const authTag = cipher.getAuthTag().toString("hex"); + + return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`; +} + +describe("encryption module", () => { + beforeEach(() => { + // Clear all env vars and reset modules before each test + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + describe("encrypt/decrypt roundtrip with static key (PRIMARY path)", () => { + it("should encrypt and decrypt a value successfully", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret-api-key"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toBeDefined(); + expect(encrypted).toMatch(/^enc:v1:/); + expect(encrypted).not.toBe(plaintext); + + const decrypted = decrypt(encrypted!); + expect(decrypted).toBe(plaintext); + }); + + it("should handle multiple encrypt/decrypt cycles", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + const values = ["token1", "token2", "token3"]; + const encrypted = values.map((v) => encrypt(v)); + const decrypted = encrypted.map((e) => decrypt(e!)); + + expect(decrypted).toEqual(values); + }); + }); + + describe("legacy fallback: decrypt values encrypted with dynamic-salt key", () => { + it("should decrypt legacy-encrypted value using fallback key", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "legacy-api-token"; + + // Manually create a legacy-encrypted value + const legacyEncrypted = createLegacyEncrypted(plaintext, secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const decrypted = decrypt(legacyEncrypted); + expect(decrypted).toBe(plaintext); + }); + + it("should handle multiple legacy-encrypted values", async () => { + const secret = "test-secret-key-12345"; + const values = ["legacy1", "legacy2", "legacy3"]; + + const legacyEncrypted = values.map((v) => createLegacyEncrypted(v, secret)); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const decrypted = legacyEncrypted.map((e) => decrypt(e)); + expect(decrypted).toEqual(values); + }); + }); + + describe("migration flag: after legacy decrypt, isMigrationNeeded() returns true", () => { + it("should set migration flag when decrypting legacy value", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "legacy-token"; + const legacyEncrypted = createLegacyEncrypted(plaintext, secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt, isMigrationNeeded } = await import("@/lib/db/encryption"); + + expect(isMigrationNeeded()).toBe(false); + + const decrypted = decrypt(legacyEncrypted); + expect(decrypted).toBe(plaintext); + expect(isMigrationNeeded()).toBe(true); + }); + + it("should NOT set migration flag when decrypting static-key value", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt, isMigrationNeeded } = await import("@/lib/db/encryption"); + + const plaintext = "modern-token"; + const encrypted = encrypt(plaintext); + + expect(isMigrationNeeded()).toBe(false); + + const decrypted = decrypt(encrypted!); + expect(decrypted).toBe(plaintext); + expect(isMigrationNeeded()).toBe(false); + }); + }); + + describe("resetMigrationFlag() clears the flag", () => { + it("should reset migration flag after it was set", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "legacy-token"; + const legacyEncrypted = createLegacyEncrypted(plaintext, secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt, isMigrationNeeded, resetMigrationFlag } = + await import("@/lib/db/encryption"); + + decrypt(legacyEncrypted); + expect(isMigrationNeeded()).toBe(true); + + resetMigrationFlag(); + expect(isMigrationNeeded()).toBe(false); + }); + }); + + describe("passthrough mode: no STORAGE_ENCRYPTION_KEY set → plaintext stored", () => { + it("should return plaintext when encryption key is not set", async () => { + // No STORAGE_ENCRYPTION_KEY set + vi.resetModules(); + + const { encrypt, decrypt, isEncryptionEnabled } = await import("@/lib/db/encryption"); + + expect(isEncryptionEnabled()).toBe(false); + + const plaintext = "my-api-key"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toBe(plaintext); + + const decrypted = decrypt(plaintext); + expect(decrypted).toBe(plaintext); + }); + + it("should handle null and undefined in passthrough mode", async () => { + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + expect(encrypt(null)).toBeNull(); + expect(encrypt(undefined)).toBeUndefined(); + expect(decrypt(null)).toBeNull(); + expect(decrypt(undefined)).toBeUndefined(); + }); + }); + + describe("encryptConnectionFields / decryptConnectionFields helpers", () => { + it("should encrypt all credential fields in a connection object", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields } = await import("@/lib/db/encryption"); + + const conn = { + id: "conn-123", + apiKey: "plain-api-key", + accessToken: "plain-access-token", + refreshToken: "plain-refresh-token", + idToken: "plain-id-token", + }; + + const encrypted = encryptConnectionFields(conn); + + expect(encrypted.id).toBe("conn-123"); + expect(encrypted.apiKey).toMatch(/^enc:v1:/); + expect(encrypted.accessToken).toMatch(/^enc:v1:/); + expect(encrypted.refreshToken).toMatch(/^enc:v1:/); + expect(encrypted.idToken).toMatch(/^enc:v1:/); + }); + + it("should decrypt all credential fields in a connection object", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields, decryptConnectionFields } = + await import("@/lib/db/encryption"); + + const conn = { + id: "conn-123", + apiKey: "plain-api-key", + accessToken: "plain-access-token", + refreshToken: "plain-refresh-token", + idToken: "plain-id-token", + }; + + const encrypted = encryptConnectionFields({ ...conn }); + const decrypted = decryptConnectionFields(encrypted); + + expect(decrypted.id).toBe("conn-123"); + expect(decrypted.apiKey).toBe("plain-api-key"); + expect(decrypted.accessToken).toBe("plain-access-token"); + expect(decrypted.refreshToken).toBe("plain-refresh-token"); + expect(decrypted.idToken).toBe("plain-id-token"); + }); + + it("should handle null and undefined connection objects", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields, decryptConnectionFields } = + await import("@/lib/db/encryption"); + + expect(encryptConnectionFields(null)).toBeNull(); + expect(encryptConnectionFields(undefined)).toBeUndefined(); + expect(decryptConnectionFields(null)).toBeNull(); + expect(decryptConnectionFields(undefined)).toBeUndefined(); + }); + + it("should handle connection objects with missing fields", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields, decryptConnectionFields } = + await import("@/lib/db/encryption"); + + const conn: import("@/lib/db/encryption").ConnectionFields = { + id: "conn-123", + apiKey: "plain-api-key", + // accessToken, refreshToken, idToken missing + }; + + const encrypted = encryptConnectionFields({ ...conn }); + expect(encrypted.apiKey).toMatch(/^enc:v1:/); + expect(encrypted.accessToken).toBeUndefined(); + + const decrypted = decryptConnectionFields(encrypted); + expect(decrypted.apiKey).toBe("plain-api-key"); + expect(decrypted.accessToken).toBeUndefined(); + }); + + it("should set migration flag when decrypting legacy-encrypted connection fields", async () => { + const secret = "test-secret-key-12345"; + const legacyApiKey = createLegacyEncrypted("legacy-api-key", secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decryptConnectionFields, isMigrationNeeded } = await import("@/lib/db/encryption"); + + const conn = { + id: "conn-123", + apiKey: legacyApiKey, + }; + + expect(isMigrationNeeded()).toBe(false); + + const decrypted = decryptConnectionFields(conn); + expect(decrypted.apiKey).toBe("legacy-api-key"); + expect(isMigrationNeeded()).toBe(true); + }); + }); + + describe("edge cases: null/undefined inputs, already-encrypted, malformed ciphertext", () => { + it("should handle null and undefined inputs", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + expect(encrypt(null)).toBeNull(); + expect(encrypt(undefined)).toBeUndefined(); + expect(decrypt(null)).toBeNull(); + expect(decrypt(undefined)).toBeUndefined(); + }); + + it("should not double-encrypt already-encrypted values", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toMatch(/^enc:v1:/); + + // Try to encrypt again + const doubleEncrypted = encrypt(encrypted!); + expect(doubleEncrypted).toBe(encrypted); + }); + + it("should return null for malformed ciphertext (missing parts)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const malformed = "enc:v1:onlyonepart"; + const result = decrypt(malformed); + expect(result).toBeNull(); + }); + + it("should return null for malformed ciphertext (invalid hex)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const malformed = "enc:v1:notvalidhex:notvalidhex:notvalidhex"; + const result = decrypt(malformed); + expect(result).toBeNull(); + }); + + it("should return null for ciphertext with wrong auth tag", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + // Tamper with the auth tag + const parts = encrypted!.split(":"); + parts[parts.length - 1] = "0000000000000000000000000000000000000000000000000000000000000000"; + const tampered = parts.join(":"); + + const result = decrypt(tampered); + expect(result).toBeNull(); + }); + + it("should return plaintext unchanged if not encrypted (legacy plaintext)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const plaintext = "not-encrypted-value"; + const result = decrypt(plaintext); + expect(result).toBe(plaintext); + }); + + it("should return null when trying to decrypt without encryption key", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt } = await import("@/lib/db/encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + // Now remove the key and try to decrypt + vi.unstubAllEnvs(); + vi.resetModules(); + + const { decrypt } = await import("@/lib/db/encryption"); + + const result = decrypt(encrypted!); + expect(result).toBeNull(); + }); + }); + + describe("validateEncryptionConfig() with various key states", () => { + it("should return valid when no key is set (passthrough mode)", async () => { + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return valid when a proper key is set", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return valid when key is empty string (treated as not set)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", ""); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return invalid when key is whitespace only", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", " "); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("@/lib/db/encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(false); + expect(result.error).toContain("empty"); + }); + }); + + describe("isEncryptionEnabled() accuracy", () => { + it("should return false when no key is set", async () => { + vi.resetModules(); + + const { isEncryptionEnabled } = await import("@/lib/db/encryption"); + + expect(isEncryptionEnabled()).toBe(false); + }); + + it("should return true when key is set", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { isEncryptionEnabled } = await import("@/lib/db/encryption"); + + expect(isEncryptionEnabled()).toBe(true); + }); + + it("should return true even when key is invalid (just checks presence)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", ""); + vi.resetModules(); + + const { isEncryptionEnabled } = await import("@/lib/db/encryption"); + + // isEncryptionEnabled only checks if the env var is truthy + expect(isEncryptionEnabled()).toBe(false); + }); + }); + + describe("new encryptions always use static salt key", () => { + it("should encrypt with static key and decrypt without migration flag", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt, isMigrationNeeded } = await import("@/lib/db/encryption"); + + const plaintext = "new-token"; + const encrypted = encrypt(plaintext); + + expect(isMigrationNeeded()).toBe(false); + + const decrypted = decrypt(encrypted!); + expect(decrypted).toBe(plaintext); + expect(isMigrationNeeded()).toBe(false); + }); + + it("should verify multiple new encryptions use static key", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt, isMigrationNeeded } = await import("@/lib/db/encryption"); + + const values = ["token1", "token2", "token3"]; + const encrypted = values.map((v) => encrypt(v)); + + expect(isMigrationNeeded()).toBe(false); + + const decrypted = encrypted.map((e) => decrypt(e!)); + expect(decrypted).toEqual(values); + expect(isMigrationNeeded()).toBe(false); + }); + }); + + describe("EXACT bug scenario: dynamic salt → legacy fallback → migration flag", () => { + it("should reproduce the exact bug: value encrypted with dynamic salt, decrypt recovers via legacy fallback, migration flag set", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "health-check-token"; + + // Simulate the bug: health-check thread encrypted with dynamic salt + const buggyEncrypted = createLegacyEncrypted(plaintext, secret); + + // Main API tries to decrypt + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt, isMigrationNeeded } = await import("@/lib/db/encryption"); + + expect(isMigrationNeeded()).toBe(false); + + // Should recover via legacy fallback + const decrypted = decrypt(buggyEncrypted); + expect(decrypted).toBe(plaintext); + + // Migration flag should be set + expect(isMigrationNeeded()).toBe(true); + }); + + it("should verify re-encryption after migration flag is set", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "health-check-token"; + + const legacyEncrypted = createLegacyEncrypted(plaintext, secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt, encrypt, isMigrationNeeded, resetMigrationFlag } = + await import("@/lib/db/encryption"); + + // Decrypt legacy value + const decrypted = decrypt(legacyEncrypted); + expect(decrypted).toBe(plaintext); + expect(isMigrationNeeded()).toBe(true); + + // Re-encrypt with static key + const reEncrypted = encrypt(decrypted!); + expect(reEncrypted).toMatch(/^enc:v1:/); + expect(reEncrypted).not.toBe(legacyEncrypted); + + // Reset migration flag + resetMigrationFlag(); + expect(isMigrationNeeded()).toBe(false); + + // Verify new encryption decrypts without migration flag + const finalDecrypted = decrypt(reEncrypted!); + expect(finalDecrypted).toBe(plaintext); + expect(isMigrationNeeded()).toBe(false); + }); + }); +});