From 8bdaee0a45e49fe2f7745b1d316f0565ed08f331 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 8 Aug 2026 12:55:22 -0700 Subject: [PATCH 1/3] fix(cli): generate per-install secrets instead of using fixed values The launcher passed the same built-in BETTER_AUTH_SECRET and ENCRYPTION_KEY to every install. Generate them once per install, persist them 0600 at ~/.simstudio/secrets.env, and reuse them on later runs so data already in the Postgres volume stays readable. Also passes INTERNAL_API_SECRET, which the realtime container requires and never received. --- packages/cli/src/index.ts | 52 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0e95d65e1bb..19b0583cbf0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,7 +1,8 @@ #!/usr/bin/env node import { execSync, spawn } from 'child_process' -import { existsSync, mkdirSync } from 'fs' +import { randomBytes } from 'crypto' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { homedir } from 'os' import { join } from 'path' import { createInterface } from 'readline' @@ -15,6 +16,43 @@ const REALTIME_CONTAINER = 'simstudio-realtime' const APP_CONTAINER = 'simstudio-app' const DEFAULT_PORT = '3000' +const SECRET_KEYS = ['BETTER_AUTH_SECRET', 'ENCRYPTION_KEY', 'INTERNAL_API_SECRET'] as const + +const AES_KEY_PATTERN = /^[0-9a-f]{64}$/i + +/** + * Per-install secrets, generated on first run and reused afterwards. + * + * They have to persist: `ENCRYPTION_KEY` decrypts credentials already stored in the + * Postgres volume under `~/.simstudio/data`, so minting a fresh one each launch would + * leave that data permanently unreadable. Any value that is not a 32-byte hex key is + * replaced. + */ +function resolveSecrets(): Record { + const configDir = join(homedir(), '.simstudio') + const secretsPath = join(configDir, 'secrets.env') + const secrets: Record = {} + + if (existsSync(secretsPath)) { + for (const line of readFileSync(secretsPath, 'utf8').split('\n')) { + const separator = line.indexOf('=') + if (separator > 0) secrets[line.slice(0, separator).trim()] = line.slice(separator + 1).trim() + } + } + + const missing = SECRET_KEYS.filter((key) => !AES_KEY_PATTERN.test(secrets[key] ?? '')) + for (const key of missing) secrets[key] = randomBytes(32).toString('hex') + + if (missing.length > 0) { + mkdirSync(configDir, { recursive: true }) + const contents = SECRET_KEYS.map((key) => `${key}=${secrets[key]}`).join('\n') + writeFileSync(secretsPath, `${contents}\n`, { mode: 0o600 }) + console.log(chalk.gray(`🔑 Generated local secrets in ${secretsPath}`)) + } + + return secrets +} + const program = new Command() program.name('simstudio').description('Run Sim using Docker').version('0.1.0') @@ -196,6 +234,8 @@ async function main() { process.exit(1) } + const secrets = resolveSecrets() + // Start the realtime server console.log(chalk.blue('🔄 Starting Realtime Server...')) const realtimeSuccess = await runCommand([ @@ -215,7 +255,9 @@ async function main() { '-e', `NEXT_PUBLIC_APP_URL=http://localhost:${port}`, '-e', - 'BETTER_AUTH_SECRET=your_auth_secret_here', + `BETTER_AUTH_SECRET=${secrets.BETTER_AUTH_SECRET}`, + '-e', + `INTERNAL_API_SECRET=${secrets.INTERNAL_API_SECRET}`, 'ghcr.io/simstudioai/realtime:latest', ]) @@ -243,9 +285,11 @@ async function main() { '-e', `NEXT_PUBLIC_APP_URL=http://localhost:${port}`, '-e', - 'BETTER_AUTH_SECRET=your_auth_secret_here', + `BETTER_AUTH_SECRET=${secrets.BETTER_AUTH_SECRET}`, + '-e', + `ENCRYPTION_KEY=${secrets.ENCRYPTION_KEY}`, '-e', - 'ENCRYPTION_KEY=your_encryption_key_here', + `INTERNAL_API_SECRET=${secrets.INTERNAL_API_SECRET}`, 'ghcr.io/simstudioai/simstudio:latest', ]) From 610b9ea4c4b5afea6774001bb90c8de5527e00a4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 8 Aug 2026 13:02:05 -0700 Subject: [PATCH 2/3] fix(cli): reassert owner-only permissions on the secrets file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeFileSync's `mode` applies only when it creates the file, and the write is skipped entirely when the stored values are already valid — so a secrets file left with permissive permissions kept them. chmod it on every run. --- packages/cli/src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 19b0583cbf0..d2be1349c27 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,7 +2,7 @@ import { execSync, spawn } from 'child_process' import { randomBytes } from 'crypto' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { homedir } from 'os' import { join } from 'path' import { createInterface } from 'readline' @@ -27,6 +27,10 @@ const AES_KEY_PATTERN = /^[0-9a-f]{64}$/i * Postgres volume under `~/.simstudio/data`, so minting a fresh one each launch would * leave that data permanently unreadable. Any value that is not a 32-byte hex key is * replaced. + * + * Permissions are reasserted on every run: `writeFileSync`'s `mode` applies only when it + * creates the file, so a file left by an earlier run — or one the user created — would + * otherwise keep whatever mode it already had and stay readable by other local accounts. */ function resolveSecrets(): Record { const configDir = join(homedir(), '.simstudio') @@ -50,6 +54,8 @@ function resolveSecrets(): Record { console.log(chalk.gray(`🔑 Generated local secrets in ${secretsPath}`)) } + chmodSync(secretsPath, 0o600) + return secrets } From e658a88c428ba1e4ca10adc564ff6f7339b35ad8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 8 Aug 2026 13:17:54 -0700 Subject: [PATCH 3/3] fix(cli): write the secrets file atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerating any one key rewrites all of them, and a plain write truncates first — a crash mid-rewrite would strand a still-valid ENCRYPTION_KEY and orphan the data it protects. Write to a temp file and rename into place. --- packages/cli/src/index.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d2be1349c27..433071985db 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,7 +2,7 @@ import { execSync, spawn } from 'child_process' import { randomBytes } from 'crypto' -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' import { homedir } from 'os' import { join } from 'path' import { createInterface } from 'readline' @@ -28,6 +28,10 @@ const AES_KEY_PATTERN = /^[0-9a-f]{64}$/i * leave that data permanently unreadable. Any value that is not a 32-byte hex key is * replaced. * + * Regenerating one key rewrites the whole file, so the write goes to a temp file and is + * renamed into place: a plain write truncates first, and a crash mid-write would strand a + * still-valid `ENCRYPTION_KEY` and orphan the data it protects. + * * Permissions are reasserted on every run: `writeFileSync`'s `mode` applies only when it * creates the file, so a file left by an earlier run — or one the user created — would * otherwise keep whatever mode it already had and stay readable by other local accounts. @@ -50,7 +54,9 @@ function resolveSecrets(): Record { if (missing.length > 0) { mkdirSync(configDir, { recursive: true }) const contents = SECRET_KEYS.map((key) => `${key}=${secrets[key]}`).join('\n') - writeFileSync(secretsPath, `${contents}\n`, { mode: 0o600 }) + const pending = `${secretsPath}.tmp` + writeFileSync(pending, `${contents}\n`, { mode: 0o600 }) + renameSync(pending, secretsPath) console.log(chalk.gray(`🔑 Generated local secrets in ${secretsPath}`)) }