Protect local state file permissions (#825)

This commit is contained in:
João Sousa Andrade
2026-05-09 16:28:23 +08:00
committed by GitHub
parent 103601705d
commit 8be2692b8a
11 changed files with 318 additions and 19 deletions
@@ -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 });
}
});
});
+4 -2
View File
@@ -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 };
@@ -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 });
}
});
});
+2 -2
View File
@@ -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;
}
@@ -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 });
}
});
});
@@ -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);
@@ -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;
}
}
@@ -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 });
}
});
});
@@ -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");
+44 -1
View File
@@ -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);
});
});
});
+8 -3
View File
@@ -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<string, unknown>): 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)");
}