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:
ZacharyZcR
2026-07-28 22:28:03 +08:00
committed by GitHub
parent 1ee0edc565
commit 0c0161a244
4 changed files with 320 additions and 69 deletions
@@ -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);
+14 -28
View File
@@ -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");
});
});
+121
View File
@@ -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;
}