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 76f35abfd..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, 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 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 d0ebd7c41..c997dc325 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,10 +57,13 @@ 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 -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. +while another thread closes a timerfd, the TLS shim (curl, Python and `openssl s_client` with the clock 182 days back: +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/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..0a9a86688 --- /dev/null +++ b/src/agent_env/computers/clock/tls_realtime.c @@ -0,0 +1,115 @@ +/* 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 */ +struct tls_openssl +{ + 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 *); +}; + +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). 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; + void *handle; + memset(out, 0, sizeof *out); + if (dladdr(caller, &info)) + { + 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) +{ + 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); + 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..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 \ - ca-certificates python3 openjdk-21-jdk-headless nodejs util-linux unzip $CHROME_PACKAGES >/dev/null \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends build-essential patch curl openssl \ + 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 19d796074..1bd599966 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,106 @@ 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). +TLS_LIVE = ["example.com", "www.google.com", "github.com", "pypi.org", "www.wikipedia.org", "letsencrypt.org"] +# 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 _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: 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) + 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))} + 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"]["untrusted"] = {c: _tls(c, "localhost", 8443) for c in clients} + 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 +675,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/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 new file mode 100644 index 000000000..48c004b9f --- /dev/null +++ b/tst/clock/tls_test.py @@ -0,0 +1,45 @@ +"""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: + """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(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, 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"] + # 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): + # 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 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