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 diff --git a/src/audit.zig b/src/audit.zig index 14c52bb..b3cf8c8 100644 --- a/src/audit.zig +++ b/src/audit.zig @@ -10,9 +10,21 @@ 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 }, + /// 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 @@ -139,6 +151,26 @@ 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); + }, + .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); @@ -249,6 +281,37 @@ 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(); + 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/cli/usage.zig b/src/cli/usage.zig index 3e0e85a..03fc580 100644 --- a/src/cli/usage.zig +++ b/src/cli/usage.zig @@ -81,13 +81,16 @@ 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 \\ 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 +290,63 @@ 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 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); +} + +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); } @@ -318,12 +378,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. @@ -344,11 +405,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). \\ ; @@ -387,6 +454,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 { @@ -441,11 +532,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. \\ @@ -493,7 +589,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 @@ -507,6 +604,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/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/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 0b9a3c3..bfe0c37 100644 --- a/src/main.zig +++ b/src/main.zig @@ -101,6 +101,22 @@ 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; + } + if (std.mem.eql(u8, sub, "rekey")) { + if (argvHasHelpFlag(args[2..])) { + usage.printRekey(io, .stdout); + return; + } + 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); @@ -141,6 +157,9 @@ 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, "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); @@ -160,6 +179,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); @@ -213,7 +233,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")) { @@ -236,6 +267,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); @@ -524,6 +563,46 @@ 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 ident = cora.identity.lookupByPid(currentPid()) 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; @@ -675,17 +754,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); } } @@ -737,6 +835,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")) { @@ -749,6 +849,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]); } @@ -759,6 +885,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| { @@ -782,6 +910,182 @@ 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", .{}); +} + +/// `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); @@ -793,7 +1097,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}); @@ -838,9 +1142,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 { @@ -893,6 +1206,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)) { @@ -940,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; diff --git a/src/policy/policy.zig b/src/policy/policy.zig index 5df5ab4..fc64e45 100644 --- a/src/policy/policy.zig +++ b/src/policy/policy.zig @@ -18,8 +18,42 @@ 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 +/// 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 +62,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 +189,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")); @@ -163,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 1df5dfe..c524382 100644 --- a/src/root.zig +++ b/src/root.zig @@ -4,11 +4,14 @@ 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"); 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"); @@ -22,11 +25,14 @@ 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"); _ = @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); +} 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 52eb584..85799f7 100644 --- a/src/service/service.zig +++ b/src/service/service.zig @@ -11,6 +11,8 @@ 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 ratelimit = @import("ratelimit.zig"); const CoraError = @import("../error.zig").CoraError; /// Per-platform stdio passthrough handle carried from `handle` into @@ -53,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 { @@ -185,6 +210,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); @@ -200,6 +231,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; @@ -211,6 +243,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(); @@ -269,6 +312,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); } @@ -314,6 +358,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, ""); @@ -552,6 +609,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). @@ -582,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); } @@ -649,6 +733,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), @@ -688,6 +778,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), @@ -741,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 @@ -998,6 +1094,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 }); + } } } @@ -1020,6 +1121,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; 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 42fa9d2..a7fff9d 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,182 @@ 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); +} + +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; + + 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 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; + + 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