diff --git a/ReadMe.md b/ReadMe.md index 884e0c3..bd13290 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -87,6 +87,16 @@ All settings are available in the **Settings** panel of the Layla Server UI. Layla Server proxies to any OpenAI-compatible endpoint. To use a different inference engine (LM Studio, Ollama, vLLM, etc.), just point the backend URL to it in Settings — no other changes needed. +### Choosing a Secret Generator + +In Settings, **Secret Generator** offers the original 16-character alphanumeric +`Math.random()` secrets (the default) or secure 64-character hex secrets from +Node's `node:crypto`, accessed through Electron's preload bridge. +`Math.random()` is not cryptographically secure. Choosing a generator preserves +your current secret. Click **Generate New Secret**, then **Save** to replace it, +or enter your own secret in **Server Secret Key**. Restart the server and scan +the QR code again to test pairing with the saved secret. + --- ## Using Your Own Models diff --git a/src/main.ts b/src/main.ts index ab61d87..3658c87 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,11 @@ import { app, BrowserWindow, ipcMain, dialog, Menu, shell } from "electron"; import * as path from "path"; import os from 'os'; +import { randomBytes } from "node:crypto"; import { spawn, exec, ChildProcess } from "child_process"; let mainWindow: BrowserWindow | null = null; +ipcMain.handle("secret:generate-crypto", () => randomBytes(32).toString("hex")); const isWindows = process.platform === "win32"; @@ -236,4 +238,4 @@ ipcMain.handle('dialog:alert', (_event, title: string, message: string) => { message, buttons: ['OK'], }); -}); \ No newline at end of file +}); diff --git a/src/preload.ts b/src/preload.ts index 66cd886..aa92c0a 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from "electron"; contextBridge.exposeInMainWorld("electronBridge", { + generateCryptoSecret: (): Promise => ipcRenderer.invoke("secret:generate-crypto"), getAppVersion: (): Promise => ipcRenderer.invoke("get-app-version"), openExternal: (url: string) => ipcRenderer.send("open-external", url), @@ -78,4 +79,4 @@ contextBridge.exposeInMainWorld("electronBridge", { * Show an alert dialog with a message and optional title. */ showAlert: (title: string, message: string) => ipcRenderer.invoke('dialog:alert', title, message), -}); \ No newline at end of file +}); diff --git a/src/screens/LLMServerPanel.tsx b/src/screens/LLMServerPanel.tsx index d98abba..26c4f0b 100644 --- a/src/screens/LLMServerPanel.tsx +++ b/src/screens/LLMServerPanel.tsx @@ -1663,6 +1663,10 @@ const cssStyles = ` user-select: text; font-family: 'Cascadia Mono', 'Fira Code', monospace; letter-spacing: 1px; + /* Wrap long secrets at 16 chars per line (a 64-char hex secret shows as + 4 rows): 16 glyphs + 16px letter-spacing + 20px padding, with 0.5ch slack. */ + overflow-wrap: anywhere; + max-width: calc(16.5ch + 36px); } .modal-hint { color: ${C.secondaryText}; diff --git a/src/screens/SettingsPage.tsx b/src/screens/SettingsPage.tsx index f31ab3e..be73684 100644 --- a/src/screens/SettingsPage.tsx +++ b/src/screens/SettingsPage.tsx @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import UserSettingsService, { UserSettingKey, USER_SETTING_DEFAULTS, + generateSecretKey, + SecretGenerationMethod, } from "../services/user-settings-service"; // ─── Theme ────────────────────────────────────────────────────────────────────── @@ -28,7 +30,8 @@ interface SettingMeta { key: UserSettingKey; label: string; description: string; - type: "text" | "file"; + type: "text" | "file" | "select"; + options?: { value: string; label: string }[]; placeholder?: string; tag?: string; } @@ -68,10 +71,29 @@ const SETTING_SECTIONS: { placeholder: "--threads 8 --ctx-size 4096", tag: "CLI", }, + { + key: UserSettingKey.SECRET_GENERATION_METHOD, + label: "Secret Generator", + description: + "Choose how new secrets are generated. Changing this option keeps your current secret. Math.random() is not cryptographically secure.", + type: "select", + options: [ + { + value: "crypto-hex", + label: "Node crypto — 64-character hex", + }, + { + value: "math-random", + label: "Math.random() — 16-character secret (default)", + }, + ], + tag: "SECRET", + }, { key: UserSettingKey.SERVER_SECRET_KEY, label: "Server Secret Key", - description: "Secret key used to authenticate with the server.", + description: + "Enter a secret or generate one below, then Save. After saving a new secret, stop and start the server and scan the QR code again to test fresh pairing.", type: "text", placeholder: "Enter your server secret key", tag: "SECRET", @@ -547,6 +569,7 @@ interface SettingRowProps { value: string; onChange: (key: UserSettingKey, value: string) => void; onClear: (key: UserSettingKey) => void; + onGenerate?: () => void; } const SettingRow: React.FC = ({ @@ -554,6 +577,7 @@ const SettingRow: React.FC = ({ value, onChange, onClear, + onGenerate, }) => { const [focused, setFocused] = useState(false); const defaultValue = USER_SETTING_DEFAULTS[meta.key]; @@ -588,9 +612,25 @@ const SettingRow: React.FC = ({
{meta.description}
- {meta.type === "text" ? ( + {meta.type === "select" ? ( + + ) : meta.type === "text" ? ( onChange(meta.key, e.target.value)} placeholder={meta.placeholder ?? ""} @@ -616,6 +656,15 @@ const SettingRow: React.FC = ({ )} + {onGenerate && ( +
+ +
+ )} ); }; @@ -713,6 +762,19 @@ const SettingsPage: React.FC = ({ onBack }) => { [handleChange], ); + const handleGenerateSecret = useCallback(async () => { + if (!settings) return; + try { + const method = settings[ + UserSettingKey.SECRET_GENERATION_METHOD + ] as SecretGenerationMethod; + const secret = await generateSecretKey(method); + handleChange(UserSettingKey.SERVER_SECRET_KEY, secret); + } catch { + showToast("Failed to generate secret. Your current secret was kept."); + } + }, [settings, handleChange, showToast]); + const handleSave = useCallback(async () => { if (!settings) return; setSaving(true); @@ -799,6 +861,11 @@ const SettingsPage: React.FC = ({ onBack }) => { value={settings[item.key] ?? ""} onChange={handleChange} onClear={handleClear} + onGenerate={ + item.key === UserSettingKey.SERVER_SECRET_KEY + ? handleGenerateSecret + : undefined + } /> ))} diff --git a/src/services/user-settings-service.ts b/src/services/user-settings-service.ts index 58d0380..37ede29 100644 --- a/src/services/user-settings-service.ts +++ b/src/services/user-settings-service.ts @@ -1,6 +1,19 @@ -function generatePassword(length: number = 16): string { +export type SecretGenerationMethod = "crypto-hex" | "math-random"; + +function generateLegacySecretKey(): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - return Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join(""); + return Array.from( + { length: 16 }, + () => chars[Math.floor(Math.random() * chars.length)], + ).join(""); +} + +export async function generateSecretKey( + method: SecretGenerationMethod = "math-random", +): Promise { + if (method === "math-random") return generateLegacySecretKey(); + // Keep the existing stored option value while using Node crypto via IPC. + return window.electronBridge.generateCryptoSecret(); } export enum UserSettingKey { @@ -10,6 +23,7 @@ export enum UserSettingKey { LOCAL_SERVER_PATH = 'local-server-path', ADDITIONAL_SERVER_CMD_ARGS = 'additional-server-cmd-args', SERVER_SECRET_KEY = 'server-secret-key', + SECRET_GENERATION_METHOD = 'secret-generation-method', VERBOSE_SSE_LOGGING = 'verbose-sse-logging', } @@ -20,6 +34,7 @@ type UserSettingDefaults = { [UserSettingKey.LOCAL_SERVER_PATH]: string; [UserSettingKey.ADDITIONAL_SERVER_CMD_ARGS]: string | null; [UserSettingKey.SERVER_SECRET_KEY]: string; + [UserSettingKey.SECRET_GENERATION_METHOD]: SecretGenerationMethod; [UserSettingKey.VERBOSE_SSE_LOGGING]: boolean; }; @@ -31,7 +46,8 @@ export const USER_SETTING_DEFAULTS: { [UserSettingKey.VISION_MODEL_PATH]: null, [UserSettingKey.LOCAL_SERVER_PATH]: './resources/server/llama-server.exe', [UserSettingKey.ADDITIONAL_SERVER_CMD_ARGS]: null, - [UserSettingKey.SERVER_SECRET_KEY]: generatePassword(), + [UserSettingKey.SERVER_SECRET_KEY]: generateLegacySecretKey(), + [UserSettingKey.SECRET_GENERATION_METHOD]: "math-random", // Logging every SSE chunk drives a React state update per token. Sustained, // that grows renderer memory by hundreds of MB per long generation, so it is // opt-in and intended only for debugging a stream. @@ -116,4 +132,4 @@ class UserSettingsService { } } -export default UserSettingsService; \ No newline at end of file +export default UserSettingsService; diff --git a/src/types/types.d.ts b/src/types/types.d.ts index 7b961a2..0adb086 100644 --- a/src/types/types.d.ts +++ b/src/types/types.d.ts @@ -1,4 +1,5 @@ export interface ElectronAPI { + generateCryptoSecret: () => Promise; getAppVersion: () => Promise; openExternal: (url: string) => void; openFileDialog: () => Promise; @@ -20,4 +21,4 @@ declare global { interface Window { electronBridge: ElectronAPI; } -} \ No newline at end of file +} diff --git a/test/secret-generation.test.cjs b/test/secret-generation.test.cjs new file mode 100644 index 0000000..71805b6 --- /dev/null +++ b/test/secret-generation.test.cjs @@ -0,0 +1,115 @@ +// Run with: node --test test/secret-generation.test.cjs +const assert = require("node:assert/strict"); +const { randomBytes } = require("node:crypto"); +const path = require("node:path"); +const { test } = require("node:test"); +const vm = require("node:vm"); +const { buildSync } = require("esbuild"); + +const bundle = buildSync({ + entryPoints: [path.join(__dirname, "../src/services/user-settings-service.ts")], + bundle: true, + platform: "browser", + format: "cjs", + write: false, +}).outputFiles[0].text; + +function loadSettings(values = new Map(), bridge = { + generateCryptoSecret: async () => randomBytes(32).toString("hex"), +}) { + const context = { + module: { exports: {} }, + window: { electronBridge: bridge }, + localStorage: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + }, + }; + vm.runInNewContext(bundle, context); + return context.module.exports; +} + +test("fresh installs default to a persistent 16-character Math.random secret", async () => { + const values = new Map(); + const { default: settings, UserSettingKey: key } = loadSettings(values); + const secret = await settings.getSetting(key.SERVER_SECRET_KEY); + assert.match(secret, /^[A-Za-z0-9]{16}$/); + assert.equal(await settings.getSetting(key.SECRET_GENERATION_METHOD), "math-random"); + const reloaded = loadSettings(values); + assert.equal(await reloaded.default.getSetting(key.SERVER_SECRET_KEY), secret); +}); + +test("selecting and persisting a generator never replaces an existing secret", async () => { + const values = new Map([ + ["user-setting:server-secret-key", JSON.stringify("legacySecret1234")], + ]); + const { default: settings, UserSettingKey: key } = loadSettings(values); + assert.equal(await settings.getSetting(key.SERVER_SECRET_KEY), "legacySecret1234"); + await settings.saveSetting(key.SECRET_GENERATION_METHOD, "math-random"); + const reloaded = loadSettings(values); + assert.equal(await reloaded.default.getSetting(key.SECRET_GENERATION_METHOD), "math-random"); + assert.equal(await reloaded.default.getSetting(key.SERVER_SECRET_KEY), "legacySecret1234"); +}); + +test("legacy generation uses Math.random and produces 16 alphanumeric characters", async () => { + let calls = 0; + const context = { + module: { exports: {} }, + + recordCall: () => calls++, + }; + vm.createContext(context); + vm.runInContext("Math.random = () => { recordCall(); return 0.5; }", context); + vm.runInContext(bundle, context); + calls = 0; + const secret = await context.module.exports.generateSecretKey("math-random"); + assert.match(secret, /^[A-Za-z0-9]{16}$/); + assert.equal(calls, 16); +}); + +test("generating a replacement leaves storage unchanged until explicitly saved", async () => { + const { default: settings, UserSettingKey: key, generateSecretKey } = loadSettings(); + await settings.saveSetting(key.SERVER_SECRET_KEY, "manual-key"); + for (const method of ["math-random", "crypto-hex"]) { + const replacement = await generateSecretKey(method); + assert.equal(await settings.getSetting(key.SERVER_SECRET_KEY), "manual-key"); + await settings.saveSetting(key.SERVER_SECRET_KEY, replacement); + assert.equal(await settings.getSetting(key.SERVER_SECRET_KEY), replacement); + await settings.saveSetting(key.SERVER_SECRET_KEY, "manual-key"); + } +}); + +test("secure generation failure never falls back to a legacy secret or changes storage", async () => { + const bridge = { generateCryptoSecret: async () => { throw new Error("random source unavailable"); } }; + const { default: settings, UserSettingKey: key, generateSecretKey } = loadSettings(new Map(), bridge); + const original = await settings.getSetting(key.SERVER_SECRET_KEY); + await assert.rejects(generateSecretKey("crypto-hex"), /random source unavailable/); + assert.equal(await settings.getSetting(key.SERVER_SECRET_KEY), original); +}); + +test("secure generation uses the preload IPC bridge and Node crypto handler", async () => { + const handlers = new Map(); + const bundleNode = (file) => buildSync({ + entryPoints: [path.join(__dirname, '../src', file)], + bundle: true, platform: 'node', format: 'cjs', external: ['electron'], write: false, + }).outputFiles[0].text; + vm.runInNewContext(bundleNode('main.ts'), { + exports: {}, require: (name) => name === 'electron' ? { + app: { whenReady: () => new Promise(() => {}), on() {} }, + ipcMain: { handle: (name, handler) => handlers.set(name, handler), on() {} }, + } : require(name), + process, console, __dirname, + }); + let bridge; + vm.runInNewContext(bundleNode('preload.ts'), { + require: (name) => name === 'electron' ? { + contextBridge: { exposeInMainWorld: (_name, api) => { bridge = api; } }, + ipcRenderer: { invoke: async (name) => handlers.get(name)() }, + } : require(name), + }); + const { generateSecretKey } = loadSettings(new Map(), bridge); + const secret = await generateSecretKey('crypto-hex'); + assert.match(secret, /^[0-9a-f]{64}$/); + assert.notEqual(await generateSecretKey('crypto-hex'), secret); +});