diff --git a/include/ghostty/vt/sys.h b/include/ghostty/vt/sys.h index ae9059692..c5e5903b2 100644 --- a/include/ghostty/vt/sys.h +++ b/include/ghostty/vt/sys.h @@ -124,6 +124,24 @@ typedef bool (*GhosttySysDecodePngFn)( size_t data_len, GhosttySysImage* out); +/** + * Callback type for secure random bytes. + * + * Fills @p buf with @p len cryptographically secure random bytes. The + * library uses this for secrets, so it must be a real CSPRNG (getrandom, + * arc4random_buf, BCryptGenRandom, crypto.getRandomValues, ...); a + * predictable source is a security hole. + * + * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA + * @param buf Buffer to fill + * @param len Number of bytes to fill + * @return true if the buffer was filled, false if no entropy is available + */ +typedef bool (*GhosttySysRandomSecureFn)( + void* userdata, + uint8_t* buf, + size_t len); + /** * System option identifiers for ghostty_sys_set(). */ @@ -165,6 +183,21 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttySysLogFn (function pointer, or NULL) */ GHOSTTY_SYS_OPT_LOG = 2, + + /** + * Override the secure random source. + * + * By default the library draws secure random bytes from the + * platform (getrandom or arc4random_buf on POSIX, CNG on Windows). + * Targets without one, such as wasm32-freestanding, have no default + * and operations that need entropy fail with GHOSTTY_IO_ERROR until + * this is set. When set, + * it is used instead of the platform source on every target. When + * cleared (NULL value), the platform default is restored. + * + * Input type: GhosttySysRandomSecureFn (function pointer, or NULL) + */ + GHOSTTY_SYS_OPT_RANDOM_SECURE = 3, GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySysOption; diff --git a/src/lib/TinyIo.zig b/src/lib/TinyIo.zig index f89278070..ddabe99fd 100644 --- a/src/lib/TinyIo.zig +++ b/src/lib/TinyIo.zig @@ -161,7 +161,7 @@ const vtable: Io.VTable = if (!supported) std.Io.failing.vtable.* else .{ .progressParentFile = Io.failingProgressParentFile, .random = Io.noRandom, - .randomSecure = Io.failingRandomSecure, + .randomSecure = randomSecure, .now = Io.noNow, .clockResolution = Io.failingClockResolution, @@ -212,6 +212,35 @@ fn swapCancelProtection( fn checkCancel(_: ?*anyopaque) Io.Cancelable!void {} +fn randomSecure(_: ?*anyopaque, buffer: []u8) Io.RandomSecureError!void { + if (buffer.len == 0) return; + + // The same sources as `std.Io.Threaded.randomSecure` minus + // cancelation and the /dev/urandom fallback: arc4random_buf where + // libc provides it (all the BSDs and Darwin, glibc 2.36+), otherwise + // the getrandom syscall on Linux. Anything else has no entropy. + if (builtin.link_libc and @TypeOf(posix.system.arc4random_buf) != void) { + posix.system.arc4random_buf(buffer.ptr, buffer.len); + return; + } + + if (builtin.os.tag == .linux) { + const linux = std.os.linux; + var i: usize = 0; + while (i < buffer.len) { + const rc = linux.getrandom(buffer[i..].ptr, buffer.len - i, 0); + switch (linux.errno(rc)) { + .SUCCESS => i += rc, + .INTR => continue, + else => return error.EntropyUnavailable, + } + } + return; + } + + return error.EntropyUnavailable; +} + fn closeFd(fd: posix.fd_t) void { // Never retry close on EINTR: POSIX leaves the fd state unspecified // and Linux always closes it, so retrying risks closing an unrelated @@ -965,6 +994,28 @@ test "Io.Mutex through TinyIo" { test_io.vtable.futexWaitUncancelable(test_io.userdata, &word, 1); } +test "randomSecure fills with fresh entropy" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var a: [32]u8 = @splat(0); + var b: [32]u8 = @splat(0); + try test_io.randomSecure(&a); + try test_io.randomSecure(&b); + + // Non-zero and non-repeating. A zero fill is what `random` does + // without a source, which would make every one-time password the + // same; identical draws would mean the same thing. + try testing.expect(!std.mem.allEqual(u8, &a, 0)); + try testing.expect(!std.mem.allEqual(u8, &b, 0)); + try testing.expect(!std.mem.eql(u8, &a, &b)); + + // Zero-length is a no-op. + try test_io.randomSecure(a[0..0]); +} + test "unused operations fail gracefully" { if (comptime !supported) return error.SkipZigTest; const tio: TinyIo = .init; diff --git a/src/terminal/c/sys.zig b/src/terminal/c/sys.zig index 319f17985..40e7e1c84 100644 --- a/src/terminal/c/sys.zig +++ b/src/terminal/c/sys.zig @@ -23,6 +23,13 @@ pub const DecodePngFn = *const fn ( *Image, ) callconv(lib.calling_conv) bool; +/// C: GhosttySysRandomSecureFn +pub const RandomSecureFn = *const fn ( + ?*anyopaque, + [*]u8, + usize, +) callconv(lib.calling_conv) bool; + /// C: GhosttySysLogLevel pub const LogLevel = enum(c_int) { @"error" = 0, @@ -55,12 +62,14 @@ pub const Option = enum(c_int) { userdata = 0, decode_png = 1, log = 2, + random_secure = 3, pub fn InType(comptime self: Option) type { return switch (self) { .userdata => ?*const anyopaque, .decode_png => ?DecodePngFn, .log => ?LogFn, + .random_secure => ?RandomSecureFn, }; } }; @@ -71,6 +80,7 @@ const Global = struct { userdata: ?*anyopaque = null, decode_png: ?DecodePngFn = null, log: ?LogFn = null, + random_secure: ?RandomSecureFn = null, }; /// Global state for the C sys interface. @@ -98,6 +108,12 @@ fn decodePngWrapper( }; } +/// Zig-compatible wrapper that calls through to the stored C callback. +fn randomSecureWrapper(buffer: []u8) terminal_sys.RandomSecureError!void { + const func = global.random_secure orelse return error.EntropyUnavailable; + if (!func(global.userdata, buffer.ptr, buffer.len)) return error.EntropyUnavailable; +} + pub fn set( option: Option, value: ?*const anyopaque, @@ -127,6 +143,10 @@ fn setTyped( terminal_sys.decode_png = if (value != null) &decodePngWrapper else null; }, .log => global.log = value, + .random_secure => { + global.random_secure = value; + terminal_sys.random_secure = if (value != null) &randomSecureWrapper else null; + }, } return .success; } diff --git a/src/terminal/kitty/clipboard_grants.zig b/src/terminal/kitty/clipboard_grants.zig index 217d5d781..59b9bef74 100644 --- a/src/terminal/kitty/clipboard_grants.zig +++ b/src/terminal/kitty/clipboard_grants.zig @@ -5,6 +5,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const clipboard_command = @import("clipboard_command.zig"); +const sys = @import("../sys.zig"); const max_pw_len = clipboard_command.max_pw_len; @@ -113,13 +114,14 @@ pub const otp_alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVW /// Generate a one-time password for a paste event. /// /// The password is a secret: a program that learns it can read the -/// clipboard without a prompt. +/// clipboard without a prompt. Entropy comes from `sys.random_secure` +/// if set, otherwise from the Io; see `sys.randomSecure`. pub fn generateOtp(io: std.Io) std.Io.RandomSecureError![otp_len]u8 { var result: [otp_len]u8 = undefined; var len: usize = 0; while (len < result.len) { var raw: [2 * otp_len]u8 = undefined; - try io.randomSecure(&raw); + try sys.randomSecure(io, &raw); const limit = (std.math.maxInt(u8) + 1) / otp_alphabet.len * otp_alphabet.len; for (raw) |byte| { if (byte >= limit) continue; @@ -216,3 +218,21 @@ test "generateOtp: no entropy is an error, never a weak password" { const testing = std.testing; try testing.expectError(error.EntropyUnavailable, generateOtp(std.Io.failing)); } + +test "generateOtp: sys override supplies entropy without an Io source" { + const testing = std.testing; + const S = struct { + var counter: u8 = 0; + fn fill(buffer: []u8) sys.RandomSecureError!void { + for (buffer) |*b| { + b.* = counter; + counter +%= 1; + } + } + }; + sys.random_secure = &S.fill; + defer sys.random_secure = null; + + const otp = try generateOtp(std.Io.failing); + for (otp) |c| try testing.expect(std.mem.indexOfScalar(u8, otp_alphabet, c) != null); +} diff --git a/src/terminal/sys.zig b/src/terminal/sys.zig index f0c64da50..3f6b5017d 100644 --- a/src/terminal/sys.zig +++ b/src/terminal/sys.zig @@ -52,3 +52,51 @@ fn decodePngWuffs( .data = result.data, }; } + +/// Fill a buffer with cryptographically secure random bytes. If null, +/// the terminal's `std.Io` (`randomSecure`) is used. This is an override +/// for embedders whose Io has no entropy source (e.g. wasm32-freestanding, +/// where TinyIo degrades to `std.Io.failing`) or that want to control +/// the source; when set it is used on every target. +/// +/// This is used for secrets, so it must be a real CSPRNG. An error +/// makes the operation that needed the entropy fail; nothing falls back +/// to weaker randomness. +pub var random_secure: ?RandomSecureFn = null; + +pub const RandomSecureError = error{EntropyUnavailable}; +pub const RandomSecureFn = *const fn ([]u8) RandomSecureError!void; + +/// Fill `buffer` with secure random bytes from `random_secure` if set, +/// otherwise from `io`. Every use of secure entropy in the terminal +/// package goes through this so the override applies uniformly. +pub fn randomSecure(io: std.Io, buffer: []u8) std.Io.RandomSecureError!void { + if (random_secure) |func| return func(buffer); + return io.randomSecure(buffer); +} + +test "randomSecure: override is preferred over the Io" { + const testing = std.testing; + const S = struct { + fn fill(buffer: []u8) RandomSecureError!void { + @memset(buffer, 0xAB); + } + fn fail(_: []u8) RandomSecureError!void { + return error.EntropyUnavailable; + } + }; + + // Without the override a failing Io fails. + var buf: [8]u8 = @splat(0); + try testing.expectError(error.EntropyUnavailable, randomSecure(std.Io.failing, &buf)); + + // With it, the Io is never consulted. + random_secure = &S.fill; + defer random_secure = null; + try randomSecure(std.Io.failing, &buf); + try testing.expect(std.mem.allEqual(u8, &buf, 0xAB)); + + // An override failure surfaces as the Io's error. + random_secure = &S.fail; + try testing.expectError(error.EntropyUnavailable, randomSecure(testing.io, &buf)); +}