diff --git a/src/cli/commands/unlock.zig b/src/cli/commands/unlock.zig index 15454cd..937b767 100644 --- a/src/cli/commands/unlock.zig +++ b/src/cli/commands/unlock.zig @@ -5,7 +5,7 @@ const terminal = @import("../terminal.zig"); const memory = @import("../../memory/secure_allocator.zig"); const agent = @import("../../services/agent.zig"); -pub fn run(allocator: std.mem.Allocator, args: []const []const u8, _: config.Config) !void { +pub fn run(allocator: std.mem.Allocator, args: []const []const u8, cfg: config.Config) !void { for (args) |arg| { if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { showHelp(); @@ -75,7 +75,7 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8, _: config.Con return; }; - var server = agent.AgentServer.init(allocator, derived_key) catch { + var server = agent.AgentServer.init(allocator, derived_key, cfg.auto_lock_minutes) catch { std.debug.print("Error: Could not initialize agent server\n", .{}); return; }; @@ -94,6 +94,9 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8, _: config.Con }; std.debug.print("Vault unlocked. Agent running.\n", .{}); + if (cfg.auto_lock_minutes > 0) { + std.debug.print("Auto-lock enabled: {d} minutes of inactivity.\n", .{cfg.auto_lock_minutes}); + } if (foreground) { std.debug.print("Press Ctrl+C to lock and exit.\n", .{}); @@ -134,6 +137,10 @@ fn showHelp() void { \\encryption key in memory, so subsequent commands don't require \\entering the master password. \\ + \\The vault will automatically lock after a period of inactivity + \\(default: 30 minutes). Configure with [security] auto_lock_minutes + \\in config.toml. Set to 0 to disable auto-lock. + \\ \\Options: \\ -f, --foreground Run agent in foreground (don't daemonize) \\ -h, --help Show this help message diff --git a/src/core/config.zig b/src/core/config.zig index 91a9701..1358c10 100644 --- a/src/core/config.zig +++ b/src/core/config.zig @@ -173,7 +173,10 @@ pub const Config = struct { fn applySecurityValue(config: *Self, key: []const u8, value: []const u8) void { if (std.mem.eql(u8, key, "auto_lock_minutes")) { - config.auto_lock_minutes = parseU32(value) orelse config.auto_lock_minutes; + if (parseU32(value)) |minutes| { + // Cap at 24 hours (1440 minutes) to prevent unreasonable values + config.auto_lock_minutes = if (minutes > 1440) 1440 else minutes; + } } } @@ -316,3 +319,25 @@ test "config path generation" { try std.testing.expect(std.mem.endsWith(u8, config_path, "zault/config.toml")); } + +test "config auto_lock_minutes capped at 24 hours" { + const content = + \\[security] + \\auto_lock_minutes = 9999 + ; + + const config = Config.parse(content); + + try std.testing.expectEqual(@as(u32, 1440), config.auto_lock_minutes); +} + +test "config auto_lock_minutes accepts valid values" { + const content = + \\[security] + \\auto_lock_minutes = 60 + ; + + const config = Config.parse(content); + + try std.testing.expectEqual(@as(u32, 60), config.auto_lock_minutes); +} diff --git a/src/services/agent.zig b/src/services/agent.zig index 19a9786..35a6406 100644 --- a/src/services/agent.zig +++ b/src/services/agent.zig @@ -71,18 +71,27 @@ pub const AgentServer = struct { derived_key: [KEY_LENGTH]u8, server: ?std.posix.socket_t, running: bool, + auto_lock_ns: ?i128, + last_activity: i128, const Self = @This(); - pub fn init(allocator: std.mem.Allocator, derived_key: [KEY_LENGTH]u8) !Self { + pub fn init(allocator: std.mem.Allocator, derived_key: [KEY_LENGTH]u8, auto_lock_minutes: u32) !Self { const socket_path = try getSocketPath(allocator); + const auto_lock_ns: ?i128 = if (auto_lock_minutes == 0) + null + else + @as(i128, auto_lock_minutes) * 60 * std.time.ns_per_s; + const self = Self{ .allocator = allocator, .socket_path = socket_path, .derived_key = derived_key, .server = null, .running = false, + .auto_lock_ns = auto_lock_ns, + .last_activity = std.time.nanoTimestamp(), }; return self; @@ -138,26 +147,73 @@ pub const AgentServer = struct { pub fn runLoop(self: *Self) void { const server = self.server orelse return; + // Poll timeout balances auto-lock responsiveness with CPU efficiency. + // Shorter = faster auto-lock detection, longer = less CPU wake-ups. + const poll_timeout_ms: i32 = 10_000; + var consecutive_poll_errors: u32 = 0; + while (self.running) { + if (self.auto_lock_ns) |timeout_ns| { + const now = std.time.nanoTimestamp(); + const elapsed = now - self.last_activity; + + // Guard against clock skew (NTP correction, VM resume, etc.) + if (elapsed < 0) { + self.last_activity = now; + continue; + } + + if (elapsed >= timeout_ns) { + memory.secureZero(&self.derived_key); + self.running = false; + break; + } + } + + var fds = [_]std.posix.pollfd{ + .{ .fd = server, .events = std.posix.POLL.IN, .revents = 0 }, + }; + + const poll_result = std.posix.poll(&fds, poll_timeout_ms) catch { + consecutive_poll_errors += 1; + if (consecutive_poll_errors > 10) { + memory.secureZero(&self.derived_key); + self.running = false; + break; + } + continue; + }; + consecutive_poll_errors = 0; + + if (poll_result == 0) continue; + + if (fds[0].revents & std.posix.POLL.IN == 0) continue; + var client_addr: std.posix.sockaddr = undefined; var addr_len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr); const client = std.posix.accept(server, &client_addr, &addr_len, 0) catch continue; defer std.posix.close(client); - self.handleClient(client); + const is_active_command = self.handleClient(client); + if (is_active_command) { + self.last_activity = std.time.nanoTimestamp(); + } } } - fn handleClient(self: *Self, client: std.posix.socket_t) void { + /// Handle a client request. Returns true if this was an active command + /// that should reset the auto-lock timer (GET_KEY), false for passive + /// commands (STATUS) or explicit lock requests. + fn handleClient(self: *Self, client: std.posix.socket_t) bool { if (!self.verifyPeerCredentials(client)) { _ = std.posix.write(client, &[_]u8{@intFromEnum(Response.err)}) catch {}; - return; + return false; } var buf: [32]u8 = undefined; - const n = std.posix.read(client, &buf) catch return; - if (n == 0) return; + const n = std.posix.read(client, &buf) catch return false; + if (n == 0) return false; const cmd_line = std.mem.trimRight(u8, buf[0..n], "\n\r"); @@ -166,13 +222,18 @@ pub const AgentServer = struct { response[0] = @intFromEnum(Response.key); @memcpy(response[1..], &self.derived_key); _ = std.posix.write(client, &response) catch {}; + return true; } else if (std.mem.eql(u8, cmd_line, "LOCK")) { _ = std.posix.write(client, &[_]u8{@intFromEnum(Response.ok)}) catch {}; + memory.secureZero(&self.derived_key); self.running = false; + return false; } else if (std.mem.eql(u8, cmd_line, "STATUS")) { _ = std.posix.write(client, &[_]u8{@intFromEnum(Response.unlocked)}) catch {}; + return false; } else { _ = std.posix.write(client, &[_]u8{@intFromEnum(Response.err)}) catch {}; + return false; } } @@ -305,3 +366,41 @@ test "socket path generation" { try std.testing.expect(path.len > 0); try std.testing.expect(std.mem.endsWith(u8, path, "agent.sock")); } + +test "auto-lock timeout calculation" { + const allocator = std.testing.allocator; + var key: [KEY_LENGTH]u8 = undefined; + @memset(&key, 0); + + var server_30min = try AgentServer.init(allocator, key, 30); + defer server_30min.deinit(); + const expected_30min: i128 = 30 * 60 * std.time.ns_per_s; + try std.testing.expectEqual(expected_30min, server_30min.auto_lock_ns.?); + + var server_1min = try AgentServer.init(allocator, key, 1); + defer server_1min.deinit(); + const expected_1min: i128 = 1 * 60 * std.time.ns_per_s; + try std.testing.expectEqual(expected_1min, server_1min.auto_lock_ns.?); +} + +test "auto-lock disabled when zero" { + const allocator = std.testing.allocator; + var key: [KEY_LENGTH]u8 = undefined; + @memset(&key, 0); + + var server = try AgentServer.init(allocator, key, 0); + defer server.deinit(); + try std.testing.expectEqual(@as(?i128, null), server.auto_lock_ns); +} + +test "auto-lock handles max timeout value" { + const allocator = std.testing.allocator; + var key: [KEY_LENGTH]u8 = undefined; + @memset(&key, 0); + + var server = try AgentServer.init(allocator, key, 1440); + defer server.deinit(); + + const expected_ns: i128 = 1440 * 60 * std.time.ns_per_s; + try std.testing.expectEqual(expected_ns, server.auto_lock_ns.?); +}