Key sync runs inside the app: scheduler addon task every 10 min, plus write guards
The per-member key seed ran from a steward cron on the co-op agent VM, shelling into this app over the Cloudron API. That made a member-facing function depend on a separate machine's uptime and paths (it went silently stale once, exiting 127 on schedule while the job looked healthy). - CloudronManifest.json: addons.scheduler gains librechat_key_sync (cd /app/code/librechat/api && node /app/data/sync-keys.js, */10 * * * *). Official addon set (postgresql, mongodb, localstorage, oidc, sendmail) and the dockerImage pin are untouched. - sync-keys.js: refuse to write unless CREDS_KEY can first decrypt an existing key row, and unless each fresh ciphertext round-trips back to the plaintext. Scheduled runs get a different environment than an interactive exec, so a wrong or absent CREDS_KEY must fail loudly instead of overwriting working member keys.
This commit is contained in:
1 parent
f7a4fdb48d
commit
4e28b7a612
2 files changed
+42
-3
No files matched your search
@@ -22,6 +22,12 @@
|
||||
},
|
||||
"sendmail": {
|
||||
"supportsDisplayName": true
|
||||
},
|
||||
"scheduler": {
|
||||
"librechat_key_sync": {
|
||||
"schedule": "*/10 * * * *",
|
||||
"command": "cd /app/code/librechat/api && node /app/data/sync-keys.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checklist": {
|
||||
|
||||
+36
-3
@@ -12,8 +12,14 @@
|
||||
*
|
||||
* Run: cd /app/code/librechat/api && node /app/data/sync-keys.js
|
||||
* (requires CLOUDRON_MONGODB_URL in the environment — present in `cloudron exec`)
|
||||
* Idempotent: safe to run repeatedly (e.g. nightly steward cron or after
|
||||
* onboarding).
|
||||
* Idempotent: safe to run repeatedly.
|
||||
*
|
||||
* SCHEDULED INSIDE THIS APP (2026-10-25): the manifest's `scheduler` addon runs
|
||||
* this every 10 minutes, so seeding no longer depends on any machine outside
|
||||
* the co-op's Cloudron box. Two guards keep a bad run from damaging members:
|
||||
* it refuses to write unless CREDS_KEY can first decrypt an existing key row
|
||||
* (proof it is the app's real key), and unless each fresh ciphertext round-trips
|
||||
* back to the plaintext.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
@@ -46,8 +52,13 @@ const MCP_AUTH_FIELD = "IC_API_KEY";
|
||||
if (!MONGO_URL) { console.error("FATAL: no MongoDB URL in environment"); process.exit(1); }
|
||||
if (!SYNC_TOKEN) { console.error("FATAL: LIBRECHAT_SYNC_TOKEN not set"); process.exit(1); }
|
||||
|
||||
if (!process.env.CREDS_KEY || !process.env.CREDS_IV) {
|
||||
console.error("FATAL: CREDS_KEY/CREDS_IV not available — refusing to write (keys would be undecryptable)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// data-schemas resolves CREDS_KEY/CREDS_IV at require time — env is ready above
|
||||
const { encrypt } = require("/app/code/librechat/node_modules/@librechat/data-schemas");
|
||||
const { encrypt, decrypt } = require("/app/code/librechat/node_modules/@librechat/data-schemas");
|
||||
const mongoose = require("/app/code/librechat/node_modules/mongoose");
|
||||
|
||||
(async () => {
|
||||
@@ -68,6 +79,24 @@ const mongoose = require("/app/code/librechat/node_modules/mongoose");
|
||||
const keys = db.collection("keys");
|
||||
const auths = db.collection("pluginauths");
|
||||
|
||||
// --- safety: prove CREDS_KEY is the app's own key before writing anything --
|
||||
// A row that decrypts to a real key proves we hold the key the app encrypted
|
||||
// with. If rows exist and none decrypt, our CREDS_KEY is wrong, and writing
|
||||
// would replace working member keys with undecryptable ones — so refuse.
|
||||
const sample = await keys.find({ name: ENDPOINT_NAME }).limit(25).toArray();
|
||||
let credsCheck = sample.length === 0 ? "no-existing-rows" : null;
|
||||
for (const row of sample) {
|
||||
try {
|
||||
const obj = JSON.parse(await decrypt(row.value));
|
||||
if (obj && typeof obj.apiKey === "string" && obj.apiKey.length > 8) { credsCheck = "decrypt-ok"; break; }
|
||||
} catch (e) { /* try the next row */ }
|
||||
}
|
||||
if (!credsCheck) {
|
||||
console.error(`FATAL: CREDS_KEY decrypts none of ${sample.length} existing key row(s) — refusing to write`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`creds check: ${credsCheck} (${sample.length} existing row(s))`);
|
||||
|
||||
let seeded = 0, skipped = 0;
|
||||
for (const m of members) {
|
||||
const email = String(m.email || "").toLowerCase();
|
||||
@@ -80,6 +109,10 @@ const mongoose = require("/app/code/librechat/node_modules/mongoose");
|
||||
const uid = user._id;
|
||||
// chat endpoint key: value is a JSON object string, same shape the UI saves
|
||||
const encChat = await encrypt(JSON.stringify({ apiKey: sk }));
|
||||
if (JSON.parse(await decrypt(encChat)).apiKey !== sk) {
|
||||
console.error("FATAL: encrypt/decrypt round-trip mismatch — refusing to write");
|
||||
process.exit(1);
|
||||
}
|
||||
await keys.updateOne(
|
||||
{ userId: uid, name: ENDPOINT_NAME },
|
||||
{ $set: { userId: uid, name: ENDPOINT_NAME, value: encChat } },
|
||||
|
||||
Reference in new issue
Block a user