mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +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 type { SSOProviderType } from "../../../types/index.js";
|
||||||
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
|
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
|
||||||
import { getOIDCConfigFromEnv } from "./user-oidc-utils.js";
|
import { getOIDCConfigFromEnv } from "./user-oidc-utils.js";
|
||||||
|
import {
|
||||||
|
decryptSsoConfigSecrets,
|
||||||
|
encryptSsoConfigSecrets,
|
||||||
|
} from "../../utils/system-secret-crypto.js";
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
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,
|
configJson: string,
|
||||||
_userId: string,
|
_userId: string,
|
||||||
): Record<string, unknown> {
|
): Promise<Record<string, unknown>> {
|
||||||
let config: Record<string, unknown>;
|
let config: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
config = JSON.parse(configJson);
|
config = JSON.parse(configJson);
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
return decryptSsoConfigSecrets(config);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function encryptProviderConfig(
|
async function encryptProviderConfig(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
_userId: string,
|
_userId: string,
|
||||||
_providerId: string,
|
_providerId: string,
|
||||||
): string {
|
): Promise<string> {
|
||||||
const encoded: Record<string, unknown> = { ...config };
|
return JSON.stringify(await encryptSsoConfigSecrets(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyProviderDefaults(
|
function applyProviderDefaults(
|
||||||
@@ -141,10 +126,12 @@ export function registerSSOProviderRoutes(router: Router): void {
|
|||||||
try {
|
try {
|
||||||
const rows = await createCurrentSsoProviderRepository().listAll();
|
const rows = await createCurrentSsoProviderRepository().listAll();
|
||||||
|
|
||||||
const result = rows.map((row) => ({
|
const result = await Promise.all(
|
||||||
...row,
|
rows.map(async (row) => ({
|
||||||
config: decryptProviderConfig(row.config, userId),
|
...row,
|
||||||
}));
|
config: await decryptProviderConfig(row.config, userId),
|
||||||
|
})),
|
||||||
|
);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to list SSO providers (admin)", 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 tempId = `new-${Date.now()}`;
|
||||||
const encryptedConfig = encryptProviderConfig(
|
const encryptedConfig = await encryptProviderConfig(
|
||||||
configWithDefaults as Record<string, unknown>,
|
configWithDefaults as Record<string, unknown>,
|
||||||
userId,
|
userId,
|
||||||
tempId,
|
tempId,
|
||||||
@@ -275,7 +262,7 @@ export function registerSSOProviderRoutes(router: Router): void {
|
|||||||
});
|
});
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
...inserted,
|
...inserted,
|
||||||
config: decryptProviderConfig(inserted.config, userId),
|
config: await decryptProviderConfig(inserted.config, userId),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to create SSO provider", err);
|
authLogger.error("Failed to create SSO provider", err);
|
||||||
@@ -332,7 +319,7 @@ export function registerSSOProviderRoutes(router: Router): void {
|
|||||||
|
|
||||||
let encryptedConfig = existing.config;
|
let encryptedConfig = existing.config;
|
||||||
if (rawConfig !== undefined) {
|
if (rawConfig !== undefined) {
|
||||||
const existingDecrypted = decryptProviderConfig(
|
const existingDecrypted = await decryptProviderConfig(
|
||||||
existing.config,
|
existing.config,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
@@ -342,7 +329,7 @@ export function registerSSOProviderRoutes(router: Router): void {
|
|||||||
),
|
),
|
||||||
...rawConfig,
|
...rawConfig,
|
||||||
};
|
};
|
||||||
encryptedConfig = encryptProviderConfig(
|
encryptedConfig = await encryptProviderConfig(
|
||||||
mergedConfig,
|
mergedConfig,
|
||||||
userId,
|
userId,
|
||||||
String(providerId),
|
String(providerId),
|
||||||
@@ -369,7 +356,7 @@ export function registerSSOProviderRoutes(router: Router): void {
|
|||||||
});
|
});
|
||||||
res.json({
|
res.json({
|
||||||
...updated,
|
...updated,
|
||||||
config: decryptProviderConfig(updated.config, userId),
|
config: await decryptProviderConfig(updated.config, userId),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to update SSO provider", err);
|
authLogger.error("Failed to update SSO provider", err);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
import type { SSOProviderType } from "../../../types/index.js";
|
import type { SSOProviderType } from "../../../types/index.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
|
import { decryptSsoConfigSecrets } from "../../utils/system-secret-crypto.js";
|
||||||
import { Agent } from "undici";
|
import { Agent } from "undici";
|
||||||
import {
|
import {
|
||||||
createCurrentSettingsRepository,
|
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>,
|
config: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Promise<Record<string, unknown>> {
|
||||||
const out = { ...config };
|
return decryptSsoConfigSecrets(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadProviderConfig(
|
export async function loadProviderConfig(
|
||||||
@@ -360,10 +346,10 @@ export async function loadProviderConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
parsed = decryptConfigSecret(parsed);
|
parsed = await decryptConfigSecret(parsed);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
parsed = decryptConfigSecret(parsed);
|
parsed = await decryptConfigSecret(parsed);
|
||||||
}
|
}
|
||||||
const providerType = row.type as SSOProviderType;
|
const providerType = row.type as SSOProviderType;
|
||||||
const config = applyProviderDefaults(
|
const config = applyProviderDefaults(
|
||||||
@@ -400,7 +386,7 @@ export async function loadProviderConfig(
|
|||||||
} catch {
|
} catch {
|
||||||
parsed = {};
|
parsed = {};
|
||||||
}
|
}
|
||||||
parsed = decryptConfigSecret(parsed);
|
parsed = await decryptConfigSecret(parsed);
|
||||||
const oidcProviderType = oidcRow.type as SSOProviderType;
|
const oidcProviderType = oidcRow.type as SSOProviderType;
|
||||||
return {
|
return {
|
||||||
config: applyProviderDefaults(
|
config: applyProviderDefaults(
|
||||||
@@ -421,7 +407,7 @@ export async function loadProviderConfig(
|
|||||||
await createCurrentSettingsRepository().get("oidc_config");
|
await createCurrentSettingsRepository().get("oidc_config");
|
||||||
if (legacyValue) {
|
if (legacyValue) {
|
||||||
let config = JSON.parse(legacyValue) as Record<string, unknown>;
|
let config = JSON.parse(legacyValue) as Record<string, unknown>;
|
||||||
config = decryptConfigSecret(config);
|
config = await decryptConfigSecret(config);
|
||||||
return {
|
return {
|
||||||
config: config as unknown as OIDCConfig,
|
config: config as unknown as OIDCConfig,
|
||||||
providerType: "oidc",
|
providerType: "oidc",
|
||||||
@@ -452,7 +438,7 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{
|
|||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
parsed = decryptConfigSecret(parsed);
|
parsed = await decryptConfigSecret(parsed);
|
||||||
const providerType = row.type as SSOProviderType;
|
const providerType = row.type as SSOProviderType;
|
||||||
const config = applyProviderDefaults(
|
const config = applyProviderDefaults(
|
||||||
parsed as unknown as OIDCConfig,
|
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