From 1b5a2a977c24ec0f380bad539db8b7bd82bc3395 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 8 Jul 2026 21:15:17 -0700 Subject: [PATCH] feat(toolchain): build mbedTLS for wasm32-wasip1 + ship CA bundle (TLS foundation) --- .gitignore | 4 + crates/native-sidecar/build.rs | 42 ++++++++- crates/native-sidecar/src/vm.rs | 101 ++++++++++++++++++++- toolchain/c/Makefile | 75 +++++++++++++++ toolchain/c/mbedtls/mbedtls_wasi_entropy.c | 60 ++++++++++++ toolchain/c/mbedtls/wasi_user_config.h | 44 +++++++++ 6 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 toolchain/c/mbedtls/mbedtls_wasi_entropy.c create mode 100644 toolchain/c/mbedtls/wasi_user_config.h diff --git a/.gitignore b/.gitignore index 91949fe0cf..da80093777 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,7 @@ crates/v8-runtime/assets/generated/ # staged local wasm command fixtures (11MB+ binaries; never commit) .local-cmds/ + +# Fetched Mozilla CA bundle (~230 KB). Pinned + fetched by `make -C toolchain/c +# ca-certificates`; staged into the sidecar at build time. Never committed. +crates/native-sidecar/assets/ca-certificates.crt diff --git a/crates/native-sidecar/build.rs b/crates/native-sidecar/build.rs index e1d617fbd1..31399eb30f 100644 --- a/crates/native-sidecar/build.rs +++ b/crates/native-sidecar/build.rs @@ -1,4 +1,7 @@ -use std::{env, fs, path::PathBuf}; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; // Stage the base filesystem fixture into OUT_DIR. In-tree builds use the // canonical AgentOS runtime-core fixture from the current workspace; the @@ -31,4 +34,41 @@ fn main() { error ) }); + + stage_ca_bundle(&manifest_dir, &out_dir); +} + +/// Stage the Mozilla CA bundle into OUT_DIR so it can be embedded via +/// `include_bytes!` and seeded into the VM at `/etc/ssl/certs/ca-certificates.crt`. +/// +/// The ~230 KB PEM blob is never committed. It is fetched into +/// `assets/ca-certificates.crt` by `make -C toolchain/c ca-certificates`. When +/// the asset is absent (fresh checkout, or `cargo publish` verification with no +/// network) we stage an empty placeholder — the sidecar then simply skips +/// seeding the bundle, matching the Pyodide "asset unavailable" pattern. Runtime +/// TLS still works via `--cacert`/`SSL_CERT_FILE` overrides in that case. +fn stage_ca_bundle(manifest_dir: &Path, out_dir: &Path) { + let asset = manifest_dir.join("assets/ca-certificates.crt"); + println!("cargo:rerun-if-changed={}", asset.display()); + + let dest = out_dir.join("ca-certificates.crt"); + if asset.exists() { + fs::copy(&asset, &dest).unwrap_or_else(|error| { + panic!( + "failed to stage ca-certificates.crt from {} to {}: {}", + asset.display(), + dest.display(), + error + ) + }); + } else { + // Empty placeholder keeps the include_bytes! of the OUT_DIR copy valid. + fs::write(&dest, b"").unwrap_or_else(|error| { + panic!( + "failed to stage empty ca-certificates.crt placeholder to {}: {}", + dest.display(), + error + ) + }); + } } diff --git a/crates/native-sidecar/src/vm.rs b/crates/native-sidecar/src/vm.rs index be3afd17c3..cad03aa8c3 100644 --- a/crates/native-sidecar/src/vm.rs +++ b/crates/native-sidecar/src/vm.rs @@ -109,6 +109,21 @@ const SHADOW_ROOT_BOOTSTRAP_DIRS: &[(&str, u32)] = &[ ("/workspace", 0o755), ]; +/// Mozilla CA bundle (the set Debian's `ca-certificates` produces), staged by +/// `build.rs` into `OUT_DIR`. It is empty when the `assets/ca-certificates.crt` +/// blob was absent at build time (fresh checkout / offline publish verify — run +/// `make -C toolchain/c ca-certificates` to populate it); an empty bundle is +/// simply not seeded. When present it is written into the VM shadow tree so +/// in-guest TLS (curl/wget's mbedTLS backend) resolves trust the Debian way via +/// `/etc/ssl/certs/ca-certificates.crt`. +const CA_CERTIFICATES_BUNDLE: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/ca-certificates.crt")); +const CA_CERTIFICATES_GUEST_PATH: &str = "/etc/ssl/certs/ca-certificates.crt"; +/// Conventional `/etc/ssl/cert.pem` -> `certs/ca-certificates.crt` symlink that +/// OpenSSL's default `OPENSSLDIR` layout expects. +const CA_CERTIFICATES_SYMLINK_PATH: &str = "/etc/ssl/cert.pem"; +const CA_CERTIFICATES_SYMLINK_TARGET: &str = "certs/ca-certificates.crt"; + fn send_kernel_socket_readiness_event( target: KernelSocketReadinessTarget, readiness: SocketReadiness, @@ -1753,6 +1768,49 @@ fn bootstrap_shadow_root(root: &Path) -> Result<(), SidecarError> { )) })?; } + seed_ca_certificates_bundle(root)?; + Ok(()) +} + +/// Seed the Mozilla CA bundle into the shadow root at +/// `/etc/ssl/certs/ca-certificates.crt` (plus the conventional +/// `/etc/ssl/cert.pem` symlink) so guest TLS clients resolve trust the standard +/// Debian way. No-op when the bundle was not staged at build time. +fn seed_ca_certificates_bundle(root: &Path) -> Result<(), SidecarError> { + if CA_CERTIFICATES_BUNDLE.is_empty() { + return Ok(()); + } + + let bundle_path = shadow_path_for_guest(root, CA_CERTIFICATES_GUEST_PATH); + if let Some(parent) = bundle_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + SidecarError::Io(format!( + "failed to create shadow CA certs directory {}: {error}", + parent.display() + )) + })?; + } + fs::write(&bundle_path, CA_CERTIFICATES_BUNDLE).map_err(|error| { + SidecarError::Io(format!( + "failed to seed CA bundle {}: {error}", + bundle_path.display() + )) + })?; + fs::set_permissions(&bundle_path, fs::Permissions::from_mode(0o644)).map_err(|error| { + SidecarError::Io(format!( + "failed to set CA bundle mode on {}: {error}", + bundle_path.display() + )) + })?; + + let symlink_path = shadow_path_for_guest(root, CA_CERTIFICATES_SYMLINK_PATH); + let _ = fs::remove_file(&symlink_path); + std::os::unix::fs::symlink(CA_CERTIFICATES_SYMLINK_TARGET, &symlink_path).map_err(|error| { + SidecarError::Io(format!( + "failed to seed CA bundle symlink {}: {error}", + symlink_path.display() + )) + })?; Ok(()) } @@ -2150,8 +2208,11 @@ mod tests { use super::{ bootstrap_native_root_filesystem, bootstrap_shadow_root, materialize_shadow_root_snapshot_entries, native_root_plugin_from_config, - prune_kernel_command_stub, shadow_path_for_guest, KERNEL_COMMAND_STUB, + prune_kernel_command_stub, shadow_path_for_guest, CA_CERTIFICATES_BUNDLE, + CA_CERTIFICATES_GUEST_PATH, CA_CERTIFICATES_SYMLINK_PATH, CA_CERTIFICATES_SYMLINK_TARGET, + KERNEL_COMMAND_STUB, }; + use std::path::Path; use crate::plugins::chunked_local::ChunkedLocalMountPlugin; use crate::protocol::{ RootFilesystemDescriptor, RootFilesystemEntry, RootFilesystemEntryKind, @@ -2207,6 +2268,44 @@ mod tests { fs::remove_dir_all(&root).expect("temp shadow root should be removed"); } + #[test] + fn bootstrap_shadow_root_seeds_ca_bundle_when_present() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be monotonic") + .as_nanos(); + let root = + std::env::temp_dir().join(format!("agentos-native-sidecar-ca-test-{unique}")); + fs::create_dir_all(&root).expect("temp shadow root should be created"); + + bootstrap_shadow_root(&root).expect("shadow bootstrap should succeed"); + + let bundle = shadow_path_for_guest(&root, CA_CERTIFICATES_GUEST_PATH); + let symlink = shadow_path_for_guest(&root, CA_CERTIFICATES_SYMLINK_PATH); + + if CA_CERTIFICATES_BUNDLE.is_empty() { + // No asset staged at build time — seeding is intentionally skipped. + assert!( + !bundle.exists(), + "CA bundle must not be seeded when the build asset is absent" + ); + } else { + let seeded = fs::read(&bundle).expect("CA bundle should be seeded"); + assert_eq!( + seeded, CA_CERTIFICATES_BUNDLE, + "seeded CA bundle should match the embedded asset" + ); + let target = fs::read_link(&symlink).expect("cert.pem symlink should be seeded"); + assert_eq!( + target, + Path::new(CA_CERTIFICATES_SYMLINK_TARGET), + "cert.pem should point at certs/ca-certificates.crt" + ); + } + + fs::remove_dir_all(&root).expect("temp shadow root should be removed"); + } + #[test] fn native_root_config_opens_chunked_local_as_persistent_root() { let unique = SystemTime::now() diff --git a/toolchain/c/Makefile b/toolchain/c/Makefile index 0190b360f2..cab2fde607 100644 --- a/toolchain/c/Makefile +++ b/toolchain/c/Makefile @@ -153,6 +153,28 @@ CURL_UPSTREAM_OVERLAY_FILES := $(wildcard $(CURL_UPSTREAM_OVERLAY_DIR)/lib/*.c $ ZLIB_VERSION := 1.3.1 ZLIB_URL := https://zlib.net/zlib-$(ZLIB_VERSION).tar.gz ZLIB_BUILD_DIR := $(BUILD_DIR)/zlib +# mbedTLS 3.6 LTS — in-guest TLS for curl/wget/git (see +# docs-internal/networking-parity-spec.md). Use the prepared release tarball +# (`mbedtls-.tar.bz2`), which bundles the generated sources and the +# `framework` submodule; the bare GitHub source archive does not. Everything +# ships under library/ in the 3.6 LTS line (crypto is not yet split into a +# separate tf-psa-crypto tree as on 4.x/master), so `make lib` needs no +# submodule fetch. We cross-compile every library/*.c against the wasi-sdk +# sysroot, mirroring the zlib rule, plus a getentropy() entropy shim. +MBEDTLS_VERSION := 3.6.2 +MBEDTLS_URL := https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-$(MBEDTLS_VERSION)/mbedtls-$(MBEDTLS_VERSION).tar.bz2 +# Deferred `=`: LIBS_CACHE is defined further down, so expand it lazily. +MBEDTLS_SRC_DIR = $(LIBS_CACHE)/mbedtls-$(MBEDTLS_VERSION) +MBEDTLS_BUILD_DIR := $(BUILD_DIR)/mbedtls +MBEDTLS_CONFIG_DIR := mbedtls +MBEDTLS_LIBS := $(MBEDTLS_BUILD_DIR)/libmbedtls.a $(MBEDTLS_BUILD_DIR)/libmbedx509.a $(MBEDTLS_BUILD_DIR)/libmbedcrypto.a +# Mozilla CA bundle (the set Debian's ca-certificates produces) shipped in the +# VM at /etc/ssl/certs/ca-certificates.crt. Pinned URL + fetch rule only; the +# ~230 KB PEM blob is never committed (the sidecar stages it via build.rs into +# OUT_DIR, falling back to an empty placeholder when absent — see +# crates/native-sidecar/build.rs). `make ca-certificates` populates the asset. +CACERT_URL := https://curl.se/ca/cacert.pem +CACERT_ASSET := ../../crates/native-sidecar/assets/ca-certificates.crt WGET_VERSION := 1.24.5 WGET_URL := https://ftp.gnu.org/gnu/wget/wget-$(WGET_VERSION).tar.gz WGET_UPSTREAM_BUILD_DIR := $(BUILD_DIR)/wget-upstream @@ -545,6 +567,59 @@ $(ZLIB_BUILD_DIR)/libz.a: $(LIBS_CACHE)/zlib-$(ZLIB_VERSION)/zlib.h $(PATCHED_SY done $(WASI_SDK_DIR)/bin/llvm-ar rcs $@ $(ZLIB_BUILD_DIR)/*.o +# --- mbedTLS 3.6 LTS for wasm32-wasip1 --- +# Fetch/extract the prepared release tarball, then cross-compile every +# library/*.c (plus the getentropy entropy shim) against the wasi-sdk sysroot, +# splitting objects into the three canonical archives by filename the same way +# mbedTLS's own Makefile does (crypto / x509 / tls). Single-threaded; entropy +# via getentropy() (MBEDTLS_ENTROPY_HARDWARE_ALT); TLS 1.3 on (default in 3.6). +$(MBEDTLS_SRC_DIR)/library/ssl_tls.c: + @echo "Fetching mbedTLS $(MBEDTLS_VERSION)..." + @mkdir -p $(LIBS_CACHE) + @curl -fSL "$(MBEDTLS_URL)" -o "$(LIBS_CACHE)/mbedtls-$(MBEDTLS_VERSION).tar.bz2" + @rm -rf "$(MBEDTLS_SRC_DIR)" + @tar -xjf "$(LIBS_CACHE)/mbedtls-$(MBEDTLS_VERSION).tar.bz2" -C "$(LIBS_CACHE)" + +.PHONY: mbedtls +mbedtls: $(MBEDTLS_LIBS) + +$(MBEDTLS_LIBS): $(MBEDTLS_SRC_DIR)/library/ssl_tls.c \ + $(MBEDTLS_CONFIG_DIR)/wasi_user_config.h \ + $(MBEDTLS_CONFIG_DIR)/mbedtls_wasi_entropy.c \ + $(WASI_SDK_DIR)/bin/clang + @echo "=== Building mbedTLS $(MBEDTLS_VERSION) for wasm32-wasip1 ===" + @mkdir -p $(MBEDTLS_BUILD_DIR)/crypto $(MBEDTLS_BUILD_DIR)/x509 $(MBEDTLS_BUILD_DIR)/tls + @for src in $(MBEDTLS_SRC_DIR)/library/*.c $(MBEDTLS_CONFIG_DIR)/mbedtls_wasi_entropy.c; do \ + name=$$(basename "$${src%.c}"); \ + case "$$name" in \ + x509*|pkcs7) grp=x509 ;; \ + ssl_*|ssl|mps_*|debug|net_sockets) grp=tls ;; \ + *) grp=crypto ;; \ + esac; \ + $(CC) --target=wasm32-wasip1 --sysroot=$(SYSROOT) -O2 \ + -I$(MBEDTLS_SRC_DIR)/include -I$(MBEDTLS_CONFIG_DIR) \ + -DMBEDTLS_USER_CONFIG_FILE='"wasi_user_config.h"' \ + -c "$$src" -o "$(MBEDTLS_BUILD_DIR)/$$grp/$$name.o" || exit 1; \ + done + @rm -f $(MBEDTLS_LIBS) + $(WASI_SDK_DIR)/bin/llvm-ar rcs $(MBEDTLS_BUILD_DIR)/libmbedcrypto.a $(MBEDTLS_BUILD_DIR)/crypto/*.o + $(WASI_SDK_DIR)/bin/llvm-ar rcs $(MBEDTLS_BUILD_DIR)/libmbedx509.a $(MBEDTLS_BUILD_DIR)/x509/*.o + $(WASI_SDK_DIR)/bin/llvm-ar rcs $(MBEDTLS_BUILD_DIR)/libmbedtls.a $(MBEDTLS_BUILD_DIR)/tls/*.o + @echo "=== mbedTLS libs built ===" + @ls -l $(MBEDTLS_LIBS) + +# --- CA bundle (Mozilla / Debian ca-certificates) --- +# Fetch the pinned Mozilla CA bundle into the sidecar asset staged by build.rs. +# The blob is gitignored; run this once to enable real HTTPS verification. +.PHONY: ca-certificates +ca-certificates: $(CACERT_ASSET) + +$(CACERT_ASSET): + @echo "Fetching Mozilla CA bundle from $(CACERT_URL)..." + @mkdir -p $(dir $(CACERT_ASSET)) + @curl -fSL "$(CACERT_URL)" -o "$(CACERT_ASSET)" + @echo "CA bundle staged at $(CACERT_ASSET) ($$(wc -c < $(CACERT_ASSET)) bytes)" + $(NATIVE_DIR)/sqlite3_cli: $(NATIVE_DIR)/sqlite3 cp $< $@ diff --git a/toolchain/c/mbedtls/mbedtls_wasi_entropy.c b/toolchain/c/mbedtls/mbedtls_wasi_entropy.c new file mode 100644 index 0000000000..ac76210218 --- /dev/null +++ b/toolchain/c/mbedtls/mbedtls_wasi_entropy.c @@ -0,0 +1,60 @@ +/* + * Hardware entropy shim for the wasm32-wasip1 (WASI) mbedTLS build. + * + * The WASI build sets MBEDTLS_NO_PLATFORM_ENTROPY (no /dev/urandom fopen path) + * and MBEDTLS_ENTROPY_HARDWARE_ALT, which routes the entropy module through + * this mbedtls_hardware_poll() implementation. We satisfy it with getentropy(), + * the WASI random primitive (backed by the kernel's random device layer) that + * the repo's host-side wasi_tls.c already uses. getentropy() fills at most 256 + * bytes per call, so we loop for larger requests. + */ + +#include +#include /* clock_gettime(), CLOCK_MONOTONIC */ +#include /* getentropy() — declared by wasi-libc */ + +#include "mbedtls/build_info.h" +#include "mbedtls/platform_util.h" /* mbedtls_ms_time_t */ + +/* + * WASI mbedtls_ms_time() over clock_gettime(CLOCK_MONOTONIC). Enabled by + * MBEDTLS_PLATFORM_MS_TIME_ALT in the WASI user config (see wasi_user_config.h), + * which suppresses mbedTLS's own platform-detected definition. + */ +#if defined(MBEDTLS_PLATFORM_MS_TIME_ALT) +mbedtls_ms_time_t mbedtls_ms_time(void) +{ + struct timespec tv; + if (clock_gettime(CLOCK_MONOTONIC, &tv) != 0) { + return (mbedtls_ms_time_t) time(NULL) * 1000; + } + return (mbedtls_ms_time_t) tv.tv_sec * 1000 + + (mbedtls_ms_time_t) (tv.tv_nsec / 1000000); +} +#endif /* MBEDTLS_PLATFORM_MS_TIME_ALT */ + +int mbedtls_hardware_poll(void *data, unsigned char *output, size_t len, + size_t *olen) +{ + (void)data; + + size_t filled = 0; + while (filled < len) { + size_t chunk = len - filled; + if (chunk > 256) { + chunk = 256; /* getentropy() rejects requests larger than 256. */ + } + if (getentropy(output + filled, chunk) != 0) { + if (olen != NULL) { + *olen = filled; + } + return -1; /* MBEDTLS_ERR_ENTROPY_SOURCE_FAILED at the call site. */ + } + filled += chunk; + } + + if (olen != NULL) { + *olen = len; + } + return 0; +} diff --git a/toolchain/c/mbedtls/wasi_user_config.h b/toolchain/c/mbedtls/wasi_user_config.h new file mode 100644 index 0000000000..88b22cb823 --- /dev/null +++ b/toolchain/c/mbedtls/wasi_user_config.h @@ -0,0 +1,44 @@ +/* + * mbedTLS user-config overrides for the wasm32-wasip1 (WASI) build. + * + * This file is included via -DMBEDTLS_USER_CONFIG_FILE after mbedTLS's own + * default `mbedtls_config.h`, so it only needs to describe the deltas required + * to cross-compile cleanly against the wasi-sdk sysroot. The default 3.6 config + * already enables TLS 1.2 + TLS 1.3, X.509, PK, PSA crypto, and MBEDTLS_FS_IO + * (needed for curl/wget's mbedtls_x509_crt_parse_file on /etc/ssl/certs). + * + * Deltas: + * - Single-threaded: MBEDTLS_THREADING_C stays off (default) — no pthreads. + * - Entropy: WASI has no /dev/urandom fopen path, so disable platform entropy + * and route the entropy module through mbedtls_hardware_poll(), which we + * implement over getentropy() (see mbedtls_wasi_entropy.c). getentropy() is + * the same primitive the repo's host-side wasi_tls.c already relies on. + * - Networking: MBEDTLS_NET_C pulls in BSD select()/socket() + * that wasi-libc does not fully provide. curl/wget own the transport and + * only need the TLS record + X.509 layers, so drop mbedTLS's own sockets. + * - Timing: MBEDTLS_TIMING_C uses gettimeofday()/select() hardware timers that + * are unnecessary here (no DTLS retransmission timers in the curl path). + */ + +#ifndef MBEDTLS_WASI_USER_CONFIG_H +#define MBEDTLS_WASI_USER_CONFIG_H + +/* Entropy: replace the POSIX /dev/urandom source with a getentropy() poll. */ +#undef MBEDTLS_PLATFORM_ENTROPY +#define MBEDTLS_NO_PLATFORM_ENTROPY +#define MBEDTLS_ENTROPY_HARDWARE_ALT + +/* + * mbedtls_ms_time(): mbedTLS's built-in POSIX branch only compiles when it can + * see _POSIX_VERSION, but it guards the include behind unix/__unix + * macros that clang does not define for wasm32, so the platform detection falls + * through to `#error "No mbedtls_ms_time available"`. Provide it ourselves over + * clock_gettime(CLOCK_MONOTONIC) (available in wasi-libc) via the ALT hook. + */ +#define MBEDTLS_PLATFORM_MS_TIME_ALT + +/* Drop mbedTLS's own BSD-sockets and hardware-timing modules for WASI. */ +#undef MBEDTLS_NET_C +#undef MBEDTLS_TIMING_C + +#endif /* MBEDTLS_WASI_USER_CONFIG_H */