From 429afce67e58d1084997db1888602a30718792ba Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 19:19:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(gui):=20=E6=A1=8C=E9=9D=A2=E7=89=88?= =?UTF-8?q?=E8=A2=AB=E8=87=AA=E5=B7=B1=E7=9A=84=E5=AF=86=E9=92=A5=E5=B0=81?= =?UTF-8?q?=E5=AD=98=E6=8C=A1=E4=BD=8F=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop build authenticates the embedded core by reading the admin key out of config.yaml with a regex and injecting it as a gw_key cookie. The core seals credentials at rest (enc:v1:...), so from the second start onward that regex yields ciphertext, the cookie is worthless, and the app asks the user for a key they never set. The key is generated and hidden by the app itself. Reproduced end to end: first start writes a plaintext profile, the core seals it, every later start reads back "enc:v1:..." and falls through to the login prompt. - when the stored value is sealed, ask the core to unseal it via -show-secrets, which only reads, prints and exits. Reimplementing the core's AEAD in JS would be a second source of truth for its key format. - cwd must be the profile dir. The core locates master.key relative to the config's runtime_file, so a call made from anywhere else has it generate a second master key in the CWD and then fail to decrypt ("master key changed?"). Electron's CWD is not the profile dir, so without this the desktop build cannot read its own key even after unsealing is wired up. - the loose regex is kept as a fallback so a future change to the -show-secrets output degrades to a login prompt rather than to a wrong credential. Verified: plaintext start -> core seals -> restart recovers the same key, with the core running the whole time. Dropping cwd:PROFILE_DIR makes the unseal fail and leaves a stray master.key in the CWD, so the cwd argument is load-bearing rather than tidiness. --- cmd/gui/main.js | 80 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 172be6d..0350c6e 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -17,7 +17,7 @@ const fs = require("fs"); const http = require("http"); const os = require("os"); const crypto = require("crypto"); -const { spawn } = require("child_process"); +const { spawn, execFileSync } = require("child_process"); const LOG_FILE = path.join(app.getPath("userData"), "gui.log"); function log(msg) { @@ -97,23 +97,73 @@ function readAdminKey() { // gateway_keys entries get marked seed:true by the core (-> "replace the // initial key" warning in the web UI), while a plain keys entry authenticates // without any seed warning. + const raw = readConfigRaw(); + if (!raw) return ""; + // keys: + // - key: sk-gw-... + const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec( + raw, + ); + let v = k && k[1] ? k[1] : ""; + if (!v) { + // legacy: gateway_keys list (pre-migration configs) + const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec(raw); + if (g && g[1]) v = g[1]; + if (!v) { + const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw); + if (g2 && g2[1]) v = g2[1]; + } + } + // The core seals credentials at rest (enc:v1:...), so from the second start + // onward the file holds ciphertext. Injecting that as a cookie is a + // guaranteed 401, which surfaces to the user as "enter your key" for a key + // they never set. Ask the core to unseal rather than reimplementing its + // crypto here. -show-secrets only reads, prints and exits: it starts nothing + // and writes nothing. + if (v && v.indexOf(SEALED_PREFIX) === 0) return unsealViaCore(); + return v; +} + +const SEALED_PREFIX = "enc:v1:"; + +function readConfigRaw() { try { if (!fs.existsSync(CONFIG_FILE)) return ""; - const raw = fs.readFileSync(CONFIG_FILE, "utf-8"); - // keys: - // - key: sk-gw-... - const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec( - raw, + return fs.readFileSync(CONFIG_FILE, "utf-8"); + } catch (e) { + return ""; + } +} + +// Returns the admin key in the clear, or "" if the core cannot be asked. +// +// cwd must be the directory that holds master.key. The core derives that path +// from the config's runtime_file, so a call made from any other directory has +// the core generate a *second* master key in the CWD and then fail to decrypt +// ("master key changed?"). Electron's CWD is not the profile dir, so this is +// not optional: without it the desktop build cannot read its own key back. +function unsealViaCore() { + try { + const out = execFileSync( + CORE_EXE, + ["-config", CONFIG_FILE, "-show-secrets"], + { + encoding: "utf-8", + timeout: 10000, + windowsHide: true, + cwd: PROFILE_DIR, + }, ); - if (k && k[1]) return k[1]; - // legacy: gateway_keys list (pre-migration configs) - const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec( - raw, - ); - if (g && g[1]) return g[1]; - const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw); - if (g2 && g2[1]) return g2[1]; - } catch (e) {} + // -show-secrets prints one line per credential: + // key role= + const admin = /^key\s+\S+\s+role=admin\s+(\S+)\s*$/m.exec(out); + if (admin && admin[1]) return admin[1]; + // Tolerate a field-order or spacing change rather than locking the user out. + const loose = /role=admin\s+(\S+)/.exec(out); + if (loose && loose[1]) return loose[1]; + } catch (e) { + console.error("unseal via core failed:", e.message); + } return ""; }