diff --git a/docs/implement.md b/docs/implement.md index 86af231..7c36756 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -1,6 +1,8 @@ -# Implementation Details (Local-Only) +# Implementation Details (Local-Only Core) -This document describes how `codex-auth` stores accounts, synchronizes auth files, and refreshes metadata. The tool never calls external APIs; it reads only local files under `~/.codex` (or `CODEX_HOME`). +This document describes how `codex-auth` stores accounts, synchronizes auth files, and refreshes metadata. The core account-management flows read and write local files under `~/.codex` (or `CODEX_HOME`) and do not include built-in HTTP/API calls. + +`codex-auth login` (without `--skip`) invokes the external `codex login` command as a child process. Any network/API behavior in that path comes from the `codex` CLI, not from `codex-auth`'s own file-sync logic. ## Packaging and Release @@ -32,6 +34,17 @@ This document describes how `codex-auth` stores accounts, synchronizes auth file - `~/.codex/accounts/registry.json.bak.` - `~/.codex/sessions/...` +## File Permissions + +- On Unix-like systems, `codex-auth` hardens sensitive files to mode `0600` after write/copy: + - `~/.codex/auth.json` (when written by `codex-auth`) + - `~/.codex/accounts/registry.json` + - `~/.codex/accounts/.auth.json` + - `~/.codex/accounts/auth.json.bak.` + - `~/.codex/accounts/registry.json.bak.` +- On Unix-like systems, `~/.codex/accounts/` is hardened to mode `0700`. +- On Windows, POSIX mode bits are not enforced; the tool logs a warning instead of failing. + `codex-auth` resolves `codex_home` in this order: 1. `CODEX_HOME` (when set and non-empty) diff --git a/src/registry.zig b/src/registry.zig index e3a9eeb..a87c36b 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -1,8 +1,11 @@ const std = @import("std"); +const builtin = @import("builtin"); pub const PlanType = enum { free, plus, pro, team, business, enterprise, edu, unknown }; pub const AuthMode = enum { chatgpt, apikey }; const registry_version: u32 = 2; +const private_file_mode: std.fs.File.Mode = 0o600; +const private_dir_mode: std.fs.File.Mode = 0o700; fn normalizeEmailAlloc(allocator: std.mem.Allocator, email: []const u8) ![]u8 { var buf = try allocator.alloc(u8, email.len); @@ -111,10 +114,31 @@ pub fn resolveCodexHome(allocator: std.mem.Allocator) ![]u8 { return error.EnvironmentVariableNotFound; } +fn hardenFilePermissions(path: []const u8) !void { + if (comptime builtin.os.tag == .windows) { + std.log.warn("cannot enforce 0600 on Windows: {s}", .{path}); + return; + } + var file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + try file.chmod(private_file_mode); +} + +fn hardenDirectoryPermissions(path: []const u8) !void { + if (comptime builtin.os.tag == .windows) { + std.log.warn("cannot enforce 0700 on Windows: {s}", .{path}); + return; + } + var dir = try std.fs.cwd().openDir(path, .{}); + defer dir.close(); + try dir.chmod(private_dir_mode); +} + pub fn ensureAccountsDir(allocator: std.mem.Allocator, codex_home: []const u8) !void { const accounts_dir = try std.fs.path.join(allocator, &[_][]const u8{ codex_home, "accounts" }); defer allocator.free(accounts_dir); try std.fs.cwd().makePath(accounts_dir); + try hardenDirectoryPermissions(accounts_dir); } pub fn registryPath(allocator: std.mem.Allocator, codex_home: []const u8) ![]u8 { @@ -143,6 +167,7 @@ pub fn activeAuthPath(allocator: std.mem.Allocator, codex_home: []const u8) ![]u pub fn copyFile(src: []const u8, dest: []const u8) !void { try std.fs.cwd().copyFile(src, std.fs.cwd(), dest, .{}); + try hardenFilePermissions(dest); } const max_backups: usize = 5; @@ -175,6 +200,7 @@ fn fileEqualsBytes(allocator: std.mem.Allocator, path: []const u8, bytes: []cons fn ensureDir(path: []const u8) !void { try std.fs.cwd().makePath(path); + try hardenDirectoryPermissions(path); } fn backupDir(allocator: std.mem.Allocator, codex_home: []const u8) ![]u8 { @@ -264,7 +290,7 @@ pub fn backupAuthIfChanged( } const backup = try makeBackupPath(allocator, dir, "auth.json"); defer allocator.free(backup); - try std.fs.cwd().copyFile(current_auth_path, std.fs.cwd(), backup, .{}); + try copyFile(current_auth_path, backup); try pruneBackups(allocator, dir, "auth.json", max_backups); } } @@ -291,7 +317,7 @@ fn backupRegistryIfChanged( const backup = try makeBackupPath(allocator, dir, "registry.json"); defer allocator.free(backup); - try std.fs.cwd().copyFile(current_registry_path, std.fs.cwd(), backup, .{}); + try copyFile(current_registry_path, backup); try pruneBackups(allocator, dir, "registry.json", max_backups); } @@ -741,9 +767,13 @@ pub fn saveRegistry(allocator: std.mem.Allocator, codex_home: []const u8, reg: * try backupRegistryIfChanged(allocator, codex_home, path, data); - var file = try std.fs.cwd().createFile(path, .{ .truncate = true }); + var file = try std.fs.cwd().createFile(path, .{ + .truncate = true, + .mode = private_file_mode, + }); defer file.close(); try file.writeAll(data); + try hardenFilePermissions(path); } const RegistryOut = struct { diff --git a/src/tests/registry_test.zig b/src/tests/registry_test.zig index ce31988..2de4dcd 100644 --- a/src/tests/registry_test.zig +++ b/src/tests/registry_test.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const registry = @import("../registry.zig"); fn b64url(allocator: std.mem.Allocator, input: []const u8) ![]u8 { @@ -41,6 +42,12 @@ fn countBackups(dir: std.fs.Dir, prefix: []const u8) !usize { return count; } +fn assertModeUnix(path: []const u8, expected_mode: u32) !void { + if (comptime builtin.os.tag == .windows) return; + const stat = try std.fs.cwd().statFile(path); + try std.testing.expectEqual(expected_mode, @as(u32, @intCast(stat.mode & 0o777))); +} + test "registry save/load" { var gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); @@ -73,6 +80,56 @@ test "registry save/load" { try std.testing.expect(loaded.accounts.items.len == 1); } +test "accounts directory is hardened to 0700" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try registry.ensureAccountsDir(gpa, codex_home); + + const accounts_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts" }); + defer gpa.free(accounts_path); + try assertModeUnix(accounts_path, 0o700); +} + +test "copyFile hardens destination to 0600" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + try tmp.dir.writeFile(.{ .sub_path = "source.json", .data = "secret" }); + const src = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "source.json" }); + defer gpa.free(src); + const dest = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "dest.json" }); + defer gpa.free(dest); + + try registry.copyFile(src, dest); + try assertModeUnix(dest, 0o600); +} + +test "saveRegistry writes registry with 0600 mode" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = registry.Registry{ .version = 2, .active_email = null, .accounts = std.ArrayList(registry.AccountRecord).empty }; + defer reg.deinit(gpa); + + try registry.saveRegistry(gpa, codex_home, ®); + + const path = try registry.registryPath(gpa, codex_home); + defer gpa.free(path); + try assertModeUnix(path, 0o600); +} + test "auth backup only on change" { var gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{});