diff --git a/src/backend/database/routes/sso-provider-routes.ts b/src/backend/database/routes/sso-provider-routes.ts index fc29e095..d5837b44 100644 --- a/src/backend/database/routes/sso-provider-routes.ts +++ b/src/backend/database/routes/sso-provider-routes.ts @@ -8,54 +8,39 @@ import { AuthManager } from "../../utils/auth-manager.js"; import type { SSOProviderType } from "../../../types/index.js"; import { createCurrentSsoProviderRepository } from "../repositories/factory.js"; import { getOIDCConfigFromEnv } from "./user-oidc-utils.js"; +import { + decryptSsoConfigSecrets, + encryptSsoConfigSecrets, +} from "../../utils/system-secret-crypto.js"; const authManager = AuthManager.getInstance(); -function decryptProviderConfig( +/** + * SSO secrets belong to the installation, not to a user: `sso_providers` has no + * userId and the values must be readable during login, before anyone is + * authenticated. They are encrypted with the system key rather than a user DEK. + * Values written by the previous base64 scheme still decode, and are upgraded + * the next time the provider is saved. + */ +async function decryptProviderConfig( configJson: string, _userId: string, -): Record { +): Promise> { let config: Record; try { config = JSON.parse(configJson); } catch { return {}; } - - for (const field of ["client_secret", "bindPassword"] as const) { - const val = config[field] as string | undefined; - if (val?.startsWith("encoded:")) { - try { - config[field] = Buffer.from(val.substring(8), "base64").toString( - "utf8", - ); - } catch { - config[field] = "[ENCODING ERROR]"; - } - } - } - return config; + return decryptSsoConfigSecrets(config); } -function encryptProviderConfig( +async function encryptProviderConfig( config: Record, _userId: string, _providerId: string, -): string { - const encoded: Record = { ...config }; - if ( - typeof config.client_secret === "string" && - !config.client_secret.startsWith("encoded:") - ) { - encoded.client_secret = `encoded:${Buffer.from(config.client_secret).toString("base64")}`; - } - if ( - typeof config.bindPassword === "string" && - !config.bindPassword.startsWith("encoded:") - ) { - encoded.bindPassword = `encoded:${Buffer.from(config.bindPassword).toString("base64")}`; - } - return JSON.stringify(encoded); +): Promise { + return JSON.stringify(await encryptSsoConfigSecrets(config)); } function applyProviderDefaults( @@ -141,10 +126,12 @@ export function registerSSOProviderRoutes(router: Router): void { try { const rows = await createCurrentSsoProviderRepository().listAll(); - const result = rows.map((row) => ({ - ...row, - config: decryptProviderConfig(row.config, userId), - })); + const result = await Promise.all( + rows.map(async (row) => ({ + ...row, + config: await decryptProviderConfig(row.config, userId), + })), + ); res.json(result); } catch (err) { authLogger.error("Failed to list SSO providers (admin)", err); @@ -253,7 +240,7 @@ export function registerSSOProviderRoutes(router: Router): void { } const tempId = `new-${Date.now()}`; - const encryptedConfig = encryptProviderConfig( + const encryptedConfig = await encryptProviderConfig( configWithDefaults as Record, userId, tempId, @@ -275,7 +262,7 @@ export function registerSSOProviderRoutes(router: Router): void { }); res.status(201).json({ ...inserted, - config: decryptProviderConfig(inserted.config, userId), + config: await decryptProviderConfig(inserted.config, userId), }); } catch (err) { authLogger.error("Failed to create SSO provider", err); @@ -332,7 +319,7 @@ export function registerSSOProviderRoutes(router: Router): void { let encryptedConfig = existing.config; if (rawConfig !== undefined) { - const existingDecrypted = decryptProviderConfig( + const existingDecrypted = await decryptProviderConfig( existing.config, userId, ); @@ -342,7 +329,7 @@ export function registerSSOProviderRoutes(router: Router): void { ), ...rawConfig, }; - encryptedConfig = encryptProviderConfig( + encryptedConfig = await encryptProviderConfig( mergedConfig, userId, String(providerId), @@ -369,7 +356,7 @@ export function registerSSOProviderRoutes(router: Router): void { }); res.json({ ...updated, - config: decryptProviderConfig(updated.config, userId), + config: await decryptProviderConfig(updated.config, userId), }); } catch (err) { authLogger.error("Failed to update SSO provider", err); diff --git a/src/backend/database/routes/user-oidc-utils.ts b/src/backend/database/routes/user-oidc-utils.ts index a1c96003..ea13d9a2 100644 --- a/src/backend/database/routes/user-oidc-utils.ts +++ b/src/backend/database/routes/user-oidc-utils.ts @@ -1,6 +1,7 @@ import { authLogger } from "../../utils/logger.js"; import type { SSOProviderType } from "../../../types/index.js"; import { DataCrypto } from "../../utils/data-crypto.js"; +import { decryptSsoConfigSecrets } from "../../utils/system-secret-crypto.js"; import { Agent } from "undici"; import { createCurrentSettingsRepository, @@ -303,30 +304,15 @@ function applyProviderDefaults( }; } -function decryptConfigSecret( +/** + * Reads the provider secrets. System-key encrypted values are decrypted; + * values still carrying a legacy base64 prefix are decoded so login keeps + * working until the provider is next saved. + */ +async function decryptConfigSecret( config: Record, -): Record { - const out = { ...config }; - for (const field of ["client_secret", "bindPassword"] as const) { - const val = out[field] as string | undefined; - if (val?.startsWith("encoded:")) { - try { - out[field] = Buffer.from(val.substring(8), "base64").toString("utf8"); - } catch { - // leave as-is - } - } else if (val?.startsWith("encrypted:")) { - // encrypted: prefix means it was encrypted with DataCrypto; without a - // userId/dataKey here we cannot decrypt it. The caller should use the - // full admin decrypt path when possible. Fall back to stripping prefix. - try { - out[field] = Buffer.from(val.substring(10), "base64").toString("utf8"); - } catch { - // leave as-is - } - } - } - return out; +): Promise> { + return decryptSsoConfigSecrets(config); } export async function loadProviderConfig( @@ -360,10 +346,10 @@ export async function loadProviderConfig( ); } } catch { - parsed = decryptConfigSecret(parsed); + parsed = await decryptConfigSecret(parsed); } } else { - parsed = decryptConfigSecret(parsed); + parsed = await decryptConfigSecret(parsed); } const providerType = row.type as SSOProviderType; const config = applyProviderDefaults( @@ -400,7 +386,7 @@ export async function loadProviderConfig( } catch { parsed = {}; } - parsed = decryptConfigSecret(parsed); + parsed = await decryptConfigSecret(parsed); const oidcProviderType = oidcRow.type as SSOProviderType; return { config: applyProviderDefaults( @@ -421,7 +407,7 @@ export async function loadProviderConfig( await createCurrentSettingsRepository().get("oidc_config"); if (legacyValue) { let config = JSON.parse(legacyValue) as Record; - config = decryptConfigSecret(config); + config = await decryptConfigSecret(config); return { config: config as unknown as OIDCConfig, providerType: "oidc", @@ -452,7 +438,7 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{ } catch { continue; } - parsed = decryptConfigSecret(parsed); + parsed = await decryptConfigSecret(parsed); const providerType = row.type as SSOProviderType; const config = applyProviderDefaults( parsed as unknown as OIDCConfig, diff --git a/src/backend/tests/utils/system-secret-crypto.test.ts b/src/backend/tests/utils/system-secret-crypto.test.ts new file mode 100644 index 00000000..df5a1c19 --- /dev/null +++ b/src/backend/tests/utils/system-secret-crypto.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import crypto from "crypto"; + +const systemKey = crypto.randomBytes(32); +const getEncryptionKey = vi.hoisted(() => vi.fn()); + +vi.mock("../../utils/system-crypto.js", () => ({ + SystemCrypto: { getInstance: () => ({ getEncryptionKey }) }, +})); + +const { + decryptSsoConfigSecrets, + decryptSystemSecret, + encryptSsoConfigSecrets, + encryptSystemSecret, + isSystemEncrypted, + SSO_SECRET_FIELDS, +} = await import("../../utils/system-secret-crypto.js"); + +beforeEach(() => { + getEncryptionKey.mockReset(); + getEncryptionKey.mockResolvedValue(systemKey); +}); + +describe("system secret encryption", () => { + it("round-trips a secret", async () => { + const sealed = await encryptSystemSecret("s3cr3t-client-secret"); + + expect(sealed).not.toContain("s3cr3t"); + expect(isSystemEncrypted(sealed)).toBe(true); + await expect(decryptSystemSecret(sealed)).resolves.toBe( + "s3cr3t-client-secret", + ); + }); + + it("produces a different ciphertext each time", async () => { + const a = await encryptSystemSecret("same"); + const b = await encryptSystemSecret("same"); + + // Random IV per call, so identical secrets are not identifiable. + expect(a).not.toBe(b); + await expect(decryptSystemSecret(a)).resolves.toBe("same"); + await expect(decryptSystemSecret(b)).resolves.toBe("same"); + }); + + it("does not double-encrypt an already sealed value", async () => { + const once = await encryptSystemSecret("value"); + const twice = await encryptSystemSecret(once); + + expect(twice).toBe(once); + }); + + it("leaves empty values alone", async () => { + await expect(encryptSystemSecret("")).resolves.toBe(""); + await expect(decryptSystemSecret("")).resolves.toBe(""); + }); + + it("detects tampering", async () => { + const sealed = await encryptSystemSecret("value"); + const parts = sealed.replace("sysenc:v1:", "").split(":"); + const flipped = Buffer.from(parts[2], "base64"); + flipped[0] ^= 0xff; + const tampered = `sysenc:v1:${parts[0]}:${parts[1]}:${flipped.toString("base64")}`; + + // GCM auth tag must reject a modified payload rather than return garbage. + await expect(decryptSystemSecret(tampered)).rejects.toThrow(); + }); + + it("rejects a malformed sealed value", async () => { + await expect( + decryptSystemSecret("sysenc:v1:only-one-part"), + ).rejects.toThrow(/Malformed/); + }); +}); + +describe("legacy compatibility", () => { + it("decodes values written by the old base64 scheme", async () => { + const legacy = `encoded:${Buffer.from("old-secret").toString("base64")}`; + + // Must keep working: an existing install cannot be locked out of SSO login + // just because the storage format changed. + await expect(decryptSystemSecret(legacy)).resolves.toBe("old-secret"); + }); + + it("decodes the mislabelled 'encrypted:' variant too", async () => { + const legacy = `encrypted:${Buffer.from("old-secret").toString("base64")}`; + + await expect(decryptSystemSecret(legacy)).resolves.toBe("old-secret"); + }); + + it("passes through a value that was never encoded", async () => { + await expect(decryptSystemSecret("plain-secret")).resolves.toBe( + "plain-secret", + ); + }); + + it("upgrades a legacy value on the next write", async () => { + const legacy = `encoded:${Buffer.from("old-secret").toString("base64")}`; + const plaintext = await decryptSystemSecret(legacy); + const sealed = await encryptSystemSecret(plaintext); + + expect(isSystemEncrypted(sealed)).toBe(true); + await expect(decryptSystemSecret(sealed)).resolves.toBe("old-secret"); + }); +}); + +describe("SSO provider config", () => { + it("seals only the secret fields", async () => { + const sealed = await encryptSsoConfigSecrets({ + client_id: "termix", + client_secret: "shhh", + bindPassword: "ldap-pw", + issuer_url: "https://idp.example", + }); + + expect(sealed.client_id).toBe("termix"); + expect(sealed.issuer_url).toBe("https://idp.example"); + expect(isSystemEncrypted(sealed.client_secret as string)).toBe(true); + expect(isSystemEncrypted(sealed.bindPassword as string)).toBe(true); + }); + + it("round-trips a whole config", async () => { + const original = { + client_id: "termix", + client_secret: "shhh", + bindPassword: "ldap-pw", + }; + + const restored = await decryptSsoConfigSecrets( + await encryptSsoConfigSecrets(original), + ); + + expect(restored).toEqual(original); + }); + + it("covers both secret fields", () => { + expect([...SSO_SECRET_FIELDS]).toEqual(["client_secret", "bindPassword"]); + }); + + it("leaves a config without secrets untouched", async () => { + const config = { client_id: "termix", scopes: "openid" }; + + await expect(encryptSsoConfigSecrets(config)).resolves.toEqual(config); + await expect(decryptSsoConfigSecrets(config)).resolves.toEqual(config); + }); + + it("does not let one unreadable secret take down the provider", async () => { + const restored = await decryptSsoConfigSecrets({ + client_id: "termix", + client_secret: "sysenc:v1:bad", + }); + + // The rest of the config survives; login fails later with a clearer error. + expect(restored.client_id).toBe("termix"); + expect(restored.client_secret).toBe("sysenc:v1:bad"); + }); +}); diff --git a/src/backend/utils/system-secret-crypto.ts b/src/backend/utils/system-secret-crypto.ts new file mode 100644 index 00000000..b177228c --- /dev/null +++ b/src/backend/utils/system-secret-crypto.ts @@ -0,0 +1,121 @@ +import crypto from "crypto"; +import { SystemCrypto } from "./system-crypto.js"; + +/** + * Encryption for secrets that belong to the installation rather than to a user. + * + * Per-user field encryption (DataCrypto/FieldCrypto) derives its key from the + * user's DEK, which works for host passwords and SSH keys. It does not work for + * SSO provider configuration: `sso_providers` has no `userId`, and the OIDC + * client secret and LDAP bind password must be readable during login — before + * any user is authenticated, let alone unlocked. + * + * Those secrets were previously stored base64-encoded behind an `encoded:` + * prefix, which is not encryption. This uses the system encryption key, the + * same one already protecting other installation-level material. + */ + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const PREFIX = "sysenc:v1:"; +const LEGACY_PREFIX = "encoded:"; +/** Written by an older path that base64-encoded behind an "encrypted:" prefix. */ +const LEGACY_MISLABELLED_PREFIX = "encrypted:"; + +export function isSystemEncrypted(value: string): boolean { + return value.startsWith(PREFIX); +} + +export async function encryptSystemSecret(plaintext: string): Promise { + if (!plaintext) return plaintext; + if (isSystemEncrypted(plaintext)) return plaintext; + + const key = await SystemCrypto.getInstance().getEncryptionKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`; +} + +/** + * Reads a stored secret, transparently handling values written before this + * existed. Legacy values are returned as plaintext so login keeps working; they + * are upgraded on the next write. + */ +export async function decryptSystemSecret(stored: string): Promise { + if (!stored) return stored; + + if (!isSystemEncrypted(stored)) { + for (const legacy of [LEGACY_PREFIX, LEGACY_MISLABELLED_PREFIX]) { + if (stored.startsWith(legacy)) { + try { + return Buffer.from(stored.slice(legacy.length), "base64").toString( + "utf8", + ); + } catch { + return stored; + } + } + } + // Never encoded at all. + return stored; + } + + const [ivPart, tagPart, dataPart] = stored.slice(PREFIX.length).split(":"); + if (!ivPart || !tagPart || !dataPart) { + throw new Error("Malformed system-encrypted secret"); + } + + const key = await SystemCrypto.getInstance().getEncryptionKey(); + const decipher = crypto.createDecipheriv( + ALGORITHM, + key, + Buffer.from(ivPart, "base64"), + ); + decipher.setAuthTag(Buffer.from(tagPart, "base64")); + + return Buffer.concat([ + decipher.update(Buffer.from(dataPart, "base64")), + decipher.final(), + ]).toString("utf8"); +} + +/** Fields inside an SSO provider config that must not be stored readable. */ +export const SSO_SECRET_FIELDS = ["client_secret", "bindPassword"] as const; + +export async function encryptSsoConfigSecrets( + config: Record, +): Promise> { + const out = { ...config }; + for (const field of SSO_SECRET_FIELDS) { + const value = out[field]; + if (typeof value === "string" && value) { + out[field] = await encryptSystemSecret(value); + } + } + return out; +} + +export async function decryptSsoConfigSecrets( + config: Record, +): Promise> { + const out = { ...config }; + for (const field of SSO_SECRET_FIELDS) { + const value = out[field]; + if (typeof value === "string" && value) { + try { + out[field] = await decryptSystemSecret(value); + } catch { + // A secret we cannot read must not take the whole provider down; + // login will fail with a clearer error downstream. + } + } + } + return out; +}