Files
edgeever/scripts/self-hosted-secrets.mjs
tianma-if a853c92350 fix(docker): persist credential encryption secrets on the data volume
NAS GUI upgrades that drop container environment variables could leave
login working while AI and object-storage credentials could no longer be
encrypted. Persist the encryption root on /data, generate one when an
existing instance has no secret, and stop the add-service form from
looking fillable when encryption is still unavailable.

Fixes #370
2026-09-09 18:11:58 +08:00

200 lines
6.9 KiB
JavaScript

import { randomBytes } from "node:crypto";
import { chmod, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
const FILE_SECRET_NAMES = [
"EDGE_EVER_AUTH_PASSWORD",
"EDGE_EVER_AUTH_PASSWORD_HASH",
// Legacy decryption fallback for credentials saved by older releases.
"EDGE_EVER_STORAGE_ENCRYPTION_KEY",
"EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY",
"EDGE_EVER_S3_ACCESS_KEY_ID",
"EDGE_EVER_S3_SECRET_ACCESS_KEY",
];
export const SELF_HOSTED_SECRETS_FILE_NAME = "edgeever-secrets.json";
const SECRETS_VERSION = 1;
const trimSecret = (value) => {
const trimmed = typeof value === "string" ? value.trim() : "";
return trimmed || undefined;
};
export const resolveSelfHostedSecretsPath = (dataDirectory) => (
join(dataDirectory, SELF_HOSTED_SECRETS_FILE_NAME)
);
export const loadSelfHostedEnvironment = async (environment = process.env) => {
const resolved = { ...environment };
for (const name of FILE_SECRET_NAMES) {
const fileName = `${name}_FILE`;
const filePath = environment[fileName]?.trim();
if (!filePath) continue;
if (environment[name] !== undefined) {
throw new Error(`Set either ${name} or ${fileName}, not both`);
}
resolved[name] = (await readFile(filePath, "utf8")).replace(/[\r\n]+$/, "");
delete resolved[fileName];
}
return resolved;
};
export const hasSelfHostedCredentialSeed = (environment) => Boolean(
trimSecret(environment.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY)
|| trimSecret(environment.EDGE_EVER_AUTH_PASSWORD)
|| trimSecret(environment.EDGE_EVER_AUTH_PASSWORD_HASH),
);
const corruptSecretsMessage = (filePath) => (
`Credential secrets file ${filePath} is corrupt or unsupported. Restore it from a /data backup; do not generate a replacement key.`
);
const readPersistedSecrets = async (filePath) => {
let raw;
try {
raw = await readFile(filePath, "utf8");
} catch (error) {
if (error && error.code === "ENOENT") return null;
throw error;
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(corruptSecretsMessage(filePath));
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.version !== SECRETS_VERSION) {
throw new Error(corruptSecretsMessage(filePath));
}
const secrets = {
version: SECRETS_VERSION,
authPassword: trimSecret(parsed.authPassword),
authPasswordHash: trimSecret(parsed.authPasswordHash),
credentialsEncryptionKey: trimSecret(parsed.credentialsEncryptionKey),
};
if (!secrets.authPassword && !secrets.authPasswordHash && !secrets.credentialsEncryptionKey) {
throw new Error(corruptSecretsMessage(filePath));
}
return secrets;
};
const serializeSecrets = (secrets) => `${JSON.stringify({
version: SECRETS_VERSION,
...(secrets.authPassword ? { authPassword: secrets.authPassword } : {}),
...(secrets.authPasswordHash ? { authPasswordHash: secrets.authPasswordHash } : {}),
...(secrets.credentialsEncryptionKey ? { credentialsEncryptionKey: secrets.credentialsEncryptionKey } : {}),
}, null, 2)}\n`;
const writePersistedSecrets = async (filePath, secrets) => {
const temporaryPath = `${filePath}.tmp`;
await writeFile(temporaryPath, serializeSecrets(secrets), { encoding: "utf8", mode: 0o600 });
await chmod(temporaryPath, 0o600);
await rename(temporaryPath, filePath);
await chmod(filePath, 0o600);
};
const fillMissingSecrets = (stored, envSecrets) => {
const next = { ...stored };
let changed = false;
if (!next.authPassword && envSecrets.authPassword) {
next.authPassword = envSecrets.authPassword;
changed = true;
}
if (!next.authPasswordHash && envSecrets.authPasswordHash) {
next.authPasswordHash = envSecrets.authPasswordHash;
changed = true;
}
if (!next.credentialsEncryptionKey && envSecrets.credentialsEncryptionKey) {
next.credentialsEncryptionKey = envSecrets.credentialsEncryptionKey;
changed = true;
}
return { secrets: next, changed };
};
const applyPersistedSecrets = (environment, stored, envSecrets) => {
if (!envSecrets.authPassword && stored.authPassword) {
environment.EDGE_EVER_AUTH_PASSWORD = stored.authPassword;
}
if (!envSecrets.authPasswordHash && stored.authPasswordHash) {
environment.EDGE_EVER_AUTH_PASSWORD_HASH = stored.authPasswordHash;
}
if (!envSecrets.credentialsEncryptionKey && stored.credentialsEncryptionKey) {
environment.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY = stored.credentialsEncryptionKey;
}
if (stored.authPassword && envSecrets.authPassword && stored.authPassword !== envSecrets.authPassword) {
environment.EDGE_EVER_AUTH_PASSWORD_FALLBACK = stored.authPassword;
}
if (
stored.credentialsEncryptionKey
&& envSecrets.credentialsEncryptionKey
&& stored.credentialsEncryptionKey !== envSecrets.credentialsEncryptionKey
) {
environment.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY_PREVIOUS = stored.credentialsEncryptionKey;
}
return environment;
};
/**
* Persist the self-hosted credential-encryption root on the data volume so NAS
* GUI upgrades that drop container environment variables can still encrypt AI
* and object-storage secrets. The file lives beside SQLite, never inside it.
*/
export const ensureSelfHostedCredentialSecrets = async (
dataDirectory,
environment,
options = {},
) => {
const generateIfMissing = options.generateIfMissing === true;
const filePath = resolveSelfHostedSecretsPath(dataDirectory);
const envSecrets = {
authPassword: trimSecret(environment.EDGE_EVER_AUTH_PASSWORD),
authPasswordHash: trimSecret(environment.EDGE_EVER_AUTH_PASSWORD_HASH),
credentialsEncryptionKey: trimSecret(environment.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY),
};
let stored = await readPersistedSecrets(filePath);
let persisted = false;
let generated = false;
if (!stored) {
stored = {
version: SECRETS_VERSION,
authPassword: envSecrets.authPassword,
authPasswordHash: envSecrets.authPasswordHash,
credentialsEncryptionKey: envSecrets.credentialsEncryptionKey,
};
if (!stored.authPassword && !stored.authPasswordHash && !stored.credentialsEncryptionKey) {
if (!generateIfMissing) return environment;
stored.credentialsEncryptionKey = randomBytes(32).toString("hex");
generated = true;
}
await writePersistedSecrets(filePath, stored);
persisted = true;
} else {
const merged = fillMissingSecrets(stored, envSecrets);
if (merged.changed) {
stored = merged.secrets;
await writePersistedSecrets(filePath, stored);
persisted = true;
} else {
stored = merged.secrets;
}
}
if (generated) {
console.info(
`[self-hosted] generated a credential encryption secret at ${filePath}. `
+ "Existing encrypted AI or object-storage credentials cannot be decrypted unless the original authentication secret is restored.",
);
} else if (persisted) {
console.info(`[self-hosted] persisted credential encryption secret to ${filePath}`);
}
return applyPersistedSecrets(environment, stored, envSecrets);
};