Skip to content

Commit 8e30769

Browse files
committed
Harden local credential file permissions
1 parent 5570ec3 commit 8e30769

3 files changed

Lines changed: 105 additions & 5 deletions

File tree

docs/implement.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
# Implementation Details (Local-Only)
1+
# Implementation Details (Local-Only Core)
22

3-
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`).
3+
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.
4+
5+
`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.
46

57
## Packaging and Release
68

@@ -32,6 +34,17 @@ This document describes how `codex-auth` stores accounts, synchronizes auth file
3234
- `~/.codex/accounts/registry.json.bak.<timestamp>`
3335
- `~/.codex/sessions/...`
3436

37+
## File Permissions
38+
39+
- On Unix-like systems, `codex-auth` hardens sensitive files to mode `0600` after write/copy:
40+
- `~/.codex/auth.json` (when written by `codex-auth`)
41+
- `~/.codex/accounts/registry.json`
42+
- `~/.codex/accounts/<email_b64>.auth.json`
43+
- `~/.codex/accounts/auth.json.bak.<timestamp>`
44+
- `~/.codex/accounts/registry.json.bak.<timestamp>`
45+
- On Unix-like systems, `~/.codex/accounts/` is hardened to mode `0700`.
46+
- On Windows, POSIX mode bits are not enforced; the tool logs a warning instead of failing.
47+
3548
`codex-auth` resolves `codex_home` in this order:
3649

3750
1. `CODEX_HOME` (when set and non-empty)

src/registry.zig

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
const std = @import("std");
2+
const builtin = @import("builtin");
23

34
pub const PlanType = enum { free, plus, pro, team, business, enterprise, edu, unknown };
45
pub const AuthMode = enum { chatgpt, apikey };
56
const registry_version: u32 = 2;
7+
const private_file_mode: std.fs.File.Mode = 0o600;
8+
const private_dir_mode: std.fs.File.Mode = 0o700;
69

710
fn normalizeEmailAlloc(allocator: std.mem.Allocator, email: []const u8) ![]u8 {
811
var buf = try allocator.alloc(u8, email.len);
@@ -111,10 +114,31 @@ pub fn resolveCodexHome(allocator: std.mem.Allocator) ![]u8 {
111114
return error.EnvironmentVariableNotFound;
112115
}
113116

117+
fn hardenFilePermissions(path: []const u8) !void {
118+
if (comptime builtin.os.tag == .windows) {
119+
std.log.warn("cannot enforce 0600 on Windows: {s}", .{path});
120+
return;
121+
}
122+
var file = try std.fs.cwd().openFile(path, .{});
123+
defer file.close();
124+
try file.chmod(private_file_mode);
125+
}
126+
127+
fn hardenDirectoryPermissions(path: []const u8) !void {
128+
if (comptime builtin.os.tag == .windows) {
129+
std.log.warn("cannot enforce 0700 on Windows: {s}", .{path});
130+
return;
131+
}
132+
var dir = try std.fs.cwd().openDir(path, .{});
133+
defer dir.close();
134+
try dir.chmod(private_dir_mode);
135+
}
136+
114137
pub fn ensureAccountsDir(allocator: std.mem.Allocator, codex_home: []const u8) !void {
115138
const accounts_dir = try std.fs.path.join(allocator, &[_][]const u8{ codex_home, "accounts" });
116139
defer allocator.free(accounts_dir);
117140
try std.fs.cwd().makePath(accounts_dir);
141+
try hardenDirectoryPermissions(accounts_dir);
118142
}
119143

120144
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
143167

144168
pub fn copyFile(src: []const u8, dest: []const u8) !void {
145169
try std.fs.cwd().copyFile(src, std.fs.cwd(), dest, .{});
170+
try hardenFilePermissions(dest);
146171
}
147172

148173
const max_backups: usize = 5;
@@ -175,6 +200,7 @@ fn fileEqualsBytes(allocator: std.mem.Allocator, path: []const u8, bytes: []cons
175200

176201
fn ensureDir(path: []const u8) !void {
177202
try std.fs.cwd().makePath(path);
203+
try hardenDirectoryPermissions(path);
178204
}
179205

180206
fn backupDir(allocator: std.mem.Allocator, codex_home: []const u8) ![]u8 {
@@ -264,7 +290,7 @@ pub fn backupAuthIfChanged(
264290
}
265291
const backup = try makeBackupPath(allocator, dir, "auth.json");
266292
defer allocator.free(backup);
267-
try std.fs.cwd().copyFile(current_auth_path, std.fs.cwd(), backup, .{});
293+
try copyFile(current_auth_path, backup);
268294
try pruneBackups(allocator, dir, "auth.json", max_backups);
269295
}
270296
}
@@ -291,7 +317,7 @@ fn backupRegistryIfChanged(
291317

292318
const backup = try makeBackupPath(allocator, dir, "registry.json");
293319
defer allocator.free(backup);
294-
try std.fs.cwd().copyFile(current_registry_path, std.fs.cwd(), backup, .{});
320+
try copyFile(current_registry_path, backup);
295321
try pruneBackups(allocator, dir, "registry.json", max_backups);
296322
}
297323

@@ -741,9 +767,13 @@ pub fn saveRegistry(allocator: std.mem.Allocator, codex_home: []const u8, reg: *
741767

742768
try backupRegistryIfChanged(allocator, codex_home, path, data);
743769

744-
var file = try std.fs.cwd().createFile(path, .{ .truncate = true });
770+
var file = try std.fs.cwd().createFile(path, .{
771+
.truncate = true,
772+
.mode = private_file_mode,
773+
});
745774
defer file.close();
746775
try file.writeAll(data);
776+
try hardenFilePermissions(path);
747777
}
748778

749779
const RegistryOut = struct {

src/tests/registry_test.zig

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const std = @import("std");
2+
const builtin = @import("builtin");
23
const registry = @import("../registry.zig");
34

45
fn b64url(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
@@ -41,6 +42,12 @@ fn countBackups(dir: std.fs.Dir, prefix: []const u8) !usize {
4142
return count;
4243
}
4344

45+
fn assertModeUnix(path: []const u8, expected_mode: u32) !void {
46+
if (comptime builtin.os.tag == .windows) return;
47+
const stat = try std.fs.cwd().statFile(path);
48+
try std.testing.expectEqual(expected_mode, @as(u32, @intCast(stat.mode & 0o777)));
49+
}
50+
4451
test "registry save/load" {
4552
var gpa = std.testing.allocator;
4653
var tmp = std.testing.tmpDir(.{});
@@ -73,6 +80,56 @@ test "registry save/load" {
7380
try std.testing.expect(loaded.accounts.items.len == 1);
7481
}
7582

83+
test "accounts directory is hardened to 0700" {
84+
const gpa = std.testing.allocator;
85+
var tmp = std.testing.tmpDir(.{});
86+
defer tmp.cleanup();
87+
88+
const codex_home = try tmp.dir.realpathAlloc(gpa, ".");
89+
defer gpa.free(codex_home);
90+
try registry.ensureAccountsDir(gpa, codex_home);
91+
92+
const accounts_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts" });
93+
defer gpa.free(accounts_path);
94+
try assertModeUnix(accounts_path, 0o700);
95+
}
96+
97+
test "copyFile hardens destination to 0600" {
98+
const gpa = std.testing.allocator;
99+
var tmp = std.testing.tmpDir(.{});
100+
defer tmp.cleanup();
101+
102+
const codex_home = try tmp.dir.realpathAlloc(gpa, ".");
103+
defer gpa.free(codex_home);
104+
105+
try tmp.dir.writeFile(.{ .sub_path = "source.json", .data = "secret" });
106+
const src = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "source.json" });
107+
defer gpa.free(src);
108+
const dest = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "dest.json" });
109+
defer gpa.free(dest);
110+
111+
try registry.copyFile(src, dest);
112+
try assertModeUnix(dest, 0o600);
113+
}
114+
115+
test "saveRegistry writes registry with 0600 mode" {
116+
const gpa = std.testing.allocator;
117+
var tmp = std.testing.tmpDir(.{});
118+
defer tmp.cleanup();
119+
120+
const codex_home = try tmp.dir.realpathAlloc(gpa, ".");
121+
defer gpa.free(codex_home);
122+
123+
var reg = registry.Registry{ .version = 2, .active_email = null, .accounts = std.ArrayList(registry.AccountRecord).empty };
124+
defer reg.deinit(gpa);
125+
126+
try registry.saveRegistry(gpa, codex_home, &reg);
127+
128+
const path = try registry.registryPath(gpa, codex_home);
129+
defer gpa.free(path);
130+
try assertModeUnix(path, 0o600);
131+
}
132+
76133
test "auth backup only on change" {
77134
var gpa = std.testing.allocator;
78135
var tmp = std.testing.tmpDir(.{});

0 commit comments

Comments
 (0)