From 9433438a02a146d8b876a615610efaa758a23113 Mon Sep 17 00:00:00 2001 From: badrus123 Date: Tue, 7 Jul 2026 17:44:02 +0700 Subject: [PATCH 01/11] security: pin allowed callers to binary SHA-256 allowed_callers entries may carry an optional @sha256= suffix. When present the service hashes the caller's binary at connect time and rejects the connection on mismatch, closing the swap-binary-at-approved-path gap in the path-only allowlist. `cr policy allow PATH --pin` records the pin. - crypto/binhash.zig: sha256Hex, hashFileHex, constant-time hexEql - policy: parseCaller, callerExpectedHash, path-aware isCallerAllowed - service: fail-closed callerHashOk gate on task_declare/spawn --- src/cli/usage.zig | 7 +++- src/crypto/binhash.zig | 75 +++++++++++++++++++++++++++++++++++++++++ src/main.zig | 29 +++++++++++++--- src/policy/policy.zig | 67 +++++++++++++++++++++++++++++++++++- src/root.zig | 2 ++ src/service/service.zig | 25 ++++++++++++++ 6 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 src/crypto/binhash.zig diff --git a/src/cli/usage.zig b/src/cli/usage.zig index 3e0e85a..056d70e 100644 --- a/src/cli/usage.zig +++ b/src/cli/usage.zig @@ -441,11 +441,16 @@ const policy_allow_text = \\cr policy allow — Add a binary to allowed_callers \\ \\Usage: - \\ cr policy allow PATH + \\ cr policy allow PATH [--pin] \\ \\Arguments: \\ PATH Absolute path of the caller binary \\ + \\Flags: + \\ --pin Pin the caller to the current + \\ SHA-256 of PATH. A swapped binary + \\ at the same path is then rejected. + \\ \\If allowed_callers is empty, the policy is in dev-mode (allow-all). \\Adding any entry switches to explicit allow-list mode. \\ diff --git a/src/crypto/binhash.zig b/src/crypto/binhash.zig new file mode 100644 index 0000000..a5ec3df --- /dev/null +++ b/src/crypto/binhash.zig @@ -0,0 +1,75 @@ +const std = @import("std"); +const Io = std.Io; + +/// A SHA-256 digest rendered as 64 lowercase hex characters. +pub const hex_len: usize = 64; + +/// Compute the lowercase-hex SHA-256 of `data`. Used to pin an allowed +/// caller to a specific binary image: the path-based allowlist alone lets a +/// same-uid attacker swap the binary at an approved path, so a pinned caller +/// additionally commits to the exact bytes of that binary. +pub fn sha256Hex(data: []const u8) [hex_len]u8 { + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(data, &digest, .{}); + return std.fmt.bytesToHex(digest, .lower); +} + +/// Hash the file at `path` (relative to `dir`, or absolute) and return the +/// lowercase-hex SHA-256. The whole file is read into memory once; caller +/// binaries are bounded well under the 256 MiB cap. Errors bubble up so the +/// service can fail closed (reject the caller) rather than skip the check. +pub fn hashFileHex( + allocator: std.mem.Allocator, + io: Io, + dir: Io.Dir, + path: []const u8, +) ![hex_len]u8 { + const bytes = try dir.readFileAlloc(io, path, allocator, .limited(256 * 1024 * 1024)); + defer allocator.free(bytes); + return sha256Hex(bytes); +} + +/// Constant-time-ish equality of two hex digests. Length-checked first, then +/// a byte compare that does not early-exit on the first mismatch. A hash +/// comparison is not a high-value timing target (the attacker would need to +/// grind a preimage, not guess a MAC), but the constant-time form costs +/// nothing and documents intent. +pub fn hexEql(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + var diff: u8 = 0; + for (a, b) |x, y| diff |= x ^ y; + return diff == 0; +} + +test "sha256Hex matches known vector for 'abc'" { + const got = sha256Hex("abc"); + try std.testing.expectEqualStrings( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + &got, + ); +} + +test "sha256Hex of empty input" { + const got = sha256Hex(""); + try std.testing.expectEqualStrings( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + &got, + ); +} + +test "hexEql true for identical, false for differing" { + try std.testing.expect(hexEql("deadbeef", "deadbeef")); + try std.testing.expect(!hexEql("deadbeef", "deadbeff")); + try std.testing.expect(!hexEql("dead", "deadbeef")); +} + +test "hashFileHex matches sha256Hex of file contents" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "blob", .data = "abc" }); + const got = try hashFileHex(std.testing.allocator, std.testing.io, tmp.dir, "blob"); + try std.testing.expectEqualStrings( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + &got, + ); +} diff --git a/src/main.zig b/src/main.zig index 0b9a3c3..a210c6c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -675,17 +675,36 @@ fn cmdPolicy(allocator: std.mem.Allocator, io: Io, path: []const u8, args: []con defer next_list.deinit(allocator); if (is_allow) { - for (pol.allowed_callers) |c| try next_list.append(allocator, c); + // `--pin` commits the caller to the exact bytes of the binary at + // PATH by hashing it now and storing `PATH@sha256=`. Without it + // the entry is path-only and a same-uid attacker who swaps the + // binary at PATH still passes the allowlist. + var pin = false; + for (args[4..]) |a| { + if (std.mem.eql(u8, a, "--pin")) pin = true; + } + for (pol.allowed_callers) |c| { - if (std.mem.eql(u8, c, target)) { + if (std.mem.eql(u8, cora.policy.parseCaller(c).path, target)) { usage.outPrint(io, "already allowed: {s}\n", .{target}); return; } } - try next_list.append(allocator, target); - } else { // is_deny — validated above + for (pol.allowed_callers) |c| try next_list.append(allocator, c); + + var entry: []const u8 = target; + if (pin) { + const h = cora.binhash.hashFileHex(allocator, io, cwd, target) catch { + std.debug.print("cannot hash {s} for --pin — is it a readable file?\n", .{target}); + std.process.exit(1); + }; + entry = try std.fmt.allocPrint(allocator, "{s}{s}{s}", .{ target, cora.policy.hash_sep, &h }); + } + try next_list.append(allocator, entry); + } else { // is_deny — validated above. Match on the path portion so a + // pinned entry is removed by its plain PATH. for (pol.allowed_callers) |c| { - if (!std.mem.eql(u8, c, target)) try next_list.append(allocator, c); + if (!std.mem.eql(u8, cora.policy.parseCaller(c).path, target)) try next_list.append(allocator, c); } } diff --git a/src/policy/policy.zig b/src/policy/policy.zig index 5df5ab4..7a13b7d 100644 --- a/src/policy/policy.zig +++ b/src/policy/policy.zig @@ -20,6 +20,32 @@ pub const Task = struct { allowed_targets: []const []const u8 = &.{}, }; +/// Separator embedded in an `allowed_callers` entry to pin the caller to a +/// specific binary image: `"/usr/local/bin/cr@sha256=<64 hex>"`. Entries +/// without the separator are path-only (unpinned) and preserve backward +/// compatibility with configs written before hash pinning existed. +pub const hash_sep = "@sha256="; + +pub const Caller = struct { + path: []const u8, + /// Lowercase-hex SHA-256 the caller binary must hash to, or null when the + /// entry is path-only. Borrowed from the source entry. + expected_hex: ?[]const u8, +}; + +/// Split an `allowed_callers` entry into its path and optional pinned hash. +/// The path is everything before the first `@sha256=`; the hash is the +/// remainder. A path-only entry returns `expected_hex = null`. +pub fn parseCaller(entry: []const u8) Caller { + if (std.mem.indexOf(u8, entry, hash_sep)) |idx| { + return .{ + .path = entry[0..idx], + .expected_hex = entry[idx + hash_sep.len ..], + }; + } + return .{ .path = entry, .expected_hex = null }; +} + pub const Policy = struct { allowed_callers: []const []const u8 = &.{}, idle_timeout_ms: i64 = default_idle_timeout_ms, @@ -28,11 +54,23 @@ pub const Policy = struct { pub fn isCallerAllowed(self: *const Policy, path: []const u8) bool { if (self.allowed_callers.len == 0) return true; for (self.allowed_callers) |a| { - if (std.mem.eql(u8, a, path)) return true; + if (std.mem.eql(u8, parseCaller(a).path, path)) return true; } return false; } + /// Return the pinned hash requirement for the caller at `path`, or null + /// when the matching entry is path-only (or nothing matches). The service + /// uses this to decide whether to hash the caller's binary and fail + /// closed on mismatch. + pub fn callerExpectedHash(self: *const Policy, path: []const u8) ?[]const u8 { + for (self.allowed_callers) |a| { + const c = parseCaller(a); + if (std.mem.eql(u8, c.path, path)) return c.expected_hex; + } + return null; + } + pub fn findTask(self: *const Policy, name: []const u8) ?*const Task { for (self.tasks) |*t| { if (std.mem.eql(u8, t.name, name)) return t; @@ -143,6 +181,33 @@ test "allow/deny mutation preserves tasks" { try std.testing.expectEqual(@as(usize, 2), back.allowed_callers.len); } +test "parseCaller splits path and pinned hash" { + const c = parseCaller("/usr/local/bin/cr@sha256=abc123"); + try std.testing.expectEqualStrings("/usr/local/bin/cr", c.path); + try std.testing.expect(c.expected_hex != null); + try std.testing.expectEqualStrings("abc123", c.expected_hex.?); +} + +test "parseCaller plain path has no hash" { + const c = parseCaller("/bin/agent"); + try std.testing.expectEqualStrings("/bin/agent", c.path); + try std.testing.expect(c.expected_hex == null); +} + +test "isCallerAllowed matches on path portion of pinned entry" { + const p = Policy{ .allowed_callers = &.{"/usr/local/bin/cr@sha256=deadbeef"} }; + try std.testing.expect(p.isCallerAllowed("/usr/local/bin/cr")); + try std.testing.expect(!p.isCallerAllowed("/usr/local/bin/cr@sha256=deadbeef")); + try std.testing.expect(!p.isCallerAllowed("/bin/evil")); +} + +test "callerExpectedHash returns pin only for pinned entries" { + const p = Policy{ .allowed_callers = &.{ "/bin/plain", "/bin/pinned@sha256=cafe" } }; + try std.testing.expect(p.callerExpectedHash("/bin/plain") == null); + try std.testing.expectEqualStrings("cafe", p.callerExpectedHash("/bin/pinned").?); + try std.testing.expect(p.callerExpectedHash("/bin/absent") == null); +} + test "isTargetAllowedForTask: empty list is dev-mode allow-all" { const t = Task{ .name = "x", .allowed_secrets = &.{}, .allowed_targets = &.{} }; try std.testing.expect(isTargetAllowedForTask(&t, "/anything")); diff --git a/src/root.zig b/src/root.zig index 1df5dfe..e2c5a91 100644 --- a/src/root.zig +++ b/src/root.zig @@ -4,6 +4,7 @@ pub const MemStore = @import("store/mem.zig").MemStore; pub const CoraError = @import("error.zig").CoraError; pub const derive = @import("crypto/derive.zig"); pub const aead = @import("crypto/aead.zig"); +pub const binhash = @import("crypto/binhash.zig"); pub const format = @import("store/format.zig"); pub const store = @import("store/store.zig"); pub const secrets_codec = @import("store/secrets_codec.zig"); @@ -22,6 +23,7 @@ test { _ = @import("error.zig"); _ = @import("crypto/derive.zig"); _ = @import("crypto/aead.zig"); + _ = @import("crypto/binhash.zig"); _ = @import("store/format.zig"); _ = @import("store/store.zig"); _ = @import("store/secrets_codec.zig"); diff --git a/src/service/service.zig b/src/service/service.zig index 52eb584..a8f94ff 100644 --- a/src/service/service.zig +++ b/src/service/service.zig @@ -11,6 +11,7 @@ const policy_mod = @import("../policy/policy.zig"); const audit = @import("../audit.zig"); const pipe_windows = @import("pipe_windows.zig"); const spawn_windows = @import("spawn_windows.zig"); +const binhash = @import("../crypto/binhash.zig"); const CoraError = @import("../error.zig").CoraError; /// Per-platform stdio passthrough handle carried from `handle` into @@ -211,6 +212,17 @@ pub const Service = struct { }; } + /// Enforce the optional per-caller binary-hash pin. Returns true when the + /// caller is unpinned (no requirement) or the binary at `path` hashes to + /// the pinned digest. Fails closed: any error hashing the file (missing, + /// unreadable) returns false, so a caller that cannot be verified is + /// rejected rather than admitted. + fn callerHashOk(self: *Service, path: []const u8) bool { + const expected = self.policy.callerExpectedHash(path) orelse return true; + const got = binhash.hashFileHex(self.allocator, self.io, Io.Dir.cwd(), path) catch return false; + return binhash.hexEql(&got, expected); + } + pub fn run(self: *Service) !void { var idle_thread = try std.Thread.spawn(.{}, idleWatch, .{self}); defer idle_thread.join(); @@ -314,6 +326,19 @@ pub const Service = struct { return; } + if (requiresCallerPolicy(op) and !self.callerHashOk(ident.path())) { + _ = self.rejected.fetchAdd(1, .monotonic); + self.emit(.{ .caller_rejected = .{ + .ts_ms = nowMs(self.io), + .pid = ident.pid, + .binary = ident.path(), + .reason = "caller binary hash mismatch", + } }); + try proto.writeFrame(&writer.interface, .err, "caller hash mismatch"); + try writer.interface.flush(); + return; + } + switch (op) { .ping => { try proto.writeFrame(&writer.interface, .ping, ""); From adcc684d919e91180b4aef1c80b050a7dfe111d0 Mon Sep 17 00:00:00 2001 From: badrus123 Date: Tue, 7 Jul 2026 17:45:48 +0700 Subject: [PATCH 02/11] feat: audit the resolved spawn target with task_spawned event The success path logged secret_injected and task_end but never named the binary that actually ran; the resolved absolute target only appeared on rejection (target_rejected). task_spawned records the resolved argv[0] and child pid once the child exists, on both POSIX and Windows spawn paths. --- src/audit.zig | 33 +++++++++++++++++++++++++++++++++ src/service/service.zig | 12 ++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/audit.zig b/src/audit.zig index 14c52bb..3b30783 100644 --- a/src/audit.zig +++ b/src/audit.zig @@ -10,6 +10,13 @@ pub const Event = union(enum) { service_locked: struct { ts_ms: i64, reason: []const u8 }, caller_rejected: struct { ts_ms: i64, pid: i32, binary: []const u8, reason: []const u8 }, task_start: struct { ts_ms: i64, caller_pid: i32, caller_bin: []const u8, task: []const u8 }, + /// Emitted once the spawn target has passed PATH resolution and the + /// allowed_targets gate and the child process exists. Records the + /// resolved absolute binary that actually ran plus its pid — the piece + /// missing from `task_start` (which fires at declare time, before the + /// target is known). No argv beyond argv[0]: later args can carry + /// caller-chosen data and are not audited to keep the log secret-free. + task_spawned: struct { ts_ms: i64, task: []const u8, target: []const u8, child_pid: i32 }, secret_injected: struct { ts_ms: i64, task: []const u8, secret_name: []const u8, target_pid: i32 }, secret_missing: struct { ts_ms: i64, task: []const u8, secret_name: []const u8, target_pid: i32 }, task_end: struct { ts_ms: i64, task: []const u8, exit_code: i32, duration_ms: i64 }, @@ -139,6 +146,16 @@ pub fn encode(ev: Event, w: *Io.Writer) !void { try s.objectField("task"); try s.write(v.task); }, + .task_spawned => |v| { + try s.objectField("ts_ms"); + try s.write(v.ts_ms); + try s.objectField("task"); + try s.write(v.task); + try s.objectField("target"); + try s.write(v.target); + try s.objectField("child_pid"); + try s.write(v.child_pid); + }, .secret_injected => |v| { try s.objectField("ts_ms"); try s.write(v.ts_ms); @@ -249,6 +266,22 @@ test "Event union no longer carries windows_preview_mode variant" { try std.testing.expect(!@hasField(Event, "windows_preview_mode")); } +test "encode task_spawned records resolved target and pid" { + var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try encode(.{ .task_spawned = .{ + .ts_ms = 55, + .task = "deploy", + .target = "/usr/bin/gh", + .child_pid = 8080, + } }, &aw.writer); + const out = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, out, "\"kind\":\"task_spawned\"") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "\"target\":\"/usr/bin/gh\"") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "\"child_pid\":8080") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "value") == null); +} + test "encode secret_missing distinct from secret_injected" { var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); defer aw.deinit(); diff --git a/src/service/service.zig b/src/service/service.zig index a8f94ff..41585ff 100644 --- a/src/service/service.zig +++ b/src/service/service.zig @@ -674,6 +674,12 @@ pub const Service = struct { ); child_pid = @intCast(child.pid); + self.emit(.{ .task_spawned = .{ + .ts_ms = nowMs(self.io), + .task = declared_task, + .target = resolved, + .child_pid = child_pid, + } }); for (injected_names.items) |name| { self.emit(.{ .secret_injected = .{ .ts_ms = nowMs(self.io), @@ -713,6 +719,12 @@ pub const Service = struct { var child = try std.process.spawn(self.io, spawn_opts); child_pid = childPid(child); + self.emit(.{ .task_spawned = .{ + .ts_ms = nowMs(self.io), + .task = declared_task, + .target = resolved, + .child_pid = child_pid, + } }); for (injected_names.items) |name| { self.emit(.{ .secret_injected = .{ .ts_ms = nowMs(self.io), From ab7ecc38c3e41306be304061ba9148597324a152 Mon Sep 17 00:00:00 2001 From: badrus123 Date: Tue, 7 Jul 2026 17:52:56 +0700 Subject: [PATCH 03/11] security: per-task spawn rate limiting Adds max_spawns/window_ms to Task. The service tracks a fixed-window spawn counter per task and refuses spawns beyond the cap, bounding how many times a trusted caller can pull a task's secrets into a subprocess. 0 = unlimited (default, backward compatible). - service/ratelimit.zig: pure fixed-window Window.allow (injected clock) - policy: max_spawns/window_ms fields + roundtrip - service: per-task window map, fail-closed gate after the target check - audit: rate_limited event - cli: --max-spawns/--window-ms on `policy task add`, shown in policy show --- src/audit.zig | 30 ++++++++++++++++++++ src/cli/usage.zig | 10 ++++++- src/error.zig | 1 + src/main.zig | 30 ++++++++++++++++++++ src/policy/policy.zig | 28 +++++++++++++++++++ src/root.zig | 2 ++ src/service/ratelimit.zig | 59 +++++++++++++++++++++++++++++++++++++++ src/service/service.zig | 35 +++++++++++++++++++++++ 8 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/service/ratelimit.zig diff --git a/src/audit.zig b/src/audit.zig index 3b30783..b3cf8c8 100644 --- a/src/audit.zig +++ b/src/audit.zig @@ -20,6 +20,11 @@ pub const Event = union(enum) { secret_injected: struct { ts_ms: i64, task: []const u8, secret_name: []const u8, target_pid: i32 }, secret_missing: struct { ts_ms: i64, task: []const u8, secret_name: []const u8, target_pid: i32 }, task_end: struct { ts_ms: i64, task: []const u8, exit_code: i32, duration_ms: i64 }, + /// Emitted when a spawn is refused because the task exceeded its + /// `max_spawns` within `window_ms`. The caller passed identity, caller, + /// and target gates; what failed is the rate limit. No target field — + /// the spawn is rejected before target resolution. + rate_limited: struct { ts_ms: i64, caller_pid: i32, caller_bin: []const u8, task: []const u8 }, /// Emitted when the spawn target (argv[0] after PATH resolution) is /// not in the task's `allowed_targets`. Distinct from `caller_rejected` /// — the caller passed kernel identity verification and the task @@ -156,6 +161,16 @@ pub fn encode(ev: Event, w: *Io.Writer) !void { try s.objectField("child_pid"); try s.write(v.child_pid); }, + .rate_limited => |v| { + try s.objectField("ts_ms"); + try s.write(v.ts_ms); + try s.objectField("caller_pid"); + try s.write(v.caller_pid); + try s.objectField("caller_bin"); + try s.write(v.caller_bin); + try s.objectField("task"); + try s.write(v.task); + }, .secret_injected => |v| { try s.objectField("ts_ms"); try s.write(v.ts_ms); @@ -266,6 +281,21 @@ test "Event union no longer carries windows_preview_mode variant" { try std.testing.expect(!@hasField(Event, "windows_preview_mode")); } +test "encode rate_limited names task and caller, carries no value" { + var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try encode(.{ .rate_limited = .{ + .ts_ms = 71, + .caller_pid = 321, + .caller_bin = "/usr/local/bin/cr", + .task = "deploy", + } }, &aw.writer); + const out = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, out, "\"kind\":\"rate_limited\"") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "\"task\":\"deploy\"") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "value") == null); +} + test "encode task_spawned records resolved target and pid" { var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); defer aw.deinit(); diff --git a/src/cli/usage.zig b/src/cli/usage.zig index 056d70e..89fb08c 100644 --- a/src/cli/usage.zig +++ b/src/cli/usage.zig @@ -498,7 +498,8 @@ const policy_task_add_text = \\cr policy task add — Define a task \\ \\Usage: - \\ cr policy task add NAME [--target PATH ...] [SECRETS...] + \\ cr policy task add NAME [--target PATH ...] [--max-spawns N] + \\ [--window-ms MS] [SECRETS...] \\ \\Arguments: \\ NAME Task name @@ -512,6 +513,13 @@ const policy_task_add_text = \\ allowed (dev mode) — same \\ behavior as allowed_callers when \\ its list is empty. + \\ --max-spawns N Cap spawns per window (0 = no + \\ limit, default). Bounds how often + \\ this task's secrets can be pulled + \\ into a subprocess. + \\ --window-ms MS Rate-limit window in ms + \\ (default 60000). Used with + \\ --max-spawns. \\ \\Replaces any existing task with the same name. \\ diff --git a/src/error.zig b/src/error.zig index 333ef6e..d7fe647 100644 --- a/src/error.zig +++ b/src/error.zig @@ -7,6 +7,7 @@ pub const CoraError = error{ CallerNotAllowed, TargetNotAllowed, TargetNotFound, + RateLimited, InvalidConfig, InvalidPassphrase, PassphraseTooShort, diff --git a/src/main.zig b/src/main.zig index a210c6c..e6a4d22 100644 --- a/src/main.zig +++ b/src/main.zig @@ -756,6 +756,8 @@ fn cmdPolicyTask( // a flag/flag-value is treated as a secret name. Order between // flags and secrets does not matter, but `--target` must be // immediately followed by a path (a missing value is rejected). + var max_spawns: u32 = 0; + var window_ms: i64 = 60_000; var i: usize = 5; while (i < args.len) : (i += 1) { if (std.mem.eql(u8, args[i], "--target")) { @@ -768,6 +770,32 @@ fn cmdPolicyTask( i += 1; continue; } + if (std.mem.eql(u8, args[i], "--max-spawns")) { + if (i + 1 >= args.len) { + std.debug.print("--max-spawns requires a number\n", .{}); + usage.printPolicyTaskAdd(io, .stderr); + std.process.exit(1); + } + max_spawns = std.fmt.parseInt(u32, args[i + 1], 10) catch { + std.debug.print("invalid --max-spawns value: {s}\n", .{args[i + 1]}); + std.process.exit(1); + }; + i += 1; + continue; + } + if (std.mem.eql(u8, args[i], "--window-ms")) { + if (i + 1 >= args.len) { + std.debug.print("--window-ms requires a number\n", .{}); + usage.printPolicyTaskAdd(io, .stderr); + std.process.exit(1); + } + window_ms = std.fmt.parseInt(i64, args[i + 1], 10) catch { + std.debug.print("invalid --window-ms value: {s}\n", .{args[i + 1]}); + std.process.exit(1); + }; + i += 1; + continue; + } try secrets_list.append(allocator, args[i]); } @@ -778,6 +806,8 @@ fn cmdPolicyTask( .name = name, .allowed_secrets = secrets_list.items, .allowed_targets = targets.items, + .max_spawns = max_spawns, + .window_ms = window_ms, }); } else { // "remove" — validated by caller for (pol.tasks) |t| { diff --git a/src/policy/policy.zig b/src/policy/policy.zig index 7a13b7d..fc64e45 100644 --- a/src/policy/policy.zig +++ b/src/policy/policy.zig @@ -18,6 +18,14 @@ pub const Task = struct { /// supposed to be trusted; the missing layer is "what is the trusted /// caller allowed to run." allowed_targets: []const []const u8 = &.{}, + /// Maximum number of spawns permitted within `window_ms`. 0 = unlimited + /// (default, backward compatible). Bounds how many times a trusted + /// caller can pull this task's secrets into a subprocess before the + /// window resets. + max_spawns: u32 = 0, + /// Rolling window length for `max_spawns`, in milliseconds. Ignored when + /// `max_spawns` is 0. + window_ms: i64 = 60_000, }; /// Separator embedded in an `allowed_callers` entry to pin the caller to a @@ -228,6 +236,26 @@ test "isTargetAllowedForTask: non-empty whitelist enforces exact match" { try std.testing.expect(!isTargetAllowedForTask(&t, "/usr/bin/gh-extension")); } +test "serialize/parse roundtrip preserves rate-limit fields" { + const allocator = std.testing.allocator; + const original = Policy{ + .tasks = &.{ + .{ .name = "deploy", .allowed_secrets = &.{"SSH_KEY"}, .max_spawns = 5, .window_ms = 30_000 }, + }, + }; + const text = try serialize(allocator, original); + defer allocator.free(text); + var back = try parse(allocator, text); + defer free(allocator, &back); + try std.testing.expectEqual(@as(u32, 5), back.tasks[0].max_spawns); + try std.testing.expectEqual(@as(i64, 30_000), back.tasks[0].window_ms); +} + +test "task defaults are unlimited (max_spawns 0)" { + const t = Task{ .name = "x" }; + try std.testing.expectEqual(@as(u32, 0), t.max_spawns); +} + test "serialize/parse roundtrip preserves allowed_targets" { const allocator = std.testing.allocator; const original = Policy{ diff --git a/src/root.zig b/src/root.zig index e2c5a91..91a9f82 100644 --- a/src/root.zig +++ b/src/root.zig @@ -10,6 +10,7 @@ pub const store = @import("store/store.zig"); pub const secrets_codec = @import("store/secrets_codec.zig"); pub const proto = @import("service/proto.zig"); pub const idle = @import("service/idle.zig"); +pub const ratelimit = @import("service/ratelimit.zig"); pub const service = @import("service/service.zig"); pub const client = @import("service/client.zig"); pub const spawn_windows = @import("service/spawn_windows.zig"); @@ -29,6 +30,7 @@ test { _ = @import("store/secrets_codec.zig"); _ = @import("service/proto.zig"); _ = @import("service/idle.zig"); + _ = @import("service/ratelimit.zig"); _ = @import("service/service.zig"); _ = @import("policy/policy.zig"); _ = @import("identity/identity.zig"); diff --git a/src/service/ratelimit.zig b/src/service/ratelimit.zig new file mode 100644 index 0000000..c275475 --- /dev/null +++ b/src/service/ratelimit.zig @@ -0,0 +1,59 @@ +const std = @import("std"); + +/// Fixed-window spawn counter for one task. The service keeps one `Window` +/// per task name and consults it before every spawn, bounding how many +/// child processes a trusted caller can launch — and therefore how many +/// times it can pull a secret into a subprocess — within a time window. +/// +/// Fixed-window (not token-bucket) on purpose: the threat is bulk +/// exfiltration attempts, not smoothing bursty legitimate traffic. A window +/// that resets wholesale is simpler to reason about and cannot leak more +/// than `max` spawns per `window_ms`, which is the property we want to state +/// in the audit log. The clock is injected so the logic is deterministic +/// under test. +pub const Window = struct { + start_ms: i64 = 0, + count: u32 = 0, + + /// Record a spawn attempt at `now_ms` and return whether it is allowed. + /// `max == 0` disables the limit (always allowed). When the current + /// window has elapsed the counter resets and the attempt starts a fresh + /// window. + pub fn allow(self: *Window, now_ms: i64, max: u32, window_ms: i64) bool { + if (max == 0) return true; + if (self.count == 0 or (now_ms - self.start_ms) >= window_ms) { + self.start_ms = now_ms; + self.count = 1; + return true; + } + if (self.count >= max) return false; + self.count += 1; + return true; + } +}; + +test "max 0 means unlimited" { + var w = Window{}; + var i: usize = 0; + while (i < 100) : (i += 1) { + try std.testing.expect(w.allow(@intCast(i), 0, 1000)); + } +} + +test "blocks after max within window, resets after window elapses" { + var w = Window{}; + try std.testing.expect(w.allow(0, 2, 1000)); // 1st + try std.testing.expect(w.allow(100, 2, 1000)); // 2nd + try std.testing.expect(!w.allow(200, 2, 1000)); // 3rd — over limit + try std.testing.expect(!w.allow(999, 2, 1000)); // still in window + try std.testing.expect(w.allow(1000, 2, 1000)); // window elapsed — reset + try std.testing.expect(w.allow(1100, 2, 1000)); // 2nd of new window + try std.testing.expect(!w.allow(1200, 2, 1000)); // over again +} + +test "max 1 allows exactly one per window" { + var w = Window{}; + try std.testing.expect(w.allow(0, 1, 500)); + try std.testing.expect(!w.allow(499, 1, 500)); + try std.testing.expect(w.allow(500, 1, 500)); +} diff --git a/src/service/service.zig b/src/service/service.zig index 41585ff..36ea138 100644 --- a/src/service/service.zig +++ b/src/service/service.zig @@ -12,6 +12,7 @@ const audit = @import("../audit.zig"); const pipe_windows = @import("pipe_windows.zig"); const spawn_windows = @import("spawn_windows.zig"); const binhash = @import("../crypto/binhash.zig"); +const ratelimit = @import("ratelimit.zig"); const CoraError = @import("../error.zig").CoraError; /// Per-platform stdio passthrough handle carried from `handle` into @@ -186,6 +187,12 @@ pub const Service = struct { rejected: std.atomic.Value(u32), audit_logger: ?*audit.Logger, environ: ?*const std.process.Environ.Map, + /// Per-task spawn windows for rate limiting. Keyed by task name, whose + /// backing bytes live in `policy` for the service's lifetime, so the + /// slice is stored directly without duplication. The accept loop is + /// single-threaded, so this map needs no lock (same rationale as the + /// rest of the service's shared state). + rate_windows: std.StringHashMapUnmanaged(ratelimit.Window), pub fn start(allocator: std.mem.Allocator, io: Io, cfg: Config, secrets: *MemStore) !Service { const server = try Server.start(io, cfg.socket_path); @@ -201,6 +208,7 @@ pub const Service = struct { .rejected = .init(0), .audit_logger = cfg.audit_logger, .environ = cfg.environ, + .rate_windows = .empty, }; svc.emit(.{ .service_unlocked = .{ .ts_ms = nowMs(svc.io) } }); return svc; @@ -281,6 +289,7 @@ pub const Service = struct { if (builtin.os.tag != .windows) { Io.Dir.cwd().deleteFile(self.io, self.socket_path) catch {}; } + self.rate_windows.deinit(self.allocator); zeroAll(self.secrets); } @@ -577,6 +586,27 @@ pub const Service = struct { return CoraError.TargetNotAllowed; } + // Rate-limit gate: bound how many times this task's secrets can be + // pulled into a subprocess per window. Checked after the target gate + // (so a rejected target does not consume the quota) and before any + // secret is touched. Unlimited tasks (max_spawns == 0) short-circuit + // inside Window.allow. + if (task.max_spawns > 0) { + const gop = try self.rate_windows.getOrPut(self.allocator, task.name); + if (!gop.found_existing) gop.value_ptr.* = .{}; + if (!gop.value_ptr.allow(nowMs(self.io), task.max_spawns, task.window_ms)) { + self.emit(.{ .rate_limited = .{ + .ts_ms = nowMs(self.io), + .caller_pid = ident.pid, + .caller_bin = ident.path(), + .task = declared_task, + } }); + try proto.writeFrame(writer, .err, "rate limit exceeded"); + try writer.flush(); + return CoraError.RateLimited; + } + } + // Rewrite argv[0] in place with the resolved absolute path so the // child sees a deterministic argv and the spawn never falls back // to its own PATH search (which might pick a different binary). @@ -1035,6 +1065,11 @@ pub fn renderPolicy(w: *Io.Writer, pol: *const policy_mod.Policy) !void { try w.print(" targets ({d}):\n", .{t.allowed_targets.len}); for (t.allowed_targets) |tgt| try w.print(" {s}\n", .{tgt}); } + if (t.max_spawns == 0) { + try w.print(" rate limit: (none)\n", .{}); + } else { + try w.print(" rate limit: {d} spawns / {d} ms\n", .{ t.max_spawns, t.window_ms }); + } } } From 9aa07eca7eeaee582637ee01dc8a62fba1d33d52 Mon Sep 17 00:00:00 2001 From: badrus123 Date: Tue, 7 Jul 2026 17:55:45 +0700 Subject: [PATCH 04/11] feat: per-project socket on POSIX + CORA_SOCK override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service socket was /tmp/cora-.sock — one per user, so two projects could not be unlocked at once. POSIX now folds a hash of the working directory into the name (/tmp/cora--.sock); same-cwd clients recompute the identical path. CORA_SOCK overrides it explicitly. Windows named-pipe naming is unchanged. - service.formatPosixSocket: pure, per-cwd, tested - defaultSocketPath: CORA_SOCK env override, cwd-derived default --- src/service/service.zig | 49 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/service/service.zig b/src/service/service.zig index 36ea138..86e7a9f 100644 --- a/src/service/service.zig +++ b/src/service/service.zig @@ -55,8 +55,31 @@ pub fn defaultSocketPath(buf: []u8) ![]u8 { if (builtin.os.tag == .windows) { return pipe_windows.defaultPipeNameUtf8(buf); } + // Explicit override wins: point several shells at one service, or + // isolate beyond the per-cwd default. The service (cr unlock) and every + // client read the same env, so they agree. + if (std.c.getenv("CORA_SOCK")) |p| { + const s = std.mem.span(p); + if (s.len == 0 or s.len > buf.len) return CoraError.InvalidConfig; + @memcpy(buf[0..s.len], s); + return buf[0..s.len]; + } const uid: u64 = @intCast(std.c.getuid()); - return std.fmt.bufPrint(buf, default_socket_format, .{uid}); + // Per-project: fold a hash of the working directory into the socket name + // so `cr unlock` in project A and project B use different sockets and + // can be unlocked at once. Same-cwd clients recompute the identical path. + var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; + const cwd_ptr = std.c.getcwd(&cwd_buf, cwd_buf.len) orelse return CoraError.Io; + const cwd = std.mem.span(@as([*:0]u8, @ptrCast(cwd_ptr))); + return formatPosixSocket(buf, uid, cwd); +} + +/// Build the POSIX per-project socket path: `/tmp/cora--.sock` +/// where `` is the first 12 hex chars of SHA-256(cwd). Pure so the +/// per-project derivation is testable without changing process cwd. +fn formatPosixSocket(buf: []u8, uid: u64, cwd: []const u8) ![]u8 { + const digest = binhash.sha256Hex(cwd); + return std.fmt.bufPrint(buf, "/tmp/cora-{d}-{s}.sock", .{ uid, digest[0..12] }); } pub const Config = struct { @@ -1092,6 +1115,30 @@ test "defaultSocketPath builds platform-appropriate path" { } } +test "formatPosixSocket is per-cwd and stable" { + var a: [128]u8 = undefined; + var b: [128]u8 = undefined; + var c: [128]u8 = undefined; + const pa = try formatPosixSocket(&a, 501, "/home/x/project-a"); + const pb = try formatPosixSocket(&b, 501, "/home/x/project-b"); + const pc = try formatPosixSocket(&c, 501, "/home/x/project-a"); + // Different working directories → different sockets. + try std.testing.expect(!std.mem.eql(u8, pa, pb)); + // Same working directory (and uid) → identical socket, so a client can + // recompute the exact path the service bound. + try std.testing.expectEqualStrings(pa, pc); + try std.testing.expect(std.mem.startsWith(u8, pa, "/tmp/cora-501-")); + try std.testing.expect(std.mem.endsWith(u8, pa, ".sock")); +} + +test "formatPosixSocket varies by uid" { + var a: [128]u8 = undefined; + var b: [128]u8 = undefined; + const pa = try formatPosixSocket(&a, 501, "/same"); + const pb = try formatPosixSocket(&b, 502, "/same"); + try std.testing.expect(!std.mem.eql(u8, pa, pb)); +} + test "restrictSocketPermissions forces 0600" { if (builtin.os.tag == .windows) return error.SkipZigTest; const io = std.testing.io; From d4c4d16baafa659b619e494351274375fa0e84fc Mon Sep 17 00:00:00 2001 From: badrus123 Date: Tue, 7 Jul 2026 18:01:39 +0700 Subject: [PATCH 05/11] feat: cr secrets import --from-env Import secret values straight from the operator's environment instead of typing them on argv (which leaks into shell history and ps). Names whose variables are unset are reported and skipped; a partial import still writes what it found. Integration test spawns cr with a one-entry environment and asserts set/unset handling and that the value never reaches stdout/stderr. --- src/cli/usage.zig | 27 +++++++++++- src/main.zig | 89 +++++++++++++++++++++++++++++++++++++ tests/cli_integration.zig | 92 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) diff --git a/src/cli/usage.zig b/src/cli/usage.zig index 89fb08c..eb904a1 100644 --- a/src/cli/usage.zig +++ b/src/cli/usage.zig @@ -318,12 +318,13 @@ const secrets_text = \\cr secrets — Manage secrets in cora.zon \\ \\Usage: - \\ cr secrets [KEY] + \\ cr secrets [KEY] \\ \\Actions: \\ set KEY Add or update a secret (prompts for value) \\ list List secret names (no values) \\ delete KEY Remove a secret + \\ import --from-env NAME... Import values from the environment \\ \\Each action prompts for the passphrase, decrypts cora.zon, mutates, \\and re-encrypts. The on-disk file is never written in plaintext. @@ -387,6 +388,30 @@ const secrets_delete_text = \\ ; +pub fn printSecretsImport(io: Io, ch: Channel) void { + write(io, ch, secrets_import_text); +} + +const secrets_import_text = + \\cr secrets import — Import secrets from the environment + \\ + \\Usage: + \\ cr secrets import --from-env NAME... + \\ + \\Arguments: + \\ NAME... Environment variable names to + \\ import. Each value is read from + \\ your own environment and stored + \\ under the same name. + \\ + \\Values come from the environment, never argv, so they do not leak into + \\shell history or `ps`. A NAME whose variable is unset is skipped. + \\ + \\ export GH_TOKEN=ghp_... + \\ cr secrets import --from-env GH_TOKEN STRIPE_KEY + \\ +; + // --- policy --------------------------------------------------------------- pub fn printPolicy(io: Io, ch: Channel) void { diff --git a/src/main.zig b/src/main.zig index e6a4d22..ce9b602 100644 --- a/src/main.zig +++ b/src/main.zig @@ -160,6 +160,7 @@ fn cmdHelp(io: Io, parts: []const []const u8) !void { if (std.mem.eql(u8, a, "set")) return usage.printSecretsSet(io, .stdout); if (std.mem.eql(u8, a, "list")) return usage.printSecretsList(io, .stdout); if (std.mem.eql(u8, a, "delete")) return usage.printSecretsDelete(io, .stdout); + if (std.mem.eql(u8, a, "import")) return usage.printSecretsImport(io, .stdout); std.debug.print("no help topic: secrets {s}\n", .{a}); usage.printSecrets(io, .stderr); std.process.exit(1); @@ -236,6 +237,14 @@ fn cmdSecrets(arena: std.mem.Allocator, io: Io, path: []const u8, args: []const try cmdSecretsDelete(arena, io, path, args[3]); return; } + if (std.mem.eql(u8, action, "import")) { + if (args.len >= 4 and usage.isHelpFlag(args[3])) { + usage.printSecretsImport(io, .stdout); + return; + } + try cmdSecretsImport(arena, io, path, args[3..]); + return; + } std.debug.print("unknown secrets action: {s}\n", .{action}); usage.printSecrets(io, .stderr); @@ -942,6 +951,86 @@ fn cmdSecretsList(allocator: std.mem.Allocator, io: Io, path: []const u8) !void while (it.next()) |k| usage.outPrint(io, "{s}\n", .{k.*}); } +/// `cr secrets import --from-env NAME...` — read each named environment +/// variable from the operator's own environment and store its value in the +/// vault under the same name. Values never appear on argv (avoiding the +/// `ps`/shell-history leak of `secrets set VALUE`); they come straight from +/// the caller's env. A name whose variable is unset is reported and skipped, +/// not treated as an error, so a partial import still writes what it found. +fn cmdSecretsImport(allocator: std.mem.Allocator, io: Io, path: []const u8, rest: []const []const u8) !void { + if (rest.len == 0 or !std.mem.eql(u8, rest[0], "--from-env")) { + usage.printSecretsImport(io, .stderr); + std.process.exit(1); + } + const names = rest[1..]; + if (names.len == 0) { + std.debug.print("no variable names given\n", .{}); + usage.printSecretsImport(io, .stderr); + std.process.exit(1); + } + + const cwd = Io.Dir.cwd(); + if (!fileExists(io, cwd, path)) { + std.debug.print("no {s} — run `cr init` first\n", .{path}); + std.process.exit(1); + } + + // Validate all names up front so a bad name never burns the passphrase + // entry or leaves a half-written vault. + for (names) |name| { + if (name.len > cora.secrets_codec.max_key_len) { + std.debug.print("key too long: {s} ({d} > {d})\n", .{ name, name.len, cora.secrets_codec.max_key_len }); + std.process.exit(1); + } + } + + var pass_buf: [256]u8 = undefined; + defer std.crypto.secureZero(u8, &pass_buf); + const passphrase = try readSecret("Passphrase: ", &pass_buf); + + const encoded = try cora.store.readFile(allocator, io, cwd, path); + defer allocator.free(encoded); + var dec = cora.store.decrypt(allocator, io, passphrase, encoded) catch |err| switch (err) { + cora.CoraError.AuthFailed => { + std.debug.print("authentication failed\n", .{}); + std.process.exit(1); + }, + else => return err, + }; + defer dec.deinit(); + + var store_ = cora.MemStore.init(allocator); + defer store_.deinit(); + try cora.secrets_codec.decode(allocator, dec.secrets_plaintext, &store_); + + var imported: usize = 0; + for (names) |name| { + const name_z = try allocator.dupeZ(u8, name); + defer allocator.free(name_z); + const val_ptr = std.c.getenv(name_z) orelse { + usage.outPrint(io, "skip (not set): {s}\n", .{name}); + continue; + }; + const value = std.mem.span(val_ptr); + store_.put(name, value) catch |err| switch (err) { + cora.CoraError.SecretTooLarge => { + std.debug.print("skip (value too large): {s}\n", .{name}); + continue; + }, + else => return err, + }; + imported += 1; + usage.outPrint(io, "imported {s}\n", .{name}); + } + + if (imported == 0) { + std.debug.print("nothing imported\n", .{}); + std.process.exit(1); + } + try cora.store.saveSecrets(allocator, io, cwd, path, passphrase, &store_, dec.config_bytes); + usage.outPrint(io, "imported {d} secret(s)\n", .{imported}); +} + fn cmdSecretsDelete(allocator: std.mem.Allocator, io: Io, path: []const u8, key: []const u8) !void { const cwd = Io.Dir.cwd(); if (!fileExists(io, cwd, path)) { diff --git a/tests/cli_integration.zig b/tests/cli_integration.zig index 42fa9d2..5fd7467 100644 --- a/tests/cli_integration.zig +++ b/tests/cli_integration.zig @@ -81,6 +81,62 @@ fn runCr( return .{ .term = term, .stdout = stdout_slice, .stderr = stderr_slice }; } +/// Like `runCr` but runs the child with an explicit, minimal environment +/// built from `extra_env` only. Used to exercise `secrets import --from-env`, +/// which reads values from the caller's environment — the import path touches +/// no other env var, so a one-entry environment is sufficient and keeps the +/// injection cross-platform (Environ.Map, not libc setenv). +fn runCrEnv( + allocator: std.mem.Allocator, + io: Io, + cwd_dir: Io.Dir, + args: []const []const u8, + stdin_input: []const u8, + extra_env: []const [2][]const u8, +) !RunResult { + var argv_list: std.ArrayList([]const u8) = .empty; + defer argv_list.deinit(allocator); + try argv_list.append(allocator, opts.cr_bin_path); + try argv_list.appendSlice(allocator, args); + + var env = std.process.Environ.Map.init(allocator); + defer env.deinit(); + for (extra_env) |kv| try env.put(kv[0], kv[1]); + + var child = try std.process.spawn(io, .{ + .argv = argv_list.items, + .cwd = .{ .dir = cwd_dir }, + .environ_map = &env, + .stdin = .pipe, + .stdout = .pipe, + .stderr = .pipe, + }); + errdefer child.kill(io); + + if (stdin_input.len > 0) { + try child.stdin.?.writeStreamingAll(io, stdin_input); + } + child.stdin.?.close(io); + child.stdin = null; + + var mr_buf: Io.File.MultiReader.Buffer(2) = undefined; + var mr: Io.File.MultiReader = undefined; + mr.init(allocator, io, mr_buf.toStreams(), &.{ child.stdout.?, child.stderr.? }); + defer mr.deinit(); + + while (mr.fill(64, .none)) |_| {} else |err| switch (err) { + error.EndOfStream => {}, + else => |e| return e, + } + try mr.checkAnyError(); + + const term = try child.wait(io); + const stdout_slice = try mr.toOwnedSlice(0); + errdefer allocator.free(stdout_slice); + const stderr_slice = try mr.toOwnedSlice(1); + return .{ .term = term, .stdout = stdout_slice, .stderr = stderr_slice }; +} + test "cr binary is installed at expected path" { var dir = try Io.Dir.cwd().openDir(std.testing.io, std.fs.path.dirname(opts.cr_bin_path).?, .{}); defer dir.close(std.testing.io); @@ -160,6 +216,42 @@ test "secrets set preserves policy (regression: finding 1)" { try std.testing.expect(std.mem.indexOf(u8, show.stdout, "API_KEY") != null); } +test "cr secrets import --from-env stores set vars and skips unset" { + const allocator = std.testing.allocator; + const io = std.testing.io; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try initFixture(allocator, io, tmp.dir); + + // CORA_IMPORT_A is provided in the child environment; CORA_IMPORT_MISSING + // is deliberately absent. + { + var r = try runCrEnv( + allocator, + io, + tmp.dir, + &.{ "secrets", "import", "--from-env", "CORA_IMPORT_A", "CORA_IMPORT_MISSING" }, + pass_line, + &.{.{ "CORA_IMPORT_A", "value-from-env" }}, + ); + defer r.deinit(allocator); + try std.testing.expect(r.exitOk()); + try std.testing.expect(std.mem.indexOf(u8, r.stdout, "imported CORA_IMPORT_A") != null); + try std.testing.expect(std.mem.indexOf(u8, r.stdout, "skip (not set): CORA_IMPORT_MISSING") != null); + // The value must never appear on stdout/stderr. + try std.testing.expect(std.mem.indexOf(u8, r.stdout, "value-from-env") == null); + try std.testing.expect(std.mem.indexOf(u8, r.stderr, "value-from-env") == null); + } + + // The imported name is now in the vault; the unset one is not. + var list = try runCr(allocator, io, tmp.dir, &.{ "secrets", "list" }, pass_line); + defer list.deinit(allocator); + try std.testing.expect(list.exitOk()); + try std.testing.expect(std.mem.indexOf(u8, list.stdout, "CORA_IMPORT_A") != null); + try std.testing.expect(std.mem.indexOf(u8, list.stdout, "CORA_IMPORT_MISSING") == null); +} + // --- Cross-platform parity contracts -------------------------------------- // Tier 2 (Named Pipes + GetNamedPipeClientProcessId + CreateProcessW // daemonize) brings Windows in line with macOS and Linux. The tests From c0d3a67098d171773b4ebb87f5afbde96cf95c1c Mon Sep 17 00:00:00 2001 From: badrus123 Date: Tue, 7 Jul 2026 18:06:53 +0700 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20cr=20daemon=20unit=20=E2=80=94=20?= =?UTF-8?q?supervised-service=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emits a systemd --user unit (Linux) or launchd LaunchAgent plist (macOS) running `cr unlock --foreground` from the current directory. No auto-start / RunAtLoad / Restart: the passphrase model requires interactive unlock, so the template supervises but never brings the service up unattended. - service/daemon.zig: pure renderSystemdUnit / renderLaunchdPlist (tested) - cli: `cr daemon unit`, resolves own path via pid lookup + cwd --- src/cli/usage.zig | 23 +++++++++- src/main.zig | 46 +++++++++++++++++++ src/root.zig | 2 + src/service/daemon.zig | 102 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 src/service/daemon.zig diff --git a/src/cli/usage.zig b/src/cli/usage.zig index eb904a1..8dfc6e2 100644 --- a/src/cli/usage.zig +++ b/src/cli/usage.zig @@ -85,9 +85,10 @@ const top_text = \\ lock Stop service, zero memory \\ status Show service state \\ exec TASK -- argv... Spawn subprocess with task secrets injected - \\ secrets [KEY] Manage secrets + \\ secrets ... Manage secrets \\ policy ... Manage policy \\ audit [opts] Read audit log + \\ daemon unit Print a service unit template \\ tui Launch interactive TUI menu \\ verify --pid PID Resolve binary path of a pid (debug) \\ version Show version @@ -287,6 +288,26 @@ const tui_text = // --- verify --------------------------------------------------------------- +pub fn printDaemon(io: Io, ch: Channel) void { + write(io, ch, daemon_text); +} + +const daemon_text = + \\cr daemon — Persistent-service unit templates + \\ + \\Usage: + \\ cr daemon unit + \\ + \\Prints a service unit for the current OS (systemd --user on Linux, + \\launchd LaunchAgent on macOS) that runs `cr unlock --foreground` from + \\the current directory. Cora does not auto-start — the unit still + \\prompts for the passphrase on start, keeping the key with the operator. + \\ + \\ cr daemon unit > ~/.config/systemd/user/cora.service # Linux + \\ cr daemon unit > ~/Library/LaunchAgents/dev.cora.agent.plist # macOS + \\ +; + pub fn printVerify(io: Io, ch: Channel) void { write(io, ch, verify_text); } diff --git a/src/main.zig b/src/main.zig index ce9b602..4673595 100644 --- a/src/main.zig +++ b/src/main.zig @@ -101,6 +101,10 @@ pub fn main(init: std.process.Init) !void { try cmdSecrets(arena, io, default_path, args); return; } + if (std.mem.eql(u8, sub, "daemon")) { + try cmdDaemon(arena, io, args); + return; + } std.debug.print("unknown subcommand: {s}\n", .{sub}); usage.printTop(io, .stderr); @@ -141,6 +145,7 @@ fn cmdHelp(io: Io, parts: []const []const u8) !void { if (std.mem.eql(u8, sub, "exec")) return usage.printExec(io, .stdout); if (std.mem.eql(u8, sub, "tui")) return usage.printTui(io, .stdout); if (std.mem.eql(u8, sub, "verify")) return usage.printVerify(io, .stdout); + if (std.mem.eql(u8, sub, "daemon")) return usage.printDaemon(io, .stdout); if (std.mem.eql(u8, sub, "version")) return printVersion(io); if (usage.isHelpFlag(sub)) return usage.printTop(io, .stdout); @@ -533,6 +538,47 @@ fn execExitByte(code: i32) u8 { return if (low == 0) 1 else low; } +/// `cr daemon unit` — print a supervised-service unit template for the +/// current OS to stdout. Cora deliberately does not auto-start (the operator +/// holds the passphrase), so the template runs `cr unlock --foreground` and +/// still prompts on start. Resolves this binary's absolute path via the same +/// pid→path lookup `cr verify` uses, and the current working directory. +fn cmdDaemon(allocator: std.mem.Allocator, io: Io, args: []const []const u8) !void { + if (args.len < 3 or usage.isHelpFlag(args[2])) { + usage.printDaemon(io, if (args.len < 3) .stderr else .stdout); + if (args.len < 3) std.process.exit(1); + return; + } + if (!std.mem.eql(u8, args[2], "unit")) { + std.debug.print("unknown daemon action: {s}\n", .{args[2]}); + usage.printDaemon(io, .stderr); + std.process.exit(1); + } + + const self_pid: i32 = @intCast(std.c.getpid()); + const ident = cora.identity.lookupByPid(self_pid) catch { + std.debug.print("could not resolve own binary path\n", .{}); + std.process.exit(1); + }; + + var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; + const cwd_ptr = std.c.getcwd(&cwd_buf, cwd_buf.len) orelse { + std.debug.print("could not resolve working directory\n", .{}); + std.process.exit(1); + }; + const cwd = std.mem.span(@as([*:0]u8, @ptrCast(cwd_ptr))); + + if (!cora.daemon.hostSupported()) { + std.debug.print("no unit template for this OS\n", .{}); + std.process.exit(1); + } + + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + try cora.daemon.renderForHost(&aw.writer, ident.path(), cwd); + usage.outPrint(io, "{s}", .{aw.written()}); +} + fn cmdVerify(io: Io, args: []const []const u8) !void { var pid: i32 = 0; var i: usize = 2; diff --git a/src/root.zig b/src/root.zig index 91a9f82..c524382 100644 --- a/src/root.zig +++ b/src/root.zig @@ -11,6 +11,7 @@ pub const secrets_codec = @import("store/secrets_codec.zig"); pub const proto = @import("service/proto.zig"); pub const idle = @import("service/idle.zig"); pub const ratelimit = @import("service/ratelimit.zig"); +pub const daemon = @import("service/daemon.zig"); pub const service = @import("service/service.zig"); pub const client = @import("service/client.zig"); pub const spawn_windows = @import("service/spawn_windows.zig"); @@ -31,6 +32,7 @@ test { _ = @import("service/proto.zig"); _ = @import("service/idle.zig"); _ = @import("service/ratelimit.zig"); + _ = @import("service/daemon.zig"); _ = @import("service/service.zig"); _ = @import("policy/policy.zig"); _ = @import("identity/identity.zig"); diff --git a/src/service/daemon.zig b/src/service/daemon.zig new file mode 100644 index 0000000..72aabf2 --- /dev/null +++ b/src/service/daemon.zig @@ -0,0 +1,102 @@ +//! Persistent-service unit generators. Cora does not ship an auto-starting +//! daemon by design — the operator holds the passphrase, so the service +//! cannot come up unattended. What these emit is a *template* the operator +//! installs and starts manually (systemd `--user` on Linux, launchd +//! LaunchAgent on macOS); the unit runs `cr unlock --foreground`, which still +//! prompts for the passphrase on start. This keeps "the human holds the key" +//! intact while giving a supervised, restart-on-crash service. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; + +/// systemd user unit. `Type=simple` + `--foreground` so systemd tracks the +/// process directly. No `Restart=always`: an unlocked service that silently +/// re-derives keys unattended would defeat the passphrase model, so a crash +/// is left for the operator to notice and restart. +pub fn renderSystemdUnit(w: *Io.Writer, exe: []const u8, workdir: []const u8) !void { + try w.print( + \\[Unit] + \\Description=Cora secret injection runtime + \\After=default.target + \\ + \\[Service] + \\Type=simple + \\WorkingDirectory={s} + \\ExecStart={s} unlock --foreground + \\# Passphrase is prompted on start; run `systemctl --user start cora` + \\# from an interactive session, or wire in systemd-ask-password. + \\ + \\[Install] + \\WantedBy=default.target + \\ + , .{ workdir, exe }); +} + +/// launchd LaunchAgent plist (macOS). RunAtLoad is intentionally false for +/// the same reason systemd gets no Restart: the service must not come up +/// without an interactive passphrase entry. +pub fn renderLaunchdPlist(w: *Io.Writer, exe: []const u8, workdir: []const u8) !void { + try w.print( + \\ + \\ + \\ + \\ + \\ Label + \\ dev.cora.agent + \\ ProgramArguments + \\ + \\ {s} + \\ unlock + \\ --foreground + \\ + \\ WorkingDirectory + \\ {s} + \\ RunAtLoad + \\ + \\ + \\ + \\ + , .{ exe, workdir }); +} + +/// True when this OS has a unit template. Windows has no user-service +/// template here yet; callers should check this before `renderForHost`. +pub fn hostSupported() bool { + return builtin.os.tag == .linux or builtin.os.tag == .macos; +} + +/// Emit the unit template appropriate for the current OS. Caller must have +/// confirmed `hostSupported()` first; an unsupported OS is unreachable. +pub fn renderForHost(w: *Io.Writer, exe: []const u8, workdir: []const u8) !void { + switch (builtin.os.tag) { + .linux => try renderSystemdUnit(w, exe, workdir), + .macos => try renderLaunchdPlist(w, exe, workdir), + else => unreachable, + } +} + +test "systemd unit carries ExecStart with --foreground and workdir" { + var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try renderSystemdUnit(&aw.writer, "/usr/local/bin/cr", "/home/x/proj"); + const out = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, out, "ExecStart=/usr/local/bin/cr unlock --foreground") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "WorkingDirectory=/home/x/proj") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "[Service]") != null); + // No unattended restart — the passphrase model forbids it. + try std.testing.expect(std.mem.indexOf(u8, out, "Restart=always") == null); +} + +test "launchd plist carries program args and RunAtLoad false" { + var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try renderLaunchdPlist(&aw.writer, "/opt/cr", "/tmp/proj"); + const out = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, out, "/opt/cr") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "--foreground") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "/tmp/proj") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "RunAtLoad") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "") != null); +} From 5480cdfe2c1de2ac93fd2fbde0dae52aae76586a Mon Sep 17 00:00:00 2001 From: badrus123 Date: Tue, 7 Jul 2026 18:09:53 +0700 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20cr=20rekey=20=E2=80=94=20change?= =?UTF-8?q?=20the=20passphrase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decrypts with the current passphrase and re-encrypts secrets + policy under a freshly derived key (new random salt/nonce), so a leaked old passphrase can no longer open the file. Secret values live in memory only for the call and are zeroed on return. Integration test covers old-fails/new-works and mismatch rejection. --- src/cli/usage.zig | 18 ++++++++++++ src/main.zig | 61 +++++++++++++++++++++++++++++++++++++++ tests/cli_integration.zig | 58 +++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/src/cli/usage.zig b/src/cli/usage.zig index 8dfc6e2..a9811b6 100644 --- a/src/cli/usage.zig +++ b/src/cli/usage.zig @@ -81,6 +81,7 @@ const top_text = \\ \\Subcommands: \\ init [path] Create encrypted cora.zon + \\ rekey Change the passphrase \\ unlock [--foreground] Start background service \\ lock Stop service, zero memory \\ status Show service state @@ -288,6 +289,23 @@ const tui_text = // --- verify --------------------------------------------------------------- +pub fn printRekey(io: Io, ch: Channel) void { + write(io, ch, rekey_text); +} + +const rekey_text = + \\cr rekey — Change the passphrase + \\ + \\Usage: + \\ cr rekey + \\ + \\Prompts for the current passphrase, then a new one (twice). Decrypts the + \\vault and re-encrypts it under a freshly derived key with a new random + \\salt. The on-disk ciphertext changes completely, so a leaked old + \\passphrase can no longer open the file. Secrets and policy are preserved. + \\ +; + pub fn printDaemon(io: Io, ch: Channel) void { write(io, ch, daemon_text); } diff --git a/src/main.zig b/src/main.zig index 4673595..895c85d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -105,6 +105,14 @@ pub fn main(init: std.process.Init) !void { try cmdDaemon(arena, io, args); return; } + if (std.mem.eql(u8, sub, "rekey")) { + if (argvHasHelpFlag(args[2..])) { + usage.printRekey(io, .stdout); + return; + } + try cmdRekey(arena, io, default_path); + return; + } std.debug.print("unknown subcommand: {s}\n", .{sub}); usage.printTop(io, .stderr); @@ -146,6 +154,7 @@ fn cmdHelp(io: Io, parts: []const []const u8) !void { if (std.mem.eql(u8, sub, "tui")) return usage.printTui(io, .stdout); if (std.mem.eql(u8, sub, "verify")) return usage.printVerify(io, .stdout); if (std.mem.eql(u8, sub, "daemon")) return usage.printDaemon(io, .stdout); + if (std.mem.eql(u8, sub, "rekey")) return usage.printRekey(io, .stdout); if (std.mem.eql(u8, sub, "version")) return printVersion(io); if (usage.isHelpFlag(sub)) return usage.printTop(io, .stdout); @@ -886,6 +895,58 @@ fn cmdPolicyTask( usage.outPrint(io, "policy updated\n", .{}); } +/// `cr rekey` — change the passphrase. Decrypts with the current passphrase, +/// then re-encrypts the same secrets and policy under a freshly derived key +/// (new random salt + nonce). The on-disk ciphertext changes entirely, so a +/// leaked old passphrase can no longer open the new file. Secret values live +/// in memory only for the span of this call and are zeroed by the defers. +fn cmdRekey(allocator: std.mem.Allocator, io: Io, path: []const u8) !void { + const cwd = Io.Dir.cwd(); + if (!fileExists(io, cwd, path)) { + std.debug.print("no {s} — run `cr init` first\n", .{path}); + std.process.exit(1); + } + + var old_buf: [256]u8 = undefined; + defer std.crypto.secureZero(u8, &old_buf); + const old_pass = try readSecret("Current passphrase: ", &old_buf); + + const encoded = try cora.store.readFile(allocator, io, cwd, path); + defer allocator.free(encoded); + var dec = cora.store.decrypt(allocator, io, old_pass, encoded) catch |err| switch (err) { + cora.CoraError.AuthFailed => { + std.debug.print("authentication failed\n", .{}); + std.process.exit(1); + }, + else => return err, + }; + defer dec.deinit(); + + var new_buf: [256]u8 = undefined; + defer std.crypto.secureZero(u8, &new_buf); + const new_pass = try readSecret("New passphrase: ", &new_buf); + + var confirm_buf: [256]u8 = undefined; + defer std.crypto.secureZero(u8, &confirm_buf); + const confirm = try readSecret("Confirm new passphrase: ", &confirm_buf); + + if (!std.mem.eql(u8, new_pass, confirm)) { + std.debug.print("passphrases do not match\n", .{}); + std.process.exit(1); + } + if (new_pass.len < cora.store.min_passphrase_len) { + std.debug.print("passphrase too short (min {d} chars)\n", .{cora.store.min_passphrase_len}); + std.process.exit(1); + } + + var store_ = cora.MemStore.init(allocator); + defer store_.deinit(); + try cora.secrets_codec.decode(allocator, dec.secrets_plaintext, &store_); + + try cora.store.saveSecrets(allocator, io, cwd, path, new_pass, &store_, dec.config_bytes); + usage.outPrint(io, "passphrase changed\n", .{}); +} + fn cmdStatus(allocator: std.mem.Allocator, io: Io) !void { var sock_buf: [128]u8 = undefined; const sock_path = try cora.service.defaultSocketPath(&sock_buf); diff --git a/tests/cli_integration.zig b/tests/cli_integration.zig index 5fd7467..8c4076b 100644 --- a/tests/cli_integration.zig +++ b/tests/cli_integration.zig @@ -252,6 +252,64 @@ test "cr secrets import --from-env stores set vars and skips unset" { try std.testing.expect(std.mem.indexOf(u8, list.stdout, "CORA_IMPORT_MISSING") == null); } +test "cr rekey changes passphrase: old fails, new works, secrets survive" { + const allocator = std.testing.allocator; + const io = std.testing.io; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try initFixture(allocator, io, tmp.dir); + + const new_pass = "brand new battery staple"; + + // Seed a secret under the original passphrase. + { + var r = try runCr(allocator, io, tmp.dir, &.{ "secrets", "set", "KEEP_ME" }, pass_line ++ "keepval\n"); + defer r.deinit(allocator); + try std.testing.expect(r.exitOk()); + } + + // Rekey: current, new, confirm. + { + const stdin = passphrase ++ "\n" ++ new_pass ++ "\n" ++ new_pass ++ "\n"; + var r = try runCr(allocator, io, tmp.dir, &.{"rekey"}, stdin); + defer r.deinit(allocator); + try std.testing.expect(r.exitOk()); + try std.testing.expect(std.mem.indexOf(u8, r.stdout, "passphrase changed") != null); + } + + // Old passphrase no longer decrypts. + { + var r = try runCr(allocator, io, tmp.dir, &.{ "secrets", "list" }, pass_line); + defer r.deinit(allocator); + try std.testing.expect(!r.exitOk()); + try std.testing.expect(std.mem.indexOf(u8, r.stderr, "authentication failed") != null); + } + + // New passphrase works and the secret survived. + { + var r = try runCr(allocator, io, tmp.dir, &.{ "secrets", "list" }, new_pass ++ "\n"); + defer r.deinit(allocator); + try std.testing.expect(r.exitOk()); + try std.testing.expect(std.mem.indexOf(u8, r.stdout, "KEEP_ME") != null); + } +} + +test "cr rekey rejects mismatched new passphrase" { + const allocator = std.testing.allocator; + const io = std.testing.io; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try initFixture(allocator, io, tmp.dir); + + const stdin = passphrase ++ "\n" ++ "new-one-here\n" ++ "different-two\n"; + var r = try runCr(allocator, io, tmp.dir, &.{"rekey"}, stdin); + defer r.deinit(allocator); + try std.testing.expect(!r.exitOk()); + try std.testing.expect(std.mem.indexOf(u8, r.stderr, "passphrases do not match") != null); +} + // --- Cross-platform parity contracts -------------------------------------- // Tier 2 (Named Pipes + GetNamedPipeClientProcessId + CreateProcessW // daemonize) brings Windows in line with macOS and Linux. The tests From 961396be23f93242fb86ec450c4853ba92b861d1 Mon Sep 17 00:00:00 2001 From: badrus123 Date: Tue, 7 Jul 2026 18:16:38 +0700 Subject: [PATCH 08/11] security: per-secret TTL / expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secrets can carry expiry (and creation) metadata. The service refuses to inject an expired secret — it is treated as missing and audited as such — so a stale credential never reaches a child process. Backward compatible: the codec reads and writes flat {"K":"v"} for metadata-free secrets and only emits the rich {"v":..,"exp":..,"cr":..} form when a TTL is set. - store/mem.zig: Entry{secret,expires_ms,created_ms}, isExpired, putWithMeta - store/secrets_codec.zig: parse/emit both flat and rich forms - service: injection gated on entry.isExpired(now) - cli: `secrets set KEY --ttl SECONDS` --- src/cli/usage.zig | 8 ++- src/main.zig | 28 +++++++-- src/service/service.zig | 16 +++-- src/store/mem.zig | 74 ++++++++++++++++++---- src/store/secrets_codec.zig | 121 +++++++++++++++++++++++++++++++++++- tests/cli_integration.zig | 23 +++++++ 6 files changed, 248 insertions(+), 22 deletions(-) diff --git a/src/cli/usage.zig b/src/cli/usage.zig index a9811b6..2541bc3 100644 --- a/src/cli/usage.zig +++ b/src/cli/usage.zig @@ -384,11 +384,17 @@ const secrets_set_text = \\cr secrets set — Add or update a secret \\ \\Usage: - \\ cr secrets set KEY + \\ cr secrets set KEY [--ttl SECONDS] \\ \\Arguments: \\ KEY Secret name (env var name) \\ + \\Flags: + \\ --ttl SECONDS Expire the secret this many + \\ seconds from now. After expiry the + \\ service refuses to inject it (it is + \\ treated as missing). + \\ \\Prompts for the passphrase, then for the secret value (echo masked). \\ ; diff --git a/src/main.zig b/src/main.zig index 895c85d..931b5f5 100644 --- a/src/main.zig +++ b/src/main.zig @@ -228,7 +228,18 @@ fn cmdSecrets(arena: std.mem.Allocator, io: Io, path: []const u8, args: []const usage.printSecretsSet(io, .stderr); std.process.exit(1); } - try cmdSecretsSet(arena, io, path, args[3]); + var ttl_seconds: i64 = 0; + var i: usize = 4; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--ttl") and i + 1 < args.len) { + ttl_seconds = std.fmt.parseInt(i64, args[i + 1], 10) catch { + std.debug.print("invalid --ttl value: {s}\n", .{args[i + 1]}); + std.process.exit(1); + }; + i += 1; + } + } + try cmdSecretsSet(arena, io, path, args[3], ttl_seconds); return; } if (std.mem.eql(u8, action, "list")) { @@ -958,7 +969,7 @@ fn cmdStatus(allocator: std.mem.Allocator, io: Io) !void { usage.outPrint(io, "status: running\n secrets: {d}\n idle remaining: {d} ms\n", .{ s.secrets_count, s.idle_remaining_ms }); } -fn cmdSecretsSet(allocator: std.mem.Allocator, io: Io, path: []const u8, key: []const u8) !void { +fn cmdSecretsSet(allocator: std.mem.Allocator, io: Io, path: []const u8, key: []const u8, ttl_seconds: i64) !void { const cwd = Io.Dir.cwd(); if (!fileExists(io, cwd, path)) { std.debug.print("no {s} — run `cr init` first\n", .{path}); @@ -1003,9 +1014,18 @@ fn cmdSecretsSet(allocator: std.mem.Allocator, io: Io, path: []const u8, key: [] defer store_.deinit(); try cora.secrets_codec.decode(allocator, dec.secrets_plaintext, &store_); - try store_.put(key, value); + if (ttl_seconds > 0) { + const now_ms = Io.Timestamp.now(io, .real).toMilliseconds(); + try store_.putWithMeta(key, value, now_ms + ttl_seconds * 1000, now_ms); + } else { + try store_.put(key, value); + } try cora.store.saveSecrets(allocator, io, cwd, path, passphrase, &store_, dec.config_bytes); - usage.outPrint(io, "set {s}\n", .{key}); + if (ttl_seconds > 0) { + usage.outPrint(io, "set {s} (expires in {d}s)\n", .{ key, ttl_seconds }); + } else { + usage.outPrint(io, "set {s}\n", .{key}); + } } fn cmdSecretsList(allocator: std.mem.Allocator, io: Io, path: []const u8) !void { diff --git a/src/service/service.zig b/src/service/service.zig index 86e7a9f..85799f7 100644 --- a/src/service/service.zig +++ b/src/service/service.zig @@ -660,14 +660,20 @@ pub const Service = struct { var missing_names = std.ArrayList([]const u8).empty; defer missing_names.deinit(self.allocator); - var tmp = SecretBuf{}; - defer tmp.zero(); + const inject_now = nowMs(self.io); for (task.allowed_secrets) |name| { - self.secrets.copyInto(name, &tmp) catch { + const entry = self.secrets.getEntry(name) orelse { try missing_names.append(self.allocator, name); continue; }; - try env_map.put(name, tmp.constSlice()); + // An expired secret is treated as missing: it must never reach a + // child process, and the audit trail records it via + // secret_missing so the operator sees the TTL fired. + if (entry.isExpired(inject_now)) { + try missing_names.append(self.allocator, name); + continue; + } + try env_map.put(name, entry.secret.constSlice()); try injected_names.append(self.allocator, name); } @@ -831,7 +837,7 @@ pub const Service = struct { fn zeroAll(s: *MemStore) void { var it = s.map.iterator(); - while (it.next()) |entry| entry.value_ptr.*.zero(); + while (it.next()) |entry| entry.value_ptr.*.secret.zero(); } /// Return true when the requested protocol op accesses secret values and diff --git a/src/store/mem.zig b/src/store/mem.zig index 7315c58..b7ecf6a 100644 --- a/src/store/mem.zig +++ b/src/store/mem.zig @@ -2,9 +2,23 @@ const std = @import("std"); const CoraError = @import("../error.zig").CoraError; const SecretBuf = @import("../crypto/secret_buf.zig").SecretBuf; +/// One stored secret: the value buffer plus optional lifetime metadata. +/// `expires_ms` of 0 means "never expires" (the default and the shape of +/// every secret written before TTLs existed). `created_ms` of 0 means +/// "unknown" for the same backward-compatibility reason. +pub const Entry = struct { + secret: SecretBuf = .{}, + expires_ms: i64 = 0, + created_ms: i64 = 0, + + pub fn isExpired(self: *const Entry, now_ms: i64) bool { + return self.expires_ms != 0 and now_ms >= self.expires_ms; + } +}; + pub const MemStore = struct { allocator: std.mem.Allocator, - map: std.StringHashMapUnmanaged(*SecretBuf) = .{}, + map: std.StringHashMapUnmanaged(*Entry) = .{}, pub fn init(allocator: std.mem.Allocator) MemStore { return .{ .allocator = allocator }; @@ -13,7 +27,7 @@ pub const MemStore = struct { pub fn deinit(self: *MemStore) void { var it = self.map.iterator(); while (it.next()) |entry| { - entry.value_ptr.*.zero(); + entry.value_ptr.*.secret.zero(); self.allocator.destroy(entry.value_ptr.*); self.allocator.free(entry.key_ptr.*); } @@ -21,30 +35,47 @@ pub const MemStore = struct { } pub fn put(self: *MemStore, name: []const u8, value: []const u8) !void { + try self.putWithMeta(name, value, 0, 0); + } + + /// Insert or overwrite a secret with lifetime metadata. Overwriting an + /// existing name zeroes the old value first and replaces its metadata. + pub fn putWithMeta(self: *MemStore, name: []const u8, value: []const u8, expires_ms: i64, created_ms: i64) !void { const gop = try self.map.getOrPut(self.allocator, name); if (gop.found_existing) { - gop.value_ptr.*.zero(); - try gop.value_ptr.*.set(value); + gop.value_ptr.*.secret.zero(); + try gop.value_ptr.*.secret.set(value); + gop.value_ptr.*.expires_ms = expires_ms; + gop.value_ptr.*.created_ms = created_ms; return; } const key_copy = try self.allocator.dupe(u8, name); errdefer self.allocator.free(key_copy); - const buf = try self.allocator.create(SecretBuf); - errdefer self.allocator.destroy(buf); - buf.* = SecretBuf{}; - try buf.set(value); + const entry = try self.allocator.create(Entry); + errdefer self.allocator.destroy(entry); + entry.* = Entry{}; + try entry.secret.set(value); + entry.expires_ms = expires_ms; + entry.created_ms = created_ms; gop.key_ptr.* = key_copy; - gop.value_ptr.* = buf; + gop.value_ptr.* = entry; } pub fn copyInto(self: *MemStore, name: []const u8, out: *SecretBuf) CoraError!void { const entry = self.map.get(name) orelse return CoraError.SecretNotFound; - try out.set(entry.constSlice()); + try out.set(entry.secret.constSlice()); + } + + /// Return the full entry (value + metadata) for `name`, or null. The + /// service uses this to gate injection on expiry: an expired secret must + /// be treated as missing so it never reaches a child process. + pub fn getEntry(self: *const MemStore, name: []const u8) ?*const Entry { + return self.map.get(name); } pub fn delete(self: *MemStore, name: []const u8) bool { const kv = self.map.fetchRemove(name) orelse return false; - kv.value.zero(); + kv.value.secret.zero(); self.allocator.destroy(kv.value); self.allocator.free(kv.key); return true; @@ -106,3 +137,24 @@ test "MemStore names returns all keys" { defer std.testing.allocator.free(ns); try std.testing.expectEqual(@as(usize, 2), ns.len); } + +test "MemStore put has no expiry by default" { + var store = MemStore.init(std.testing.allocator); + defer store.deinit(); + try store.put("K", "v"); + const e = store.getEntry("K").?; + try std.testing.expectEqual(@as(i64, 0), e.expires_ms); + try std.testing.expect(!e.isExpired(9_999_999_999)); +} + +test "MemStore putWithMeta records expiry and isExpired honors now" { + var store = MemStore.init(std.testing.allocator); + defer store.deinit(); + try store.putWithMeta("K", "v", 1000, 500); + const e = store.getEntry("K").?; + try std.testing.expectEqual(@as(i64, 1000), e.expires_ms); + try std.testing.expectEqual(@as(i64, 500), e.created_ms); + try std.testing.expect(!e.isExpired(999)); + try std.testing.expect(e.isExpired(1000)); + try std.testing.expect(e.isExpired(2000)); +} diff --git a/src/store/secrets_codec.zig b/src/store/secrets_codec.zig index bf54cae..66b104f 100644 --- a/src/store/secrets_codec.zig +++ b/src/store/secrets_codec.zig @@ -35,6 +35,7 @@ pub fn decode(allocator: std.mem.Allocator, source: []const u8, out: *MemStore) const vtok = try scanner.nextAllocMax(allocator, .alloc_always, max_secret_len); switch (vtok) { + // Flat form: {"K":"value"} — no metadata (pre-TTL shape). .allocated_string => |value| { defer { std.crypto.secureZero(u8, value); @@ -42,6 +43,8 @@ pub fn decode(allocator: std.mem.Allocator, source: []const u8, out: *MemStore) } try out.put(key, value); }, + // Rich form: {"K":{"v":"value","exp":,"cr":}}. + .object_begin => try decodeRichValue(allocator, &scanner, key, out), else => return CoraError.InvalidConfig, } }, @@ -50,6 +53,64 @@ pub fn decode(allocator: std.mem.Allocator, source: []const u8, out: *MemStore) } } +/// Parse a rich value object `{"v":"...","exp":,"cr":}` whose opening +/// brace has already been consumed, and insert it under `key`. `v` is +/// required; `exp` (expiry) and `cr` (created) default to 0 (never / unknown). +/// Unknown fields are rejected so a typo cannot silently drop a TTL. +fn decodeRichValue( + allocator: std.mem.Allocator, + scanner: *std.json.Scanner, + key: []const u8, + out: *MemStore, +) !void { + var value: ?[]u8 = null; + defer if (value) |v| { + std.crypto.secureZero(u8, v); + allocator.free(v); + }; + var expires_ms: i64 = 0; + var created_ms: i64 = 0; + + while (true) { + const ftok = try scanner.nextAllocMax(allocator, .alloc_always, max_key_len); + switch (ftok) { + .object_end => break, + .allocated_string => |field| { + defer allocator.free(field); + if (std.mem.eql(u8, field, "v")) { + const s = try scanner.nextAllocMax(allocator, .alloc_always, max_secret_len); + switch (s) { + .allocated_string => |vv| { + if (value) |old| { + std.crypto.secureZero(u8, old); + allocator.free(old); + } + value = vv; + }, + else => return CoraError.InvalidConfig, + } + } else if (std.mem.eql(u8, field, "exp") or std.mem.eql(u8, field, "cr")) { + const nt = scanner.nextAllocMax(allocator, .alloc_always, 32) catch return CoraError.InvalidConfig; + switch (nt) { + .allocated_number => |ns| { + defer allocator.free(ns); + const n = std.fmt.parseInt(i64, ns, 10) catch return CoraError.InvalidConfig; + if (std.mem.eql(u8, field, "exp")) expires_ms = n else created_ms = n; + }, + else => return CoraError.InvalidConfig, + } + } else { + return CoraError.InvalidConfig; + } + }, + else => return CoraError.InvalidConfig, + } + } + + const v = value orelse return CoraError.InvalidConfig; + try out.putWithMeta(key, v, expires_ms, created_ms); +} + pub const Encoded = struct { bytes: []u8, allocator: std.mem.Allocator, @@ -70,7 +131,26 @@ pub fn encode(allocator: std.mem.Allocator, store_: *const MemStore) !Encoded { var it = store_.map.iterator(); while (it.next()) |entry| { try stringify.objectField(entry.key_ptr.*); - try stringify.write(entry.value_ptr.*.constSlice()); + const e = entry.value_ptr.*; + // Flat form when there is no metadata (keeps files small and + // backward-compatible); rich object only when a TTL / creation time + // is present. + if (e.expires_ms == 0 and e.created_ms == 0) { + try stringify.write(e.secret.constSlice()); + } else { + try stringify.beginObject(); + try stringify.objectField("v"); + try stringify.write(e.secret.constSlice()); + if (e.expires_ms != 0) { + try stringify.objectField("exp"); + try stringify.write(e.expires_ms); + } + if (e.created_ms != 0) { + try stringify.objectField("cr"); + try stringify.write(e.created_ms); + } + try stringify.endObject(); + } } try stringify.endObject(); @@ -101,6 +181,45 @@ test "decode populated object" { try std.testing.expectEqualStrings("sk_yyy", out.constSlice()); } +test "decode accepts rich value form with expiry" { + var store_ = MemStore.init(std.testing.allocator); + defer store_.deinit(); + try decode(std.testing.allocator, "{\"K\":{\"v\":\"secret\",\"exp\":1700,\"cr\":900}}", &store_); + const e = store_.getEntry("K").?; + try std.testing.expectEqual(@as(i64, 1700), e.expires_ms); + try std.testing.expectEqual(@as(i64, 900), e.created_ms); + try std.testing.expectEqualStrings("secret", e.secret.constSlice()); +} + +test "encode/decode roundtrip preserves expiry metadata" { + var src = MemStore.init(std.testing.allocator); + defer src.deinit(); + try src.put("PLAIN", "p"); + try src.putWithMeta("TTL", "t", 5000, 100); + + var enc = try encode(std.testing.allocator, &src); + defer enc.deinit(); + // PLAIN stays flat; TTL becomes a rich object. + try std.testing.expect(std.mem.indexOf(u8, enc.bytes, "\"PLAIN\":\"p\"") != null); + try std.testing.expect(std.mem.indexOf(u8, enc.bytes, "\"exp\":5000") != null); + + var dst = MemStore.init(std.testing.allocator); + defer dst.deinit(); + try decode(std.testing.allocator, enc.bytes, &dst); + try std.testing.expectEqual(@as(i64, 0), dst.getEntry("PLAIN").?.expires_ms); + try std.testing.expectEqual(@as(i64, 5000), dst.getEntry("TTL").?.expires_ms); + try std.testing.expectEqual(@as(i64, 100), dst.getEntry("TTL").?.created_ms); +} + +test "decode rejects unknown field in rich value" { + var store_ = MemStore.init(std.testing.allocator); + defer store_.deinit(); + try std.testing.expectError( + error.InvalidConfig, + decode(std.testing.allocator, "{\"K\":{\"v\":\"x\",\"bogus\":1}}", &store_), + ); +} + test "decode rejects non-object" { var store_ = MemStore.init(std.testing.allocator); defer store_.deinit(); diff --git a/tests/cli_integration.zig b/tests/cli_integration.zig index 8c4076b..225bd67 100644 --- a/tests/cli_integration.zig +++ b/tests/cli_integration.zig @@ -252,6 +252,29 @@ test "cr secrets import --from-env stores set vars and skips unset" { try std.testing.expect(std.mem.indexOf(u8, list.stdout, "CORA_IMPORT_MISSING") == null); } +test "cr secrets set --ttl persists a secret with expiry metadata" { + const allocator = std.testing.allocator; + const io = std.testing.io; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try initFixture(allocator, io, tmp.dir); + + { + var r = try runCr(allocator, io, tmp.dir, &.{ "secrets", "set", "SHORT_LIVED", "--ttl", "3600" }, pass_line ++ "v\n"); + defer r.deinit(allocator); + try std.testing.expect(r.exitOk()); + try std.testing.expect(std.mem.indexOf(u8, r.stdout, "expires in 3600s") != null); + } + + // The rich (metadata-carrying) value must decode cleanly on the next + // read — a broken rich-form roundtrip would fail to list. + var list = try runCr(allocator, io, tmp.dir, &.{ "secrets", "list" }, pass_line); + defer list.deinit(allocator); + try std.testing.expect(list.exitOk()); + try std.testing.expect(std.mem.indexOf(u8, list.stdout, "SHORT_LIVED") != null); +} + test "cr rekey changes passphrase: old fails, new works, secrets survive" { const allocator = std.testing.allocator; const io = std.testing.io; From 1d7923ccb389112ec18d784c8cdbf5a89e0a20c6 Mon Sep 17 00:00:00 2001 From: badrus123 Date: Tue, 7 Jul 2026 18:19:51 +0700 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20cr=20recovery=20backup/restore=20?= =?UTF-8?q?=E2=80=94=20break-glass=20second=20passphrase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recovery backup` seals an independent encrypted copy (cora.zon.recovery) under a separate passphrase; `recovery restore` rebuilds the vault from it under a new primary passphrase, regaining access when the primary is lost. Implemented as a point-in-time encrypted snapshot reusing createEncrypted / decrypt, deliberately leaving the on-disk format and save path untouched. A live second key slot would need envelope encryption and a master-key save refactor across every mutation path; the snapshot trades manual refresh (re-run backup after changing secrets) for that risk. Integration test covers backup -> restore -> new passphrase works, old passphrase rejected. --- src/cli/usage.zig | 21 +++++++ src/main.zig | 129 ++++++++++++++++++++++++++++++++++++++ tests/cli_integration.zig | 59 +++++++++++++++++ 3 files changed, 209 insertions(+) diff --git a/src/cli/usage.zig b/src/cli/usage.zig index 2541bc3..03fc580 100644 --- a/src/cli/usage.zig +++ b/src/cli/usage.zig @@ -82,6 +82,7 @@ const top_text = \\Subcommands: \\ init [path] Create encrypted cora.zon \\ rekey Change the passphrase + \\ recovery Break-glass recovery passphrase \\ unlock [--foreground] Start background service \\ lock Stop service, zero memory \\ status Show service state @@ -306,6 +307,26 @@ const rekey_text = \\ ; +pub fn printRecovery(io: Io, ch: Channel) void { + write(io, ch, recovery_text); +} + +const recovery_text = + \\cr recovery — Break-glass recovery passphrase + \\ + \\Usage: + \\ cr recovery backup Write cora.zon.recovery under a + \\ separate recovery passphrase + \\ cr recovery restore Rebuild cora.zon from the recovery + \\ copy under a new passphrase + \\ + \\`backup` seals an independent encrypted copy of the vault so a lost + \\primary passphrase is recoverable. It is a point-in-time snapshot: secrets + \\changed after backup are not included until you re-run it. `restore` + \\overwrites cora.zon from the recovery copy. + \\ +; + pub fn printDaemon(io: Io, ch: Channel) void { write(io, ch, daemon_text); } diff --git a/src/main.zig b/src/main.zig index 931b5f5..f29814d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -113,6 +113,10 @@ pub fn main(init: std.process.Init) !void { try cmdRekey(arena, io, default_path); return; } + if (std.mem.eql(u8, sub, "recovery")) { + try cmdRecovery(arena, io, default_path, args); + return; + } std.debug.print("unknown subcommand: {s}\n", .{sub}); usage.printTop(io, .stderr); @@ -155,6 +159,7 @@ fn cmdHelp(io: Io, parts: []const []const u8) !void { if (std.mem.eql(u8, sub, "verify")) return usage.printVerify(io, .stdout); if (std.mem.eql(u8, sub, "daemon")) return usage.printDaemon(io, .stdout); if (std.mem.eql(u8, sub, "rekey")) return usage.printRekey(io, .stdout); + if (std.mem.eql(u8, sub, "recovery")) return usage.printRecovery(io, .stdout); if (std.mem.eql(u8, sub, "version")) return printVersion(io); if (usage.isHelpFlag(sub)) return usage.printTop(io, .stdout); @@ -958,6 +963,130 @@ fn cmdRekey(allocator: std.mem.Allocator, io: Io, path: []const u8) !void { usage.outPrint(io, "passphrase changed\n", .{}); } +/// `cr recovery ` — break-glass second passphrase. +/// +/// `backup` writes `.recovery`, an independent encrypted copy of the +/// vault sealed under a separate recovery passphrase. `restore` rebuilds the +/// main vault from that copy under a new primary passphrase, regaining access +/// when the primary passphrase is lost. +/// +/// This is a point-in-time snapshot, not a live second key slot: secrets +/// changed after `backup` are not reflected until `backup` is re-run. That +/// keeps the on-disk format and the save path untouched — the recovery copy +/// is just `createEncrypted` under another passphrase — at the cost of manual +/// refresh. Re-run `cr recovery backup` after changing secrets. +fn cmdRecovery(allocator: std.mem.Allocator, io: Io, path: []const u8, args: []const []const u8) !void { + if (args.len < 3 or usage.isHelpFlag(args[2])) { + usage.printRecovery(io, if (args.len < 3) .stderr else .stdout); + if (args.len < 3) std.process.exit(1); + return; + } + const action = args[2]; + const rec_path = try std.fmt.allocPrint(allocator, "{s}.recovery", .{path}); + defer allocator.free(rec_path); + const cwd = Io.Dir.cwd(); + + if (std.mem.eql(u8, action, "backup")) { + if (!fileExists(io, cwd, path)) { + std.debug.print("no {s} — run `cr init` first\n", .{path}); + std.process.exit(1); + } + var cur_buf: [256]u8 = undefined; + defer std.crypto.secureZero(u8, &cur_buf); + const cur = try readSecret("Current passphrase: ", &cur_buf); + + const encoded = try cora.store.readFile(allocator, io, cwd, path); + defer allocator.free(encoded); + var dec = cora.store.decrypt(allocator, io, cur, encoded) catch |err| switch (err) { + cora.CoraError.AuthFailed => { + std.debug.print("authentication failed\n", .{}); + std.process.exit(1); + }, + else => return err, + }; + defer dec.deinit(); + + var rec_buf: [256]u8 = undefined; + defer std.crypto.secureZero(u8, &rec_buf); + const rec = try readSecret("New recovery passphrase: ", &rec_buf); + var rec2_buf: [256]u8 = undefined; + defer std.crypto.secureZero(u8, &rec2_buf); + const rec2 = try readSecret("Confirm recovery passphrase: ", &rec2_buf); + if (!std.mem.eql(u8, rec, rec2)) { + std.debug.print("passphrases do not match\n", .{}); + std.process.exit(1); + } + + var ef = cora.store.createEncrypted(allocator, io, .{ + .passphrase = rec, + .config_bytes = dec.config_bytes, + .secrets_plaintext = dec.secrets_plaintext, + }) catch |err| switch (err) { + cora.CoraError.PassphraseTooShort => { + std.debug.print("recovery passphrase too short (min {d} chars)\n", .{cora.store.min_passphrase_len}); + std.process.exit(1); + }, + else => return err, + }; + defer ef.deinit(); + try cora.store.writeFile(io, cwd, rec_path, ef.bytes); + usage.outPrint(io, "recovery copy written to {s}\n", .{rec_path}); + return; + } + + if (std.mem.eql(u8, action, "restore")) { + if (!fileExists(io, cwd, rec_path)) { + std.debug.print("no {s} — run `cr recovery backup` first\n", .{rec_path}); + std.process.exit(1); + } + var rec_buf: [256]u8 = undefined; + defer std.crypto.secureZero(u8, &rec_buf); + const rec = try readSecret("Recovery passphrase: ", &rec_buf); + + const encoded = try cora.store.readFile(allocator, io, cwd, rec_path); + defer allocator.free(encoded); + var dec = cora.store.decrypt(allocator, io, rec, encoded) catch |err| switch (err) { + cora.CoraError.AuthFailed => { + std.debug.print("authentication failed\n", .{}); + std.process.exit(1); + }, + else => return err, + }; + defer dec.deinit(); + + var new_buf: [256]u8 = undefined; + defer std.crypto.secureZero(u8, &new_buf); + const new_pass = try readSecret("New passphrase: ", &new_buf); + var conf_buf: [256]u8 = undefined; + defer std.crypto.secureZero(u8, &conf_buf); + const conf = try readSecret("Confirm new passphrase: ", &conf_buf); + if (!std.mem.eql(u8, new_pass, conf)) { + std.debug.print("passphrases do not match\n", .{}); + std.process.exit(1); + } + + var ef = cora.store.createEncrypted(allocator, io, .{ + .passphrase = new_pass, + .config_bytes = dec.config_bytes, + .secrets_plaintext = dec.secrets_plaintext, + }) catch |err| switch (err) { + cora.CoraError.PassphraseTooShort => { + std.debug.print("passphrase too short (min {d} chars)\n", .{cora.store.min_passphrase_len}); + std.process.exit(1); + }, + else => return err, + }; + defer ef.deinit(); + try cora.store.writeFile(io, cwd, path, ef.bytes); + usage.outPrint(io, "vault restored to {s}\n", .{path}); + return; + } + + std.debug.print("unknown recovery action: {s}\n", .{action}); + usage.printRecovery(io, .stderr); + std.process.exit(1); +} + fn cmdStatus(allocator: std.mem.Allocator, io: Io) !void { var sock_buf: [128]u8 = undefined; const sock_path = try cora.service.defaultSocketPath(&sock_buf); diff --git a/tests/cli_integration.zig b/tests/cli_integration.zig index 225bd67..a7fff9d 100644 --- a/tests/cli_integration.zig +++ b/tests/cli_integration.zig @@ -318,6 +318,65 @@ test "cr rekey changes passphrase: old fails, new works, secrets survive" { } } +test "cr recovery backup then restore regains access with a new passphrase" { + const allocator = std.testing.allocator; + const io = std.testing.io; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try initFixture(allocator, io, tmp.dir); + + const recovery_pass = "my recovery pass phrase"; + const new_primary = "fresh primary passphrase"; + + // Seed a secret under the primary passphrase. + { + var r = try runCr(allocator, io, tmp.dir, &.{ "secrets", "set", "TOKEN" }, pass_line ++ "tokval\n"); + defer r.deinit(allocator); + try std.testing.expect(r.exitOk()); + } + + // Back up: current passphrase, then recovery passphrase twice. + { + const stdin = passphrase ++ "\n" ++ recovery_pass ++ "\n" ++ recovery_pass ++ "\n"; + var r = try runCr(allocator, io, tmp.dir, &.{ "recovery", "backup" }, stdin); + defer r.deinit(allocator); + try std.testing.expect(r.exitOk()); + try std.testing.expect(std.mem.indexOf(u8, r.stdout, "cora.zon.recovery") != null); + } + + // The recovery sidecar exists and is encrypted (CORA magic). + { + const blob = try tmp.dir.readFileAlloc(io, "cora.zon.recovery", allocator, .limited(64 * 1024)); + defer allocator.free(blob); + try std.testing.expectEqualStrings("CORA", blob[0..4]); + } + + // Restore: recovery passphrase, then a new primary passphrase twice. + { + const stdin = recovery_pass ++ "\n" ++ new_primary ++ "\n" ++ new_primary ++ "\n"; + var r = try runCr(allocator, io, tmp.dir, &.{ "recovery", "restore" }, stdin); + defer r.deinit(allocator); + try std.testing.expect(r.exitOk()); + try std.testing.expect(std.mem.indexOf(u8, r.stdout, "vault restored") != null); + } + + // The new primary passphrase opens the vault and the secret survived. + { + var r = try runCr(allocator, io, tmp.dir, &.{ "secrets", "list" }, new_primary ++ "\n"); + defer r.deinit(allocator); + try std.testing.expect(r.exitOk()); + try std.testing.expect(std.mem.indexOf(u8, r.stdout, "TOKEN") != null); + } + + // The old primary passphrase no longer works. + { + var r = try runCr(allocator, io, tmp.dir, &.{ "secrets", "list" }, pass_line); + defer r.deinit(allocator); + try std.testing.expect(!r.exitOk()); + } +} + test "cr rekey rejects mismatched new passphrase" { const allocator = std.testing.allocator; const io = std.testing.io; From b9091bb3b980b22e24edf38d82dda0205abbc232 Mon Sep 17 00:00:00 2001 From: badrus123 Date: Tue, 7 Jul 2026 18:23:54 +0700 Subject: [PATCH 10/11] fix: cross-platform currentPid for cr daemon std.c.getpid() returns an opaque handle on Windows, breaking the Windows build. Resolve the pid via GetCurrentProcessId there. Verified by cross- compiling the exe for x86_64-windows, x86_64-linux-gnu, and aarch64-macos. --- src/main.zig | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main.zig b/src/main.zig index f29814d..bfe0c37 100644 --- a/src/main.zig +++ b/src/main.zig @@ -580,8 +580,7 @@ fn cmdDaemon(allocator: std.mem.Allocator, io: Io, args: []const []const u8) !vo std.process.exit(1); } - const self_pid: i32 = @intCast(std.c.getpid()); - const ident = cora.identity.lookupByPid(self_pid) catch { + const ident = cora.identity.lookupByPid(currentPid()) catch { std.debug.print("could not resolve own binary path\n", .{}); std.process.exit(1); }; @@ -1334,6 +1333,15 @@ fn cmdSecretsDelete(allocator: std.mem.Allocator, io: Io, path: []const u8, key: usage.outPrint(io, "deleted {s}\n", .{key}); } +extern "kernel32" fn GetCurrentProcessId() callconv(.winapi) u32; + +/// Own process id, cross-platform. `std.c.getpid()` returns an opaque handle +/// on Windows (not an integer), so branch to GetCurrentProcessId there. +fn currentPid() i32 { + if (builtin.os.tag == .windows) return @intCast(GetCurrentProcessId()); + return @intCast(std.c.getpid()); +} + fn fileExists(io: Io, dir: Io.Dir, path: []const u8) bool { dir.access(io, path, .{}) catch return false; return true; From d588464f8fe73309789e64bc1da72704546c8775 Mon Sep 17 00:00:00 2001 From: badrus123 Date: Tue, 7 Jul 2026 18:24:33 +0700 Subject: [PATCH 11/11] docs: document security hardening commands and policy fields --- CLAUDE.md | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cfddad9..ca3e01a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -228,15 +228,29 @@ Plaintext shape of `Policy` (what `cr policy show` reveals): .{ .allowed_callers = .{ "/usr/local/bin/cr", - "/usr/local/bin/claude", + // optional @sha256= pins the caller to an exact binary image + "/usr/local/bin/claude@sha256=<64 hex>", }, .idle_timeout_ms = 900000, .tasks = .{ - .{ .name = "claude-task", .allowed_secrets = .{ "ANTHROPIC_API_KEY" } }, + .{ + .name = "claude-task", + .allowed_secrets = .{ "ANTHROPIC_API_KEY" }, + // optional spawn rate limit (0 = unlimited) + .max_spawns = 0, + .window_ms = 60000, + }, }, } ``` +Security hardening layers (see CLI above): +- **Caller hash pinning** — `allowed_callers` entries may carry `@sha256=`; the service hashes the caller binary at connect time and rejects a mismatch (`cr policy allow PATH --pin`). +- **Spawn rate limiting** — `Task.max_spawns`/`window_ms` bound how often a task's secrets can be pulled into a subprocess. +- **Secret TTL** — `cr secrets set KEY --ttl SECONDS`; an expired secret is treated as missing at injection time and never reaches a child. +- **Per-project socket (POSIX)** — `/tmp/cora--.sock`; two projects unlock at once. `CORA_SOCK` overrides. +- **Rekey / recovery** — `cr rekey` rotates the passphrase; `cr recovery backup|restore` is a break-glass second passphrase (point-in-time encrypted copy). + Decrypted secrets block (never on disk in this form): ```json @@ -256,19 +270,28 @@ cr # prints usage # Setup cr init [path] # default: ./cora.zon +cr rekey # change passphrase (re-encrypt, fresh salt) + +# Recovery (break-glass second passphrase) +cr recovery backup # write .recovery under a recovery passphrase +cr recovery restore # rebuild vault from recovery copy under a new passphrase # Secret management (always re-encrypts) -cr secrets set KEY # prompts passphrase + value +cr secrets set KEY [--ttl SECONDS] # prompts passphrase + value; optional expiry cr secrets list # prompts passphrase → names only cr secrets delete KEY # prompts passphrase +cr secrets import --from-env NAME... # import values from the environment # Policy (re-encrypts on each mutation) -cr policy show # callers + idle + tasks -cr policy allow PATH # add binary to allowed_callers +cr policy show # callers + idle + tasks + rate limits +cr policy allow PATH [--pin] # add caller; --pin commits to its SHA-256 cr policy deny PATH # remove -cr policy task add NAME SECRETS... # define/update task +cr policy task add NAME SECRETS... [--target PATH] [--max-spawns N] [--window-ms MS] cr policy task remove NAME +# Service unit templates +cr daemon unit # print systemd/launchd unit for this OS + # Service lifecycle cr unlock [--foreground] # daemonizes by default cr lock # sends lock op → service zeros + exits