Add safe Rust target and harden native boundaries
ober
d33020a2cfeb33201606a1492a353f475bdb052b
--- a/Dockerfile +++ b/Dockerfile @@ -68,7 +68,7 @@ WORKDIR /build RUN git clone --depth 1 https://github.com/ober/ChezScheme.git && \ cd ChezScheme && \ git submodule update --init --depth 1 && \ - ./configure --threads --disable-x11 --installprefix=/usr/local && \ + ./configure --threads --enable-harden --disable-x11 --installprefix=/usr/local && \ make -j$(nproc) && \ make install && \ cd /build && rm -rf ChezScheme @@ -81,12 +81,12 @@ RUN git clone --depth 1 https://github.com/ober/ChezScheme.git && \ RUN git clone https://github.com/ober/ChezScheme.git chez-musl-src && \ cd chez-musl-src && \ git submodule update --init && \ - ./configure --threads --disable-x11 --installprefix=/build/chez-musl && \ + ./configure --threads --enable-harden --disable-x11 --installprefix=/build/chez-musl && \ make -j$(nproc) && \ cp ta6le/boot/ta6le/petite.boot /tmp/petite.boot && \ cp ta6le/boot/ta6le/scheme.boot /tmp/scheme.boot && \ make clean && \ - ./configure --threads --disable-x11 --static CC=musl-gcc --installprefix=/build/chez-musl && \ + ./configure --threads --enable-harden --disable-x11 --static CC=musl-gcc --installprefix=/build/chez-musl && \ mkdir -p ta6le/boot/ta6le && \ cp /tmp/petite.boot ta6le/boot/ta6le/ && \ cp /tmp/scheme.boot ta6le/boot/ta6le/ && \ --- a/Makefile +++ b/Makefile @@ -10,9 +10,10 @@ SCHEME ?= $(CHEZ_PREFIX)/bin/scheme CHEZ_MACHINE_TYPE := $(shell $(JERBOA_HOME)/support/detect-chez-machine.sh) CHEZ_INSTALL_FLAGS = \ - --installprefix=$(CHEZ_PREFIX) \ - --installbin=$(CHEZ_PREFIX)/bin \ - --installlib=$(CHEZ_PREFIX)/lib \ + --enable-harden \ + --installprefix=$(CHEZ_PREFIX) \ + --installbin=$(CHEZ_PREFIX)/bin \ + --installlib=$(CHEZ_PREFIX)/lib \ --installman=$(CHEZ_PREFIX)/share/man \ --installdoc=$(CHEZ_PREFIX)/share/doc \ --as-is @@ -152,7 +153,7 @@ CHEZ_XPATCH = $(CHEZ_BUILD_DIR)/xc-$(CHEZ_TARGET_MACHINE)/s/xpatch # for fully-static musl builds. We default to disabling curses/x11/iconv # because (a) self-contained binaries don't need a REPL or X11, and (b) the # musl-cross sysroot on macOS typically lacks ncurses/x11 headers. -CROSS_CHEZ_CONFIGURE_FLAGS ?= --threads --disable-x11 --disable-curses --disable-iconv +CROSS_CHEZ_CONFIGURE_FLAGS ?= --threads --enable-harden --disable-x11 --disable-curses --disable-iconv CHEZ_CROSS_INSTALL_FLAGS = \ --installprefix=$(CHEZ_CROSS_PREFIX) \ --- a/docs/chez-hardening.md +++ b/docs/chez-hardening.md @@ -8,10 +8,9 @@ shim and to the final link line, but **not** to `libkernel.a` itself, which is the ~1 MB block of native code that contributes nearly all ROP gadgets in the binary. -This document is the engineering plan to close that gap, broken into -phases ranked by effort, with the hazards (notably `call/cc` × -shadow-stack interaction) called out as research items rather than -buried. +This document tracks the hardening work, broken into phases ranked by +effort, with the hazards (notably `call/cc` × shadow-stack interaction) +called out as research items rather than buried. --- @@ -65,16 +64,19 @@ LDFLAGS += -Wl,-z,relro,-z,now # if not --static: CFLAGS += -fPIC ; mdlinkflags += -pie ``` -**The gap is in Jerboa, not Chez:** +Jerboa now passes `--enable-harden` through the standard Chez build +entry points: | Build path | Passes `--enable-harden`? | |------------------------------------|:-------------------------:| -| `Makefile` `$(CHEZ_INSTALL_FLAGS)` | ✗ | -| `Makefile` `chez-cross` target | ✗ | -| `Dockerfile` (musl pipeline) | ✗ | - -Net effect today: `libkernel.a` is built with `-O2` only. Every -hardening claim in `secure.md` about Chez is technically aspirational. +| `Makefile` `$(CHEZ_INSTALL_FLAGS)` | ✓ | +| `Makefile` `chez-cross` target | ✓ | +| `Dockerfile` (musl pipeline) | ✓ | +| `support/musl-chez-build*.sh` | ✓ | + +Net effect: newly built `libkernel.a` uses the Chez fork's hardening +flags. Existing installed Chez artifacts still need to be rebuilt before +the binary on disk reflects this configuration. --- --- a/docs/native-rust.md +++ b/docs/native-rust.md @@ -295,6 +295,10 @@ pub fn ffi_wrap<F: FnOnce() -> i32 + panic::UnwindSafe>(f: F) -> i32 { } ``` +Release builds use `panic = "unwind"` so these wrappers remain effective +outside debug/test builds. Rust panics should be converted to error +sentinels plus `jerboa_last_error()`, not abort the Scheme process. + ### Example: Crypto Module ```rust @@ -527,92 +531,12 @@ The secure region allocator from `vs-rust.md`, implemented in Rust: ```rust // src/secure_mem.rs -use libc::{mmap, munmap, mlock, munlock, madvise, mprotect}; -use libc::{MAP_PRIVATE, MAP_ANONYMOUS, PROT_READ, PROT_WRITE, PROT_NONE}; -use libc::{MADV_DONTDUMP, MADV_DONTFORK}; -use std::ptr; -use crate::panic::ffi_wrap; - -const GUARD_PAGE_SIZE: usize = 4096; - -#[no_mangle] -pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 { - ffi_wrap_ptr(|| { - // Allocate: guard page + data + guard page - let total = GUARD_PAGE_SIZE + size + GUARD_PAGE_SIZE; - let base = unsafe { - mmap(ptr::null_mut(), total, PROT_READ | PROT_WRITE, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0) - }; - if base == libc::MAP_FAILED { return ptr::null_mut(); } - - // Protect guard pages (PROT_NONE — any access = SIGSEGV) - unsafe { - mprotect(base, GUARD_PAGE_SIZE, PROT_NONE); - mprotect(base.add(GUARD_PAGE_SIZE + size), GUARD_PAGE_SIZE, PROT_NONE); - } - - let data = unsafe { base.add(GUARD_PAGE_SIZE) as *mut u8 }; - - // Lock into RAM — never swapped to disk - unsafe { mlock(data as *mut _, size); } - - // Exclude from core dumps - unsafe { madvise(data as *mut _, size, MADV_DONTDUMP); } - - // Don't inherit in child processes - unsafe { madvise(data as *mut _, size, MADV_DONTFORK); } - - data - }) -} - -#[no_mangle] -pub extern "C" fn jerboa_secure_free(ptr: *mut u8, size: usize) -> i32 { - ffi_wrap(|| { - if ptr.is_null() { return -1; } - - // Wipe — explicit_bzero is guaranteed not to be optimized away - unsafe { libc::explicit_bzero(ptr as *mut _, size); } - - // Unlock - unsafe { munlock(ptr as *mut _, size); } - - // Unmap entire region including guard pages - let base = unsafe { ptr.sub(GUARD_PAGE_SIZE) }; - let total = GUARD_PAGE_SIZE + size + GUARD_PAGE_SIZE; - unsafe { munmap(base as *mut _, total); } - - 0 - }) -} - -#[no_mangle] -pub extern "C" fn jerboa_secure_wipe(ptr: *mut u8, size: usize) -> i32 { - ffi_wrap(|| { - if ptr.is_null() { return -1; } - unsafe { libc::explicit_bzero(ptr as *mut _, size); } - 0 - }) -} - -#[no_mangle] -pub extern "C" fn jerboa_secure_random_fill(ptr: *mut u8, size: usize) -> i32 { - ffi_wrap(|| { - if ptr.is_null() { return -1; } - let rng = ring::rand::SystemRandom::new(); - let buf = unsafe { std::slice::from_raw_parts_mut(ptr, size) }; - ring::rand::SecureRandom::fill(&rng, buf).map(|_| 0).unwrap_or(-1) - }) -} - -// Helper: ffi_wrap for pointer-returning functions -fn ffi_wrap_ptr<F: FnOnce() -> *mut u8 + std::panic::UnwindSafe>(f: F) -> *mut u8 { - match std::panic::catch_unwind(f) { - Ok(ptr) => ptr, - Err(_) => ptr::null_mut(), - } -} +// Implementation details: +// - mmap a page-rounded region with guard pages on both sides. +// - Place the returned pointer so a one-byte overflow hits the right guard. +// - Fail closed if mprotect, mlock, MADV_DONTDUMP, or MADV_DONTFORK fails. +// - Wipe with volatile stores before munlock/munmap. +// - Return null / -1 on any failed hardening step. ``` Jerboa side: new file mode 100644 --- /dev/null +++ b/docs/rust-target.md @@ -0,0 +1,59 @@ +# Safe Rust Target + +Jerboa can generate Rust for a deliberately small safe subset: + +- typed, pure functions +- integer and boolean values +- `if`, `let`, `begin`, function calls +- checked integer `+`, `-`, `*`, `/`, `mod` +- comparisons and boolean operators +- `#![forbid(unsafe_code)]` in the generated Rust + +This is not a full Jerboa-to-Rust compiler. It intentionally rejects mutation, +FFI, `eval`, `read`, `lambda`, `quote`, and dynamic data structures. The goal is +to write small security-sensitive kernels, validators, parsers, and arithmetic +helpers in Jerboa syntax while producing boring safe Rust as the delivery +artifact. + +```scheme +(import (jerboa rust codegen)) + +(write-safe-rust-program + '((module calc) + (define (add-one (x i32)) -> i32 + (+ x 1)) + (define (abs-i32 (x i32)) -> i32 + (if (< x 0) (- 0 x) x)) + (define (square-sum (x i32) (y i32)) -> i32 + (let ([sum (+ x y)]) + (* sum sum)))) + "calc.rs") +``` + +Output: + +```rust +#![forbid(unsafe_code)] + +// Generated by Jerboa's safe Rust target. +// Safe subset: typed pure functions, checked integer arithmetic, no FFI. +// source module: calc + +pub fn add_one(x: i32) -> i32 { + (x).checked_add(1_i32).expect("checked integer operation failed") +} +``` + +The current API is `(jerboa rust codegen)`: + +- `safe-rust-program->string` +- `safe-rust-form->string` +- `safe-rust-expression->string` +- `write-safe-rust-program` +- `safe-rust-type?` +- `safe-rust-form?` +- `safe-rust-expression?` + +Near-term extensions should stay narrow: typed byte slices, explicit `Result` +returns instead of panics on checked arithmetic failure, generated Rust tests, +and FFI wrapper generation from declarative Jerboa specs. --- a/docs/security-reference.md +++ b/docs/security-reference.md @@ -290,7 +290,7 @@ Phases 1-4 are implemented and tested (42 tests in `tests/test-security2-parsers | 3 | Capability intersection checked type only, not permissions | HIGH | `intersect-capabilities` now ANDs boolean permissions and set-intersects list permissions. | | 4 | Empty network host list meant "all allowed" | HIGH | Empty list now means no hosts allowed. Explicit `"*"` required for wildcard. | | 5 | Path canonicalization didn't resolve symlinks | HIGH | `canonicalize-path` now uses `realpath(3)` via FFI. | -| 6 | Distributed actors used `read` for deserialization (remote code exec via `#.`) | CRITICAL | `deserialize-message` wraps `read` in `(parameterize ([read-eval #f]) ...)`. Message size limit enforced. | +| 6 | Distributed actors used `read` for deserialization (remote code exec via `#.`) | CRITICAL | `deserialize-message` uses `jerboa-read`. Message size limit enforced. | | 7 | Seccomp/Landlock were stubs | HIGH | Both now have real implementations with actual syscalls (BPF bytecode generation, Landlock ABI detection). | | 8 | Taint tracking had no automatic sink enforcement | MEDIUM | Added `safe-open-input-file`, `safe-open-output-file`, `safe-system`, `safe-delete-file` that auto-reject tainted args. | | 9 | Restricted environment allowlist included `read` and `gensym` | MEDIUM | `read` removed (replaced by `jerboa-read`). `gensym` removed. | @@ -445,10 +445,10 @@ These are known gaps. They are not on any roadmap in this document -- just hones - **No FIPS 140-3 validation.** The crypto uses ring (recommended) or OpenSSL (legacy), which can be FIPS-validated, but Jerboa itself has not undergone FIPS evaluation. - **No covert channel analysis.** Chez Scheme's GC is a timing side channel. No mitigation exists for timing, storage, or resource-exhaustion covert channels. - **No Common Criteria evaluation.** No Protection Profile, Security Target, or EAL evaluation has been performed. -- **Seccomp is x86_64 only.** The BPF bytecode generator hardcodes `AUDIT_ARCH_X86_64` and x86_64 syscall numbers. +- **Seccomp architecture coverage is limited.** The BPF bytecode generator supports x86_64 and aarch64 syscall numbers. - **Landlock requires Linux 5.13+.** No equivalent on macOS, BSDs, or older Linux kernels. `landlock-available?` returns `#f` on unsupported systems. - **Taint tracking is opt-in.** Only the `safe-*` wrappers enforce taint checks. Native Chez operations (`open-input-file`, `system`, etc.) do not check taint. No static analysis enforcement exists. -- **No message authentication for distributed actors.** `deserialize-message` disables `#.` read-eval but messages are still plaintext with no HMAC. +- **No message authentication for distributed actors.** `deserialize-message` uses `jerboa-read`, but messages are still plaintext with no HMAC. - **No TOCTOU-safe path checking.** `canonicalize-path` uses `realpath(3)` before access, not `O_NOFOLLOW` + `/proc/self/fd/N` after open. - **`define-syntax` remains in the sandbox allowlist.** Macro definition in sandboxed code is possible. Whether this is a risk depends on the use case. - **No max-output-size for sandboxes.** A sandboxed expression can produce unbounded output via `display`/`write`. --- a/jerboa-native-rs/Cargo.toml +++ b/jerboa-native-rs/Cargo.toml @@ -70,4 +70,4 @@ inotify = { version = "0.11", default-features = false } lto = true codegen-units = 1 strip = true -panic = "abort" +panic = "unwind" --- a/jerboa-native-rs/src/crypto.rs +++ b/jerboa-native-rs/src/crypto.rs @@ -4,6 +4,13 @@ use argon2::{Argon2, Algorithm, Version, Params}; use crate::panic::{ffi_wrap, set_last_error}; use std::num::NonZeroU32; +fn secure_zero_bytes(buf: &mut [u8]) { + for byte in buf { + unsafe { std::ptr::write_volatile(byte as *mut u8, 0); } + } + std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); +} + // --- Digest --- fn digest_impl(algorithm: &'static digest::Algorithm, input: *const u8, input_len: usize, @@ -244,19 +251,21 @@ pub extern "C" fn jerboa_aead_open( Err(_) => return -1, }; - // Copy ciphertext+tag to output, open in place - let out = unsafe { std::slice::from_raw_parts_mut(output, output_max.max(ct_len)) }; - out[..ct_len].copy_from_slice(ct); + let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) }; + let mut in_out = ct.to_vec(); let aad_obj = aead::Aad::from(ad); - match opening_key.open_in_place(nonce_val, aad_obj, &mut out[..ct_len]) { + let rc = match opening_key.open_in_place(nonce_val, aad_obj, &mut in_out) { Ok(plaintext) => { let plen = plaintext.len(); + out[..plen].copy_from_slice(plaintext); unsafe { *output_len = plen; } 0 } Err(_) => -1, - } + }; + secure_zero_bytes(&mut in_out); + rc }) } @@ -351,18 +360,21 @@ pub extern "C" fn jerboa_chacha20_open( Err(_) => return -1, }; - let out = unsafe { std::slice::from_raw_parts_mut(output, output_max.max(ct_len)) }; - out[..ct_len].copy_from_slice(ct); + let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) }; + let mut in_out = ct.to_vec(); let aad_obj = aead::Aad::from(ad); - match opening_key.open_in_place(nonce_val, aad_obj, &mut out[..ct_len]) { + let rc = match opening_key.open_in_place(nonce_val, aad_obj, &mut in_out) { Ok(plaintext) => { let plen = plaintext.len(); + out[..plen].copy_from_slice(plaintext); unsafe { *output_len = plen; } 0 } Err(_) => -1, - } + }; + secure_zero_bytes(&mut in_out); + rc }) } @@ -526,3 +538,74 @@ pub extern "C" fn jerboa_argon2id_verify( } }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aes_gcm_open_accepts_plaintext_sized_output_buffer() { + let key = [7u8; 32]; + let nonce = [3u8; 12]; + let plaintext = b"secret message"; + let aad = b"context"; + let mut ciphertext = vec![0u8; plaintext.len() + 16]; + let mut ciphertext_len = 0usize; + + assert_eq!(jerboa_aead_seal( + key.as_ptr(), key.len(), + nonce.as_ptr(), nonce.len(), + plaintext.as_ptr(), plaintext.len(), + aad.as_ptr(), aad.len(), + ciphertext.as_mut_ptr(), ciphertext.len(), + &mut ciphertext_len, + ), 0); + ciphertext.truncate(ciphertext_len); + + let mut output = vec![0u8; plaintext.len()]; + let mut output_len = 0usize; + assert_eq!(jerboa_aead_open( + key.as_ptr(), key.len(), + nonce.as_ptr(), nonce.len(), + ciphertext.as_ptr(), ciphertext.len(), + aad.as_ptr(), aad.len(), + output.as_mut_ptr(), output.len(), + &mut output_len, + ), 0); + output.truncate(output_len); + assert_eq!(output, plaintext); + } + + #[test] + fn chacha20_open_accepts_plaintext_sized_output_buffer() { + let key = [9u8; 32]; + let nonce = [4u8; 12]; + let plaintext = b"another secret"; + let aad = b"context"; + let mut ciphertext = vec![0u8; plaintext.len() + 16]; + let mut ciphertext_len = 0usize; + + assert_eq!(jerboa_chacha20_seal( + key.as_ptr(), key.len(), + nonce.as_ptr(), nonce.len(), + plaintext.as_ptr(), plaintext.len(), + aad.as_ptr(), aad.len(), + ciphertext.as_mut_ptr(), ciphertext.len(), + &mut ciphertext_len, + ), 0); + ciphertext.truncate(ciphertext_len); + + let mut output = vec![0u8; plaintext.len()]; + let mut output_len = 0usize; + assert_eq!(jerboa_chacha20_open( + key.as_ptr(), key.len(), + nonce.as_ptr(), nonce.len(), + ciphertext.as_ptr(), ciphertext.len(), + aad.as_ptr(), aad.len(), + output.as_mut_ptr(), output.len(), + &mut output_len, + ), 0); + output.truncate(output_len); + assert_eq!(output, plaintext); + } +} --- a/jerboa-native-rs/src/seccomp.rs +++ b/jerboa-native-rs/src/seccomp.rs @@ -14,19 +14,40 @@ const SECCOMP_MODE_FILTER: libc::c_ulong = 2; const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; const SECCOMP_RET_KILL_PROCESS: u32 = 0x8000_0000; -// Audit arch for x86_64 -const AUDIT_ARCH_X86_64: u32 = 0xC000_003E; +#[cfg(target_arch = "x86_64")] +const AUDIT_ARCH_CURRENT: u32 = 0xC000_003E; +#[cfg(target_arch = "aarch64")] +const AUDIT_ARCH_CURRENT: u32 = 0xC000_00B7; -// x86_64 syscall numbers to block in default mode -const NR_PTRACE: u32 = 101; -const NR_PROCESS_VM_READV: u32 = 310; -const NR_PROCESS_VM_WRITEV: u32 = 311; -const NR_PERSONALITY: u32 = 135; -const NR_MEMFD_CREATE: u32 = 319; // prevents code injection via memfd +#[cfg(target_arch = "x86_64")] +const BLOCKED_DEBUG_SYSCALLS: [u32; 5] = [101, 310, 311, 135, 319]; +#[cfg(target_arch = "aarch64")] +const BLOCKED_DEBUG_SYSCALLS: [u32; 5] = [117, 270, 271, 92, 279]; // seccomp_data field offsets (for BPF_ABS loads) const OFFSET_NR: u32 = 0; // offsetof(struct seccomp_data, nr) const OFFSET_ARCH: u32 = 4; // offsetof(struct seccomp_data, arch) +const MAX_STRICT_SYSCALLS: usize = 254; + +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +fn current_audit_arch() -> Option<u32> { + Some(AUDIT_ARCH_CURRENT) +} + +#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] +fn current_audit_arch() -> Option<u32> { + None +} + +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +fn blocked_debug_syscalls() -> Option<&'static [u32; 5]> { + Some(&BLOCKED_DEBUG_SYSCALLS) +} + +#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] +fn blocked_debug_syscalls() -> Option<&'static [u32; 5]> { + None +} #[repr(C)] #[derive(Clone, Copy)] @@ -92,6 +113,21 @@ fn install_filter(filter: &[SockFilter]) -> Result<(), String> { #[no_mangle] pub extern "C" fn jerboa_seccomp_lock() -> i32 { ffi_wrap(|| { + let audit_arch = match current_audit_arch() { + Some(arch) => arch, + None => { + set_last_error("seccomp lock is not implemented for this CPU architecture".to_string()); + return -1; + } + }; + let blocked = match blocked_debug_syscalls() { + Some(syscalls) => syscalls, + None => { + set_last_error("seccomp lock is not implemented for this CPU architecture".to_string()); + return -1; + } + }; + if let Err(e) = set_no_new_privs() { set_last_error(e); return -1; @@ -99,32 +135,28 @@ pub extern "C" fn jerboa_seccomp_lock() -> i32 { // BPF program: // 0: load arch - // 1: if arch != x86_64, kill + // 1: if arch != current architecture, kill // 2: load syscall nr - // 3: if nr == ptrace, kill - // 4: if nr == process_vm_readv, kill - // 5: if nr == process_vm_writev, kill - // 6: if nr == personality, kill - // 7: if nr == memfd_create, kill + // 3..7: block ptrace/process_vm_readv/process_vm_writev/personality/memfd_create // 8: allow // 9: kill let filter = [ // 0: Load architecture bpf_stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARCH), - // 1: Verify x86_64 — if not, jump to kill (offset +7 -> instruction 9) - bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0, 7), + // 1: Verify architecture — if not, jump to kill (offset +7 -> instruction 9) + bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, audit_arch, 0, 7), // 2: Load syscall number bpf_stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_NR), // 3: Check ptrace — if match, jump to kill (+5 -> instruction 9) - bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, NR_PTRACE, 5, 0), + bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, blocked[0], 5, 0), // 4: Check process_vm_readv - bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, NR_PROCESS_VM_READV, 4, 0), + bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, blocked[1], 4, 0), // 5: Check process_vm_writev - bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, NR_PROCESS_VM_WRITEV, 3, 0), + bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, blocked[2], 3, 0), // 6: Check personality - bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, NR_PERSONALITY, 2, 0), + bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, blocked[3], 2, 0), // 7: Check memfd_create — prevents code injection via memfd - bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, NR_MEMFD_CREATE, 1, 0), + bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, blocked[4], 1, 0), // 8: Allow bpf_stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), // 9: Kill process @@ -152,12 +184,19 @@ pub extern "C" fn jerboa_seccomp_lock_strict( allowed_count: usize, ) -> i32 { ffi_wrap(|| { + let audit_arch = match current_audit_arch() { + Some(arch) => arch, + None => { + set_last_error("strict seccomp is not implemented for this CPU architecture".to_string()); + return -1; + } + }; if allowed.is_null() && allowed_count > 0 { set_last_error("null pointer with nonzero count".to_string()); return -1; } - if allowed_count > 1024 { - set_last_error("too many allowed syscalls (max 1024)".to_string()); + if allowed_count > MAX_STRICT_SYSCALLS { + set_last_error(format!("too many allowed syscalls (max {})", MAX_STRICT_SYSCALLS)); return -1; } @@ -174,7 +213,7 @@ pub extern "C" fn jerboa_seccomp_lock_strict( // Build BPF program: // 0: load arch - // 1: verify x86_64 (jf -> kill) + // 1: verify architecture (jf -> kill) // 2: load syscall nr // 3..3+N-1: check each allowed syscall (jt -> allow) // 3+N: kill (default) @@ -184,9 +223,9 @@ pub extern "C" fn jerboa_seccomp_lock_strict( // 0: Load arch filter.push(bpf_stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARCH)); - // 1: Verify x86_64 — if not, jump to kill (at index 3+N) + // 1: Verify architecture — if not, jump to kill (at index 3+N) let kill_offset = (allowed_count + 1) as u8; // skip load_nr + N checks - filter.push(bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0, kill_offset)); + filter.push(bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, audit_arch, 0, kill_offset)); // 2: Load syscall number filter.push(bpf_stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_NR)); --- a/jerboa-native-rs/src/secure_mem.rs +++ b/jerboa-native-rs/src/secure_mem.rs @@ -3,12 +3,34 @@ use std::ptr; const GUARD_PAGE_SIZE: usize = 4096; +fn round_up_to_page(size: usize) -> Option<usize> { + size.checked_add(GUARD_PAGE_SIZE - 1) + .map(|n| (n / GUARD_PAGE_SIZE) * GUARD_PAGE_SIZE) +} + +unsafe fn secure_zero(ptr: *mut u8, size: usize) { + for i in 0..size { + ptr.add(i).write_volatile(0); + } + std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); +} + #[no_mangle] pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 { ffi_wrap_ptr(|| { if size == 0 { return ptr::null_mut(); } - let total = GUARD_PAGE_SIZE + size + GUARD_PAGE_SIZE; + let rounded = match round_up_to_page(size) { + Some(n) => n, + None => return ptr::null_mut(), + }; + let total = match GUARD_PAGE_SIZE + .checked_add(rounded) + .and_then(|n| n.checked_add(GUARD_PAGE_SIZE)) + { + Some(n) if n <= isize::MAX as usize => n, + _ => return ptr::null_mut(), + }; let base = unsafe { libc::mmap( ptr::null_mut(), @@ -21,28 +43,46 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 { }; if base == libc::MAP_FAILED { return ptr::null_mut(); } - // Protect guard pages (PROT_NONE — any access = SIGSEGV) - unsafe { - libc::mprotect(base, GUARD_PAGE_SIZE, libc::PROT_NONE); - libc::mprotect( - (base as *mut u8).add(GUARD_PAGE_SIZE + size) as *mut _, - GUARD_PAGE_SIZE, - libc::PROT_NONE, - ); + let cleanup = || unsafe { + libc::munmap(base, total); + }; + + let protect_ok = unsafe { + libc::mprotect(base, GUARD_PAGE_SIZE, libc::PROT_NONE) == 0 + && libc::mprotect( + (base as *mut u8).add(GUARD_PAGE_SIZE + rounded) as *mut _, + GUARD_PAGE_SIZE, + libc::PROT_NONE, + ) == 0 + }; + if !protect_ok { + cleanup(); + return ptr::null_mut(); } - let data = unsafe { (base as *mut u8).add(GUARD_PAGE_SIZE) }; + // Put the usable range flush against the right guard page. + let data = unsafe { (base as *mut u8).add(GUARD_PAGE_SIZE + (rounded - size)) }; - // Lock into RAM — never swapped to disk - unsafe { libc::mlock(data as *const _, size); } + if unsafe { libc::mlock(data as *const _, size) } != 0 { + cleanup(); + return ptr::null_mut(); + } // Exclude from core dumps (MADV_DONTDUMP is Linux-specific) #[cfg(target_os = "linux")] - unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTDUMP); } + if unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTDUMP) } != 0 { + unsafe { libc::munlock(data as *const _, size); } + cleanup(); + return ptr::null_mut(); + } // Don't inherit in child processes (MADV_DONTFORK is Linux-specific) #[cfg(target_os = "linux")] - unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTFORK); } + if unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTFORK) } != 0 { + unsafe { libc::munlock(data as *const _, size); } + cleanup(); + return ptr::null_mut(); + } data }) @@ -52,20 +92,28 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 { pub extern "C" fn jerboa_secure_free(ptr: *mut u8, size: usize) -> i32 { ffi_wrap(|| { if ptr.is_null() { return -1; } + if size == 0 { return -1; } + let rounded = match round_up_to_page(size) { + Some(n) => n, + None => return -1, + }; - // Wipe — volatile write guaranteed not to be optimized away - unsafe { std::ptr::write_bytes(ptr, 0, size); } - std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); + unsafe { secure_zero(ptr, size); } - // Unlock - unsafe { libc::munlock(ptr as *const _, size); } + let unlock_rc = unsafe { libc::munlock(ptr as *const _, size) }; // Unmap entire region including guard pages - let base = unsafe { ptr.sub(GUARD_PAGE_SIZE) }; - let total = GUARD_PAGE_SIZE + size + GUARD_PAGE_SIZE; - unsafe { libc::munmap(base as *mut _, total); } + let base = unsafe { ptr.sub(GUARD_PAGE_SIZE + (rounded - size)) }; + let total = match GUARD_PAGE_SIZE + .checked_add(rounded) + .and_then(|n| n.checked_add(GUARD_PAGE_SIZE)) + { + Some(n) => n, + None => return -1, + }; + let unmap_rc = unsafe { libc::munmap(base as *mut _, total) }; - 0 + if unlock_rc == 0 && unmap_rc == 0 { 0 } else { -1 } }) } @@ -73,8 +121,7 @@ pub extern "C" fn jerboa_secure_free(ptr: *mut u8, size: usize) -> i32 { pub extern "C" fn jerboa_secure_wipe(ptr: *mut u8, size: usize) -> i32 { ffi_wrap(|| { if ptr.is_null() { return -1; } - unsafe { std::ptr::write_bytes(ptr, 0, size); } - std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); + unsafe { secure_zero(ptr, size); } 0 }) } --- a/jerboa-native-rs/src/tls.rs +++ b/jerboa-native-rs/src/tls.rs @@ -175,11 +175,11 @@ pub extern "C" fn jerboa_tls_connect_pinned( } }; - let expected_pin = if !pin_sha256.is_null() && pin_len == 32 { - Some(unsafe { std::slice::from_raw_parts(pin_sha256, pin_len) }.to_vec()) - } else { - None - }; + if pin_sha256.is_null() || pin_len != 32 { + set_last_error("certificate pin must be exactly 32 bytes".to_string()); + return 0; + } + let expected_pin = unsafe { std::slice::from_raw_parts(pin_sha256, pin_len) }.to_vec(); // Build config that skips CA verification (we verify via pin) let config = ClientConfig::builder() @@ -231,10 +231,11 @@ pub extern "C" fn jerboa_tls_connect_pinned( } } -// Certificate pin verifier — accepts any cert whose SHA-256 matches +// Certificate pin verifier — verifies the presented cert hash and delegates +// TLS handshake signature verification to rustls/webpki. #[derive(Debug)] struct PinVerifier { - expected_sha256: Option<Vec<u8>>, + expected_sha256: Vec<u8>, } impl rustls::client::danger::ServerCertVerifier for PinVerifier { @@ -246,35 +247,42 @@ impl rustls::client::danger::ServerCertVerifier for PinVerifier { _ocsp_response: &[u8], _now: rustls::pki_types::UnixTime, ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> { - if let Some(ref expected) = self.expected_sha256 { - let digest = ring::digest::digest(&ring::digest::SHA256, end_entity.as_ref()); - if digest.as_ref() == expected.as_slice() { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } else { - Err(rustls::Error::General("certificate pin mismatch".to_string())) - } - } else { - // No pin — accept anything (insecure, for testing only) + let digest = ring::digest::digest(&ring::digest::SHA256, end_entity.as_ref()); + if digest.as_ref() == self.expected_sha256.as_slice() { Ok(rustls::client::danger::ServerCertVerified::assertion()) + } else { + Err(rustls::Error::General("certificate pin mismatch".to_string())) } } fn verify_tls12_signature( &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + let provider = rustls::crypto::ring::default_provider(); + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &provider.signature_verification_algorithms, + ) } fn verify_tls13_signature( &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + let provider = rustls::crypto::ring::default_provider(); + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &provider.signature_verification_algorithms, + ) } fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { @@ -316,20 +324,32 @@ impl rustls::server::danger::ClientCertVerifier for PinnedClientVerifier { fn verify_tls12_signature( &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + let provider = rustls::crypto::ring::default_provider(); + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &provider.signature_verification_algorithms, + ) } fn verify_tls13_signature( &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + let provider = rustls::crypto::ring::default_provider(); + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &provider.signature_verification_algorithms, + ) } fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { @@ -853,17 +873,24 @@ pub extern "C" fn jerboa_tls_connect_mtls( Ok(f) => f, Err(e) => { set_last_error(format!("open server CA: {}", e)); return 0; } }; - let _ca_certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut std::io::BufReader::new(ca_file)) + let ca_certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut std::io::BufReader::new(ca_file)) .filter_map(|r| r.ok()) .collect(); + if ca_certs.is_empty() { + set_last_error("no CA certificates found in server CA file".to_string()); + return 0; + } + + let mut root_store = rustls::RootCertStore::empty(); + for cert in ca_certs { + if let Err(e) = root_store.add(cert) { + set_last_error(format!("add server CA cert: {}", e)); + return 0; + } + } - // Build client config: skip hostname verification (self-signed), - // but present client cert for mutual authentication. let config = match ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(PinVerifier { - expected_sha256: None, - })) + .with_root_certificates(root_store) .with_client_auth_cert(client_certs, PrivateKeyDer::from(client_key)) { Ok(c) => c, @@ -1051,10 +1078,15 @@ pub extern "C" fn jerboa_tls_connect_mtls_mem( Err(e) => { set_last_error(format!("read client key PEM: {}", e)); return 0; } }; + // Without a CA parameter this legacy entry point can only be secure for + // symmetric self-signed deployments where both peers use the same cert. + let server_cert_pin = + ring::digest::digest(&ring::digest::SHA256, client_certs[0].as_ref()).as_ref().to_vec(); + let config = match ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(PinVerifier { - expected_sha256: None, + expected_sha256: server_cert_pin, })) .with_client_auth_cert(client_certs, PrivateKeyDer::from(client_key)) { @@ -1266,3 +1298,53 @@ pub extern "C" fn jerboa_tls_get_fd(handle: u64) -> i32 { None => -1, } } + +#[cfg(test)] +mod tests { + use super::*; + use rustls::client::danger::ServerCertVerifier; + + #[test] + fn pinned_connect_rejects_missing_pin_before_network_io() { + let host = b"example.com"; + let handle = jerboa_tls_connect_pinned( + host.as_ptr(), + host.len(), + 443, + std::ptr::null(), + 0, + ); + assert_eq!(handle, 0); + } + + #[test] + fn pin_verifier_matches_exact_certificate_digest() { + let cert = CertificateDer::from(vec![1u8, 2, 3, 4]); + let pin = ring::digest::digest(&ring::digest::SHA256, cert.as_ref()).as_ref().to_vec(); + let verifier = PinVerifier { expected_sha256: pin }; + let server_name = ServerName::try_from("example.com").unwrap(); + + assert!(verifier.verify_server_cert( + &cert, + &[], + &server_name, + &[], + rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(0)), + ).is_ok()); + } + + #[test] + fn pin_verifier_rejects_wrong_certificate_digest() { + let cert = CertificateDer::from(vec![1u8, 2, 3, 4]); + let verifier = PinVerifier { expected_sha256: vec![0u8; 32] }; + let server_name = ServerName::try_from("example.com").unwrap(); + + assert!(verifier.verify_server_cert( + &cert, + &[], + &server_name, + &[], + rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(0)), + ).is_err()); + } +} new file mode 100644 --- /dev/null +++ b/lib/jerboa/rust/codegen.ss @@ -0,0 +1,409 @@ +#!chezscheme +;;; (jerboa rust codegen) -- Safe-subset Jerboa to Rust source generation +;;; +;;; This is not a general Scheme compiler. It is a deliberately small target +;;; for pure, typed functions that can be emitted as safe Rust with no unsafe +;;; blocks, no FFI, no mutation, no eval, and checked integer arithmetic. + +(library (jerboa rust codegen) + (export + safe-rust-program->string + safe-rust-form->string + safe-rust-expression->string + write-safe-rust-program + safe-rust-type? + safe-rust-form? + safe-rust-expression?) + + (import (chezscheme)) ; jerboa-security: suppress direct-chezscheme-import-user-code -- trusted compiler backend module + + (define safe-rust-types + '(i8 i16 i32 i64 i128 isize + u8 u16 u32 u64 u128 usize + bool))