diff --git a/src/lib/dataPaths.ts b/src/lib/dataPaths.ts index c73d139f36..01c93c2bad 100644 --- a/src/lib/dataPaths.ts +++ b/src/lib/dataPaths.ts @@ -91,7 +91,7 @@ export function resolveDataDir({ isCloud = false }: { isCloud?: boolean } = {}): * plus the AGENTS.md single-file command, which does NOT load * `tests/_setup/isolateDataDir.ts`. */ -function isTestContext(): boolean { +export function isTestContext(): boolean { return ( process.env.NODE_ENV === "test" || !!process.env.VITEST || diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index 484d7669f8..7b519f9480 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -76,6 +76,43 @@ function decryptFailureSignature( const RECOVERY_HINT = "Re-authenticate this account, or verify STORAGE_ENCRYPTION_KEY matches the key used to store it."; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { isTestContext, resolveDataDir } from "../dataPaths.ts"; + +function ensureSecretLoaded(): string | undefined { + if (isTestContext()) { + return process.env.STORAGE_ENCRYPTION_KEY; + } + if (process.env.STORAGE_ENCRYPTION_KEY) { + return process.env.STORAGE_ENCRYPTION_KEY; + } + const candidates = [ + path.join(resolveDataDir(), ".env"), + path.join(process.cwd(), ".env"), + path.join(os.homedir(), ".hermes", ".env"), + ]; + for (const envPath of candidates) { + try { + if (fs.existsSync(envPath)) { + const content = fs.readFileSync(envPath, "utf8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("STORAGE_ENCRYPTION_KEY=")) { + const val = trimmed.split("=", 2)[1]?.trim().replace(/^["'](.*)["']$/, "$1"); + if (val) { + process.env.STORAGE_ENCRYPTION_KEY = val; + return val; + } + } + } + } + } catch {} + } + return undefined; +} + /** * Derive the PRIMARY encryption key using the static salt. * This is the canonical key derivation that all new encryptions use. @@ -84,7 +121,7 @@ const RECOVERY_HINT = function getStaticKey(): Buffer | null { if (_staticKey !== null) return _staticKey; - const secret = process.env.STORAGE_ENCRYPTION_KEY; + const secret = ensureSecretLoaded(); if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null; try { @@ -110,7 +147,7 @@ function getStaticKey(): Buffer | null { function getLegacyDynamicKey(): Buffer | null { if (_legacyDynamicKey !== null) return _legacyDynamicKey; - const secret = process.env.STORAGE_ENCRYPTION_KEY; + const secret = ensureSecretLoaded(); if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null; const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16); @@ -124,7 +161,7 @@ function getLegacyDynamicKey(): Buffer | null { /** Check if encryption is enabled. */ export function isEncryptionEnabled(): boolean { - return !!process.env.STORAGE_ENCRYPTION_KEY; + return !!ensureSecretLoaded(); } /** diff --git a/tests/unit/db-encryption.test.ts b/tests/unit/db-encryption.test.ts index 296fc736c3..17234732c2 100644 --- a/tests/unit/db-encryption.test.ts +++ b/tests/unit/db-encryption.test.ts @@ -123,3 +123,18 @@ test("legacy encryption migration parses ciphertext in canonical payload order", assert.match(migrated.value, /^enc:v1:/); assert.equal(encryption.decrypt(migrated.value), "legacy-provider-token"); }); + +test("ensureSecretLoaded loads key from .env file when process.env.STORAGE_ENCRYPTION_KEY is unset", async () => { + delete process.env.STORAGE_ENCRYPTION_KEY; + const originalNodeEnv = process.env.NODE_ENV; + try { + delete process.env.NODE_ENV; + const encryption = await importFresh("src/lib/db/encryption.ts"); + // Under non-test NODE_ENV, if a local .env exists with a key, it discovers it + const enabled = encryption.isEncryptionEnabled(); + assert.equal(typeof enabled, "boolean"); + } finally { + process.env.NODE_ENV = originalNodeEnv; + } +}); +