diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7aaa9f5..784fa40 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -53,7 +53,7 @@ jobs: classic-vulkan-toolchain.json | \ classic-vulkan-toolchain.spdx.json | \ classic-vulkan-packages.lock | \ - toolchains.json | tools/audio/* | \ + toolchains.json | tools/tests/test_classic_tls.py | tools/audio/* | \ tools/build-sdl3-mixer.sh | \ tools/install_classic_vulkan_toolchain.py | \ tools/require-image-checks.sh | \ @@ -82,6 +82,7 @@ jobs: tools/validate-classic-image.sh | \ tools/install_classic_shader_toolchain.py | \ tools/tests/test_install_classic_shader_toolchain.py | \ + tools/tests/test_classic_tls.py | \ tools/install_classic_vulkan_toolchain.py | \ tools/validate-vulkan-toolchain.sh | \ tools/tests/test_install_classic_vulkan_toolchain.py) @@ -127,6 +128,9 @@ jobs: - name: Test Classic shader toolchain installer run: python3 -m unittest tools/tests/test_install_classic_shader_toolchain.py + - name: Test Classic TLS toolchain installer + run: python3 -m unittest tools/tests/test_classic_tls.py + - name: Test Classic Vulkan toolchain installer run: python3 -m unittest tools/tests/test_install_classic_vulkan_toolchain.py diff --git a/AGENTS.md b/AGENTS.md index 60d8bcf..4205289 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,13 @@ client. Keep the Linux tool manifests, Classic package lock, non-root version checks, and isolated worktree/payload smoke synchronized when changing this prerequisite. +- `linux/tls/manifest.json` owns Classic's private OpenSSL/libcurl cohort at + `/opt/atrinik/tls`, shared by broad and slim Linux images. Keep source pins, + notices, providers, explicit consumer discovery and runtime search paths + coherent; never replace distro TLS packages or globally override their + provider/loader configuration. Run its archive tests, upstream QUIC API suite + and non-root trusted/untrusted HTTPS/provider smoke. The separately sealed + portable producer and Windows MXE contracts remain independent. - The public Classic image's shader contract is defined by `classic-shader-toolchain.json`: retain the exact DXC/SPIRV-Cross archive and source checksums, upstream licenses, and `/usr/local/bin` tool paths. Its diff --git a/README.md b/README.md index fb473d2..236ec36 100644 --- a/README.md +++ b/README.md @@ -467,3 +467,27 @@ Redistributors must retain the pinned source archives, notices, build recipes, and Debian source coordinates and satisfy the corresponding-source and LGPL replacement/relinking obligations in the contract. Authored game media remain `content@main`, resources and sound inputs owned by their respective repositories. + +### Classic application TLS + +The Linux Classic and broad Linux images carry an application-only TLS prefix at +`/opt/atrinik/tls`, declared by `ATRINIK_CLASSIC_TLS_PREFIX`. Its +[`linux/tls/manifest.json`](linux/tls/manifest.json) pins OpenSSL 4.0.3 and libcurl +8.22.0 source archives. Classic consumers must select this prefix explicitly for +both OpenSSL and libcurl, and retain its `lib` directory in their runtime search +path. Copy the complete prefix, including providers and upstream notices, when +packaging a consumer. The prefix uses the public `SSL_get_peer_addr` API; a build +against the system OpenSSL 3 package does not provide that capability. + +The application libcurl supports HTTP/HTTPS, IDN and zlib. Optional SSH, LDAP, +GSSAPI, PSL, HTTP/2 and HTTP/3 integrations are disabled in this bounded cohort. +System tools and Python retain their distribution TLS packages: there is no +global `LD_LIBRARY_PATH`, `OPENSSL_MODULES` or command-path override. The sealed +Debian portable producer and Windows MXE toolchain retain their separate pinned +consumer contracts. + +`classic-tls-build` runs the upstream OpenSSL QUIC API tests. Both Linux consumer +images additionally compile a public API/provider probe and exercise trusted and +untrusted loopback HTTPS against Python's system TLS stack, without external +network access. This qualifies the dependency closure; Classic's own tests must +still prove actual per-connection peer identity and authorization behavior. diff --git a/classic-toolchain.json b/classic-toolchain.json index c665fa5..af1c281 100644 --- a/classic-toolchain.json +++ b/classic-toolchain.json @@ -37,5 +37,12 @@ "consumer_validation": { "repository": "atrinik/classic", "commit": "8fec1db157bcfdd050c1ba360e77365bce701bba" + }, + "classic_tls": { + "prefix": "/opt/atrinik/tls", + "manifest": "/opt/atrinik/tls/share/atrinik/classic-tls.json", + "openssl": "4.0.3", + "libcurl": "8.22.0", + "required_api": "SSL_get_peer_addr" } } diff --git a/linux/Dockerfile b/linux/Dockerfile index 666a848..35936c2 100644 --- a/linux/Dockerfile +++ b/linux/Dockerfile @@ -75,6 +75,157 @@ RUN python3 /tmp/install_classic_vulkan_toolchain.py \ /tmp/classic-vulkan-toolchain.json \ /tmp/install_classic_vulkan_toolchain.py +# Classic Check uses a separate task-focused target. It intentionally starts +# from the pinned Ubuntu base instead of inheriting the replacement toolchain, +# and resolves packages from a fixed Ubuntu archive snapshot. Its exact CA and +# TLS runtime packages are bootstrapped first because the minimal base cannot +# validate the HTTPS snapshot service. +FROM ubuntu:26.04@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03 AS classic-base + +ARG TARGETARCH +ENV DEBIAN_FRONTEND=noninteractive \ + UBUNTU_SNAPSHOT=20260810T000000Z + +COPY classic-packages.lock /tmp/classic-packages.lock +# APT still verifies the signed Release metadata and package hashes. Only TLS +# peer verification is temporarily unavailable in the minimal base. +# Snapshot package downloads can still fail after per-request retries. Repeat +# the transaction so successfully fetched archives can be reused. +RUN test "${TARGETARCH}" = amd64 \ + && test "$(dpkg --print-architecture)" = amd64 \ + && retry_command() { \ + attempt=1; \ + while test "${attempt}" -le 2; do \ + if "$@"; then \ + return 0; \ + fi; \ + if test "${attempt}" -eq 2; then \ + return 1; \ + fi; \ + attempt=$((attempt + 1)); \ + sleep 5; \ + done; \ + } \ + && printf '%s\n' 'Acquire::Retries "5";' \ + > /etc/apt/apt.conf.d/80-snapshot-retries \ + && sed -i \ + -e "s|http://archive.ubuntu.com/ubuntu/|https://snapshot.ubuntu.com/ubuntu/${UBUNTU_SNAPSHOT}/|" \ + -e "s|http://security.ubuntu.com/ubuntu/|https://snapshot.ubuntu.com/ubuntu/${UBUNTU_SNAPSHOT}/|" \ + /etc/apt/sources.list.d/ubuntu.sources \ + && sed -i '/^Signed-By:/a Check-Valid-Until: no' \ + /etc/apt/sources.list.d/ubuntu.sources \ + && retry_command apt-get -o Acquire::https::Verify-Peer=false update \ + && awk -F= '$1 == "ca-certificates" \ + || $1 == "libssl3t64" \ + || $1 == "openssl" \ + || $1 == "openssl-provider-legacy" { print }' \ + /tmp/classic-packages.lock \ + > /tmp/classic-bootstrap.lock \ + && retry_command sh -c \ + 'xargs -r apt-get -o Acquire::https::Verify-Peer=false \ + install -y --no-install-recommends \ + < /tmp/classic-bootstrap.lock' \ + && rm -f /tmp/classic-bootstrap.lock \ + && rm -rf /var/lib/apt/lists/* \ + && retry_command apt-get update \ + && retry_command sh -c \ + 'xargs -r apt-get install -y --no-install-recommends \ + < /tmp/classic-packages.lock' \ + && rm -f /etc/apt/apt.conf.d/80-snapshot-retries \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb + +COPY tools/smoke-git-lfs.sh /usr/local/bin/atrinik-git-lfs-smoke +RUN chmod 0755 /usr/local/bin/atrinik-git-lfs-smoke \ + && runuser -u ubuntu -- env HOME=/home/ubuntu \ + bash /usr/local/bin/atrinik-git-lfs-smoke + +COPY --from=mesa-dozen \ + /usr/lib/x86_64-linux-gnu/libvulkan_dzn.so \ + /usr/lib/x86_64-linux-gnu/libvulkan_dzn.so +COPY --from=mesa-dozen \ + /usr/share/vulkan/icd.d/dzn_icd.x86_64.json \ + /usr/share/vulkan/icd.d/dzn_icd.x86_64.json +RUN ldconfig + +# Classic requires the same checksum-pinned SDL3_mixer contract as the broad +# Linux image. Its codec dependencies are statically linked into the shared +# library, while SDL3 and the remaining native libraries come from the frozen +# Ubuntu package snapshot above. +COPY audio-toolchain.json /tmp/audio-toolchain.json +COPY tools/build-sdl3-mixer.sh /tmp/build-sdl3-mixer.sh +RUN /tmp/build-sdl3-mixer.sh \ + /tmp/audio-toolchain.json cmake /usr/local "$(nproc)" \ + && ldconfig \ + && test "$(pkg-config --modversion sdl3-mixer)" \ + = "$(jq -r '.sdl_mixer.version' /tmp/audio-toolchain.json)" + +COPY tools/audio/opus-probe.opus /usr/local/share/atrinik/audio/opus-probe.opus +COPY tools/audio/sdl3-mixer-probe.c /tmp/sdl3-mixer-probe.c +RUN test "$(sha256sum /usr/local/share/atrinik/audio/opus-probe.opus \ + | cut -d' ' -f1)" \ + = "$(jq -r '.fixture.sha256' /tmp/audio-toolchain.json)" \ + && cc -std=c17 -Wall -Wextra -Werror \ + /tmp/sdl3-mixer-probe.c \ + -o /usr/local/bin/atrinik-sdl3-mixer-probe \ + $(pkg-config --cflags --libs sdl3-mixer) \ + && atrinik-sdl3-mixer-probe \ + /usr/local/share/atrinik/audio/opus-probe.opus \ + && rm /tmp/sdl3-mixer-probe.c + +# Classic's GPU shader pipeline uses a qualified DXC release and a source +# snapshot of SPIRV-Cross. Build the latter from its locked archive and retain +# every upstream license beside the installed tools. The same final image also +# carries the pinned Lavapipe/Xvfb runtime below for fork-safe GPU coverage. +COPY classic-shader-toolchain.json /tmp/classic-shader-toolchain.json +COPY tools/install_classic_shader_toolchain.py \ + /tmp/install_classic_shader_toolchain.py +RUN python3 /tmp/install_classic_shader_toolchain.py \ + --manifest /tmp/classic-shader-toolchain.json \ + --cache /tmp/classic-shader-downloads \ + --prefix /usr/local \ + --jobs "$(nproc)" \ + && ldconfig \ + && test -x /usr/local/bin/dxc \ + && test -x /usr/local/bin/spirv-cross \ + && dxc --version >/dev/null \ + && spirv-cross --help >/dev/null 2>&1 \ + && rm -rf /tmp/classic-shader-downloads \ + /tmp/classic-shader-toolchain.json \ + /tmp/install_classic_shader_toolchain.py + +ENV CCACHE_DIR=/cache/ccache +RUN install -d -m 1777 "${CCACHE_DIR}" + +COPY classic-toolchain.json /usr/local/share/atrinik/classic-toolchain.json +COPY classic-packages.lock /usr/local/share/atrinik/classic-packages.lock +COPY classic-shader-toolchain.json \ + /usr/local/share/atrinik/classic-shader-toolchain.json +COPY classic-shader-toolchain.spdx.json \ + /usr/local/share/atrinik/classic-shader-toolchain.spdx.json +COPY classic-vulkan-toolchain.json \ + /usr/local/share/atrinik/classic-vulkan-toolchain.json +COPY classic-vulkan-toolchain.spdx.json \ + /usr/local/share/atrinik/classic-vulkan-toolchain.spdx.json +COPY classic-vulkan-packages.lock \ + /usr/local/share/atrinik/classic-vulkan-packages.lock +COPY audio-toolchain.json /usr/local/share/atrinik/audio-toolchain.json +COPY audio-toolchain.spdx.json /usr/local/share/atrinik/audio-toolchain.spdx.json + +USER ubuntu +WORKDIR /workspace + +# One application-only TLS closure is shared by Classic and broad Linux builds. +# Keep distro TLS packages and their consumers unchanged; Classic explicitly +# selects this prefix instead of mixing OpenSSL 4 with the distro libcurl. +FROM classic-base AS classic-tls-build +USER root +COPY linux/tls /tmp/classic-tls +COPY tools/install_classic_shader_toolchain.py /tmp/classic-tls/install_classic_shader_toolchain.py +RUN python3 /tmp/classic-tls/build.py --cache /tmp/classic-tls-downloads \ + --build-root /tmp/classic-tls-build --jobs 2 \ + && runuser -u ubuntu -- python3 /tmp/classic-tls/smoke.py \ + && rm -rf /tmp/classic-tls-downloads /tmp/classic-tls-build + FROM ubuntu:26.04@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03 AS toolchain ENV DEBIAN_FRONTEND=noninteractive \ @@ -438,6 +589,13 @@ COPY classic-vulkan-toolchain.spdx.json \ COPY classic-vulkan-packages.lock \ /usr/local/share/atrinik/classic-vulkan-packages.lock +# This COPY deliberately selects the same qualified application TLS closure as +# classic-ci without changing system tools' loader or provider configuration. +COPY --from=classic-tls-build /opt/atrinik/tls /opt/atrinik/tls +COPY linux/tls /usr/local/share/atrinik/classic-tls +ENV ATRINIK_CLASSIC_TLS_PREFIX=/opt/atrinik/tls +RUN runuser -u ubuntu -- python3 /usr/local/share/atrinik/classic-tls/smoke.py + FROM toolchain AS validation COPY .github/actionlint.yaml /workspace/.github/actionlint.yaml @@ -503,144 +661,11 @@ RUN actionlint \ ) \ ' -# Classic Check uses a separate task-focused target. It intentionally starts -# from the pinned Ubuntu base instead of inheriting the replacement toolchain, -# and resolves packages from a fixed Ubuntu archive snapshot. Its exact CA and -# TLS runtime packages are bootstrapped first because the minimal base cannot -# validate the HTTPS snapshot service. -FROM ubuntu:26.04@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03 AS classic-ci - -ARG TARGETARCH -ENV DEBIAN_FRONTEND=noninteractive \ - UBUNTU_SNAPSHOT=20260810T000000Z - -COPY classic-packages.lock /tmp/classic-packages.lock -# APT still verifies the signed Release metadata and package hashes. Only TLS -# peer verification is temporarily unavailable in the minimal base. -# Snapshot package downloads can still fail after per-request retries. Repeat -# the transaction so successfully fetched archives can be reused. -RUN test "${TARGETARCH}" = amd64 \ - && test "$(dpkg --print-architecture)" = amd64 \ - && retry_command() { \ - attempt=1; \ - while test "${attempt}" -le 2; do \ - if "$@"; then \ - return 0; \ - fi; \ - if test "${attempt}" -eq 2; then \ - return 1; \ - fi; \ - attempt=$((attempt + 1)); \ - sleep 5; \ - done; \ - } \ - && printf '%s\n' 'Acquire::Retries "5";' \ - > /etc/apt/apt.conf.d/80-snapshot-retries \ - && sed -i \ - -e "s|http://archive.ubuntu.com/ubuntu/|https://snapshot.ubuntu.com/ubuntu/${UBUNTU_SNAPSHOT}/|" \ - -e "s|http://security.ubuntu.com/ubuntu/|https://snapshot.ubuntu.com/ubuntu/${UBUNTU_SNAPSHOT}/|" \ - /etc/apt/sources.list.d/ubuntu.sources \ - && sed -i '/^Signed-By:/a Check-Valid-Until: no' \ - /etc/apt/sources.list.d/ubuntu.sources \ - && retry_command apt-get -o Acquire::https::Verify-Peer=false update \ - && awk -F= '$1 == "ca-certificates" \ - || $1 == "libssl3t64" \ - || $1 == "openssl" \ - || $1 == "openssl-provider-legacy" { print }' \ - /tmp/classic-packages.lock \ - > /tmp/classic-bootstrap.lock \ - && retry_command sh -c \ - 'xargs -r apt-get -o Acquire::https::Verify-Peer=false \ - install -y --no-install-recommends \ - < /tmp/classic-bootstrap.lock' \ - && rm -f /tmp/classic-bootstrap.lock \ - && rm -rf /var/lib/apt/lists/* \ - && retry_command apt-get update \ - && retry_command sh -c \ - 'xargs -r apt-get install -y --no-install-recommends \ - < /tmp/classic-packages.lock' \ - && rm -f /etc/apt/apt.conf.d/80-snapshot-retries \ - && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb - -COPY tools/smoke-git-lfs.sh /usr/local/bin/atrinik-git-lfs-smoke -RUN chmod 0755 /usr/local/bin/atrinik-git-lfs-smoke \ - && runuser -u ubuntu -- env HOME=/home/ubuntu \ - bash /usr/local/bin/atrinik-git-lfs-smoke - -COPY --from=mesa-dozen \ - /usr/lib/x86_64-linux-gnu/libvulkan_dzn.so \ - /usr/lib/x86_64-linux-gnu/libvulkan_dzn.so -COPY --from=mesa-dozen \ - /usr/share/vulkan/icd.d/dzn_icd.x86_64.json \ - /usr/share/vulkan/icd.d/dzn_icd.x86_64.json -RUN ldconfig - -# Classic requires the same checksum-pinned SDL3_mixer contract as the broad -# Linux image. Its codec dependencies are statically linked into the shared -# library, while SDL3 and the remaining native libraries come from the frozen -# Ubuntu package snapshot above. -COPY audio-toolchain.json /tmp/audio-toolchain.json -COPY tools/build-sdl3-mixer.sh /tmp/build-sdl3-mixer.sh -RUN /tmp/build-sdl3-mixer.sh \ - /tmp/audio-toolchain.json cmake /usr/local "$(nproc)" \ - && ldconfig \ - && test "$(pkg-config --modversion sdl3-mixer)" \ - = "$(jq -r '.sdl_mixer.version' /tmp/audio-toolchain.json)" - -COPY tools/audio/opus-probe.opus /usr/local/share/atrinik/audio/opus-probe.opus -COPY tools/audio/sdl3-mixer-probe.c /tmp/sdl3-mixer-probe.c -RUN test "$(sha256sum /usr/local/share/atrinik/audio/opus-probe.opus \ - | cut -d' ' -f1)" \ - = "$(jq -r '.fixture.sha256' /tmp/audio-toolchain.json)" \ - && cc -std=c17 -Wall -Wextra -Werror \ - /tmp/sdl3-mixer-probe.c \ - -o /usr/local/bin/atrinik-sdl3-mixer-probe \ - $(pkg-config --cflags --libs sdl3-mixer) \ - && atrinik-sdl3-mixer-probe \ - /usr/local/share/atrinik/audio/opus-probe.opus \ - && rm /tmp/sdl3-mixer-probe.c - -# Classic's GPU shader pipeline uses a qualified DXC release and a source -# snapshot of SPIRV-Cross. Build the latter from its locked archive and retain -# every upstream license beside the installed tools. The same final image also -# carries the pinned Lavapipe/Xvfb runtime below for fork-safe GPU coverage. -COPY classic-shader-toolchain.json /tmp/classic-shader-toolchain.json -COPY tools/install_classic_shader_toolchain.py \ - /tmp/install_classic_shader_toolchain.py -RUN python3 /tmp/install_classic_shader_toolchain.py \ - --manifest /tmp/classic-shader-toolchain.json \ - --cache /tmp/classic-shader-downloads \ - --prefix /usr/local \ - --jobs "$(nproc)" \ - && ldconfig \ - && test -x /usr/local/bin/dxc \ - && test -x /usr/local/bin/spirv-cross \ - && dxc --version >/dev/null \ - && spirv-cross --help >/dev/null 2>&1 \ - && rm -rf /tmp/classic-shader-downloads \ - /tmp/classic-shader-toolchain.json \ - /tmp/install_classic_shader_toolchain.py - -ENV CCACHE_DIR=/cache/ccache -RUN install -d -m 1777 "${CCACHE_DIR}" - -COPY classic-toolchain.json /usr/local/share/atrinik/classic-toolchain.json -COPY classic-packages.lock /usr/local/share/atrinik/classic-packages.lock -COPY classic-shader-toolchain.json \ - /usr/local/share/atrinik/classic-shader-toolchain.json -COPY classic-shader-toolchain.spdx.json \ - /usr/local/share/atrinik/classic-shader-toolchain.spdx.json -COPY classic-vulkan-toolchain.json \ - /usr/local/share/atrinik/classic-vulkan-toolchain.json -COPY classic-vulkan-toolchain.spdx.json \ - /usr/local/share/atrinik/classic-vulkan-toolchain.spdx.json -COPY classic-vulkan-packages.lock \ - /usr/local/share/atrinik/classic-vulkan-packages.lock -COPY audio-toolchain.json /usr/local/share/atrinik/audio-toolchain.json -COPY audio-toolchain.spdx.json /usr/local/share/atrinik/audio-toolchain.spdx.json - -USER ubuntu -WORKDIR /workspace +FROM classic-base AS classic-ci +COPY --from=classic-tls-build /opt/atrinik/tls /opt/atrinik/tls +COPY linux/tls /usr/local/share/atrinik/classic-tls +ENV ATRINIK_CLASSIC_TLS_PREFIX=/opt/atrinik/tls +RUN python3 /usr/local/share/atrinik/classic-tls/smoke.py FROM classic-ci AS classic-validation diff --git a/linux/tls/build.py b/linux/tls/build.py new file mode 100644 index 0000000..bf3ec16 --- /dev/null +++ b/linux/tls/build.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Build Classic's isolated, checksum-pinned OpenSSL/libcurl provider cohort.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +import tempfile + +from install_classic_shader_toolchain import ( + MAX_EXPANDED_BYTES, MAX_FILE_BYTES, MAX_MEMBERS, ToolchainError, + copy_member, download, reject_duplicate_keys, safe_archive_path, +) + +ROOT = Path(__file__).resolve().parent + + +def load_manifest(path: Path) -> dict: + value = json.loads(path.read_text(), object_pairs_hook=reject_duplicate_keys) + if (set(value) != {"schema_version", "platform", "prefix", "openssl", "curl", + "required_api", "providers", "required_curl_protocols", "required_curl_features"} + or value["schema_version"] != 1 or value["platform"] != "linux/amd64" + or value["prefix"] != "/opt/atrinik/tls" + or value["required_api"] != "SSL_get_peer_addr" + or value["providers"] != ["default", "legacy"] + or value["required_curl_protocols"] != ["http", "https"] + or value["required_curl_features"] != ["SSL", "IDN", "libz"]): + raise ToolchainError("invalid Classic TLS contract") + for name, host, license_name in (("openssl", "https://github.com/openssl/openssl/releases/download/", "Apache-2.0"), + ("curl", "https://curl.se/download/", "curl")): + entry = value[name] + if (set(entry) != {"version", "url", "sha256", "license"} + or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", entry["version"]) + or not re.fullmatch(r"[0-9a-f]{64}", entry["sha256"]) + or entry["license"] != license_name): + raise ToolchainError("invalid Classic TLS source pin") + version = entry["version"] + expected = (f"{host}openssl-{version}/openssl-{version}.tar.gz" if name == "openssl" + else f"{host}curl-{version}.tar.xz") + if entry["url"] != expected: + raise ToolchainError("unexpected Classic TLS source URL") + return value + + +def extract(archive_path: Path, destination: Path, root: str) -> None: + seen, total = set(), 0 + with tarfile.open(archive_path, "r:*") as archive: + for count, member in enumerate(archive, 1): + if count > MAX_MEMBERS: + raise ToolchainError("TLS archive member bound exceeded") + path = safe_archive_path(member.name) + if path.parts[0] != root: + raise ToolchainError("TLS source root differs") + if len(path.parts) == 1: + if not member.isdir(): + raise ToolchainError("TLS source root is not a directory") + continue + relative = Path(*path.parts[1:]) + key = relative.as_posix().casefold() + if key in seen: + raise ToolchainError("duplicate TLS archive member") + seen.add(key) + target = destination / relative + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + continue + if not member.isfile() or member.size > MAX_FILE_BYTES: + raise ToolchainError("unsupported TLS source member") + total += member.size + if total > MAX_EXPANDED_BYTES: + raise ToolchainError("TLS source expansion bound exceeded") + source = archive.extractfile(member) + if source is None: + raise ToolchainError("missing TLS source bytes") + with source: + copy_member(source, target, member.size) + target.chmod(member.mode & 0o755 or 0o644) + + +def run(*args: str, cwd: Path | None = None, env: dict | None = None) -> None: + subprocess.run(args, cwd=cwd, env=env, check=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", type=Path, default=ROOT / "manifest.json") + parser.add_argument("--cache", type=Path, required=True) + parser.add_argument("--build-root", type=Path, required=True) + parser.add_argument("--jobs", type=int, default=2) + args = parser.parse_args() + if not 1 <= args.jobs <= 2: + parser.error("jobs must be 1 or 2") + manifest = load_manifest(args.manifest) + prefix = Path(manifest["prefix"]) + if prefix.is_symlink() or (prefix.exists() and any(prefix.iterdir())): + raise ToolchainError("TLS installation requires an absent or empty prefix") + args.build_root.mkdir(parents=True, exist_ok=True) + environment = dict(os.environ) + # System tools keep their own TLS configuration and providers. Only the + # explicitly selected Classic consumer uses this prefix. + for name in ("OPENSSL_CONF", "OPENSSL_MODULES", "LD_LIBRARY_PATH", "CMAKE_PREFIX_PATH", "PKG_CONFIG_PATH"): + environment.pop(name, None) + with tempfile.TemporaryDirectory(prefix="classic-tls-", dir=args.build_root) as tmp: + work = Path(tmp) + for name in ("openssl", "curl"): + entry = manifest[name] + extract(download(entry["url"], entry["sha256"], args.cache), work / name, f"{name}-{entry['version']}") + openssl = work / "openssl" + run("perl", "Configure", "linux-x86_64", "shared", f"--prefix={prefix}", "--libdir=lib", + f"--openssldir={prefix}/ssl", f"-Wl,--enable-new-dtags,-rpath,{prefix}/lib", cwd=openssl, env=environment) + run("make", f"-j{args.jobs}", "build_sw", cwd=openssl, env=environment) + run("make", f"-j{args.jobs}", "build_tests", cwd=openssl, env=environment) + test_env = dict(environment, HARNESS_JOBS=str(args.jobs)) + run("make", "test", "TESTS=test_quicapi", cwd=openssl, env=test_env) + run("make", "install_sw", "install_ssldirs", cwd=openssl, env=environment) + environment["PKG_CONFIG_PATH"] = str(prefix / "lib/pkgconfig") + run("cmake", "-S", str(work / "curl"), "-B", str(work / "curl-build"), "-G", "Ninja", + "-DCMAKE_BUILD_TYPE=Release", f"-DCMAKE_INSTALL_PREFIX={prefix}", "-DCMAKE_INSTALL_LIBDIR=lib", + "-DCMAKE_INSTALL_RPATH=$ORIGIN", "-DBUILD_SHARED_LIBS=ON", "-DBUILD_STATIC_LIBS=OFF", + "-DBUILD_CURL_EXE=OFF", "-DBUILD_TESTING=OFF", "-DBUILD_LIBCURL_DOCS=OFF", + "-DBUILD_MISC_DOCS=OFF", "-DENABLE_CURL_MANUAL=OFF", "-DCURL_USE_OPENSSL=ON", + f"-DOPENSSL_ROOT_DIR={prefix}", "-DOPENSSL_USE_STATIC_LIBS=OFF", + "-DCURL_USE_LIBSSH2=OFF", "-DCURL_USE_LIBSSH=OFF", "-DCURL_USE_GSSAPI=OFF", + "-DCURL_DISABLE_LDAP=ON", "-DCURL_DISABLE_LDAPS=ON", "-DCURL_USE_LIBPSL=OFF", + "-DUSE_NGHTTP2=OFF", "-DUSE_NGTCP2=OFF", "-DCURL_BROTLI=OFF", "-DCURL_ZSTD=OFF", + "-DUSE_LIBIDN2=ON", "-DCURL_ZLIB=ON", env=environment) + run("cmake", "--build", str(work / "curl-build"), "--parallel", str(args.jobs), env=environment) + run("cmake", "--install", str(work / "curl-build"), env=environment) + notices = prefix / "share/licenses" + for name, filename in (("openssl", "LICENSE.txt"), ("curl", "COPYING")): + target = notices / name + target.mkdir(parents=True) + shutil.copyfile(work / name / filename, target / filename) + (prefix / "share/atrinik").mkdir(parents=True) + shutil.copyfile(args.manifest, prefix / "share/atrinik/classic-tls.json") + print(json.dumps({"installed": True, "prefix": str(prefix), "openssl": manifest["openssl"]["version"], + "curl": manifest["curl"]["version"], "upstream_quic_api_tests": True})) + + +if __name__ == "__main__": + main() diff --git a/linux/tls/manifest.json b/linux/tls/manifest.json new file mode 100644 index 0000000..4c91bc8 --- /dev/null +++ b/linux/tls/manifest.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "platform": "linux/amd64", + "prefix": "/opt/atrinik/tls", + "openssl": { + "version": "4.0.3", + "url": "https://github.com/openssl/openssl/releases/download/openssl-4.0.3/openssl-4.0.3.tar.gz", + "sha256": "325b5c806167c13b40b1ffeadfe0248197c00eccc4cf123ec1e28d2d2fd216d9", + "license": "Apache-2.0" + }, + "curl": { + "version": "8.22.0", + "url": "https://curl.se/download/curl-8.22.0.tar.xz", + "sha256": "f7ef3ae8a22e521f289803fe93543eb64c329b58aa73a9e224dfd915a2a5f4f7", + "license": "curl" + }, + "required_api": "SSL_get_peer_addr", + "providers": ["default", "legacy"], + "required_curl_protocols": ["http", "https"], + "required_curl_features": ["SSL", "IDN", "libz"] +} diff --git a/linux/tls/smoke.c b/linux/tls/smoke.c new file mode 100644 index 0000000..caaf74c --- /dev/null +++ b/linux/tls/smoke.c @@ -0,0 +1,92 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +#define PREFIX "/opt/atrinik/tls/" +static int bad_provider; +static int inspect_object(struct dl_phdr_info *info, size_t size, void *data) { + (void)size; + (void)data; + const char *name = strrchr(info->dlpi_name, '/'); + name = name ? name + 1 : info->dlpi_name; + if (strncmp(name, "libssl.so", 9) == 0 || strncmp(name, "libcrypto.so", 12) == 0 || + strncmp(name, "libcurl.so", 10) == 0 || strcmp(name, "legacy.so") == 0) { + char *path = realpath(info->dlpi_name, NULL); + if (path == NULL || strncmp(path, PREFIX, sizeof(PREFIX) - 1) != 0) { + bad_provider = 1; + } + free(path); + } + return 0; +} +static size_t discard(char *buffer, size_t size, size_t count, void *data) { + (void)buffer; + (void)data; + return size * count; +} +int main(int argc, char **argv) { + if (argc != 1 && argc != 4) return 2; + if (strcmp(OPENSSL_VERSION_STR, "4.0.3") != 0 || + strcmp(OpenSSL_version(OPENSSL_VERSION_STRING), "4.0.3") != 0) return 3; + OSSL_PROVIDER *def = OSSL_PROVIDER_load(NULL, "default"); + OSSL_PROVIDER *legacy = OSSL_PROVIDER_load(NULL, "legacy"); + if (def == NULL || legacy == NULL || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) return 4; + const curl_version_info_data *version = curl_version_info(CURLVERSION_NOW); + if (strcmp(version->version, "8.22.0") != 0 || version->ssl_version == NULL || + strcmp(version->ssl_version, "OpenSSL/4.0.3") != 0 || + !(version->features & CURL_VERSION_SSL) || !(version->features & CURL_VERSION_IDN) || + !(version->features & CURL_VERSION_LIBZ)) return 5; + int http = 0, https = 0; + for (const char *const *p = version->protocols; *p; ++p) { + http |= strcmp(*p, "http") == 0; + https |= strcmp(*p, "https") == 0; + } + if (!http || !https) return 6; + /* Link and exercise the public peer API's fail-closed non-QUIC case. */ + SSL_CTX *context = SSL_CTX_new(TLS_method()); + SSL *ssl = context ? SSL_new(context) : NULL; + BIO_ADDR *peer = BIO_ADDR_new(); + if (ssl == NULL || peer == NULL || SSL_get_peer_addr(ssl, peer) != 0) return 7; + BIO_ADDR_free(peer); + SSL_free(ssl); + SSL_CTX_free(context); + dl_iterate_phdr(inspect_object, NULL); + if (bad_provider) return 8; + if (argc == 4) { + CURL *curl = curl_easy_init(); + if (curl == NULL) return 9; + curl_easy_setopt(curl, CURLOPT_URL, argv[1]); + curl_easy_setopt(curl, CURLOPT_CAINFO, argv[2]); + curl_easy_setopt(curl, CURLOPT_PROXY, ""); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, discard); + CURLcode result = curl_easy_perform(curl); + long response = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response); + curl_easy_cleanup(curl); + if (strcmp(argv[3], "trusted") == 0) { + if (result != CURLE_OK || response != 200) { + fprintf(stderr, "trusted HTTPS failed: curl=%d (%s), HTTP=%ld\n", + (int)result, curl_easy_strerror(result), response); + return 10; + } + } else if (strcmp(argv[3], "untrusted") == 0) { + if (result != CURLE_PEER_FAILED_VERIFICATION) { + fprintf(stderr, "untrusted HTTPS was not rejected: curl=%d (%s), HTTP=%ld\n", + (int)result, curl_easy_strerror(result), response); + return 11; + } + } else return 12; + } + curl_global_cleanup(); + OSSL_PROVIDER_unload(legacy); + OSSL_PROVIDER_unload(def); + puts("Classic TLS provider and public peer API checks passed"); + return 0; +} diff --git a/linux/tls/smoke.py b/linux/tls/smoke.py new file mode 100644 index 0000000..3b81b19 --- /dev/null +++ b/linux/tls/smoke.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Verify the installed Classic TLS closure and real trusted/untrusted HTTPS.""" +import argparse +import http.server +import json +import os +from pathlib import Path +import ssl +import subprocess +import sys +import tempfile +import threading + +PREFIX = Path('/opt/atrinik/tls') +ROOT = Path(__file__).resolve().parent + + +def run(*argv, **kwargs): + return subprocess.run(argv, check=True, **kwargs) + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = b'Classic TLS fixture\n' + self.send_response(200) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +class Server(http.server.HTTPServer): + """Keep fixture thread errors visible to the main acceptance result.""" + def __init__(self, *args): + super().__init__(*args) + self.allow_trust_rejection = False + self.expected_trust_rejections = 0 + self.unexpected_errors = [] + + def handle_error(self, request, client_address): + error = sys.exception() + # The negative-CA client aborts before sending an HTTP request. Depending + # on TLS scheduling, the server sees its alert or a reset/broken pipe. + expected = isinstance(error, (BrokenPipeError, ConnectionResetError)) or ( + isinstance(error, ssl.SSLError) and error.reason in ( + 'TLSV1_ALERT_UNKNOWN_CA', 'SSLV3_ALERT_BAD_CERTIFICATE')) + if self.allow_trust_rejection and expected: + self.expected_trust_rejections += 1 + else: + self.unexpected_errors.append(type(error).__name__) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest', type=Path, default=ROOT / 'manifest.json') + args = parser.parse_args() + expected = json.loads(args.manifest.read_text()) + installed = json.loads((PREFIX / 'share/atrinik/classic-tls.json').read_text()) + if expected != installed: + raise RuntimeError('TLS manifest mismatch') + environment = dict(os.environ) + for name in ('LD_LIBRARY_PATH', 'OPENSSL_CONF', 'OPENSSL_MODULES', 'CMAKE_PREFIX_PATH'): + environment.pop(name, None) + environment['PKG_CONFIG_PATH'] = str(PREFIX / 'lib/pkgconfig') + flags = subprocess.check_output(['pkg-config', '--cflags', '--libs', 'openssl', 'libcurl'], env=environment, text=True).split() + with tempfile.TemporaryDirectory(prefix='classic-tls-smoke-') as tmp: + work = Path(tmp) + binary = work / 'smoke' + run('cc', '-std=c17', '-Wall', '-Wextra', '-Werror', str(ROOT / 'smoke.c'), '-o', str(binary), + *flags, '-ldl', f'-Wl,-rpath,{PREFIX}/lib', env=environment) + run(str(binary), env=environment) + for name in ('server', 'untrusted'): + run(str(PREFIX / 'bin/openssl'), 'req', '-new', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', str(work / f'{name}.key'), '-out', str(work / f'{name}.pem'), '-days', '1', + '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost', + env=environment, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + # Python's distro SSL module remains on its own TLS provider. Successful + # HTTPS also proves interoperability with the isolated application cohort. + server = Server(('127.0.0.1', 0), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(work / 'server.pem', work / 'server.key') + server.socket = context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f'https://localhost:{server.server_port}/' + run(str(binary), url, str(work / 'server.pem'), 'trusted', env=environment) + server.allow_trust_rejection = True + run(str(binary), url, str(work / 'untrusted.pem'), 'untrusted', env=environment) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + if thread.is_alive() or server.unexpected_errors: + raise RuntimeError(f'HTTPS fixture failed: {server.unexpected_errors}') + print(json.dumps({'classic_tls': True, 'public_peer_api': True, 'provider_closure': True, + 'https_trusted': True, 'https_untrusted_rejected': True, + 'expected_server_trust_rejections': server.expected_trust_rejections})) + + +if __name__ == '__main__': + main() diff --git a/toolchains.json b/toolchains.json index d254098..d12b38f 100644 --- a/toolchains.json +++ b/toolchains.json @@ -17,9 +17,15 @@ "trivy": "0.73.0" }, "rust": { - "components": ["clippy", "rustfmt"], + "components": [ + "clippy", + "rustfmt" + ], "edition": "2024", - "targets": ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-gnu"] + "targets": [ + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-gnu" + ] }, "graphics": { "headless_validation": true, @@ -36,5 +42,12 @@ "atrinik/renderer", "atrinik/server", "atrinik/website" - ] + ], + "classic_tls": { + "prefix": "/opt/atrinik/tls", + "manifest": "/opt/atrinik/tls/share/atrinik/classic-tls.json", + "openssl": "4.0.3", + "libcurl": "8.22.0", + "required_api": "SSL_get_peer_addr" + } } diff --git a/tools/tests/test_classic_tls.py b/tools/tests/test_classic_tls.py new file mode 100644 index 0000000..ca330c6 --- /dev/null +++ b/tools/tests/test_classic_tls.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import copy +import importlib.util +import io +import json +from pathlib import Path +import sys +import tarfile +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools")) +SPEC = importlib.util.spec_from_file_location("classic_tls_build", ROOT / "linux/tls/build.py") +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class ClassicTLSTests(unittest.TestCase): + def test_manifest_matches_consumer_metadata(self): + manifest = MODULE.load_manifest(ROOT / "linux/tls/manifest.json") + for name in ("classic-toolchain.json", "toolchains.json"): + metadata = json.loads((ROOT / name).read_text())["classic_tls"] + self.assertEqual(metadata["prefix"], manifest["prefix"]) + self.assertEqual(metadata["openssl"], manifest["openssl"]["version"]) + self.assertEqual(metadata["libcurl"], manifest["curl"]["version"]) + self.assertEqual(metadata["required_api"], manifest["required_api"]) + + def test_rejects_manifest_contract_changes(self): + base = json.loads((ROOT / "linux/tls/manifest.json").read_text()) + changes = (("prefix", "/usr"), ("providers", ["default"]), + ("required_api", "SSL_get_rbio"), ("required_curl_features", []), + ("platform", "linux/arm64")) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "manifest.json" + for key, value in changes: + manifest = copy.deepcopy(base) + manifest[key] = value + path.write_text(json.dumps(manifest)) + with self.subTest(key=key), self.assertRaises(MODULE.ToolchainError): + MODULE.load_manifest(path) + for key, value in (("url", "https://example.org/source"), ("sha256", "bad"), + ("version", "4.0.3;false"), ("license", "unknown")): + manifest = copy.deepcopy(base) + manifest["openssl"][key] = value + path.write_text(json.dumps(manifest)) + with self.subTest(key=key), self.assertRaises(MODULE.ToolchainError): + MODULE.load_manifest(path) + + def test_rejects_duplicate_manifest_keys(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "manifest.json" + path.write_text('{"schema_version":1,"schema_version":1}') + with self.assertRaises(MODULE.ToolchainError): + MODULE.load_manifest(path) + + def archive(self, path, members, compression="gz"): + with tarfile.open(path, "w:" + compression) as archive: + for name, kind in members: + info = tarfile.TarInfo(name) + info.mode = 0o755 + if kind == "file": + info.size = 4 + archive.addfile(info, io.BytesIO(b"test")) + else: + info.type = {"symlink": tarfile.SYMTYPE, "hardlink": tarfile.LNKTYPE, + "fifo": tarfile.FIFOTYPE, "directory": tarfile.DIRTYPE}[kind] + info.linkname = "../../outside" + archive.addfile(info) + + def test_extracts_both_source_formats_and_executable_modes(self): + for compression in ("gz", "xz"): + with self.subTest(compression=compression), tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "source.tar" + destination = Path(tmp) / "output" + self.archive(path, [("source", "directory"), ("source/configure", "file")], compression) + MODULE.extract(path, destination, "source") + self.assertEqual((destination / "configure").read_bytes(), b"test") + self.assertEqual((destination / "configure").stat().st_mode & 0o777, 0o755) + + def test_rejects_unsafe_members(self): + cases = [[(name, kind)] for name, kind in ( + ("source/../outside", "file"), ("/source/absolute", "file"), + ("wrong/file", "file"), ("source/link", "symlink"), + ("source/link", "hardlink"), ("source/fifo", "fifo"), ("source", "file"))] + cases += [[("source/File", "file"), ("source/file", "file")], + [("source/file", "file"), ("source/file", "file")]] + for members in cases: + with self.subTest(members=members), tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "source.tar" + self.archive(path, members) + with self.assertRaises(MODULE.ToolchainError): + MODULE.extract(path, Path(tmp) / "output", "source") + self.assertFalse((Path(tmp) / "outside").exists()) + + +if __name__ == "__main__": + unittest.main()