From 4e28b7a61256623760bdceaf350476b522189616 Mon Sep 17 00:00:00 2001 From: Inferencebot Date: Sun, 25 Oct 2026 22:25:10 +0000 Subject: [PATCH] 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. --- CloudronManifest.json | 6 ++++++ sync-keys.js | 39 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CloudronManifest.json b/CloudronManifest.json index 6e8bcea..5c7ba63 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -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": { diff --git a/sync-keys.js b/sync-keys.js index efc9212..9d471c6 100644 --- a/sync-keys.js +++ b/sync-keys.js @@ -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 } },