Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ This document describes how `codex-auth` stores accounts, synchronizes auth file
- `<codex_home>/accounts/registry.json.bak.YYYYMMDD-hhmmss[.N]`
- `<codex_home>/sessions/...`

## File Permissions

File-permission behavior is documented in [docs/permissions.md](./permissions.md).

`codex-auth` resolves `codex_home` in this order:

1. `CODEX_HOME` when it is set to a non-empty existing directory
Expand Down
41 changes: 41 additions & 0 deletions docs/permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# File Permissions

This document describes how `codex-auth` manages file and directory permissions under `~/.codex`.

## Directory permissions

On Unix-like systems, `codex-auth` hardens this managed directory to `0700`:

- `<codex_home>/accounts/`

It does not currently force `<codex_home>/` itself to `0700`.

## Managed sensitive files

On Unix-like systems, `codex-auth` creates these managed sensitive files with `0600` immediately and keeps them private on rewrite/sync paths:

- `<codex_home>/accounts/registry.json`
- `<codex_home>/accounts/<account file key>.auth.json`
- `<codex_home>/accounts/auth.json.bak.YYYYMMDD-hhmmss[.N]`
- `<codex_home>/accounts/registry.json.bak.YYYYMMDD-hhmmss[.N]`

Important details:

- Managed copy paths create the destination with `0600` at copy time instead of copying first and fixing the mode afterward.
- The atomic `registry.json` save path creates the replacement file with `0600` before the final rename.
- Lock files under `<codex_home>/accounts/` are not secrets; they rely on the parent `0700` directory instead of extra per-file hardening.

## Live auth.json behavior

The live `<codex_home>/auth.json` is intentionally treated differently from the managed files above.

On Unix-like systems:

- `codex-auth login` leaves the live file at whatever mode the external `codex login` flow produced.
- Foreground sync updates the managed snapshot under `accounts/` and does not re-harden the live `auth.json`.
- When a switch-style flow replaces an existing `<codex_home>/auth.json`, it preserves that file's current mode instead of forcing `0600`.
- When `<codex_home>/auth.json` is missing and must be recreated from a managed snapshot, the recreated file ends up private because the managed snapshot source is already `0600`.

## Windows behavior

On Windows, POSIX mode bits are skipped.
2 changes: 2 additions & 0 deletions src/compat_fs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ pub const Dir = struct {
};
pub const AtomicFileOptions = struct {
write_buffer: []u8 = &.{},
permissions: File.Permissions = .default_file,
};
pub const AtomicFile = struct {
inner: std.Io.File.Atomic,
Expand Down Expand Up @@ -287,6 +288,7 @@ pub const Dir = struct {

pub fn atomicFile(self: Dir, sub_path: []const u8, options: AtomicFileOptions) !AtomicFile {
var atomic = try self.inner.createFileAtomic(io(), sub_path, .{
.permissions = options.permissions,
.replace = true,
});
return .{
Expand Down
2 changes: 1 addition & 1 deletion src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1417,7 +1417,7 @@ fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.L
defer allocator.free(dest);

try registry.ensureAccountsDir(allocator, codex_home);
try registry.copyFile(auth_path, dest);
try registry.copyManagedFile(auth_path, dest);

const record = try registry.accountFromAuth(allocator, "", &info);
try registry.upsertAccount(allocator, &reg, record);
Expand Down
102 changes: 81 additions & 21 deletions src/registry.zig
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ pub const min_supported_schema_version: u32 = 2;
pub const default_auto_switch_threshold_5h_percent: u8 = 10;
pub const default_auto_switch_threshold_weekly_percent: u8 = 5;
pub const account_name_refresh_lock_file_name = "account-name-refresh.lock";
const private_file_permissions: fs.File.Permissions = switch (builtin.os.tag) {
.windows => .default_file,
else => fs.File.Permissions.fromMode(0o600),
};
const private_dir_permissions: fs.File.Permissions = switch (builtin.os.tag) {
.windows => .default_dir,
else => fs.File.Permissions.fromMode(0o700),
};

fn normalizeEmailAlloc(allocator: std.mem.Allocator, email: []const u8) ![]u8 {
var buf = try allocator.alloc(u8, email.len);
Expand Down Expand Up @@ -362,10 +370,28 @@ pub fn resolveUserHome(allocator: std.mem.Allocator) ![]u8 {
return error.EnvironmentVariableNotFound;
}

fn hardenPathPermissions(path: []const u8, permissions: fs.File.Permissions) !void {
if (comptime builtin.os.tag == .windows) return;
try fs.cwd().inner.setFilePermissions(fs.io(), path, permissions, .{});
}

pub fn hardenSensitiveFile(path: []const u8) !void {
try hardenPathPermissions(path, private_file_permissions);
}

fn hardenSensitiveDir(path: []const u8) !void {
try hardenPathPermissions(path, private_dir_permissions);
}

fn ensurePrivateDir(path: []const u8) !void {
try fs.cwd().makePath(path);
try hardenSensitiveDir(path);
}

pub fn ensureAccountsDir(allocator: std.mem.Allocator, codex_home: []const u8) !void {
const accounts_dir = try fs.path.join(allocator, &[_][]const u8{ codex_home, "accounts" });
defer allocator.free(accounts_dir);
try fs.cwd().makePath(accounts_dir);
try ensurePrivateDir(accounts_dir);
}

pub fn registryPath(allocator: std.mem.Allocator, codex_home: []const u8) ![]u8 {
Expand Down Expand Up @@ -423,14 +449,40 @@ pub fn activeAuthPath(allocator: std.mem.Allocator, codex_home: []const u8) ![]u
return try fs.path.join(allocator, &[_][]const u8{ codex_home, "auth.json" });
}

fn copyFileWithPermissions(src: []const u8, dest: []const u8, permissions: ?fs.File.Permissions) !void {
try fs.cwd().copyFile(src, fs.cwd(), dest, .{ .permissions = permissions });
}

fn existingFilePermissions(path: []const u8) !?fs.File.Permissions {
const stat = fs.cwd().statFile(path) catch |err| switch (err) {
error.FileNotFound => return null,
else => return err,
};
return stat.permissions;
}

pub fn copyFile(src: []const u8, dest: []const u8) !void {
try fs.cwd().copyFile(src, fs.cwd(), dest, .{});
try copyFileWithPermissions(src, dest, null);
}

pub fn copyManagedFile(src: []const u8, dest: []const u8) !void {
try copyFileWithPermissions(src, dest, private_file_permissions);
try hardenSensitiveFile(dest);
}

fn replaceFilePreservingPermissions(src: []const u8, dest: []const u8) !void {
const permissions = try existingFilePermissions(dest);
try copyFileWithPermissions(src, dest, permissions);
}

fn writeFile(path: []const u8, data: []const u8) !void {
var file = try fs.cwd().createFile(path, .{ .truncate = true });
var file = try fs.cwd().createFile(path, .{
.truncate = true,
.permissions = private_file_permissions,
});
defer file.close();
try file.writeAll(data);
try hardenSensitiveFile(path);
}

const max_backups: usize = 5;
Expand Down Expand Up @@ -475,10 +527,6 @@ fn fileEqualsBytes(allocator: std.mem.Allocator, path: []const u8, bytes: []cons
return std.mem.eql(u8, data.?, bytes);
}

fn ensureDir(path: []const u8) !void {
try fs.cwd().makePath(path);
}

fn backupDir(allocator: std.mem.Allocator, codex_home: []const u8) ![]u8 {
return try fs.path.join(allocator, &[_][]const u8{ codex_home, "accounts" });
}
Expand Down Expand Up @@ -710,7 +758,7 @@ pub fn backupAuthIfChanged(
) !void {
const dir = try backupDir(allocator, codex_home);
defer allocator.free(dir);
try ensureDir(dir);
try ensureAccountsDir(allocator, codex_home);

if (!(try filesEqual(allocator, current_auth_path, new_auth_path))) {
if (fs.cwd().openFile(current_auth_path, .{})) |file| {
Expand All @@ -720,7 +768,7 @@ pub fn backupAuthIfChanged(
}
const backup = try makeBackupPath(allocator, dir, "auth.json");
defer allocator.free(backup);
try fs.cwd().copyFile(current_auth_path, fs.cwd(), backup, .{});
try copyManagedFile(current_auth_path, backup);
try pruneBackups(allocator, dir, "auth.json", max_backups);
}
}
Expand All @@ -733,7 +781,7 @@ fn backupRegistryIfChanged(
) !void {
const dir = try backupDir(allocator, codex_home);
defer allocator.free(dir);
try ensureDir(dir);
try ensureAccountsDir(allocator, codex_home);

if (try fileEqualsBytes(allocator, current_registry_path, new_registry_bytes)) {
return;
Expand All @@ -747,7 +795,7 @@ fn backupRegistryIfChanged(

const backup = try makeBackupPath(allocator, dir, "registry.json");
defer allocator.free(backup);
try fs.cwd().copyFile(current_registry_path, fs.cwd(), backup, .{});
try copyManagedFile(current_registry_path, backup);
try pruneBackups(allocator, dir, "registry.json", max_backups);
}

Expand Down Expand Up @@ -1227,7 +1275,7 @@ fn importAuthInfo(
defer allocator.free(dest);

try ensureAccountsDir(allocator, codex_home);
try copyFile(auth_file, dest);
try copyManagedFile(auth_file, dest);

const record = try accountFromAuth(allocator, alias, info);
try upsertAccount(allocator, reg, record);
Expand Down Expand Up @@ -1555,7 +1603,7 @@ fn syncCurrentAuthBestEffort(
const dest = try accountAuthPath(allocator, codex_home, record_key);
defer allocator.free(dest);
try ensureAccountsDir(allocator, codex_home);
try copyFile(auth_path, dest);
try copyManagedFile(auth_path, dest);

if (existing_idx) |idx| {
const email = info.email.?;
Expand Down Expand Up @@ -1669,7 +1717,7 @@ pub fn syncActiveAccountFromAuth(allocator: std.mem.Allocator, codex_home: []con
defer allocator.free(dest);

try ensureAccountsDir(allocator, codex_home);
try copyFile(auth_path, dest);
try copyManagedFile(auth_path, dest);

var record = try accountFromAuth(allocator, "", &info);
var record_owned = true;
Expand Down Expand Up @@ -1707,8 +1755,10 @@ pub fn syncActiveAccountFromAuth(allocator: std.mem.Allocator, codex_home: []con
const dest = try accountAuthPath(allocator, codex_home, rec_account_key);
defer allocator.free(dest);
if (!(try fileEqualsBytes(allocator, dest, auth_bytes))) {
try copyFile(auth_path, dest);
try copyManagedFile(auth_path, dest);
changed = true;
} else {
try hardenSensitiveFile(dest);
}

try setActiveAccountKey(allocator, reg, rec_account_key);
Expand Down Expand Up @@ -1945,7 +1995,7 @@ pub fn activateAccountByKey(
defer allocator.free(dest);

try backupAuthIfChanged(allocator, codex_home, dest, src);
try copyFile(src, dest);
try replaceFilePreservingPermissions(src, dest);
try setActiveAccountKey(allocator, reg, account_key);
}

Expand All @@ -1962,7 +2012,8 @@ pub fn replaceActiveAuthWithAccountByKey(
const dest = try activeAuthPath(allocator, codex_home);
defer allocator.free(dest);

try copyFile(src, dest);
try ensureAccountsDir(allocator, codex_home);
try replaceFilePreservingPermissions(src, dest);
try setActiveAccountKey(allocator, reg, account_key);
}

Expand Down Expand Up @@ -2187,7 +2238,7 @@ fn parseOptionalStoredStringAlloc(allocator: std.mem.Allocator, value: ?std.json

fn maybeCopyFile(src: []const u8, dest: []const u8) !void {
if (std.mem.eql(u8, src, dest)) return;
try copyFile(src, dest);
try copyManagedFile(src, dest);
}

fn resolveLegacySnapshotPathForEmail(
Expand Down Expand Up @@ -2517,7 +2568,10 @@ fn writeRegistryFileReplace(path: []const u8, data: []const u8) !void {
defer allocator.free(backup_path);

{
var file = try fs.cwd().createFile(temp_path, .{ .truncate = true });
var file = try fs.cwd().createFile(temp_path, .{
.truncate = true,
.permissions = private_file_permissions,
});
defer file.close();
try file.writeAll(data);
try file.sync();
Expand All @@ -2543,17 +2597,22 @@ fn writeRegistryFileReplace(path: []const u8, data: []const u8) !void {
else => return err,
};
}
try hardenSensitiveFile(path);
}

fn writeRegistryFileAtomic(path: []const u8, data: []const u8) !void {
if (builtin.os.tag == .windows) {
return writeRegistryFileReplace(path, data);
}
var buf: [4096]u8 = undefined;
var atomic_file = try fs.cwd().atomicFile(path, .{ .write_buffer = &buf });
var atomic_file = try fs.cwd().atomicFile(path, .{
.write_buffer = &buf,
.permissions = private_file_permissions,
});
defer atomic_file.deinit();
try atomic_file.file_writer.interface.writeAll(data);
try atomic_file.finish();
try hardenSensitiveFile(path);
}

pub fn saveRegistry(allocator: std.mem.Allocator, codex_home: []const u8, reg: *Registry) !void {
Expand All @@ -2577,6 +2636,7 @@ pub fn saveRegistry(allocator: std.mem.Allocator, codex_home: []const u8, reg: *
const data = aw.written();

if (try fileEqualsBytes(allocator, path, data)) {
try hardenSensitiveFile(path);
return;
}

Expand Down Expand Up @@ -2798,7 +2858,7 @@ pub fn autoImportActiveAuth(allocator: std.mem.Allocator, codex_home: []const u8
defer allocator.free(dest);

try ensureAccountsDir(allocator, codex_home);
try copyFile(auth_path, dest);
try copyManagedFile(auth_path, dest);

const record = try accountFromAuth(allocator, "", &info);
try upsertAccount(allocator, reg, record);
Expand Down
Loading
Loading