diff --git a/src/gocoon/__tests__/cli-permissions.test.ts b/src/gocoon/__tests__/cli-permissions.test.ts new file mode 100644 index 00000000..8a35b6f0 --- /dev/null +++ b/src/gocoon/__tests__/cli-permissions.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const { tempRoot, runGocoonMock } = vi.hoisted(() => ({ + tempRoot: require("fs").mkdtempSync( + require("path").join(require("os").tmpdir(), "teleton-gocoon-permissions-") + ) as string, + runGocoonMock: vi.fn(), +})); + +vi.mock("../../workspace/paths.js", () => ({ TELETON_ROOT: tempRoot })); + +vi.mock("../installer.js", () => ({ + ensureGocoonBinaries: vi.fn(async () => ({ gocoon: "/tmp/gocoon" })), +})); + +vi.mock("child_process", () => ({ + execFile: (...args: unknown[]) => runGocoonMock(...args), + spawn: vi.fn(), +})); + +const { gocoonInit } = await import("../cli.js"); +const { clientConfigPath, gocoonDataDir, walletPath } = await import("../paths.js"); + +const mode = (path: string): number => statSync(path).mode & 0o777; +type ExecCallback = (error: Error | null, result: { stdout: string; stderr: string }) => void; + +describe("gocoon data permissions", () => { + beforeEach(() => { + rmSync(gocoonDataDir(), { recursive: true, force: true }); + runGocoonMock.mockReset(); + }); + + afterAll(() => { + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("creates the gocoon data directory with mode 0o700", async () => { + const previousUmask = process.umask(0o022); + runGocoonMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { + mkdirSync(gocoonDataDir(), { recursive: true }); + writeFileSync(walletPath(), "{}", { mode: 0o600 }); + callback(null, { + stdout: JSON.stringify({ fund_address: "fund", owner_address: "owner" }), + stderr: "", + }); + } + ); + + try { + await gocoonInit(); + expect(mode(gocoonDataDir())).toBe(0o700); + expect(mode(walletPath())).toBe(0o600); + } finally { + process.umask(previousUmask); + } + }); + + it("tightens an existing gocoon data directory to mode 0o700", async () => { + mkdirSync(gocoonDataDir(), { recursive: true }); + chmodSync(gocoonDataDir(), 0o755); + runGocoonMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => + callback(null, { + stdout: JSON.stringify({ fund_address: "fund", owner_address: "owner" }), + stderr: "", + }) + ); + + await gocoonInit(); + + expect(mode(gocoonDataDir())).toBe(0o700); + }); + + it("tightens an existing wallet to mode 0o600 when it is reused", async () => { + mkdirSync(gocoonDataDir(), { recursive: true }); + writeFileSync(walletPath(), "{}", { mode: 0o644 }); + writeFileSync(clientConfigPath(), "{}", { mode: 0o600 }); + chmodSync(walletPath(), 0o644); + runGocoonMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => + callback(null, { + stdout: JSON.stringify({ + fund_address: "fund", + owner_address: "owner", + balance_nano: "0", + }), + stderr: "", + }) + ); + + expect(existsSync(walletPath())).toBe(true); + await gocoonInit(); + + expect(mode(walletPath())).toBe(0o600); + }); +}); diff --git a/src/gocoon/cli.ts b/src/gocoon/cli.ts index 54792039..7f40d176 100644 --- a/src/gocoon/cli.ts +++ b/src/gocoon/cli.ts @@ -1,6 +1,6 @@ import { execFile, spawn } from "child_process"; import { promisify } from "util"; -import { existsSync, mkdirSync } from "fs"; +import { chmodSync, existsSync, mkdirSync } from "fs"; import { createLogger } from "../utils/logger.js"; import { fetchWithTimeout } from "../utils/fetch.js"; import { ensureGocoonBinaries } from "./installer.js"; @@ -15,6 +15,8 @@ import { const log = createLogger("gocoon"); const execFileAsync = promisify(execFile); const MAX_BUFFER = 8 * 1024 * 1024; +const PRIVATE_DIR_MODE = 0o700; +const PRIVATE_FILE_MODE = 0o600; // Run a gocoon subcommand and capture stdout (for --json / short commands). export async function runGocoon(args: string[], timeoutMs = 60_000): Promise { @@ -68,8 +70,15 @@ export function walletExists(): boolean { return existsSync(walletPath()) && existsSync(clientConfigPath()); } +function hardenGocoonDataPermissions(): void { + mkdirSync(gocoonDataDir(), { recursive: true, mode: PRIVATE_DIR_MODE }); + chmodSync(gocoonDataDir(), PRIVATE_DIR_MODE); + if (existsSync(walletPath())) chmodSync(walletPath(), PRIVATE_FILE_MODE); +} + // Create the COCOON wallet, or reuse the existing one (stable funding address). export async function gocoonInit(): Promise { + hardenGocoonDataPermissions(); if (walletExists()) { const info = await walletInfo(); return { @@ -81,10 +90,10 @@ export async function gocoonInit(): Promise { configPath: clientConfigPath(), }; } - mkdirSync(gocoonDataDir(), { recursive: true }); const j = parseJson( await runGocoon(["init", "--dir", gocoonDataDir(), "--json", "--force"], 120_000) ); + hardenGocoonDataPermissions(); return { fundAddress: String(j.fund_address ?? ""), ownerAddress: String(j.owner_address ?? ""),