Skip to content
Open
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
17 changes: 15 additions & 2 deletions docs/implement.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -32,6 +34,17 @@ This document describes how `codex-auth` stores accounts, synchronizes auth file
- `~/.codex/accounts/registry.json.bak.<timestamp>`
- `~/.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/<email_b64>.auth.json`
- `~/.codex/accounts/auth.json.bak.<timestamp>`
- `~/.codex/accounts/registry.json.bak.<timestamp>`
- 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)
Expand Down
36 changes: 33 additions & 3 deletions src/registry.zig
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions src/tests/registry_test.zig
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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(.{});
Expand Down Expand Up @@ -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, &reg);

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(.{});
Expand Down