mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-23 06:26:51 +00:00
encrypt SSO secrets instead of base64-encoding them (#1135)
The OIDC client secret and LDAP bind password were stored behind an encoded: prefix that is base64, not encryption. Anyone reading the database read the secrets. A second path wrote the same thing behind an encrypted: prefix, which was also base64 — and the reader even documented that it could not decrypt it. These belong to the installation rather than to a user: sso_providers has no userId, and the values must be readable during login, before anyone has authenticated, so the per-user DEK used elsewhere does not apply. They are now sealed with AES-256-GCM under the system encryption key, which already protects other installation-level material. Reading handles both legacy prefixes so an existing install is not locked out of SSO login, and a legacy value is upgraded the next time the provider is saved. The three scattered encode/decode sites are replaced by one module.
This commit is contained in:
@@ -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<string, unknown> {
|
||||
): Promise<Record<string, unknown>> {
|
||||
let config: Record<string, unknown>;
|
||||
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<string, unknown>,
|
||||
_userId: string,
|
||||
_providerId: string,
|
||||
): string {
|
||||
const encoded: Record<string, unknown> = { ...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<string> {
|
||||
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<string, unknown>,
|
||||
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);
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user