From 84746390c59c664b05be2d9691f356893abab930 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 12 Nov 2026 09:10:21 -0800 Subject: [PATCH 1/3] feat(computers): a TLS shim keeps OpenSSL's certificate checks on real time on a computer's universe clock (FD-5475) A program on universe time months from real time finds every live site's certificate not yet valid or expired. Most clients go through the egress proxy; tls_realtime.c, preloaded after the clock library, covers the ones that don't. It defines X509_verify_cert, sets the check time to real time (a raw clock_gettime system call) unless the caller set one, and calls the real one from the caller's own libcrypto (dladdr on the caller, then its own dependencies), so a process holding two OpenSSL copies verifies each with its own. build.sh builds it as libtls_realtime.so. The clock tier's tls gate runs curl, Python and openssl s_client with the clock 182 days back and no proxy: six live sites verify, badssl's expired and untrusted-root ones don't, and two local servers show the check is real time's (a certificate valid only from now, refused without the shim and accepted with it; one valid at the universe date and since expired, the other way round). The clock desktop test preloads it and reaches a live site from the session. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/agent_env/computers/clock/README.md | 13 +- src/agent_env/computers/clock/build.sh | 4 +- src/agent_env/computers/clock/tls_realtime.c | 119 ++++++++++++++++++ tst/clock/Dockerfile | 2 +- tst/clock/harness.py | 99 ++++++++++++++- tst/clock/tls_test.py | 22 ++++ .../clock-desktop/envs/universe-clock/app.py | 11 +- .../envs/universe-clock/install.sh | 6 +- .../computer/clock_desktop_test.py | 20 ++- 10 files changed, 275 insertions(+), 23 deletions(-) create mode 100644 src/agent_env/computers/clock/tls_realtime.c create mode 100644 tst/clock/tls_test.py diff --git a/AGENTS.md b/AGENTS.md index 76f35abfd..51a3d7e17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ or one of them only where it is used. | `make int-test-fast` | `tst/integration` minus `int_test_slow`, in parallel; needs Docker and a local OCI registry (`docker run -d -p 5000:5000 public.ecr.aws/docker/library/registry:2`) | | `make int-test-slow` | the `int_test_slow` tests, serially (they build the same Docker tags from module fixtures and race under xdist) | | `make installer-test` | `tst/installer`: `plugin add` / `remove` through the real pip, uv and pipx, offline against wheels built from the checkout, and the container user journey (`python:3.12-slim`, `--network none`) with the PEP 668 refusal; needs uv, pipx and Docker | -| `make clock-test` | `tst/clock`: agent-env's libfaketime (`src/agent_env/computers/clock`) and stock 0.9.12 built in an Ubuntu 24.04 image for this machine's Docker, and its gates run there: every wait API in C, Python, Java and Node at several rates, jumps, cancelled waits, timerfd duplicates, condvar and semaphore stress, the waits again with `futex_waitv` refused (as before Linux 5.16 and under gVisor), a stalled writer, a dead one, torn reads, the marker, the environment race, forks while another thread closes a timerfd, and Chromium launched 40 times with it and 10 with stock, side by side, lateness held to `tst/clock/bounds.py`; needs Docker, and fetches Chrome's archive and upstream's tarball once into `AGENTENV_TEST_CLOCK_CACHE` | +| `make clock-test` | `tst/clock`: agent-env's libfaketime (`src/agent_env/computers/clock`) and stock 0.9.12 built in an Ubuntu 24.04 image for this machine's Docker, and its gates run there: every wait API in C, Python, Java and Node at several rates, jumps, cancelled waits, timerfd duplicates, condvar and semaphore stress, the waits again with `futex_waitv` refused (as before Linux 5.16 and under gVisor), a stalled writer, a dead one, torn reads, the marker, the environment race, forks while another thread closes a timerfd, the TLS shim's certificate checks on real time (curl, Python and `openssl s_client` at 182 days back, live sites and badssl's), and Chromium launched 40 times with it and 10 with stock, side by side, lateness held to `tst/clock/bounds.py`; needs Docker, and fetches Chrome's archive and upstream's tarball once into `AGENTENV_TEST_CLOCK_CACHE` | | `make trl-test` | `tst/trl`: one `GRPOTrainer` step on CPU through `agent_env.training.trl`, with a tiny model from the Hub; needs trl and CPU torch, which the `dev` extra leaves out | | `make nemo-gym-test` | `tst/nemo_gym`: rollouts through `agent_env.training.nemo_gym`'s resources server in-process; needs the `nemo-gym` extra, on Python 3.13.14 or later, which the `dev` extra leaves out (`uv sync --extra dev --extra nemo-gym`) | | `make verl-test` | `tst/verl`: rows played through `agent_env.training.verl`'s agent loop on verl itself, with Qwen3's tokenizer from the Hub and a stand-in rollout server; needs verl and CPU torch, which the `dev` extra leaves out | diff --git a/src/agent_env/computers/clock/README.md b/src/agent_env/computers/clock/README.md index d0ebd7c41..43ef73491 100644 --- a/src/agent_env/computers/clock/README.md +++ b/src/agent_env/computers/clock/README.md @@ -14,6 +14,11 @@ What follows the universe, and what doesn't: has no `futex_waitv` (before 5.16, or gVisor), realtime futex and semaphore waits read the page between slices of `AGENTENV_WAIT_SLICE_MS` (100 ms by default) instead, so they see a change within a slice. - **Real time:** the monotonic and boot clocks, CPU-time clocks, and every relative sleep and timeout, at any rate. +- **Certificates:** OpenSSL checks a peer's certificates against real time, through the TLS shim (`tls_realtime.c`, + preloaded after the library), so a client that reaches a live site without the computer's egress proxy still + verifies it months from real time, and still refuses an expired or untrusted one. The shim sets + `X509_verify_cert`'s check time unless the caller set one, and calls the caller's own libcrypto. TLS stacks of + their own (Chromium's BoringSSL, GnuTLS, NSS, Java's) and static binaries aren't covered. - **Who:** only the session user's processes (`AGENTENV_SESSION_UID`). A program started with `AGENTENV_REAL_TIME=1` stays real; the marker is taken out of its environment block, so its children are faked. - **Settings:** read once as the library loads, from `/etc/agentenv/clock.conf` (`clock.conf` is the default), or from @@ -29,7 +34,8 @@ What follows the universe, and what doesn't: | `agentenv_clock_page.c` | settings, who is faked, the clock page reader; included by the patched `libfaketime.c` | GPL-2.0-only | | `agentenv_clock_waits.c` | universe-time reads and waits, the per-process helper thread; included the same way | GPL-2.0-only | | `COPYING` | the GNU General Public License version 2, libfaketime's license | | -| `build.sh` | builds `libfaketimeMT.so.1` from upstream's sha256-pinned tarball and the files above | Apache-2.0 | +| `build.sh` | builds `libfaketimeMT.so.1` from upstream's sha256-pinned tarball and the files above, and the shim's `libtls_realtime.so` | Apache-2.0 | +| `tls_realtime.c` | the TLS shim: OpenSSL's certificate checks on real time, preloaded after the library | Apache-2.0 | | `clockpage.py` | the clock page's writer, which the follower uses | Apache-2.0 | | `clock.conf` | the library's default settings | Apache-2.0 | @@ -51,8 +57,9 @@ computed from a read a moment earlier) continues from where the page has it inst there: every wait API in C, Python, Java and Node at several rates, jumps, cancelled waits, timerfd duplicates, condition-variable and semaphore stress, the waits again in a container that refuses `futex_waitv` (as kernels before 5.16 and gVisor do), a stalled writer, one that died mid-write, torn reads, the marker, the environment race, forks -while another thread closes a timerfd, and Chromium launches. `.github/workflows/clock.yml` runs them on x86_64 and -arm64. The gates hold correctness strictly (no wait ends early, every wait returns what it should, nothing is lost or +while another thread closes a timerfd, the TLS shim (curl, Python and `openssl s_client` with the clock 182 days back: +live sites verify, badssl's expired and untrusted ones don't, and two local servers show the check is on real time), +and Chromium launches. `.github/workflows/clock.yml` runs them on x86_64 and arm64. The gates hold correctness strictly (no wait ends early, every wait returns what it should, nothing is lost or torn) and lateness to `tst/clock/bounds.py`, a bound a broken library misses by seconds and a slow machine doesn't reach. `tst/integration/computer/clock_desktop_test.py` composes the library into a real computer (the ubuntu-24.04 desktop, the reference driver and agentenv's chromium, every process preloading it), which must pass the conformance diff --git a/src/agent_env/computers/clock/build.sh b/src/agent_env/computers/clock/build.sh index b50c2bf39..8d7227284 100755 --- a/src/agent_env/computers/clock/build.sh +++ b/src/agent_env/computers/clock/build.sh @@ -1,7 +1,8 @@ #!/bin/sh # Builds agent-env's libfaketime: upstream 0.9.12 from its sha256-pinned tarball, libfaketime.patch applied and the two # engine files beside it copied in, the multithreaded library with stat and utime faking compiled out (file times stay -# real). Writes OUT/libfaketimeMT.so.1. Needs a C toolchain, make, patch and curl; builds on x86_64 and aarch64. +# real); and the TLS shim beside it (tls_realtime.c). Writes OUT/libfaketimeMT.so.1 and OUT/libtls_realtime.so, which +# /etc/ld.so.preload lists in that order. Needs a C toolchain, make, patch and curl; builds on x86_64 and aarch64. # build.sh OUT [TARBALL] (TARBALL: a local copy of upstream's v0.9.12 tarball, else it is downloaded) set -eu out=$1 @@ -18,3 +19,4 @@ cp "$here/agentenv_clock_page.c" "$here/agentenv_clock_waits.c" "$src/src/" make -s -C "$src/src" FAKETIME_COMPILE_CFLAGS="-UFAKE_STAT -UFAKE_UTIME" libfaketimeMT.so.1 mkdir -p "$out" cp "$src/src/libfaketimeMT.so.1" "$out/" +${CC:-cc} -std=gnu99 -O2 -fPIC -shared -Wall -Wextra -Werror -o "$out/libtls_realtime.so" "$here/tls_realtime.c" -ldl diff --git a/src/agent_env/computers/clock/tls_realtime.c b/src/agent_env/computers/clock/tls_realtime.c new file mode 100644 index 000000000..99f3aec51 --- /dev/null +++ b/src/agent_env/computers/clock/tls_realtime.c @@ -0,0 +1,119 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* + * agent-env's TLS shim: OpenSSL checks a peer's certificates against real time, whatever the universe's clock says. + * + * Preloaded after the clock library (/etc/ld.so.preload). A program on universe time months from real time would find + * every live site's certificate not yet valid or expired. Most clients reach live sites through the computer's egress + * proxy, which hands them certificates valid at the universe date; this is for the ones that bypass it. OpenSSL's + * libssl verifies a chain with X509_verify_cert, which this defines first in the global scope: it sets the + * verification's check time to real time, read with a raw system call the clock library doesn't intercept, unless the + * caller set one, then calls OpenSSL's own, the one in the caller's own libcrypto, so a process holding two copies of + * OpenSSL (a bundled one beside the system's) verifies each with its own. + * + * Not covered: TLS stacks of their own (Chromium's BoringSSL, GnuTLS, NSS, Java's) and static binaries. + */ +#define _GNU_SOURCE +#include +#include +#include +#include + +typedef struct x509_store_ctx_st X509_STORE_CTX; +typedef struct X509_VERIFY_PARAM_st X509_VERIFY_PARAM; + +/* OpenSSL's X509_V_FLAG_USE_CHECK_TIME (x509_vfy.h), the same in 1.1 and 3 */ +#define TLS_USE_CHECK_TIME 0x2 +#define TLS_SYSTEM_LIBCRYPTO "libcrypto.so.3" + +/* OpenSSL's functions as one copy of it has them, and the object whose calls they answer */ +struct tls_openssl +{ + void *caller_base; + int (*verify)(X509_STORE_CTX *); + void (*set_time)(X509_STORE_CTX *, unsigned long, time_t); + X509_VERIFY_PARAM *(*get0_param)(X509_STORE_CTX *); + unsigned long (*get_flags)(const X509_VERIFY_PARAM *); +}; +/* the last calling object's, per thread: libssl calls from the same place handshake after handshake */ +static __thread struct tls_openssl tls_last; + +int X509_verify_cert(X509_STORE_CTX *ctx); + +/* the real time in seconds, read with the system call itself (the clock library intercepts libc's), 0 if it fails */ +static long tls_real_time(void) +{ + struct timespec ts = {0, 0}; +#if defined(__x86_64__) + long ret; + __asm__ volatile("syscall" : "=a"(ret) : "0"(228L), "D"((long) CLOCK_REALTIME), "S"(&ts) : "rcx", "r11", "memory"); + if (ret != 0) return 0; +#elif defined(__aarch64__) + register long x8 __asm__("x8") = 113; + register long x0 __asm__("x0") = CLOCK_REALTIME; + register long x1 __asm__("x1") = (long) &ts; + __asm__ volatile("svc 0" : "+r"(x0) : "r"(x8), "r"(x1) : "memory"); + if (x0 != 0) return 0; +#else +#error the TLS shim reads real time with a raw system call on x86_64 or aarch64 +#endif + return ts.tv_sec; +} + +/* ``name`` in ``scope`` (a handle, or RTLD_NEXT), never this file's own definition */ +static void *tls_lookup(void *scope, const char *name) +{ + void *found = dlsym(scope, name); + return found == (void *) X509_verify_cert ? NULL : found; +} + +static void tls_take(void *scope, struct tls_openssl *out) +{ + out->verify = (int (*)(X509_STORE_CTX *)) tls_lookup(scope, "X509_verify_cert"); + out->set_time = (void (*)(X509_STORE_CTX *, unsigned long, time_t)) tls_lookup(scope, "X509_STORE_CTX_set_time"); + out->get0_param = (X509_VERIFY_PARAM * (*)(X509_STORE_CTX *)) tls_lookup(scope, "X509_STORE_CTX_get0_param"); + out->get_flags = (unsigned long (*)(const X509_VERIFY_PARAM *)) tls_lookup(scope, "X509_VERIFY_PARAM_get_flags"); +} + +/* OpenSSL as the object at ``caller`` sees it: from that object's own dependencies (libssl's libcrypto, whichever copy + it is); for a caller that isn't a shared library depending on one (a program calling it itself), the next definition + in the global scope; else the system's libcrypto where it was loaded privately (a Python extension's) */ +static void tls_resolve(void *caller, struct tls_openssl *out) +{ + Dl_info info; + void *handle; + memset(out, 0, sizeof *out); + if (dladdr(caller, &info)) + { + out->caller_base = info.dli_fbase; + if (info.dli_fname != NULL && info.dli_fname[0] != 0 + && (handle = dlopen(info.dli_fname, RTLD_NOW | RTLD_NOLOAD)) != NULL) + { + tls_take(handle, out); + dlclose(handle); + if (out->verify != NULL) return; + } + } + tls_take(RTLD_NEXT, out); + if (out->verify != NULL) return; + if ((handle = dlopen(TLS_SYSTEM_LIBCRYPTO, RTLD_NOW | RTLD_NOLOAD)) != NULL) + { + tls_take(handle, out); + dlclose(handle); + } +} + +int X509_verify_cert(X509_STORE_CTX *ctx) +{ + void *caller = __builtin_return_address(0); + Dl_info info; + struct tls_openssl *ssl = &tls_last; + if (ssl->verify == NULL || !dladdr(caller, &info) || info.dli_fbase != ssl->caller_base) tls_resolve(caller, ssl); + if (ssl->verify == NULL) return -1; + if (ctx != NULL && ssl->set_time != NULL && ssl->get0_param != NULL && ssl->get_flags != NULL) + { + X509_VERIFY_PARAM *param = ssl->get0_param(ctx); + long now = tls_real_time(); + if (param != NULL && !(ssl->get_flags(param) & TLS_USE_CHECK_TIME) && now > 0) ssl->set_time(ctx, 0, (time_t) now); + } + return ssl->verify(ctx); +} diff --git a/tst/clock/Dockerfile b/tst/clock/Dockerfile index c2952166e..f8d56d452 100644 --- a/tst/clock/Dockerfile +++ b/tst/clock/Dockerfile @@ -5,7 +5,7 @@ FROM public.ecr.aws/docker/library/ubuntu:24.04@sha256:24737cef17f4e22b1977df7d9 ARG CHROME_SHA256 ARG CHROME_PACKAGES RUN apt-get update -q \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends build-essential patch curl \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends build-essential patch curl openssl \ ca-certificates python3 openjdk-21-jdk-headless nodejs util-linux unzip $CHROME_PACKAGES >/dev/null \ && rm -rf /var/lib/apt/lists/* \ && userdel -r ubuntu && useradd -m -u 1000 user diff --git a/tst/clock/harness.py b/tst/clock/harness.py index 19d796074..2e4d13368 100644 --- a/tst/clock/harness.py +++ b/tst/clock/harness.py @@ -3,7 +3,7 @@ under test runs as the session user, whom the library fakes. Each gate prints one JSON document on stdout. harness.py waits | ratechange | jumps [ROUNDS] | cancel | timerfds | condstress [SECONDS] | semstress [SECONDS] - | stall | torn [SECONDS] | deadwriter | marker | identity | envrace [SECONDS] | forklock [FORKS] + | stall | torn [SECONDS] | deadwriter | marker | identity | envrace [SECONDS] | forklock [FORKS] | tls | chromium [agentenv|stock LAUNCHES PARALLEL LIMIT] | probe """ import ctypes @@ -21,6 +21,7 @@ import clockpage # noqa: E402 LIB = "/opt/clock/lib/libfaketimeMT.so.1" +SHIM = "/opt/clock/lib/libtls_realtime.so" STOCK = "/opt/clock/stock/libfaketimeMT.so.1" PROGRAMS = "/opt/clock/programs" CHROME = "/opt/chrome/chrome" @@ -56,9 +57,10 @@ def raw() -> float: return time.clock_gettime(RAW) -def preload(lib=LIB) -> None: +def preload(lib=LIB, shim=False) -> None: + """/etc/ld.so.preload listing ``lib`` (none for None), and the TLS shim after it with ``shim``.""" with open("/etc/ld.so.preload", "w") as f: - f.write(lib + "\n" if lib else "") + f.write("".join(f"{path}\n" for path in (lib, SHIM if shim else None) if path)) def user_cmd(argv, env=None): @@ -565,6 +567,95 @@ def forklock(forks=1000): return json.loads(r.stdout) +#: Live sites the TLS gate reaches, with the clock armed 182 days back (most rotate certificates within 90 days), and +#: ones whose certificates are refused at any time. +TLS_LIVE = ["example.com", "www.google.com", "github.com", "pypi.org", "www.wikipedia.org", "letsencrypt.org"] +TLS_REFUSED = ["expired.badssl.com", "untrusted-root.badssl.com"] +# A TLS client's answer for https://HOST:PORT: ok (the certificate verified), refused (it didn't), or network ... +TLS_PYTHON = """import ssl, sys, urllib.error, urllib.request +host, port, cafile = sys.argv[1], int(sys.argv[2]), sys.argv[3] or None +context = ssl.create_default_context(cafile=cafile) +opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), urllib.request.HTTPSHandler(context=context)) +try: + opener.open(f"https://{host}:{port}/", timeout=20) + print("ok") +except urllib.error.HTTPError: + print("ok") +except Exception as e: + reason = getattr(e, "reason", e) + refused = isinstance(reason, ssl.SSLCertVerificationError) + print("refused" if refused else f"network {type(reason).__name__}: {reason}") +""" + + +def _tls(client: str, host: str, port: int = 443, cafile: str = "") -> str: + """``client``'s answer for https://``host``:``port`` as the session user, trusting ``cafile`` (else the system's + roots), through no proxy: ok, refused, or network (tried three times).""" + for _ in range(3): + if client == "curl": + r = subprocess.run(user_cmd(["curl", "-sS", "-o", "/dev/null", "--noproxy", "*", "--max-time", "20", + *(["--cacert", cafile] if cafile else []), f"https://{host}:{port}/"]), + capture_output=True, text=True, timeout=60) + answer = ("ok" if r.returncode == 0 else "refused" if r.returncode == 60 else + f"network {r.returncode}: {r.stderr[-160:]}") + elif client == "python": + r = subprocess.run(user_cmd(["python3", "-c", TLS_PYTHON, host, str(port), cafile]), capture_output=True, + text=True, timeout=60) + answer = r.stdout.strip() or f"network: {r.stderr[-160:]}" + else: + r = subprocess.run(user_cmd(["openssl", "s_client", "-connect", f"{host}:{port}", "-servername", host, + "-verify_return_error", *(["-CAfile", cafile] if cafile else [])]), + input="", capture_output=True, text=True, timeout=60) + text = r.stdout + r.stderr + refused = "verify error" in text or "Verify return code: " in text + answer = ("ok" if r.returncode == 0 and "Verify return code: 0 (ok)" in text else + "refused" if refused else f"network: {text[-160:]}") + if not answer.startswith("network"): + return answer + time.sleep(2) + return answer + + +def tls(): + """The TLS shim, with the clock 182 days back and no proxy, through curl, Python and openssl s_client. Two local + servers check it uses real time: one's certificate is valid only from now (not yet valid at the universe date), + the other's was valid at the universe date but has expired. With the clock library alone each client answers by + the universe date; with the shim by real time, and live sites verify while badssl's bad ones still don't.""" + clients = ("curl", "python", "s_client") + work = "/tmp/tlsgate" + os.makedirs(work, exist_ok=True) + os.chmod(work, 0o777) + preload() + fresh_page() + req = ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=localhost", + "-addext", "subjectAltName=DNS:localhost"] + subprocess.run([*req, "-days", "30", "-keyout", f"{work}/now.key", "-out", f"{work}/now.pem"], check=True, + capture_output=True) + set_clock(time.time() - 200 * DAY, 1) # made at the universe's now, by the session user: expired 100 days ago + subprocess.run(user_cmd([*req, "-days", "100", "-keyout", f"{work}/past.key", "-out", f"{work}/past.pem"]), + check=True, capture_output=True) + servers = [subprocess.Popen(["openssl", "s_server", "-quiet", "-www", "-accept", str(port), + "-cert", f"{work}/{name}.pem", "-key", f"{work}/{name}.key"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for name, port in (("now", 8443), ("past", 8444))] + time.sleep(1) + set_clock(time.time() - 182 * DAY, 1) + out = {} + try: + for shim in (False, True): + preload(shim=shim) + local = {name: {c: _tls(c, "localhost", port, f"{work}/{name}.pem") for c in clients} + for name, port in (("now", 8443), ("past", 8444))} + out["with_shim" if shim else "without_shim"] = {"local": local} + out["with_shim"]["live"] = {host: {c: _tls(c, host) for c in clients} for host in TLS_LIVE} + out["with_shim"]["bad"] = {host: {c: _tls(c, host) for c in clients} for host in TLS_REFUSED} + finally: + for server in servers: + server.kill() + preload(None) + return out + + def probe(): """This container's answer to futex_waitv: the errno of an empty call (EINVAL where the kernel has it).""" libc = ctypes.CDLL(None, use_errno=True) @@ -573,7 +664,7 @@ def probe(): GATES = {f.__name__: f for f in (waits, ratechange, jumps, cancel, timerfds, condstress, semstress, stall, torn, - deadwriter, marker, identity, envrace, forklock, chromium, probe)} + deadwriter, marker, identity, envrace, forklock, chromium, tls, probe)} if __name__ == "__main__": gate = GATES[sys.argv[1]] diff --git a/tst/clock/tls_test.py b/tst/clock/tls_test.py new file mode 100644 index 000000000..b661b2c89 --- /dev/null +++ b/tst/clock/tls_test.py @@ -0,0 +1,22 @@ +"""The TLS shim: OpenSSL clients on universe time check certificates against real time, through no proxy.""" + +CLIENTS = {"curl", "python", "s_client"} + + +def _all(results: dict, answer: str) -> dict: + """What isn't ``answer`` among ``results`` ({site: {client: answer}}).""" + return {(site, client): got for site, by_client in results.items() for client, got in by_client.items() + if got != answer} + + +def test_with_the_shim_certificates_are_checked_against_real_time_and_bad_ones_still_refused(gate): + r = gate("tls") + alone, shimmed = r["without_shim"]["local"], r["with_shim"]["local"] + assert all(set(by_client) == CLIENTS for part in (alone, shimmed) for by_client in part.values()), r + # The clock library alone checks against the universe's date: a certificate valid only from real now isn't valid + # yet, and one that expired since the universe's date still is. + assert (_all({"now": alone["now"]}, "refused"), _all({"past": alone["past"]}, "ok")) == ({}, {}), alone + # With the shim, against real time: the other way round. + assert (_all({"now": shimmed["now"]}, "ok"), _all({"past": shimmed["past"]}, "refused")) == ({}, {}), shimmed + assert len(r["with_shim"]["live"]) == 6 and _all(r["with_shim"]["live"], "ok") == {}, r["with_shim"]["live"] + assert len(r["with_shim"]["bad"]) == 2 and _all(r["with_shim"]["bad"], "refused") == {}, r["with_shim"]["bad"] diff --git a/tst/integration/computer/clock-desktop/envs/universe-clock/app.py b/tst/integration/computer/clock-desktop/envs/universe-clock/app.py index 75e55d4aa..5f5626474 100644 --- a/tst/integration/computer/clock-desktop/envs/universe-clock/app.py +++ b/tst/integration/computer/clock-desktop/envs/universe-clock/app.py @@ -1,7 +1,8 @@ -"""agent-env's libfaketime on the computer, for the clock desktop test: built at compose from the recipe the test -copies into ./clock (src/agent_env/computers/clock) and upstream's tarball, which the app downloads at the sha256 -build.sh pins, then preloaded into every process with its settings and the clock page's writer beside it (install.sh). -The page itself is the test's to write, as root, as the computer's clock follower will.""" +"""agent-env's libfaketime and its TLS shim on the computer, for the clock desktop test: built at compose from the +recipe the test copies into ./clock (src/agent_env/computers/clock) and upstream's tarball, which the app downloads at +the sha256 build.sh pins, then preloaded into every process with the library's settings and the clock page's writer +beside them (install.sh), and curl, which the base doesn't have, to reach a live site with. The page itself is the +test's to write, as root, as the computer's clock follower will.""" import re from pathlib import Path @@ -13,7 +14,7 @@ @computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) -@linux(packages=Packages(apt=["build-essential", "patch"]), +@linux(packages=Packages(apt=["build-essential", "patch", "curl"]), downloads=[Download(url=TARBALL, to="libfaketime.tar.gz", sha256=SHA256)], run=[RunScript(script="install.sh")]) class UniverseClock(ComputerAppEnvironment): diff --git a/tst/integration/computer/clock-desktop/envs/universe-clock/install.sh b/tst/integration/computer/clock-desktop/envs/universe-clock/install.sh index c7420e256..72f94bc28 100755 --- a/tst/integration/computer/clock-desktop/envs/universe-clock/install.sh +++ b/tst/integration/computer/clock-desktop/envs/universe-clock/install.sh @@ -1,8 +1,8 @@ #!/bin/sh -# Builds agent-env's libfaketime from ./clock and the downloaded tarball, installs its settings and the page's writer, -# and preloads it into every process the computer starts. +# Builds agent-env's libfaketime and its TLS shim from ./clock and the downloaded tarball, installs the library's +# settings and the page's writer, and preloads both into every process the computer starts, the library first. set -eu sh clock/build.sh /opt/agentenv/clock libfaketime.tar.gz install -m 0644 clock/clockpage.py /opt/agentenv/clock/clockpage.py install -D -m 0644 clock/clock.conf /etc/agentenv/clock.conf -echo /opt/agentenv/clock/libfaketimeMT.so.1 > /etc/ld.so.preload +printf '%s\n' /opt/agentenv/clock/libfaketimeMT.so.1 /opt/agentenv/clock/libtls_realtime.so > /etc/ld.so.preload diff --git a/tst/integration/computer/clock_desktop_test.py b/tst/integration/computer/clock_desktop_test.py index f119ade31..cd206fd03 100644 --- a/tst/integration/computer/clock_desktop_test.py +++ b/tst/integration/computer/clock_desktop_test.py @@ -1,9 +1,10 @@ """agent-env's libfaketime on a real computer: the clock desktop test bundle's, the ubuntu-24.04 desktop with the -reference driver and agentenv's chromium, every process loading the library. Composed, it passes the conformance probe -at full. Deployed, with its clock page written as root, as the computer's follower will write it: a shell, Chrome's -JavaScript and the driver read universe time through 30 forward jumps of 8 h, a day or a week, a rate change and a -freeze; Chrome, Xvfb and the desktop's panel run on through them; and the driver's input reaches Chrome after every jump -and, frozen, is drawn. Needs Docker and the local registry on localhost:5000.""" +reference driver and agentenv's chromium, every process loading the library. Composed, it passes the conformance +probe at full. Deployed, with its clock page written as root, as the computer's follower will write it: curl and +Python reach a live site, its certificate checked against real time by the TLS shim; a shell, Chrome's JavaScript and +the driver read universe time through 30 forward jumps of 8 h, a day or a week, a rate change and a freeze; Chrome, +Xvfb and the desktop's panel run on through them; and the driver's input reaches Chrome after every jump and, frozen, +is drawn. Needs Docker and the local registry on localhost:5000.""" import ast import asyncio @@ -40,6 +41,11 @@ #: The processes that must run on through the clock's changes: Xvfb, the desktop's panel and Chrome's browser process. PIDS = "pgrep -o -x Xvfb; pgrep -o -x xfce4-panel; pgrep -o -f -- --remote-debugging-pipe" PAGE_WRITER = "/opt/agentenv/clock/clockpage.py" +#: A live site reached from the session, the clock months back, with no proxy: each exits 0 once its certificate +#: verifies. +REACH = {"curl": "curl -sS -o /dev/null --noproxy '*' --max-time 30 https://example.com/", + "python": ("python3 -c \"import urllib.request; urllib.request.build_opener(urllib.request.ProxyHandler({}))" + ".open('https://example.com/', timeout=30)\"")} LIBRARY = "/opt/agentenv/clock/libfaketimeMT.so.1" @@ -65,6 +71,8 @@ async def test_the_desktop_follows_the_universe_through_jumps_a_rate_change_and_ loaded = await _run(deployed, f"grep -c {LIBRARY} /proc/{pids[2]}/maps") clock.set(time.time() - 182 * DAY, 1) await asyncio.sleep(2) # each program maps the page the next time it reads the clock, within a second + reached = {client: await inside.run(deployed, ["sh", "-c", command], timeout_s=60) + for client, command in REACH.items()} async with streamablehttp_client(f"{deployed.environment_url}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() @@ -95,6 +103,8 @@ async def test_the_desktop_follows_the_universe_through_jumps_a_rate_change_and_ report = await teardown_run(context) assert int(loaded) >= 1, "Chrome's browser process didn't load the library" + # The TLS shim: a live site's certificate verifies against real time, the clock 182 days back. + assert {client: ran["exit_code"] for client, ran in reached.items()} == {"curl": 0, "python": 0}, reached assert [r for r in rounds if not (r["shell"] and r["chrome_s"] is not None and r["typed"] and r["screenshot"])] == [] assert max(r["chrome_s"] for r in rounds) <= LATE_S + 0.2 # the page's title updates every 200 ms From 665270c16ebf223131355d55371999c800754a1a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 12 Nov 2026 09:26:14 -0800 Subject: [PATCH 2/3] test(clock): two copies of OpenSSL in one process each check certificates with their own The TLS gate verifies its two certificates through the system's OpenSSL 3.0 and a 1.1.1w built beside it, each through a library loaded privately, with the system's also in the global scope or not. With the prototype's lookup the program segfaults in both modes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/clock.yml | 8 ++--- AGENTS.md | 2 +- src/agent_env/computers/clock/README.md | 3 +- tst/clock/Dockerfile | 17 ++++++++-- tst/clock/conftest.py | 10 ++++-- tst/clock/harness.py | 16 ++++++++-- tst/clock/programs/tlscopies.c | 42 +++++++++++++++++++++++++ tst/clock/programs/tlsverify.c | 28 +++++++++++++++++ tst/clock/tls_test.py | 33 +++++++++++++++---- 9 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 tst/clock/programs/tlscopies.c create mode 100644 tst/clock/programs/tlsverify.c diff --git a/.github/workflows/clock.yml b/.github/workflows/clock.yml index dc519a766..127cc4b2b 100644 --- a/.github/workflows/clock.yml +++ b/.github/workflows/clock.yml @@ -4,8 +4,8 @@ name: Clock gates # universe time, built with stock 0.9.12 beside it in an Ubuntu 24.04 image and run through tst/clock's gates on x86_64 # and arm64: every wait API in C, Python, Java and Node at several rates, jumps, cancelled waits, timerfd duplicates, # condvar and semaphore stress, the waits again with futex_waitv refused, a stalled writer and a dead one, torn page -# reads, the marker, the environment race and Chromium launches. It runs on pull requests that touch the library, its -# tier or Chrome's pin, weekly, and on demand; it isn't a required check. +# reads, the marker, the environment race, the TLS shim and Chromium launches. It runs on pull requests that touch the +# library, its tier or Chrome's pin, weekly, and on demand; it isn't a required check. on: pull_request: @@ -47,12 +47,12 @@ jobs: run: | python -m pip install --quiet -r .github/ci-requirements.txt uv sync --locked --extra dev --python "$(command -v python)" - # Chrome's archive and upstream's tarball, checked by sha256 when the tier uses them; a new pin is a new key. + # Chrome's archive and the tarballs, checked by sha256 when the tier uses them; a new pin is a new key. - name: Downloads uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ env.AGENTENV_TEST_CLOCK_CACHE }} - key: clock-downloads-${{ matrix.arch }}-${{ hashFiles('src/agent_env/browsers/envs/chromium/chromium.py', 'src/agent_env/computers/clock/build.sh') }} + key: clock-downloads-${{ matrix.arch }}-${{ hashFiles('src/agent_env/browsers/envs/chromium/chromium.py', 'src/agent_env/computers/clock/build.sh', 'tst/clock/conftest.py') }} - name: Clock gates run: .venv/bin/python -m pytest tst/clock -rA --timeout=2400 --junitxml=junit-clock-${{ matrix.arch }}.xml - name: No skips diff --git a/AGENTS.md b/AGENTS.md index 51a3d7e17..4cd50ce65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ or one of them only where it is used. | `make int-test-fast` | `tst/integration` minus `int_test_slow`, in parallel; needs Docker and a local OCI registry (`docker run -d -p 5000:5000 public.ecr.aws/docker/library/registry:2`) | | `make int-test-slow` | the `int_test_slow` tests, serially (they build the same Docker tags from module fixtures and race under xdist) | | `make installer-test` | `tst/installer`: `plugin add` / `remove` through the real pip, uv and pipx, offline against wheels built from the checkout, and the container user journey (`python:3.12-slim`, `--network none`) with the PEP 668 refusal; needs uv, pipx and Docker | -| `make clock-test` | `tst/clock`: agent-env's libfaketime (`src/agent_env/computers/clock`) and stock 0.9.12 built in an Ubuntu 24.04 image for this machine's Docker, and its gates run there: every wait API in C, Python, Java and Node at several rates, jumps, cancelled waits, timerfd duplicates, condvar and semaphore stress, the waits again with `futex_waitv` refused (as before Linux 5.16 and under gVisor), a stalled writer, a dead one, torn reads, the marker, the environment race, forks while another thread closes a timerfd, the TLS shim's certificate checks on real time (curl, Python and `openssl s_client` at 182 days back, live sites and badssl's), and Chromium launched 40 times with it and 10 with stock, side by side, lateness held to `tst/clock/bounds.py`; needs Docker, and fetches Chrome's archive and upstream's tarball once into `AGENTENV_TEST_CLOCK_CACHE` | +| `make clock-test` | `tst/clock`: agent-env's libfaketime (`src/agent_env/computers/clock`) and stock 0.9.12 built in an Ubuntu 24.04 image for this machine's Docker, and its gates run there: every wait API in C, Python, Java and Node at several rates, jumps, cancelled waits, timerfd duplicates, condvar and semaphore stress, the waits again with `futex_waitv` refused (as before Linux 5.16 and under gVisor), a stalled writer, a dead one, torn reads, the marker, the environment race, forks while another thread closes a timerfd, the TLS shim's certificate checks on real time (curl, Python and `openssl s_client` at 182 days back, live sites and badssl's, and two copies of OpenSSL in one process, the system's 3.0 and a 1.1, each checking with its own), and Chromium launched 40 times with it and 10 with stock, side by side, lateness held to `tst/clock/bounds.py`; needs Docker, and fetches Chrome's archive, upstream's tarball and OpenSSL 1.1.1w's once into `AGENTENV_TEST_CLOCK_CACHE` | | `make trl-test` | `tst/trl`: one `GRPOTrainer` step on CPU through `agent_env.training.trl`, with a tiny model from the Hub; needs trl and CPU torch, which the `dev` extra leaves out | | `make nemo-gym-test` | `tst/nemo_gym`: rollouts through `agent_env.training.nemo_gym`'s resources server in-process; needs the `nemo-gym` extra, on Python 3.13.14 or later, which the `dev` extra leaves out (`uv sync --extra dev --extra nemo-gym`) | | `make verl-test` | `tst/verl`: rows played through `agent_env.training.verl`'s agent loop on verl itself, with Qwen3's tokenizer from the Hub and a stand-in rollout server; needs verl and CPU torch, which the `dev` extra leaves out | diff --git a/src/agent_env/computers/clock/README.md b/src/agent_env/computers/clock/README.md index 43ef73491..6fa94839a 100644 --- a/src/agent_env/computers/clock/README.md +++ b/src/agent_env/computers/clock/README.md @@ -58,7 +58,8 @@ there: every wait API in C, Python, Java and Node at several rates, jumps, cance condition-variable and semaphore stress, the waits again in a container that refuses `futex_waitv` (as kernels before 5.16 and gVisor do), a stalled writer, one that died mid-write, torn reads, the marker, the environment race, forks while another thread closes a timerfd, the TLS shim (curl, Python and `openssl s_client` with the clock 182 days back: -live sites verify, badssl's expired and untrusted ones don't, and two local servers show the check is on real time), +live sites verify, badssl's expired and untrusted ones don't, two local servers show the check is on real time, +and two copies of OpenSSL in one process, the system's 3.0 and a 1.1, each check with their own), and Chromium launches. `.github/workflows/clock.yml` runs them on x86_64 and arm64. The gates hold correctness strictly (no wait ends early, every wait returns what it should, nothing is lost or torn) and lateness to `tst/clock/bounds.py`, a bound a broken library misses by seconds and a slow machine doesn't reach. `tst/integration/computer/clock_desktop_test.py` composes the library into a real computer (the ubuntu-24.04 diff --git a/tst/clock/Dockerfile b/tst/clock/Dockerfile index f8d56d452..573d1796e 100644 --- a/tst/clock/Dockerfile +++ b/tst/clock/Dockerfile @@ -1,12 +1,13 @@ # The clock gates' image: Ubuntu 24.04 as the base computer has it, agent-env's libfaketime built by its build.sh and -# stock 0.9.12 from the same tarball beside it, the gate programs in C, Python, Java and Node, and Chrome for Testing -# at the browsers bundle's pin. tst/clock/conftest.py assembles its context, with both downloads, checked. +# stock 0.9.12 from the same tarball beside it, the gate programs in C, Python, Java and Node, Chrome for Testing at +# the browsers bundle's pin, and OpenSSL 1.1.1w's libraries, a second copy of OpenSSL for the TLS gate. +# tst/clock/conftest.py assembles its context, with the downloads, checked. FROM public.ecr.aws/docker/library/ubuntu:24.04@sha256:24737cef17f4e22b1977df7d9a05e77c4e28b7da5b8911f8d8ccda136bf3573c ARG CHROME_SHA256 ARG CHROME_PACKAGES RUN apt-get update -q \ && DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends build-essential patch curl openssl \ - ca-certificates python3 openjdk-21-jdk-headless nodejs util-linux unzip $CHROME_PACKAGES >/dev/null \ + libssl-dev ca-certificates python3 openjdk-21-jdk-headless nodejs util-linux unzip $CHROME_PACKAGES >/dev/null \ && rm -rf /var/lib/apt/lists/* \ && userdel -r ubuntu && useradd -m -u 1000 user COPY downloads/chrome.zip /tmp/chrome.zip @@ -20,9 +21,19 @@ RUN /opt/clock-src/build.sh /opt/clock/lib /tmp/lft.tgz \ && rm -rf /tmp/lft.tgz /tmp/libfaketime-0.9.12 \ && cp /opt/clock-src/clockpage.py /opt/clock/ \ && mkdir -p /etc/agentenv /run/agentenv-clock && cp /opt/clock-src/clock.conf /etc/agentenv/clock.conf +COPY downloads/openssl-1.1.1w.tar.gz /tmp/openssl.tgz +RUN tar xzf /tmp/openssl.tgz -C /tmp && cd /tmp/openssl-1.1.1w \ + && ./config shared no-tests >/dev/null && make -s -j"$(nproc)" build_libs >/dev/null \ + && mkdir -p /opt/openssl-1.1/lib /opt/openssl-1.1/include \ + && cp -P libcrypto.so libcrypto.so.1.1 /opt/openssl-1.1/lib/ && cp -r include/openssl /opt/openssl-1.1/include/ \ + && cd / && rm -rf /tmp/openssl.tgz /tmp/openssl-1.1.1w COPY programs /opt/clock/programs RUN mkdir -p /opt/clock/bin /opt/clock/java && cd /opt/clock/programs \ && for p in uwait jumpwaits condstress2 semstress envrace rtread torn cancel tfdalias forklock; do \ gcc -O2 -o /opt/clock/bin/$p $p.c -lpthread || exit 1; done \ + && mkdir -p /opt/clock/tls && gcc -O2 -fPIC -shared -o /opt/clock/tls/tlsverify3.so tlsverify.c -lcrypto \ + && gcc -O2 -fPIC -shared -I/opt/openssl-1.1/include -o /opt/clock/tls/tlsverify11.so tlsverify.c \ + -L/opt/openssl-1.1/lib -lcrypto -Wl,-rpath,/opt/openssl-1.1/lib \ + && gcc -O2 -o /opt/clock/bin/tlscopies tlscopies.c -ldl \ && javac -d /opt/clock/java UWait.java && chmod 755 marker.sh COPY harness.py /opt/clock/harness.py diff --git a/tst/clock/conftest.py b/tst/clock/conftest.py index 9d5befb04..c135f9d00 100644 --- a/tst/clock/conftest.py +++ b/tst/clock/conftest.py @@ -1,8 +1,8 @@ """The clock tier: agent-env's libfaketime (src/agent_env/computers/clock) built in an Ubuntu 24.04 image with stock 0.9.12 beside it and the gate programs, each gate run in a container of that image by harness.py. Needs Docker; the -image is for the Docker daemon's own arch, and Chrome is the browsers bundle's pinned build for it. Chrome's archive and -upstream libfaketime's tarball are fetched once into AGENTENV_TEST_CLOCK_CACHE (~/.cache/agentenv-clock by default), -checked by sha256, which CI keeps between runs.""" +image is for the Docker daemon's own arch, and Chrome is the browsers bundle's pinned build for it. Chrome's archive, +upstream libfaketime's tarball and OpenSSL 1.1.1w's are fetched once into AGENTENV_TEST_CLOCK_CACHE +(~/.cache/agentenv-clock by default), checked by sha256, which CI keeps between runs.""" import hashlib import importlib.util import json @@ -24,6 +24,9 @@ #: Tries at the downloads and the image build (its apt-get), which fail now and then on a network's hiccup. TRIES = 3 CACHE = Path(os.environ.get("AGENTENV_TEST_CLOCK_CACHE") or Path.home() / ".cache" / "agentenv-clock") +#: OpenSSL 1.1.1w's release, the TLS gate's second copy of OpenSSL beside the system's 3.0, whose symbol versions differ. +OPENSSL_1_1 = ("https://github.com/openssl/openssl/releases/download/OpenSSL_1_1_1w/openssl-1.1.1w.tar.gz", + "cf3098950cb4d853ad95c0841f1f9c6d3dc102dccfcacd521d93925208b76ac8") def _sha256(path: Path) -> str: @@ -99,6 +102,7 @@ def gate(tmp_path_factory): (context / "downloads").mkdir() shutil.copy(_fetched(url, sha256, f"chrome-{arch}-{sha256[:12]}.zip"), context / "downloads" / "chrome.zip") shutil.copy(_fetched(*_libfaketime_pin(), "libfaketime-0.9.12.tar.gz"), context / "downloads" / "libfaketime.tar.gz") + shutil.copy(_fetched(*OPENSSL_1_1, "openssl-1.1.1w.tar.gz"), context / "downloads" / "openssl-1.1.1w.tar.gz") for name in ("Dockerfile", "harness.py"): shutil.copy(HERE / name, context / name) _built(context, {"CHROME_SHA256": sha256, "CHROME_PACKAGES": packages}) diff --git a/tst/clock/harness.py b/tst/clock/harness.py index 2e4d13368..d5a913cf4 100644 --- a/tst/clock/harness.py +++ b/tst/clock/harness.py @@ -616,11 +616,22 @@ def _tls(client: str, host: str, port: int = 443, cafile: str = "") -> str: return answer +def _copies(work: str, mode: str) -> dict: + """tlscopies' answers in ``mode`` for the two local certificates by copy ({"3.0"|"1.1": {"version", "now", "past"}}), + or how it failed.""" + r = subprocess.run(user_cmd(["tlscopies", mode, "now.pem", "past.pem"]), cwd=work, capture_output=True, text=True, + timeout=60) + if r.returncode: + return {"failed": {"exit": r.returncode, "stderr": r.stderr[-300:]}} + return {copy: {k.removesuffix(".pem"): v for k, v in answers.items()} for copy, answers in json.loads(r.stdout).items()} + + def tls(): """The TLS shim, with the clock 182 days back and no proxy, through curl, Python and openssl s_client. Two local servers check it uses real time: one's certificate is valid only from now (not yet valid at the universe date), the other's was valid at the universe date but has expired. With the clock library alone each client answers by - the universe date; with the shim by real time, and live sites verify while badssl's bad ones still don't.""" + the universe date; with the shim by real time, and live sites verify while badssl's bad ones still don't. The same + two certificates are verified through two copies of OpenSSL in one process (tlscopies.c), each with its own.""" clients = ("curl", "python", "s_client") work = "/tmp/tlsgate" os.makedirs(work, exist_ok=True) @@ -646,7 +657,8 @@ def tls(): preload(shim=shim) local = {name: {c: _tls(c, "localhost", port, f"{work}/{name}.pem") for c in clients} for name, port in (("now", 8443), ("past", 8444))} - out["with_shim" if shim else "without_shim"] = {"local": local} + copies = {mode: _copies(work, mode) for mode in ("global", "private")} + out["with_shim" if shim else "without_shim"] = {"local": local, "copies": copies} out["with_shim"]["live"] = {host: {c: _tls(c, host) for c in clients} for host in TLS_LIVE} out["with_shim"]["bad"] = {host: {c: _tls(c, host) for c in clients} for host in TLS_REFUSED} finally: diff --git a/tst/clock/programs/tlscopies.c b/tst/clock/programs/tlscopies.c new file mode 100644 index 000000000..684207062 --- /dev/null +++ b/tst/clock/programs/tlscopies.c @@ -0,0 +1,42 @@ +/* Two copies of OpenSSL in one process, as a program with a bundled one beside the system's has them: the system's 3.0 + and a 1.1 built beside it, each reached through tlsverify.c built against it and loaded privately (RTLD_LOCAL), as + Python loads an extension. With MODE global the system's libcrypto is loaded into the global scope first, as a + program linked against it has it. Each CERT is verified through both copies in turn, so their calls alternate. + tlscopies MODE CERT... prints {"": {"version": ..., "": [result, error], ...}, ...}. */ +#define _GNU_SOURCE +#include +#include +#include + +#define COPIES 2 +#define CERTS 8 + +int main(int argc, char **argv) +{ + const char *names[COPIES] = {"3.0", "1.1"}; + const char *paths[COPIES] = {"/opt/clock/tls/tlsverify3.so", "/opt/clock/tls/tlsverify11.so"}; + int (*verify[COPIES])(const char *, int *); + const char *(*version[COPIES])(void); + int ok[COPIES][CERTS], error[COPIES][CERTS], certs = argc - 2; + if (argc < 3 || certs > CERTS) return 2; + if (!strcmp(argv[1], "global") && !dlopen("libcrypto.so.3", RTLD_NOW | RTLD_GLOBAL)) return 2; + for (int i = 0; i < COPIES; i++) { + void *h = dlopen(paths[i], RTLD_NOW | RTLD_LOCAL); + if (!h) { fprintf(stderr, "%s\n", dlerror()); return 2; } + verify[i] = (int (*)(const char *, int *)) dlsym(h, "tls_verify"); + version[i] = (const char *(*)(void)) dlsym(h, "tls_version"); + } + for (int c = 0; c < certs; c++) + for (int i = 0; i < COPIES; i++) { + error[i][c] = -1; + ok[i][c] = verify[i](argv[c + 2], &error[i][c]); + } + printf("{"); + for (int i = 0; i < COPIES; i++) { + printf("%s\"%s\": {\"version\": \"%s\"", i ? ", " : "", names[i], version[i]()); + for (int c = 0; c < certs; c++) printf(", \"%s\": [%d, %d]", argv[c + 2], ok[i][c], error[i][c]); + printf("}"); + } + printf("}\n"); + return 0; +} diff --git a/tst/clock/programs/tlsverify.c b/tst/clock/programs/tlsverify.c new file mode 100644 index 000000000..fb1f01dd9 --- /dev/null +++ b/tst/clock/programs/tlsverify.c @@ -0,0 +1,28 @@ +/* A stand-in for a libssl: a library linked against one copy of OpenSSL that verifies certificates through it. Built + twice, against the system's OpenSSL 3.0 and against a 1.1 built beside it, and loaded by tlscopies. */ +#include +#include +#include +#include +#include + +/* The certificate at path verified with itself as the one trust anchor: X509_verify_cert's result, and its error. */ +int tls_verify(const char *path, int *error) +{ + FILE *f = fopen(path, "r"); + X509 *cert = f ? PEM_read_X509(f, NULL, NULL, NULL) : NULL; + X509_STORE *store = X509_STORE_new(); + X509_STORE_CTX *ctx = X509_STORE_CTX_new(); + int ok = -2; + if (f) fclose(f); + if (cert && store && ctx && X509_STORE_add_cert(store, cert) && X509_STORE_CTX_init(ctx, store, cert, NULL)) { + ok = X509_verify_cert(ctx); + *error = X509_STORE_CTX_get_error(ctx); + } + X509_STORE_CTX_free(ctx); + X509_STORE_free(store); + X509_free(cert); + return ok; +} + +const char *tls_version(void) { return OpenSSL_version(OPENSSL_VERSION); } diff --git a/tst/clock/tls_test.py b/tst/clock/tls_test.py index b661b2c89..12b234faf 100644 --- a/tst/clock/tls_test.py +++ b/tst/clock/tls_test.py @@ -1,6 +1,14 @@ """The TLS shim: OpenSSL clients on universe time check certificates against real time, through no proxy.""" +import pytest CLIENTS = {"curl", "python", "s_client"} +#: X509_V_ERR_CERT_NOT_YET_VALID and X509_V_ERR_CERT_HAS_EXPIRED, the same in OpenSSL 1.1 and 3. +NOT_YET_VALID, EXPIRED = 9, 10 + + +@pytest.fixture(scope="module") +def tls(gate): + return gate("tls") def _all(results: dict, answer: str) -> dict: @@ -9,14 +17,27 @@ def _all(results: dict, answer: str) -> dict: if got != answer} -def test_with_the_shim_certificates_are_checked_against_real_time_and_bad_ones_still_refused(gate): - r = gate("tls") - alone, shimmed = r["without_shim"]["local"], r["with_shim"]["local"] - assert all(set(by_client) == CLIENTS for part in (alone, shimmed) for by_client in part.values()), r +def test_with_the_shim_certificates_are_checked_against_real_time_and_bad_ones_still_refused(tls): + alone, shimmed = tls["without_shim"]["local"], tls["with_shim"]["local"] + assert all(set(by_client) == CLIENTS for part in (alone, shimmed) for by_client in part.values()), tls # The clock library alone checks against the universe's date: a certificate valid only from real now isn't valid # yet, and one that expired since the universe's date still is. assert (_all({"now": alone["now"]}, "refused"), _all({"past": alone["past"]}, "ok")) == ({}, {}), alone # With the shim, against real time: the other way round. assert (_all({"now": shimmed["now"]}, "ok"), _all({"past": shimmed["past"]}, "refused")) == ({}, {}), shimmed - assert len(r["with_shim"]["live"]) == 6 and _all(r["with_shim"]["live"], "ok") == {}, r["with_shim"]["live"] - assert len(r["with_shim"]["bad"]) == 2 and _all(r["with_shim"]["bad"], "refused") == {}, r["with_shim"]["bad"] + assert len(tls["with_shim"]["live"]) == 6 and _all(tls["with_shim"]["live"], "ok") == {}, tls["with_shim"]["live"] + assert len(tls["with_shim"]["bad"]) == 2 and _all(tls["with_shim"]["bad"], "refused") == {}, tls["with_shim"]["bad"] + + +def test_two_copies_of_openssl_in_one_process_each_check_with_their_own(tls): + # The system's OpenSSL 3.0 and a 1.1 beside it, each reached through a library loaded privately, with the system's + # also in the global scope or not: a 1.1 check handed to 3.0's libcrypto, or the other way, can't give these. + answers = {"without_shim": {"now": [0, NOT_YET_VALID], "past": [1, 0]}, + "with_shim": {"now": [1, 0], "past": [0, EXPIRED]}} + for shim, expected in answers.items(): + copies = tls[shim]["copies"] + got = {mode: {copy: {cert: answer.get(cert) for cert in expected} for copy, answer in by_copy.items()} + for mode, by_copy in copies.items()} + assert got == {mode: {"3.0": expected, "1.1": expected} for mode in ("global", "private")}, (shim, copies) + assert {copy: answer["version"].split()[1][:3] for by_copy in copies.values() + for copy, answer in by_copy.items()} == {"3.0": "3.0", "1.1": "1.1"}, copies From 4da7ce5a1544736317778a12e431d970bb0a6e1e Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 12 Nov 2026 09:37:16 -0800 Subject: [PATCH 3/3] fix(computers): the TLS shim looks OpenSSL up on every check, and the gate refuses certificates without badssl.com The per-thread cache keyed by the caller's load address could hand a copy of OpenSSL loaded where an unloaded one was the old one's functions; the lookup costs microseconds once a handshake. The gate's refusals are its own servers: the certificate expired at real time, and the one valid now checked against the system's roots, which don't hold its issuer. Only the live sites still need the network. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/agent_env/computers/clock/README.md | 17 +++++++------ src/agent_env/computers/clock/tls_realtime.c | 26 +++++++++----------- tst/clock/harness.py | 11 ++++----- tst/clock/tls_test.py | 6 +++-- 5 files changed, 30 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4cd50ce65..2d15f623a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ or one of them only where it is used. | `make int-test-fast` | `tst/integration` minus `int_test_slow`, in parallel; needs Docker and a local OCI registry (`docker run -d -p 5000:5000 public.ecr.aws/docker/library/registry:2`) | | `make int-test-slow` | the `int_test_slow` tests, serially (they build the same Docker tags from module fixtures and race under xdist) | | `make installer-test` | `tst/installer`: `plugin add` / `remove` through the real pip, uv and pipx, offline against wheels built from the checkout, and the container user journey (`python:3.12-slim`, `--network none`) with the PEP 668 refusal; needs uv, pipx and Docker | -| `make clock-test` | `tst/clock`: agent-env's libfaketime (`src/agent_env/computers/clock`) and stock 0.9.12 built in an Ubuntu 24.04 image for this machine's Docker, and its gates run there: every wait API in C, Python, Java and Node at several rates, jumps, cancelled waits, timerfd duplicates, condvar and semaphore stress, the waits again with `futex_waitv` refused (as before Linux 5.16 and under gVisor), a stalled writer, a dead one, torn reads, the marker, the environment race, forks while another thread closes a timerfd, the TLS shim's certificate checks on real time (curl, Python and `openssl s_client` at 182 days back, live sites and badssl's, and two copies of OpenSSL in one process, the system's 3.0 and a 1.1, each checking with its own), and Chromium launched 40 times with it and 10 with stock, side by side, lateness held to `tst/clock/bounds.py`; needs Docker, and fetches Chrome's archive, upstream's tarball and OpenSSL 1.1.1w's once into `AGENTENV_TEST_CLOCK_CACHE` | +| `make clock-test` | `tst/clock`: agent-env's libfaketime (`src/agent_env/computers/clock`) and stock 0.9.12 built in an Ubuntu 24.04 image for this machine's Docker, and its gates run there: every wait API in C, Python, Java and Node at several rates, jumps, cancelled waits, timerfd duplicates, condvar and semaphore stress, the waits again with `futex_waitv` refused (as before Linux 5.16 and under gVisor), a stalled writer, a dead one, torn reads, the marker, the environment race, forks while another thread closes a timerfd, the TLS shim's certificate checks on real time (curl, Python and `openssl s_client` at 182 days back: live sites verify, expired and untrusted certificates don't; and two copies of OpenSSL in one process, the system's 3.0 and a 1.1, each checking with its own), and Chromium launched 40 times with it and 10 with stock, side by side, lateness held to `tst/clock/bounds.py`; needs Docker, and fetches Chrome's archive, upstream's tarball and OpenSSL 1.1.1w's once into `AGENTENV_TEST_CLOCK_CACHE` | | `make trl-test` | `tst/trl`: one `GRPOTrainer` step on CPU through `agent_env.training.trl`, with a tiny model from the Hub; needs trl and CPU torch, which the `dev` extra leaves out | | `make nemo-gym-test` | `tst/nemo_gym`: rollouts through `agent_env.training.nemo_gym`'s resources server in-process; needs the `nemo-gym` extra, on Python 3.13.14 or later, which the `dev` extra leaves out (`uv sync --extra dev --extra nemo-gym`) | | `make verl-test` | `tst/verl`: rows played through `agent_env.training.verl`'s agent loop on verl itself, with Qwen3's tokenizer from the Hub and a stand-in rollout server; needs verl and CPU torch, which the `dev` extra leaves out | diff --git a/src/agent_env/computers/clock/README.md b/src/agent_env/computers/clock/README.md index 6fa94839a..c997dc325 100644 --- a/src/agent_env/computers/clock/README.md +++ b/src/agent_env/computers/clock/README.md @@ -58,11 +58,12 @@ there: every wait API in C, Python, Java and Node at several rates, jumps, cance condition-variable and semaphore stress, the waits again in a container that refuses `futex_waitv` (as kernels before 5.16 and gVisor do), a stalled writer, one that died mid-write, torn reads, the marker, the environment race, forks while another thread closes a timerfd, the TLS shim (curl, Python and `openssl s_client` with the clock 182 days back: -live sites verify, badssl's expired and untrusted ones don't, two local servers show the check is on real time, -and two copies of OpenSSL in one process, the system's 3.0 and a 1.1, each check with their own), -and Chromium launches. `.github/workflows/clock.yml` runs them on x86_64 and arm64. The gates hold correctness strictly (no wait ends early, every wait returns what it should, nothing is lost or -torn) and lateness to `tst/clock/bounds.py`, a bound a broken library misses by seconds and a slow machine doesn't -reach. `tst/integration/computer/clock_desktop_test.py` composes the library into a real computer (the ubuntu-24.04 -desktop, the reference driver and agentenv's chromium, every process preloading it), which must pass the conformance -probe at full; then, its page written as root, a shell, Chrome's JavaScript and the driver's input follow the universe -through 30 jumps, a rate change and a freeze, with Chrome, Xvfb and the desktop's panel running on. +live sites verify, two local servers show the check is on real time and expired and untrusted certificates are still +refused, and two copies of OpenSSL in one process, the system's 3.0 and a 1.1, each check with their own), and +Chromium launches. `.github/workflows/clock.yml` runs them on x86_64 and arm64. The gates hold correctness strictly +(no wait ends early, every wait returns what it should, nothing is lost or torn) and lateness to +`tst/clock/bounds.py`, a bound a broken library misses by seconds and a slow machine doesn't reach. +`tst/integration/computer/clock_desktop_test.py` composes the library into a real computer (the ubuntu-24.04 desktop, +the reference driver and agentenv's chromium, every process preloading it), which must pass the conformance probe at +full; then, its page written as root, a shell, Chrome's JavaScript and the driver's input follow the universe through +30 jumps, a rate change and a freeze, with Chrome, Xvfb and the desktop's panel running on. diff --git a/src/agent_env/computers/clock/tls_realtime.c b/src/agent_env/computers/clock/tls_realtime.c index 99f3aec51..0a9a86688 100644 --- a/src/agent_env/computers/clock/tls_realtime.c +++ b/src/agent_env/computers/clock/tls_realtime.c @@ -25,17 +25,14 @@ typedef struct X509_VERIFY_PARAM_st X509_VERIFY_PARAM; #define TLS_USE_CHECK_TIME 0x2 #define TLS_SYSTEM_LIBCRYPTO "libcrypto.so.3" -/* OpenSSL's functions as one copy of it has them, and the object whose calls they answer */ +/* OpenSSL's functions as one copy of it has them */ struct tls_openssl { - void *caller_base; int (*verify)(X509_STORE_CTX *); void (*set_time)(X509_STORE_CTX *, unsigned long, time_t); X509_VERIFY_PARAM *(*get0_param)(X509_STORE_CTX *); unsigned long (*get_flags)(const X509_VERIFY_PARAM *); }; -/* the last calling object's, per thread: libssl calls from the same place handshake after handshake */ -static __thread struct tls_openssl tls_last; int X509_verify_cert(X509_STORE_CTX *ctx); @@ -76,7 +73,9 @@ static void tls_take(void *scope, struct tls_openssl *out) /* OpenSSL as the object at ``caller`` sees it: from that object's own dependencies (libssl's libcrypto, whichever copy it is); for a caller that isn't a shared library depending on one (a program calling it itself), the next definition - in the global scope; else the system's libcrypto where it was loaded privately (a Python extension's) */ + in the global scope; else the system's libcrypto where it was loaded privately (a Python extension's). Looked up on + every call, once a handshake: a copy of OpenSSL unloaded and another loaded at its address must not be handed the + first one's functions. */ static void tls_resolve(void *caller, struct tls_openssl *out) { Dl_info info; @@ -84,7 +83,6 @@ static void tls_resolve(void *caller, struct tls_openssl *out) memset(out, 0, sizeof *out); if (dladdr(caller, &info)) { - out->caller_base = info.dli_fbase; if (info.dli_fname != NULL && info.dli_fname[0] != 0 && (handle = dlopen(info.dli_fname, RTLD_NOW | RTLD_NOLOAD)) != NULL) { @@ -104,16 +102,14 @@ static void tls_resolve(void *caller, struct tls_openssl *out) int X509_verify_cert(X509_STORE_CTX *ctx) { - void *caller = __builtin_return_address(0); - Dl_info info; - struct tls_openssl *ssl = &tls_last; - if (ssl->verify == NULL || !dladdr(caller, &info) || info.dli_fbase != ssl->caller_base) tls_resolve(caller, ssl); - if (ssl->verify == NULL) return -1; - if (ctx != NULL && ssl->set_time != NULL && ssl->get0_param != NULL && ssl->get_flags != NULL) + struct tls_openssl ssl; + tls_resolve(__builtin_return_address(0), &ssl); + if (ssl.verify == NULL) return -1; + if (ctx != NULL && ssl.set_time != NULL && ssl.get0_param != NULL && ssl.get_flags != NULL) { - X509_VERIFY_PARAM *param = ssl->get0_param(ctx); + X509_VERIFY_PARAM *param = ssl.get0_param(ctx); long now = tls_real_time(); - if (param != NULL && !(ssl->get_flags(param) & TLS_USE_CHECK_TIME) && now > 0) ssl->set_time(ctx, 0, (time_t) now); + if (param != NULL && !(ssl.get_flags(param) & TLS_USE_CHECK_TIME) && now > 0) ssl.set_time(ctx, 0, (time_t) now); } - return ssl->verify(ctx); + return ssl.verify(ctx); } diff --git a/tst/clock/harness.py b/tst/clock/harness.py index d5a913cf4..1bd599966 100644 --- a/tst/clock/harness.py +++ b/tst/clock/harness.py @@ -567,10 +567,8 @@ def forklock(forks=1000): return json.loads(r.stdout) -#: Live sites the TLS gate reaches, with the clock armed 182 days back (most rotate certificates within 90 days), and -#: ones whose certificates are refused at any time. +#: Live sites the TLS gate reaches, with the clock armed 182 days back (most rotate certificates within 90 days). TLS_LIVE = ["example.com", "www.google.com", "github.com", "pypi.org", "www.wikipedia.org", "letsencrypt.org"] -TLS_REFUSED = ["expired.badssl.com", "untrusted-root.badssl.com"] # A TLS client's answer for https://HOST:PORT: ok (the certificate verified), refused (it didn't), or network ... TLS_PYTHON = """import ssl, sys, urllib.error, urllib.request host, port, cafile = sys.argv[1], int(sys.argv[2]), sys.argv[3] or None @@ -630,8 +628,9 @@ def tls(): """The TLS shim, with the clock 182 days back and no proxy, through curl, Python and openssl s_client. Two local servers check it uses real time: one's certificate is valid only from now (not yet valid at the universe date), the other's was valid at the universe date but has expired. With the clock library alone each client answers by - the universe date; with the shim by real time, and live sites verify while badssl's bad ones still don't. The same - two certificates are verified through two copies of OpenSSL in one process (tlscopies.c), each with its own.""" + the universe date; with the shim by real time: live sites verify, while the expired certificate, and the one valid + now checked against the system's roots, which don't hold its issuer, are still refused. The same two certificates + are verified through two copies of OpenSSL in one process (tlscopies.c), each with its own.""" clients = ("curl", "python", "s_client") work = "/tmp/tlsgate" os.makedirs(work, exist_ok=True) @@ -660,7 +659,7 @@ def tls(): copies = {mode: _copies(work, mode) for mode in ("global", "private")} out["with_shim" if shim else "without_shim"] = {"local": local, "copies": copies} out["with_shim"]["live"] = {host: {c: _tls(c, host) for c in clients} for host in TLS_LIVE} - out["with_shim"]["bad"] = {host: {c: _tls(c, host) for c in clients} for host in TLS_REFUSED} + out["with_shim"]["untrusted"] = {c: _tls(c, "localhost", 8443) for c in clients} finally: for server in servers: server.kill() diff --git a/tst/clock/tls_test.py b/tst/clock/tls_test.py index 12b234faf..48c004b9f 100644 --- a/tst/clock/tls_test.py +++ b/tst/clock/tls_test.py @@ -23,10 +23,12 @@ def test_with_the_shim_certificates_are_checked_against_real_time_and_bad_ones_s # The clock library alone checks against the universe's date: a certificate valid only from real now isn't valid # yet, and one that expired since the universe's date still is. assert (_all({"now": alone["now"]}, "refused"), _all({"past": alone["past"]}, "ok")) == ({}, {}), alone - # With the shim, against real time: the other way round. + # With the shim, against real time: the other way round, the expired one refused. assert (_all({"now": shimmed["now"]}, "ok"), _all({"past": shimmed["past"]}, "refused")) == ({}, {}), shimmed assert len(tls["with_shim"]["live"]) == 6 and _all(tls["with_shim"]["live"], "ok") == {}, tls["with_shim"]["live"] - assert len(tls["with_shim"]["bad"]) == 2 and _all(tls["with_shim"]["bad"], "refused") == {}, tls["with_shim"]["bad"] + # Valid at real time but issued by no one the system's roots trust: still refused. + assert _all({"untrusted": tls["with_shim"]["untrusted"]}, "refused") == {}, tls["with_shim"]["untrusted"] + assert set(tls["with_shim"]["untrusted"]) == CLIENTS, tls["with_shim"]["untrusted"] def test_two_copies_of_openssl_in_one_process_each_check_with_their_own(tls):