diff --git a/packages/server/src/server/daemon-keypair.test.ts b/packages/server/src/server/daemon-keypair.test.ts new file mode 100644 index 000000000..7719297b9 --- /dev/null +++ b/packages/server/src/server/daemon-keypair.test.ts @@ -0,0 +1,47 @@ +import { chmodSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; +import { PRIVATE_FILE_MODE } from "./private-files.js"; + +const MODE_MASK = 0o777; +const PERMISSIVE_FILE_MODE = 0o644; + +function createTempHome(): string { + return mkdtempSync(path.join(tmpdir(), "paseo-keypair-")); +} + +function modeOf(filePath: string): number { + return statSync(filePath).mode & MODE_MASK; +} + +describe.skipIf(process.platform === "win32")("daemon keypair file permissions", () => { + test("creates daemon-keypair.json with private permissions", async () => { + const home = createTempHome(); + try { + await loadOrCreateDaemonKeyPair(home); + + expect(modeOf(path.join(home, "daemon-keypair.json"))).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("repairs existing daemon-keypair.json permissions when loading", async () => { + const home = createTempHome(); + const keypairPath = path.join(home, "daemon-keypair.json"); + try { + const created = await loadOrCreateDaemonKeyPair(home); + chmodSync(keypairPath, PERMISSIVE_FILE_MODE); + + const loaded = await loadOrCreateDaemonKeyPair(home); + + expect(loaded.publicKeyB64).toBe(created.publicKeyB64); + expect(modeOf(keypairPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/daemon-keypair.ts b/packages/server/src/server/daemon-keypair.ts index 301da2452..6916996ac 100644 --- a/packages/server/src/server/daemon-keypair.ts +++ b/packages/server/src/server/daemon-keypair.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; import type pino from "pino"; @@ -11,6 +11,7 @@ import { importSecretKey, type KeyPair, } from "@getpaseo/relay/e2ee"; +import { ensurePrivateFile, writePrivateFileSync } from "./private-files.js"; const KeyPairSchema = z.object({ v: z.literal(2), @@ -36,6 +37,7 @@ export async function loadOrCreateDaemonKeyPair( if (existsSync(filePath)) { try { + ensurePrivateFile(filePath); const raw = readFileSync(filePath, "utf8"); const parsed = KeyPairSchema.parse(JSON.parse(raw)); @@ -60,7 +62,7 @@ export async function loadOrCreateDaemonKeyPair( secretKeyB64, }; - writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 }); + writePrivateFileSync(filePath, JSON.stringify(payload, null, 2) + "\n"); log?.info({ filePath }, "Saved daemon keypair"); return { keyPair, publicKeyB64 }; diff --git a/packages/server/src/server/paseo-home.test.ts b/packages/server/src/server/paseo-home.test.ts new file mode 100644 index 000000000..99a6c30d2 --- /dev/null +++ b/packages/server/src/server/paseo-home.test.ts @@ -0,0 +1,26 @@ +import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { resolvePaseoHome } from "./paseo-home.js"; +import { PRIVATE_DIRECTORY_MODE } from "./private-files.js"; + +const MODE_MASK = 0o777; + +function modeOf(filePath: string): number { + return statSync(filePath).mode & MODE_MASK; +} + +describe.skipIf(process.platform === "win32")("resolvePaseoHome permissions", () => { + test("creates PASEO_HOME with private permissions", () => { + const parent = mkdtempSync(path.join(tmpdir(), "paseo-home-parent-")); + const paseoHome = path.join(parent, "home"); + try { + expect(resolvePaseoHome({ PASEO_HOME: paseoHome })).toBe(paseoHome); + expect(modeOf(paseoHome)).toBe(PRIVATE_DIRECTORY_MODE); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/paseo-home.ts b/packages/server/src/server/paseo-home.ts index 41d6bcfea..f933653b8 100644 --- a/packages/server/src/server/paseo-home.ts +++ b/packages/server/src/server/paseo-home.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { mkdirSync } from "node:fs"; +import { ensurePrivateDirectory } from "./private-files.js"; function expandHomeDir(input: string): string { if (input.startsWith("~/")) { @@ -15,6 +15,6 @@ function expandHomeDir(input: string): string { export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string { const raw = env.PASEO_HOME ?? "~/.paseo"; const resolved = path.resolve(expandHomeDir(raw)); - mkdirSync(resolved, { recursive: true }); + ensurePrivateDirectory(resolved); return resolved; } diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 8f4a4dde7..bcd277eba 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -1,6 +1,25 @@ +import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, test } from "vitest"; -import { PersistedConfigSchema } from "./persisted-config.js"; +import { + loadPersistedConfig, + PersistedConfigSchema, + savePersistedConfig, +} from "./persisted-config.js"; +import { PRIVATE_FILE_MODE } from "./private-files.js"; + +const MODE_MASK = 0o777; +const PERMISSIVE_FILE_MODE = 0o644; + +function createTempHome(): string { + return mkdtempSync(path.join(tmpdir(), "paseo-config-")); +} + +function modeOf(filePath: string): number { + return statSync(filePath).mode & MODE_MASK; +} describe("PersistedConfigSchema daemon auth config", () => { test("accepts optional daemon password hash", () => { @@ -465,3 +484,48 @@ describe("PersistedConfigSchema voice mode config", () => { expect(parsed.features?.voiceMode?.turnDetection?.provider).toBe("local"); }); }); + +describe.skipIf(process.platform === "win32")("persisted config file permissions", () => { + test("initializes config.json with private permissions", () => { + const home = createTempHome(); + try { + loadPersistedConfig(home); + + expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("repairs permissive config.json permissions when loading", () => { + const home = createTempHome(); + const configPath = path.join(home, "config.json"); + try { + writeFileSync(configPath, "{}\n", { mode: PERMISSIVE_FILE_MODE }); + chmodSync(configPath, PERMISSIVE_FILE_MODE); + + loadPersistedConfig(home); + + expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("saves config.json with private permissions", () => { + const home = createTempHome(); + try { + savePersistedConfig(home, { + providers: { + openai: { + apiKey: "secret", + }, + }, + }); + + expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index f8eb0fc9d..5e8f4386e 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; @@ -8,6 +8,7 @@ import { ProviderOverrideSchema, } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; +import { ensurePrivateFile, writePrivateFileSync } from "./private-files.js"; export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); export const LogFormatSchema = z.enum(["pretty", "json"]); @@ -369,8 +370,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per if (!existsSync(configPath)) { try { - mkdirSync(path.dirname(configPath), { recursive: true }); - writeFileSync(configPath, JSON.stringify(DEFAULT_PERSISTED_CONFIG, null, 2) + "\n"); + writePrivateFileSync(configPath, JSON.stringify(DEFAULT_PERSISTED_CONFIG, null, 2) + "\n"); log?.info(`Initialized config file at ${configPath}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -380,6 +380,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per let raw: string; try { + ensurePrivateFile(configPath); raw = readFileSync(configPath, "utf-8"); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -424,7 +425,7 @@ export function savePersistedConfig( } try { - writeFileSync(configPath, JSON.stringify(result.data, null, 2) + "\n"); + writePrivateFileSync(configPath, JSON.stringify(result.data, null, 2) + "\n"); log?.info(`Saved to ${configPath}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/packages/server/src/server/private-files.ts b/packages/server/src/server/private-files.ts new file mode 100644 index 000000000..049e92d29 --- /dev/null +++ b/packages/server/src/server/private-files.ts @@ -0,0 +1,55 @@ +import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; + +export const PRIVATE_DIRECTORY_MODE = 0o700; +export const PRIVATE_FILE_MODE = 0o600; + +function chmodBestEffort(targetPath: string, mode: number): void { + if (process.platform === "win32") { + return; + } + + try { + chmodSync(targetPath, mode); + } catch { + // Keep startup resilient if the filesystem does not support POSIX modes. + } +} + +export function ensurePrivateDirectory(directoryPath: string): void { + mkdirSync(directoryPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); + chmodBestEffort(directoryPath, PRIVATE_DIRECTORY_MODE); +} + +export function ensurePrivateFile(filePath: string): void { + chmodBestEffort(filePath, PRIVATE_FILE_MODE); +} + +export function writePrivateFileSync( + filePath: string, + data: string | NodeJS.ArrayBufferView, +): void { + ensurePrivateDirectory(path.dirname(filePath)); + writeFileSync(filePath, data, { mode: PRIVATE_FILE_MODE }); + ensurePrivateFile(filePath); +} + +export function writePrivateFileAtomicSync( + filePath: string, + data: string | NodeJS.ArrayBufferView, +): void { + ensurePrivateDirectory(path.dirname(filePath)); + const tmpPath = path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`, + ); + try { + writeFileSync(tmpPath, data, { mode: PRIVATE_FILE_MODE }); + renameSync(tmpPath, filePath); + ensurePrivateFile(filePath); + } catch (error) { + rmSync(tmpPath, { force: true }); + throw error; + } +} diff --git a/packages/server/src/server/push/token-store.test.ts b/packages/server/src/server/push/token-store.test.ts new file mode 100644 index 000000000..b5c66e10c --- /dev/null +++ b/packages/server/src/server/push/token-store.test.ts @@ -0,0 +1,57 @@ +import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type pino from "pino"; +import { describe, expect, test } from "vitest"; + +import { PRIVATE_FILE_MODE } from "../private-files.js"; +import { PushTokenStore } from "./token-store.js"; + +const MODE_MASK = 0o777; +const PERMISSIVE_FILE_MODE = 0o644; + +function createLogger(): pino.Logger { + const logger = { + child: () => logger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + }; + return logger as unknown as pino.Logger; +} + +function modeOf(filePath: string): number { + return statSync(filePath).mode & MODE_MASK; +} + +describe.skipIf(process.platform === "win32")("PushTokenStore file permissions", () => { + test("persists push tokens with private permissions", () => { + const home = mkdtempSync(path.join(tmpdir(), "paseo-push-tokens-")); + const tokenPath = path.join(home, "push-tokens.json"); + try { + const store = new PushTokenStore(createLogger(), tokenPath); + + store.addToken("ExponentPushToken[test]"); + + expect(modeOf(tokenPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("repairs existing push token file permissions when loading", () => { + const home = mkdtempSync(path.join(tmpdir(), "paseo-push-tokens-")); + const tokenPath = path.join(home, "push-tokens.json"); + try { + writeFileSync(tokenPath, JSON.stringify({ tokens: ["ExponentPushToken[test]"] })); + chmodSync(tokenPath, PERMISSIVE_FILE_MODE); + + const store = new PushTokenStore(createLogger(), tokenPath); + + expect(store.getAllTokens()).toEqual(["ExponentPushToken[test]"]); + expect(modeOf(tokenPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/push/token-store.ts b/packages/server/src/server/push/token-store.ts index 42caf3a21..438c87af2 100644 --- a/packages/server/src/server/push/token-store.ts +++ b/packages/server/src/server/push/token-store.ts @@ -1,6 +1,7 @@ import type pino from "pino"; -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; + +import { ensurePrivateFile, writePrivateFileAtomicSync } from "../private-files.js"; /** * Store for Expo push tokens. @@ -46,6 +47,7 @@ export class PushTokenStore { if (!existsSync(this.filePath)) { return; } + ensurePrivateFile(this.filePath); const raw = readFileSync(this.filePath, "utf-8"); const parsed = JSON.parse(raw) as { tokens?: unknown }; const tokens = Array.isArray(parsed.tokens) @@ -61,11 +63,8 @@ export class PushTokenStore { private persist(): void { try { - mkdirSync(dirname(this.filePath), { recursive: true }); - const tmpPath = `${this.filePath}.tmp`; const payload = JSON.stringify({ tokens: Array.from(this.tokens) }, null, 2) + "\n"; - writeFileSync(tmpPath, payload); - renameSync(tmpPath, this.filePath); + writePrivateFileAtomicSync(this.filePath, payload); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.logger.warn({ err }, "Failed to persist push tokens"); diff --git a/packages/server/src/server/server-id.test.ts b/packages/server/src/server/server-id.test.ts index c7048c0a5..9abb93fdf 100644 --- a/packages/server/src/server/server-id.test.ts +++ b/packages/server/src/server/server-id.test.ts @@ -1,13 +1,29 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { getOrCreateServerId } from "./server-id.js"; +import { PRIVATE_FILE_MODE } from "./private-files.js"; + +const MODE_MASK = 0o777; +const PERMISSIVE_FILE_MODE = 0o644; function tmpHome(): string { return mkdtempSync(path.join(tmpdir(), "paseo-server-id-")); } +function modeOf(filePath: string): number { + return statSync(filePath).mode & MODE_MASK; +} + describe("getOrCreateServerId", () => { let home: string; const originalEnv = process.env; @@ -43,4 +59,31 @@ describe("getOrCreateServerId", () => { expect(existsSync(idPath)).toBe(true); expect(readFileSync(idPath, "utf8").trim()).toBe("test-daemon-id"); }); + + describe.skipIf(process.platform === "win32")("file permissions", () => { + it("creates server-id with private permissions", () => { + getOrCreateServerId(home); + + expect(modeOf(path.join(home, "server-id"))).toBe(PRIVATE_FILE_MODE); + }); + + it("repairs existing server-id permissions when loading", () => { + const idPath = path.join(home, "server-id"); + writeFileSync(idPath, "srv_existing\n", { mode: PERMISSIVE_FILE_MODE }); + chmodSync(idPath, PERMISSIVE_FILE_MODE); + + expect(getOrCreateServerId(home)).toBe("srv_existing"); + expect(modeOf(idPath)).toBe(PRIVATE_FILE_MODE); + }); + + it("repairs existing server-id permissions when using an env override", () => { + const idPath = path.join(home, "server-id"); + process.env.PASEO_SERVER_ID = "test-daemon-id"; + writeFileSync(idPath, "srv_existing\n", { mode: PERMISSIVE_FILE_MODE }); + chmodSync(idPath, PERMISSIVE_FILE_MODE); + + expect(getOrCreateServerId(home)).toBe("test-daemon-id"); + expect(modeOf(idPath)).toBe(PRIVATE_FILE_MODE); + }); + }); }); diff --git a/packages/server/src/server/server-id.ts b/packages/server/src/server/server-id.ts index 889387346..98153f53b 100644 --- a/packages/server/src/server/server-id.ts +++ b/packages/server/src/server/server-id.ts @@ -1,7 +1,9 @@ import path from "node:path"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { randomBytes } from "node:crypto"; +import { ensurePrivateFile, writePrivateFileSync } from "./private-files.js"; + interface LoggerLike { child(bindings: Record): LoggerLike; info(...args: unknown[]): void; @@ -47,17 +49,20 @@ export function getOrCreateServerId( // Persist the override for consistent identity across restarts. if (!existsSync(serverIdPath)) { try { - writeFileSync(serverIdPath, `${envOverride}\n`, "utf8"); + writePrivateFileSync(serverIdPath, `${envOverride}\n`); log?.info({ serverId: envOverride }, "Persisted PASEO_SERVER_ID override"); } catch (error) { log?.warn({ error }, "Failed to persist PASEO_SERVER_ID override"); } + } else { + ensurePrivateFile(serverIdPath); } return envOverride; } if (existsSync(serverIdPath)) { try { + ensurePrivateFile(serverIdPath); const raw = readFileSync(serverIdPath, "utf8"); const parsed = raw.trim(); if (parsed.length > 0) { @@ -70,7 +75,7 @@ export function getOrCreateServerId( const created = generateServerId(); try { - writeFileSync(serverIdPath, `${created}\n`, "utf8"); + writePrivateFileSync(serverIdPath, `${created}\n`); } catch (error) { log?.warn({ error }, "Failed to persist serverId (continuing with in-memory id)"); }