sys: add secure random override option with a platform default

This commit is contained in:
Mitchell Hashimoto
2026-08-22 14:54:04 -07:00
parent 8760323165
commit dda8e6f314
5 changed files with 175 additions and 3 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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));
}