clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

Merge pull request #3 from FelineStateMachine/fix/secret-file-permissions

FelineStateMachine committed b53228d
fix: owner-only modes for secret files (config.json 0600, data dir 0700)
commit
b53228d3204507dfb1c4dbebff8d373bdb8f1eec
parent
eb6a3dfd70aa6fc38ad5c5acca01496dbde64ea6
parent
593c5d0280fb36600f591487aff6e476bcf31c52
author
FelineStateMachine <dami.etoile@gmail.com>
2 files changed, +94 -6
Msrc/config.ts35+++++++++++++---
Atests/config_permissions_test.ts65++++++++++++++++++++++++++++++
M src/config.ts +29 -6
@@ -106,11 +106,27 @@ export function configPath(dataDir: string): string {
106106 return `${dataDir}/config.json`;107107 }108108 109+/** Tighten a path's mode. POSIX only — Windows has no chmod; there the data110+ * dir inherits the profile directory's ACLs instead. */111+async function restrictMode(path: string, mode: number): Promise<void> {112+ if (Deno.build.os === "windows") return;113+ await Deno.chmod(path, mode);114+}115+116+/** config.json and iroh.key hold the node's identity and Jazz credentials —117+ * owner-only on disk. Installs created before modes were set are healed on118+ * every load. */119+async function restrictSecretModes(dataDir: string): Promise<void> {120+ await restrictMode(dataDir, 0o700);121+ await restrictMode(configPath(dataDir), 0o600);122+}123+109124 /** Read config.json (null when absent); v1 files are mapped to v2 in110125 * memory — persisted as v2 on the next explicit {@link saveConfig}. */111126 export async function loadConfig(dataDir: string): Promise<NodeConfig | null> {112127 try {113128 const raw = await Deno.readTextFile(configPath(dataDir));129+ await restrictSecretModes(dataDir);114130 const parsed = JSON.parse(raw) as { v: number } & Partial<Omit<NodeConfig, "v">>;115131 if (parsed.v === 1) {116132 // Lazy v1→v2 migration: existing dirs keep exact current behavior.
@@ -137,10 +153,15 @@ export async function loadConfig(dataDir: string): Promise<NodeConfig | null> {
137153 }138154 }139155 140-/** Write config.json (creates the data directory when needed). */156+/** Write config.json, owner-only (creates the data directory when needed). */141157 export async function saveConfig(dataDir: string, config: NodeConfig): Promise<void> {142- await Deno.mkdir(dataDir, { recursive: true });143- await Deno.writeTextFile(configPath(dataDir), JSON.stringify(config, null, 2) + "\n");158+ await Deno.mkdir(dataDir, { recursive: true, mode: 0o700 });159+ await Deno.writeTextFile(configPath(dataDir), JSON.stringify(config, null, 2) + "\n", {160+ mode: 0o600,161+ });162+ // mkdir/write modes apply only on creation (and are umask-masked) — chmod163+ // pins them for pre-existing paths too.164+ await restrictSecretModes(dataDir);144165 }145166 146167 /** Create (or return the existing) daemon config; new inits default to
@@ -181,13 +202,15 @@ export async function loadOrCreateIrohKey(dataDir: string): Promise<Uint8Array>
181202 const path = `${dataDir}/iroh.key`;182203 try {183204 const hex = (await Deno.readTextFile(path)).trim();184- if (hex.length === 64) return fromHex(hex);185- throw new Error(`corrupt iroh.key at ${path}`);205+ if (hex.length !== 64) throw new Error(`corrupt iroh.key at ${path}`);206+ await restrictMode(path, 0o600);207+ return fromHex(hex);186208 } catch (e) {187209 if (!(e instanceof Deno.errors.NotFound)) throw e;188210 }189211 const key = crypto.getRandomValues(new Uint8Array(32));190- await Deno.mkdir(dataDir, { recursive: true });212+ await Deno.mkdir(dataDir, { recursive: true, mode: 0o700 });191213 await Deno.writeTextFile(path, toHex(key) + "\n", { mode: 0o600 });214+ await restrictMode(path, 0o600);192215 return key;193216 }
A tests/config_permissions_test.ts +65 -0
@@ -0,0 +1,65 @@
1+// Secret files are owner-only on disk: config.json (backendSecret,2+// adminSecret) 0600, iroh.key 0600, the data dir 0700 — and installs created3+// before modes were set are healed on load. POSIX-only; Windows has no chmod.4+5+import { assertEquals } from "@std/assert";6+import { configPath, initConfig, loadConfig, loadOrCreateIrohKey } from "../src/config.ts";7+8+const posixOnly = { ignore: Deno.build.os === "windows" };9+10+async function modeOf(path: string): Promise<number> {11+ const stat = await Deno.stat(path);12+ return (stat.mode ?? 0) & 0o777;13+}14+15+Deno.test({16+ ...posixOnly,17+ name: "permissions: initConfig writes config.json 0600 in a 0700 data dir",18+ fn: async () => {19+ const parent = await Deno.makeTempDir();20+ const dir = `${parent}/data`;21+ try {22+ await initConfig(dir);23+ assertEquals(await modeOf(dir), 0o700);24+ assertEquals(await modeOf(configPath(dir)), 0o600);25+ } finally {26+ await Deno.remove(parent, { recursive: true });27+ }28+ },29+});30+31+Deno.test({32+ ...posixOnly,33+ name: "permissions: loadConfig heals a loose existing install",34+ fn: async () => {35+ const dir = await Deno.makeTempDir();36+ try {37+ await initConfig(dir);38+ // An install from before modes were set: world-readable everywhere.39+ await Deno.chmod(dir, 0o755);40+ await Deno.chmod(configPath(dir), 0o644);41+ await loadConfig(dir);42+ assertEquals(await modeOf(dir), 0o700);43+ assertEquals(await modeOf(configPath(dir)), 0o600);44+ } finally {45+ await Deno.remove(dir, { recursive: true });46+ }47+ },48+});49+50+Deno.test({51+ ...posixOnly,52+ name: "permissions: iroh.key is 0600 on create and healed on load",53+ fn: async () => {54+ const dir = await Deno.makeTempDir();55+ try {56+ await loadOrCreateIrohKey(dir);57+ assertEquals(await modeOf(`${dir}/iroh.key`), 0o600);58+ await Deno.chmod(`${dir}/iroh.key`, 0o644);59+ await loadOrCreateIrohKey(dir);60+ assertEquals(await modeOf(`${dir}/iroh.key`), 0o600);61+ } finally {62+ await Deno.remove(dir, { recursive: true });63+ }64+ },65+});