clone

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

fix: owner-only modes for secret files (config.json 0600, data dir 0700)

Dami committed 593c5d0
config.json holds backendSecret and adminSecret but was written with the
default umask while iroh.key already got 0600. Write config 0600 and the
data dir 0700, chmod after write so pre-existing paths are pinned too, and
heal installs created before modes were set on every load (config.json,
iroh.key, and the data dir). POSIX only — Windows has no chmod; there the
data dir inherits the profile directory's ACLs.

Part of FelineStateMachine/lofi#123 (item A1 + C4).
commit
593c5d0280fb36600f591487aff6e476bcf31c52
parent
eb6a3dfd70aa6fc38ad5c5acca01496dbde64ea6
author
Dami <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+});