From dce073ed9fa6fc66e8a75175d543bebd821ac580 Mon Sep 17 00:00:00 2001 From: Michael Akushsky Date: Wed, 20 May 2026 14:26:41 +0300 Subject: [PATCH 1/6] DFLOW-150 - JVM client setup (Linux) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Linux client-side installer + validator that wires a corporate CA into the JVM trust path so Maven / Gradle / sbt / Apache Ivy traffic redirected through package-reroute validates correctly. JVM trust only — does not configure Node/npm/Python and does not touch Docker credentials. Pair with install_certs_debian_ubuntu.sh if you need those flows. Based on the published research wiki (DFLOW-136): https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931 Two lab-verified paths, auto-detected: Path A — update-ca-trust RHEL/Fedora/CentOS/Amazon-Linux when a JDK whose lib/security/cacerts symlinks to /etc/pki/ca-trust/extracted/java/cacerts is on PATH (Red Hat OpenJDK). Drops the CA into /etc/pki/ca-trust/source/anchors/ and runs `update-ca-trust extract`. No env var. Path B — JKS + JAVA_TOOL_OPTIONS Everything else (Debian/Ubuntu, Amazon Corretto, Eclipse Temurin, SDKMAN, manual .tar.gz installs). Builds a JKS at /etc/ssl/package-route-jvm/truststore.jks containing only the customer CA; writes JAVA_TOOL_OPTIONS to /etc/environment plus the developer user's .bashrc/.zshrc. JDK-version-agnostic by construction. Detection rule (detect_mode): 1. No update-ca-trust on PATH -> Path B 2. /etc/pki/ca-trust/extracted/java/cacerts absent -> Path B 3. No `java` on PATH -> Path A (assumes Red Hat OpenJDK will follow via dnf; emits a loud end-of-run warning) 4. `java`'s cacerts symlinks to RHEL store -> Path A; else Path B --mode java-tool-options / --mode update-ca-trust overrides detection. Files: install_certs_jvm_linux.sh Installer (mirrors install_certs_debian_ubuntu.sh patterns). validate_certs_jvm_linux.sh Companion validator; subject-substring match. _jvm_linux_paths.sh Shared constants sourced by both scripts so they cannot drift. testing/test_install_certs_jvm_linux.sh Docker matrix runner (4 distros x 10 invariants). .github/workflows/ci.yml New test-linux-jvm job runs the matrix runner. README.md New "Linux (JVM)" section with the full design + caveats. Hardening fixes folded in from two rounds of pr-review-toolkit review: Critical: - --cert-name actually propagates to the Path B JKS alias and the validator accepts --cert-name so Path A's anchor file can be located when a non-default basename was used. - validate_pem rejects expired certs, leaf certs (CA:FALSE), warns on bundles (keytool -importcert -noprompt only imports the first cert). - install_via_update_ca_trust and validate_keystore_contains_subject capture keytool output explicitly; the previous pipefail+SIGPIPE pattern (... | grep -qi "$subject") silently turned real keytool failures into "subject not found" misdiagnoses AND real positive matches into false negatives. - validator detect_mode "both paths present" branch now routes the whole warn block to stderr; previously the warn lines leaked into the command-substitution capture and broke the case dispatch, yielding a silent exit-0 with NO checks run. Important: - validator FAILs (not OKs) when keytool is missing on the JKS path. - --all-users root check moved into parse_args (fast-fail exit 1). - update_user_shell_rc tracks RC_UPDATED and the final summary prints a loud WARNING when the per-user rc step was skipped. - --mode jto -> --mode java-tool-options for self-documenting CLI. - mktemp is -p $(dirname target) so `mv` is rename(2) (atomic), and an empty-awk-output check refuses to clobber /etc/environment. - --cert-name regex-validated to [A-Za-z0-9._-]+ to prevent path traversal in the anchor file path. - chown failure on the user's rc file captured + warned. - get_target_user verifies the picked username exists in /etc/passwd. Smoke matrix (testing/test_install_certs_jvm_linux.sh): Ubuntu 22.04, Debian 12, RHEL UBI 9, Amazon Linux 2023, in parallel. Per-container invariants: positive install+validate, subject mismatch -> exit 1, idempotent re-install (no duplicate lines/aliases), custom --cert-name round-trips, path-traversal rejected, malformed PEM rejected, expired CA rejected (skipped when openssl can't produce a verifiably-expired test cert), leaf cert (CA:FALSE) rejected, forced --mode update-ca-trust (Path A) on hosts with update-ca-trust, dual-artifact validator regression test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 14 + README.md | 120 ++++- _jvm_linux_paths.sh | 55 +++ install_certs_jvm_linux.sh | 612 ++++++++++++++++++++++++ testing/test_install_certs_jvm_linux.sh | 344 +++++++++++++ validate_certs_jvm_linux.sh | 333 +++++++++++++ 6 files changed, 1477 insertions(+), 1 deletion(-) create mode 100644 _jvm_linux_paths.sh create mode 100755 install_certs_jvm_linux.sh create mode 100755 testing/test_install_certs_jvm_linux.sh create mode 100755 validate_certs_jvm_linux.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b383d4..3dc8d37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,17 @@ jobs: - name: Run Windows tests shell: pwsh run: ./testing/test_install_certs_windows.ps1 + + test-linux-jvm: + name: Test (Linux JVM) + runs-on: ubuntu-latest + # No actions/setup-java here — unlike the macOS/Windows JVM jobs that + # exercise a single host JDK, this job runs a 4-distro Docker matrix + # (Ubuntu / Debian / RHEL / Amazon Linux) and installs a JDK *inside* + # each container. The host runner doesn't need a JDK; setting one up + # would only mask divergence in the per-distro keytool versions. + steps: + - uses: actions/checkout@v4 + + - name: Run Linux JVM smoke matrix + run: ./testing/test_install_certs_jvm_linux.sh diff --git a/README.md b/README.md index 4335a06..5f43255 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,28 @@ Scripts to install a CA certificate, configure Node/npm, Python (pip, uv, Huggin This document describes the certificate installation and validation scripts for **macOS**, **Linux (Debian/Ubuntu)**, and **Windows**. +## Quickstart — which script do I need? + +| Your toolchain | Your OS | Go to | +|---|---|---| +| **Maven / Gradle / sbt / Ivy** (JVM) | Linux | [Linux (JVM)](#linux-jvm-install_certs_jvm_linuxsh) | +| **Node / npm or Python (pip / uv / Hugging Face)** | macOS | [macOS](#macos-install_certs_macossh) | +| **Node / npm or Python** | Linux (Debian / Ubuntu) | [Linux (Debian/Ubuntu)](#linux-debianubuntu-install_certs_debian_ubuntush) | +| **Node / npm or Python** | Windows | [Windows](#windows-install_certs_windowsps1) | + +Reference: research wiki [Maven Support in package-reroute (DFLOW-136 / DFLOW-116)](https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/). + +## Script index + | Script | Platform | Purpose | |--------|----------|---------| | **install_certs_macos.sh** | macOS | Install cert, set env vars (Node/Python), and clear Docker Hub credentials | | **validate_install_macos.sh** | macOS | Validate PEM and env config | | **install_certs_debian_ubuntu.sh** | Debian/Ubuntu | Install cert into system trust + profile.d + user shell rc + Docker cleanup | | **validate_certs_debian_ubuntu.sh** | Debian/Ubuntu | Validate PEM and env config | +| **install_certs_jvm_linux.sh** | Linux (JVM) | Install CA for Maven/Gradle/sbt/Ivy: RHEL family → `update-ca-trust extract` into system anchors; others → per-host JKS + `JAVA_TOOL_OPTIONS` in `/etc/environment` | +| **validate_certs_jvm_linux.sh** | Linux (JVM) | Validate JVM truststore install (auto-detects Path A vs B; checks anchor file or JKS subject + `/etc/environment` + shell-rc) | +| **_jvm_linux_paths.sh** | Linux (JVM) | Shared constants dot-sourced by installer + validator. Not directly executable. | | **install_certs_windows.ps1** | Windows | Install cert, set env vars (Node/Python/Ruby), and clear Docker Hub credentials | | **validate_install_windows.ps1** | Windows | Validate PEM and env config | @@ -176,7 +192,7 @@ sudo ./validate_install_macos.sh --expected-subject "" --all ### Testing -Tests live in **testing/**. Automated tests cover **macOS** and **Windows** only (not Debian/Ubuntu). +Tests live in **testing/**. Automated tests cover **macOS**, **Windows**, and **Linux JVM** (not Debian/Ubuntu). **test_install_certs_macos.sh** runs automated tests for **install_certs_macos.sh** (CLI and argument validation) and **validate_install_macos.sh** (validation with a temp PEM and mock home). No root required for the default test run. @@ -349,6 +365,107 @@ sudo ./validate_certs_debian_ubuntu.sh --all-users --expected-subject "O=Example --- +## Linux (JVM): install_certs_jvm_linux.sh + +### Overview + +`install_certs_jvm_linux.sh` wires a custom CA certificate into the JVM trust path so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_debian_ubuntu.sh` if you need the Node/Python flows or Docker Hub credential cleanup. + +The script auto-detects between two lab-verified paths: + +- **Path A — `update-ca-trust`** for RHEL/Fedora/CentOS/Amazon-Linux when a JDK whose `lib/security/cacerts` is symlinked to `/etc/pki/ca-trust/extracted/java/cacerts` is on `PATH` (Red Hat OpenJDK, and on some images Corretto). Drops the CA into `/etc/pki/ca-trust/source/anchors/` and runs `update-ca-trust extract`. **No env var is set.** +- **Path B — JKS + `JAVA_TOOL_OPTIONS`** for everything else (Debian/Ubuntu, manual JDK installs that don't symlink to the system store, SDKMAN, snap-confined JDKs). Builds a JKS truststore at `/etc/ssl/package-route-jvm/truststore.jks` containing only the customer CA, then sets `JAVA_TOOL_OPTIONS` in `/etc/environment`. JDK-version-agnostic by construction — one env var serves all current and future JDKs. + +Auto-detection logic (`detect_mode` in the script): + +1. If `update-ca-trust` is not on `PATH` → **Path B**. +2. If `/etc/pki/ca-trust/extracted/java/cacerts` does not exist → **Path B**. +3. If no `java` is on `PATH` → **Path A** (assumes Red Hat OpenJDK will be installed via `dnf`; the installer prints a loud end-of-run warning instructing the user to re-run with `--mode java-tool-options` if they install Corretto/Temurin/SDKMAN instead). +4. If the resolved `java`'s `lib/security/cacerts` symlinks to the RHEL system store → **Path A**; otherwise **Path B**. + +`--mode java-tool-options` or `--mode update-ca-trust` overrides the detection. + +Both scripts source a small shared file `_jvm_linux_paths.sh` for the constants block (CA basename default, anchor dir, JKS path, password, env file) so the installer and validator cannot drift. + +### Requirements + +- **Linux** (Debian/Ubuntu family OR RHEL/Fedora/CentOS/Amazon-Linux family). +- **Root** (`sudo`). +- **`openssl`** on `PATH`. +- **`keytool`** on `PATH` (provided by any JDK) **for Path B**. On Path A the verification step uses `keytool` opportunistically but the install itself does not need it; the installer emits a warning if `keytool` is missing on Path A. + +### Options + +| Option | Required | Description | +|--------|----------|-------------| +| `--use-cert ` | **Yes** | Path to an existing PEM/CRT certificate file. Validated: must be a parseable X.509, not expired, with `CA:TRUE` in basicConstraints. Bundles emit a warning (only the first cert imports). | +| `--mode auto\|java-tool-options\|update-ca-trust` | No (default: **auto**) | Override path detection. | +| `--cert-name ` | No (default: `package-route-custom-ca`) | Base name applied to the Path A anchor file (`/etc/pki/ca-trust/source/anchors/.crt`) AND the Path B JKS alias. Must match `[A-Za-z0-9._-]+`. Pass the same value to the validator. | +| `-h`, `--help` | — | Usage. | + +### Examples + +```bash +# Auto-detect; works on both RHEL family and Debian/Ubuntu +sudo ./install_certs_jvm_linux.sh --use-cert /tmp/ZscalerRoot0.pem + +# Force JAVA_TOOL_OPTIONS path even on a RHEL host +sudo ./install_certs_jvm_linux.sh --use-cert /tmp/ZscalerRoot0.pem --mode java-tool-options + +# Custom basename for the anchor file / JKS alias +sudo ./install_certs_jvm_linux.sh --use-cert /tmp/ZscalerRoot0.pem --cert-name zscaler-root +``` + +### Validation: validate_certs_jvm_linux.sh + +**`--expected-subject` is required.** The validator auto-detects which path was used (by checking for the JKS file or the anchor file), then asserts the customer CA is present by case-insensitive subject substring match. `--all-users` iterates `/home/*` and is Path B-specific (requires root; checked at `parse_args` time, fails fast). Pass `--cert-name ` if the installer was invoked with a non-default value. + +```bash +./validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" +sudo ./validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" --all-users +./validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" --cert-name zscaler-root +``` + +Exit code 0 if all checks pass, 1 otherwise. Missing `JAVA_TOOL_OPTIONS` in a user rc file is a **warning** (not a failure) — `/etc/environment` is the authoritative source for system-wide config. Missing `keytool` while a keystore exists is a **failure** (cannot verify the core invariant). + +### Caveats + +- **`/etc/environment` activation** (Path B). GUI-launched apps (IntelliJ from the GNOME/KDE launcher) inherit `/etc/environment` via the session manager at login. Existing sessions need a logoff/login to pick up the new env var. The script updates the SUDO_USER's `.bashrc`/`.zshrc` so the *current* shell session has it without re-login. +- **Gradle Daemon caching** (Path B). A Gradle Daemon started before the env var was set still uses its captured environment. Run `gradle --stop` after onboarding. +- **`Picked up JAVA_TOOL_OPTIONS:` banner** (Path B). Every JVM prints this to stderr at startup. CI log parsers that strict-match empty-stderr need to tolerate it. +- **`changeit` truststore password.** The JKS at `/etc/ssl/package-route-jvm/truststore.jks` uses the OpenJDK convention password `changeit`. This is **not** a secret — JKS truststores protect file integrity, not contents, and the trust anchor inside is a public CA certificate. The password is persisted in `/etc/environment` via the `-Djavax.net.ssl.trustStorePassword` flag so unattended JVMs can open the store. +- **Path B truststore extends the JDK's bundled cacerts.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The installer therefore copies `$JAVA_HOME/lib/security/cacerts` to `/etc/ssl/package-route-jvm/truststore.jks` first, then `keytool -importcert` appends the corporate CA. The resulting store has ~150 public roots **plus** the corporate one. Path A is unaffected — `update-ca-trust extract` already builds the system-wide Java cacerts by merging system anchors with the JDK's defaults. +- **Mixed-distro auto-detection.** On RHEL family with a non-Red-Hat JDK on PATH (Corretto, Temurin, SDKMAN), auto-detection lands on Path B because the JDK's `lib/security/cacerts` is not symlinked to the RHEL system store. On RHEL family with **no** JDK on PATH yet, auto-detection picks Path A on the assumption that Red Hat OpenJDK will follow via `dnf` — the installer emits a loud end-of-run warning naming this assumption. +- **Snap-confined JDKs** are not configured by Path A (read-only squashfs). Auto-detection lands on Path B because the snap cacerts is not symlinked to the system one. Path B works because `JAVA_TOOL_OPTIONS` is read by the JVM at startup regardless of how the JDK was installed. +- **Container-internal JDKs.** Maven/Gradle running inside Docker on a developer machine need the CA wired into the *container* image — host-side install does not propagate. Use a `RUN` step in the Dockerfile or pass `JAVA_TOOL_OPTIONS` via `docker run -e`. +- **`MAVEN_OPTS` clobbering.** If your shell or `~/.mavenrc` sets `MAVEN_OPTS`, those args land AFTER `JAVA_TOOL_OPTIONS` and can override the trust store flags. If `mvn` fails TLS after install, check `env | grep -E '^(JAVA_TOOL_OPTIONS|MAVEN_OPTS)='` — if both are set, ensure `MAVEN_OPTS` does NOT also include `-Djavax.net.ssl.trustStore`. +- **IntelliJ per-IDE SSL store.** `~/.config/JetBrains//ssl/cacerts` is a separate trust store used by the IDE for the plugin marketplace and VCS integration — NOT by Maven/Gradle runs spawned from IntelliJ (those use the JBR's truststore which the env var path covers). If `mvn` works in Terminal but IntelliJ Maven sync fails, add the CA via Settings → Tools → Server Certificates. +- **Idempotent re-runs.** Path B re-creates the JKS each run (single alias guaranteed) and replaces (not appends) the env var line in `/etc/environment`. Path A re-copies the anchor and re-runs `update-ca-trust extract` (with fingerprint-compare to surface a deliberate replacement vs. an idempotent re-run). Running the script twice produces the same final state. +- **Existing anchor file replacement (Path A).** A file at `/etc/pki/ca-trust/source/anchors/.crt` placed by other tooling is replaced if its SHA-256 fingerprint differs from `--use-cert`. The installer prints a `[warn] Replacing existing anchor` line with both fingerprints so the swap is auditable. + +### Testing + +`./testing/test_install_certs_jvm_linux.sh` runs the Docker smoke matrix across four distro × JDK combinations in parallel from any host with Docker. Each container builds a self-signed lab CA, runs the installer, runs the validator, then exercises negative subject, idempotency, custom `--cert-name`, path-traversal rejection, malformed-PEM rejection, expired-CA rejection, and leaf-cert rejection. + +```bash +# From repo root (any host with Docker) +./testing/test_install_certs_jvm_linux.sh +``` + +Exit code 0 if all containers pass, 1 if any test fails. + +The same matrix runs on every push and pull request via `.github/workflows/ci.yml` (`test-linux-jvm` job). + +### Summary (Linux JVM) + +- **One run as root**, single cert source via `--use-cert`. +- **Path A**: anchor in `/etc/pki/ca-trust/source/anchors/.crt`; `update-ca-trust extract` updates the system Java trust store. No env var. +- **Path B**: JKS at `/etc/ssl/package-route-jvm/truststore.jks` with alias ``; `JAVA_TOOL_OPTIONS` in `/etc/environment` and in the developer user's `.bashrc`/`.zshrc`. +- **Idempotent** (both paths), **re-runnable**, **JDK-version-agnostic** across currently-supported JDKs — JKS format is still read by JDK 8–25 (Path B); Path A piggy-backs on the OS trust store that Red Hat OpenJDK already symlinks. A future JDK that drops JKS support would require a Path B format bump. +- Users must open a new login shell (or `source /etc/environment`) for env changes to take effect. `gradle --stop` to refresh the Gradle Daemon. + +--- + ## Windows: install_certs_windows.ps1 ### Overview @@ -452,5 +569,6 @@ On **push** and **pull request** to `main` or `master`, GitHub Actions runs: |-----|--------|---------| | Test (macOS) | `macos-latest` | `sudo ./testing/test_install_certs_macos.sh` | | Test (Windows) | `windows-latest` | `./testing/test_install_certs_windows.ps1` (PowerShell) | +| Test (Linux JVM) | `ubuntu-latest` | `./testing/test_install_certs_jvm_linux.sh` | There is no CI job for the Debian/Ubuntu scripts in this workflow. diff --git a/_jvm_linux_paths.sh b/_jvm_linux_paths.sh new file mode 100644 index 0000000..cb51932 --- /dev/null +++ b/_jvm_linux_paths.sh @@ -0,0 +1,55 @@ +# (c) JFrog Ltd. (2026) +# Shared constants for install_certs_jvm_linux.sh and validate_certs_jvm_linux.sh. +# Sourced — must NOT be executed directly. Has no shebang on purpose. +# +# Both scripts read this file via: +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# . "${SCRIPT_DIR}/_jvm_linux_paths.sh" +# +# Keep installer and validator in lockstep by changing only this file. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# _jvm_macos_paths.sh — per-user JKS under ~/Library +# _jvm_windows_paths.ps1 — per-user JKS under %LOCALAPPDATA% + +# Default base name for the installed CA file (Path A) and the JKS alias (Path B). +# Overridable via --cert-name on the installer; the validator must be invoked with +# the same --cert-name when a non-default value was used. +# +# CROSS-PLATFORM NOTE: Linux is the only sibling where --cert-name affects +# a filesystem-visible path (Path A: /etc/pki/ca-trust/source/anchors/${CERT_BASENAME}.crt). +# macOS and Windows treat --cert-name as a JKS-alias-only cosmetic. A fleet +# script that wraps all three installers with the same --cert-name must +# remember to also pass --cert-name to the LINUX validator. +JVM_LINUX_DEFAULT_CERT_BASENAME="package-route-custom-ca" + +# Path A — RHEL family with Red Hat OpenJDK. The JDK symlinks its +# lib/security/cacerts to RHEL_JAVA_CACERTS, so system trust IS Java trust. +RHEL_ANCHOR_DIR="/etc/pki/ca-trust/source/anchors" +RHEL_JAVA_CACERTS="/etc/pki/ca-trust/extracted/java/cacerts" + +# Path B — JKS truststore + JAVA_TOOL_OPTIONS. Used on Debian/Ubuntu, Amazon +# Corretto, Eclipse Temurin, SDKMAN-installed JDKs, and manual .tar.gz installs. +# +# Note: Linux uses /etc/ssl/package-route-jvm rather than nesting under +# /etc/ssl/JFrog/package-route-jvm (as macOS/Windows do under their per-user +# trees). The flat path matches /etc/ssl conventions; if a future JFrog tool +# needs a sibling dir, it can carve out /etc/ssl/jfrog-/ alongside. +JKS_DIR="/etc/ssl/package-route-jvm" +JKS_PATH="${JKS_DIR}/truststore.jks" + +# OpenJDK convention for cacerts and similar truststores. NOT a secret in +# this script's use case: we import only `trustedCertEntry` records (public +# CA certs), so the password protects file *integrity* via the keystore MAC +# but not any private key material. A JKS that ever holds a PrivateKeyEntry +# would additionally rely on this password to encrypt the key — not relevant +# here. Persisted in /etc/environment via -Djavax.net.ssl.trustStorePassword +# so unattended JVMs can open the store. +JKS_PASSWORD="changeit" + +ENVIRONMENT_FILE="/etc/environment" + +# 30-day expiry warn threshold (in seconds) — used by validate_pem. Must stay +# in lockstep with the macOS bash sibling (-checkend 2592000) and the Windows +# .NET sibling (AddDays(30)). Do not change without updating all three. +JVM_LINUX_EXPIRY_WARN_SECONDS=2592000 diff --git a/install_certs_jvm_linux.sh b/install_certs_jvm_linux.sh new file mode 100755 index 0000000..301f0c8 --- /dev/null +++ b/install_certs_jvm_linux.sh @@ -0,0 +1,612 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Install a custom CA certificate on Linux for JVM clients (Maven, Gradle, sbt, +# Apache Ivy). Two paths depending on distro + JDK distribution: +# +# Path A — RHEL/Fedora/CentOS/Amazon-Linux + Red Hat OpenJDK: +# Drop the cert into /etc/pki/ca-trust/source/anchors/ and run +# update-ca-trust extract. Red Hat's OpenJDK symlinks its cacerts to the +# system-managed copy, so system trust IS Java trust. +# +# Path B — everything else (Debian/Ubuntu, Amazon Corretto, Eclipse Temurin, +# SDKMAN-installed JDKs, manual .tar.gz installs): +# Build a JKS truststore at /etc/ssl/package-route-jvm/truststore.jks +# containing the customer CA and set JAVA_TOOL_OPTIONS in /etc/environment +# so every JDK on the box picks it up at startup. +# +# Run: +# sudo bash install_certs_jvm_linux.sh --use-cert /path/to/cert.pem +# [--mode auto|java-tool-options|update-ca-trust] [--cert-name ] +# +# Notes: +# - Linux only (Debian/Ubuntu and RHEL/Fedora/CentOS/Amazon-Linux families). +# - Must run as root. +# - JVM trust only — does not configure npm/Python/HF and does not touch +# Docker credentials. Pair with install_certs_debian_ubuntu.sh if needed. +# - GUI-launched IDEs need a logoff/login to pick up /etc/environment. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# install_certs_jvm_macos.sh — LaunchAgent + per-user JKS +# install_certs_jvm_windows.ps1 — HKCU\Environment + per-user JKS +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ ! -f "${SCRIPT_DIR}/_jvm_linux_paths.sh" ]]; then + echo "Error: _jvm_linux_paths.sh not found next to this installer (${SCRIPT_DIR})." >&2 + echo " This script reads its constants from a sibling file." >&2 + echo " Invoke as ./install_certs_jvm_linux.sh — not via 'curl | bash'" >&2 + echo " or 'sh -c \"\$(cat …)\"' (those lose BASH_SOURCE[0])." >&2 + exit 1 +fi +# shellcheck disable=SC1091 +. "${SCRIPT_DIR}/_jvm_linux_paths.sh" + +USE_CERT="" +MODE_OPT="auto" +CERT_BASENAME="${JVM_LINUX_DEFAULT_CERT_BASENAME}" + +# Tracked so install_via_jto's final summary can warn loudly if the per-user +# rc step was skipped (otherwise the [3/4] header reads like a success). +RC_UPDATED=0 + +usage() { + cat < [--mode auto|java-tool-options|update-ca-trust] [--cert-name ] + +Options: + --use-cert Path to an existing PEM/CRT certificate file (required). + --mode auto|java-tool-options|update-ca-trust Override path detection (default: auto). + auto - detect by distro + JDK trust integration + java-tool-options - force JAVA_TOOL_OPTIONS + JKS path + update-ca-trust - force RHEL system-trust path + --cert-name Base name for installed cert (default: ${CERT_BASENAME}). + Applied to the anchor file (Path A) AND the JKS alias (Path B). + -h, --help Show this help. + +Examples: + sudo $0 --use-cert /tmp/ZscalerRoot0.pem + sudo $0 --use-cert /tmp/ca.pem --mode java-tool-options + sudo $0 --use-cert /tmp/ca.pem --cert-name zscaler-root +EOF +} + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "Error: this script must be run as root." >&2 + echo "Use: sudo $0 --use-cert [--mode auto|java-tool-options|update-ca-trust]" >&2 + exit 1 + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --use-cert) + USE_CERT="${2:?Error: --use-cert requires a value}" + shift 2 + ;; + --mode) + MODE_OPT="${2:?Error: --mode requires a value}" + shift 2 + ;; + --cert-name) + CERT_BASENAME="${2:?Error: --cert-name requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + case "$MODE_OPT" in + auto|java-tool-options|update-ca-trust) ;; + *) + echo "Error: --mode must be auto, java-tool-options, or update-ca-trust (got: $MODE_OPT)." >&2 + exit 1 + ;; + esac + + if [[ -z "$USE_CERT" ]]; then + echo "Error: --use-cert is required." >&2 + usage >&2 + exit 1 + fi + + if [[ ! -f "$USE_CERT" ]]; then + echo "Error: certificate file not found: $USE_CERT" >&2 + exit 1 + fi + + if [[ -z "$CERT_BASENAME" ]]; then + echo "Error: --cert-name cannot be empty." >&2 + exit 1 + fi + + # Reject path-traversal characters so $CERT_BASENAME stays a single path segment. + if [[ ! "$CERT_BASENAME" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Error: --cert-name must match [A-Za-z0-9._-]+ (got: $CERT_BASENAME)." >&2 + exit 1 + fi +} + +check_os() { + if [[ ! -r /etc/os-release ]]; then + echo "Error: cannot determine OS." >&2 + exit 1 + fi + + # shellcheck disable=SC1091 + . /etc/os-release + + local id="${ID:-}" id_like="${ID_LIKE:-}" + case "$id" in + ubuntu|debian|rhel|fedora|centos|rocky|almalinux|amzn) return 0 ;; + esac + case "$id_like" in + *debian*|*rhel*|*fedora*|*centos*) return 0 ;; + esac + + echo "Error: this script supports Debian/Ubuntu and RHEL/Fedora/CentOS/Amazon-Linux families." >&2 + echo "Detected ID=${id:-unknown}, ID_LIKE=${id_like:-unknown}" >&2 + exit 1 +} + +check_dependencies() { + if ! command -v openssl >/dev/null 2>&1; then + echo "Error: openssl is required but not found." >&2 + exit 1 + fi + # keytool is needed only by Path B; checked at the install_via_jto entry point. +} + +# Locate the JDK's default cacerts file. -Djavax.net.ssl.trustStore in OpenJDK +# REPLACES the JVM trust source rather than extending it — pointing JVMs at a +# JKS containing only the corporate CA breaks every public-CA TLS handshake +# (Maven Central, Gradle plugin portal, Let's Encrypt-fronted artifact mirrors). +# We copy the JDK's bundled cacerts into the target keystore first, then +# `keytool -importcert` appends our CA, so the merged store has ~150 public +# roots PLUS the corporate one. +# +# Resolution precedence: +# 1. $JAVA_HOME/lib/security/cacerts (set by every standard JDK installer) +# 2. $(dirname $(command -v keytool))/../lib/security/cacerts (works for +# stock Adoptium / Corretto / Microsoft / Zulu / RHEL OpenJDK layouts) +# 3. Hard fail with a clear message. +# +# Echoes the resolved path on stdout; exits non-zero on failure. +find_jdk_cacerts() { + local candidate="" + if [[ -n "${JAVA_HOME:-}" && -f "${JAVA_HOME}/lib/security/cacerts" ]]; then + candidate="${JAVA_HOME}/lib/security/cacerts" + else + local keytool_path keytool_dir + keytool_path="$(command -v keytool 2>/dev/null || true)" + if [[ -n "$keytool_path" ]]; then + keytool_dir="$(dirname "$(readlink -f "$keytool_path" 2>/dev/null || echo "$keytool_path")")" + if [[ -f "${keytool_dir}/../lib/security/cacerts" ]]; then + candidate="${keytool_dir}/../lib/security/cacerts" + fi + fi + fi + if [[ -z "$candidate" ]]; then + echo "Error: cannot locate the JDK's default cacerts file." >&2 + echo " Set JAVA_HOME, or ensure keytool resolves under a standard JDK bin/ layout." >&2 + echo " Tried: \$JAVA_HOME/lib/security/cacerts, \$(dirname keytool)/../lib/security/cacerts" >&2 + exit 1 + fi + echo "$candidate" +} + +validate_pem() { + local path="$1" + + # Require PEM-encoded input (text `-----BEGIN CERTIFICATE-----` block). + # Cross-platform contract: the Windows sibling auto-detects DER via + # System.Security.Cryptography.X509Certificates and silently accepts + # it, but the bash branch only handles PEM. Keep the trilogy honest by + # rejecting DER everywhere with a clear conversion hint, so the same + # cert file produces the same outcome regardless of the platform the + # operator runs the installer on. + if ! grep -q -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null; then + echo "Error: certificate is not PEM-encoded: $path" >&2 + echo " If it's DER, convert first:" >&2 + echo " openssl x509 -inform der -in $path -out $path.pem" >&2 + echo " then re-run with --use-cert $path.pem" >&2 + exit 1 + fi + + if ! openssl x509 -in "$path" -noout >/dev/null 2>&1; then + echo "Error: invalid PEM/CRT certificate file: $path" >&2 + exit 1 + fi + + # Reject expired anchors: keytool -importcert -noprompt accepts them silently + # and the user gets cryptic CertificateExpiredException at TLS handshake time. + if ! openssl x509 -in "$path" -checkend 0 -noout >/dev/null 2>&1; then + echo "Error: certificate has already expired: $path" >&2 + exit 1 + fi + + # Warn (don't fail) on a cert expiring within JVM_LINUX_EXPIRY_WARN_SECONDS + # (30 days). Threshold is in lockstep with the macOS/Windows siblings. + if ! openssl x509 -in "$path" -checkend "$JVM_LINUX_EXPIRY_WARN_SECONDS" -noout >/dev/null 2>&1; then + echo "[warn] certificate expires within 30 days: $path" >&2 + fi + + # Reject leaf certs: a cert without CA:TRUE in basicConstraints will import + # into a JKS truststore but PKIX path-building won't use it as a trust anchor. + local bc + bc="$(openssl x509 -in "$path" -noout -ext basicConstraints 2>/dev/null || true)" + if [[ -n "$bc" ]] && ! grep -qi 'CA:TRUE' <<<"$bc"; then + echo "Error: certificate is not a CA (basicConstraints missing CA:TRUE): $path" >&2 + echo " JKS imports succeed but PKIX rejects non-CA trust anchors." >&2 + exit 1 + fi + + # Warn on bundles: keytool -importcert -noprompt reads only the first cert, + # silently dropping intermediates. Users should split bundles or supply only the root. + local count + count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null || echo 0)" + if [[ "$count" -gt 1 ]]; then + echo "[warn] PEM file contains $count certificates; only the first will be imported as the JVM trust anchor." >&2 + echo " Supply only the root CA (or split the bundle) if intermediates are needed." >&2 + fi +} + +replace_export_in_file() { + local file="$1" + local var="$2" + local value="$3" + local tmp escaped + + escaped="${value//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + + # Place tmp in the same directory as the target so `mv` is rename(2) (atomic) + # and a cross-filesystem copy can't half-truncate the target on disk-full. + tmp=$(mktemp -p "$(dirname "$file")") + awk -v var="$var" -v val="$escaped" ' + $0 ~ "^export " var "=" { + print "export " var "=\"" val "\"" + next + } + { print } + ' "$file" > "$tmp" + if [[ ! -s "$tmp" && -s "$file" ]]; then + rm -f "$tmp" + echo "Error: awk produced empty output; refusing to overwrite $file" >&2 + exit 1 + fi + mv "$tmp" "$file" +} + +ensure_export_in_file() { + local file="$1" + local var="$2" + local value="$3" + + touch "$file" + + if grep -qE "^export ${var}=" "$file" 2>/dev/null; then + replace_export_in_file "$file" "$var" "$value" + else + printf 'export %s="%s"\n' "$var" "$value" >> "$file" + fi +} + +get_target_user() { + local candidate + + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + candidate="$SUDO_USER" + else + candidate="$(logname 2>/dev/null || true)" + if [[ -z "$candidate" || "$candidate" == "root" ]]; then + if command -v loginctl >/dev/null 2>&1; then + candidate="$(loginctl list-sessions --no-legend 2>/dev/null | awk ' + $2 >= 1000 && $3 != "root" && $4 == "seat0" { print $3; found=1; exit } + $2 >= 1000 && $3 != "root" && !fallback { fallback=$3 } + END { if (!found && fallback) print fallback } + ')" + fi + fi + fi + + if [[ -z "$candidate" || "$candidate" == "root" ]]; then + return 0 + fi + + # Reject users that don't exist in passwd (transient sessions etc.). + if ! getent passwd "$candidate" >/dev/null 2>&1; then + return 0 + fi + + echo "$candidate" +} + +get_user_home() { + local user="$1" + getent passwd "$user" | cut -d: -f6 +} + +get_user_shell() { + local user="$1" + getent passwd "$user" | cut -d: -f7 +} + +# Stdout contract: exactly one of {java-tool-options, update-ca-trust}. +# Callers MUST equality-match the returned string. Keep in sync with the +# validator's detect_mode (which adds a "none" case when no artifacts exist). +detect_mode() { + if ! command -v update-ca-trust >/dev/null 2>&1; then + echo "java-tool-options" + return + fi + if [[ ! -e "$RHEL_JAVA_CACERTS" ]]; then + echo "java-tool-options" + return + fi + + local java_bin java_home cacerts + java_bin="$(command -v java 2>/dev/null || true)" + if [[ -z "$java_bin" ]]; then + # update-ca-trust present and the RHEL system Java cacerts exists, but + # no JDK is on PATH yet. Pick the system-trust path on the assumption + # that Red Hat OpenJDK will be installed via dnf. If the user later + # installs a non-Red-Hat JDK (Corretto, Temurin, SDKMAN), they must + # re-run with --mode java-tool-options — install_via_update_ca_trust + # emits a loud end-of-run warning to that effect. + echo "update-ca-trust" + return + fi + + java_home="$(dirname "$(dirname "$(readlink -f "$java_bin")")")" + cacerts="${java_home}/lib/security/cacerts" + if [[ "$(readlink -f "$cacerts" 2>/dev/null)" == "$RHEL_JAVA_CACERTS" ]]; then + echo "update-ca-trust" + else + echo "java-tool-options" + fi +} + +install_via_update_ca_trust() { + local anchor_path="${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt" + local java_present=0 + command -v java >/dev/null 2>&1 && java_present=1 + + echo "[1/4] Installing CA into system anchor: $anchor_path" + mkdir -p "$RHEL_ANCHOR_DIR" + + # Pre-flight: if a file already exists at the anchor path, compare + # SHA-256 fingerprints. Identical -> idempotent re-run, skip the cp. + # Different -> warn the operator that we're replacing an existing + # trust anchor so a silent overwrite (potentially clobbering a CA + # placed by other tooling under the same --cert-name basename) leaves + # a clear paper trail. + if [[ -e "$anchor_path" ]]; then + local existing_fp installer_fp + existing_fp="$(openssl x509 -in "$anchor_path" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//' || true)" + installer_fp="$(openssl x509 -in "$USE_CERT" -noout -fingerprint -sha256 | sed 's/.*=//')" + if [[ "$existing_fp" == "$installer_fp" ]]; then + echo " Anchor already present with matching fingerprint; skipping copy." + else + echo " [warn] Replacing existing anchor at $anchor_path" >&2 + echo " existing fingerprint: $existing_fp" >&2 + echo " new fingerprint: $installer_fp" >&2 + cp "$USE_CERT" "$anchor_path" + fi + else + cp "$USE_CERT" "$anchor_path" + fi + chmod 0644 "$anchor_path" + + echo "[2/4] Running update-ca-trust extract..." + update-ca-trust extract + + echo "[3/4] Verifying Java cacerts contains the CA..." + if ! command -v keytool >/dev/null 2>&1; then + echo " [warn] keytool not on PATH; skipping Java-side verification. Install a JDK and re-run validate_certs_jvm_linux.sh." >&2 + else + local fingerprint listing + fingerprint="$(openssl x509 -in "$anchor_path" -noout -fingerprint -sha256 | sed 's/.*=//')" + + # Capture keytool's output explicitly so a real keytool failure (wrong + # password, corrupt store, missing JDK at runtime) is reported as such + # and not as a misleading "fingerprint not yet visible". + if ! listing="$(keytool -list -keystore "$RHEL_JAVA_CACERTS" -storepass "$JKS_PASSWORD" 2>&1)"; then + echo " [warn] keytool could not read $RHEL_JAVA_CACERTS. Output:" >&2 + printf '%s\n' "$listing" | head -n5 >&2 + elif grep -qiF "$fingerprint" <<<"$listing"; then + echo " OK: CA fingerprint visible in $RHEL_JAVA_CACERTS" + else + echo " [warn] CA fingerprint not yet visible in $RHEL_JAVA_CACERTS — system trust may need a fresh login session or a non-Red-Hat JDK is on PATH." >&2 + fi + fi + + echo "[4/4] Done. No env var needed; Red Hat OpenJDK reads $RHEL_JAVA_CACERTS directly." + echo + echo "Installed certificate:" + echo " $anchor_path" + echo "Java trust path:" + echo " $RHEL_JAVA_CACERTS" + + if [[ "$java_present" -eq 0 ]]; then + echo + echo "WARNING: no JDK is on PATH right now. The system-trust path was picked because" + echo " /etc/pki/ca-trust/extracted/java/cacerts exists, assuming Red Hat OpenJDK" + echo " will be installed via dnf. If you instead install Corretto, Eclipse Temurin," + echo " or any JDK whose lib/security/cacerts is NOT symlinked to the system store," + echo " this installer will NOT have configured trust for it. Re-run with" + echo " --mode java-tool-options in that case." >&2 + fi +} + +require_keytool() { + if ! command -v keytool >/dev/null 2>&1; then + echo "Error: keytool is required for --mode java-tool-options (provided by any JDK)." >&2 + echo " Debian/Ubuntu: sudo apt-get install -y default-jdk-headless" >&2 + echo " RHEL/Fedora: sudo dnf install -y java-21-openjdk-headless" >&2 + echo " Manual JDK: add \$JAVA_HOME/bin to PATH (or symlink keytool into /usr/local/bin)." >&2 + exit 1 + fi +} + +ensure_kv_in_environment_file() { + local key="$1" value="$2" tmp escaped + + touch "$ENVIRONMENT_FILE" + + escaped="${value//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + + if grep -qE "^${key}=" "$ENVIRONMENT_FILE" 2>/dev/null; then + # Same-directory mktemp keeps `mv` atomic and avoids cross-fs truncation risk. + tmp=$(mktemp -p "$(dirname "$ENVIRONMENT_FILE")") + awk -v k="$key" -v v="$escaped" ' + $0 ~ "^" k "=" { print k "=\"" v "\""; next } + { print } + ' "$ENVIRONMENT_FILE" > "$tmp" + if [[ ! -s "$tmp" && -s "$ENVIRONMENT_FILE" ]]; then + rm -f "$tmp" + echo "Error: awk produced empty output; refusing to overwrite $ENVIRONMENT_FILE" >&2 + exit 1 + fi + mv "$tmp" "$ENVIRONMENT_FILE" + else + printf '%s="%s"\n' "$key" "$escaped" >> "$ENVIRONMENT_FILE" + fi + + chmod 0644 "$ENVIRONMENT_FILE" +} + +update_user_shell_rc() { + local jto_value="$1" + local target_user user_home user_shell rc_file chown_err + + target_user="$(get_target_user)" + if [[ -z "$target_user" || "$target_user" == "root" ]]; then + echo " [warn] Per-user rc not updated: could not determine non-root target user." >&2 + echo " Run with sudo as the developer user (or set SUDO_USER) so the current shell" >&2 + echo " session picks up JAVA_TOOL_OPTIONS without re-login. /etc/environment is" >&2 + echo " written either way and will reach new login sessions." >&2 + return 0 + fi + + user_home="$(get_user_home "$target_user")" + if [[ -z "$user_home" || ! -d "$user_home" ]]; then + echo " [warn] Per-user rc not updated: home not found for $target_user." >&2 + return 0 + fi + + user_shell="$(get_user_shell "$target_user")" + case "$user_shell" in + */zsh) rc_file="$user_home/.zshrc" ;; + *) rc_file="$user_home/.bashrc" ;; + esac + + touch "$rc_file" + ensure_export_in_file "$rc_file" "JAVA_TOOL_OPTIONS" "$jto_value" + if ! chown_err="$(chown "$target_user":"$target_user" "$rc_file" 2>&1)"; then + echo " [warn] chown failed on $rc_file: $chown_err" >&2 + fi + + echo " Updated $rc_file" + RC_UPDATED=1 +} + +install_via_jto() { + require_keytool + + local src_cacerts + src_cacerts="$(find_jdk_cacerts)" + echo "[1/4] Building JKS truststore at $JKS_PATH (extending $src_cacerts)..." + mkdir -p "$JKS_DIR" + chmod 0755 "$JKS_DIR" + # Copy the JDK's bundled cacerts (~150 public root CAs) as the base so the + # merged store keeps trusting Maven Central, Let's Encrypt, etc. Then + # keytool -importcert appends the corporate CA next to them. No -storetype + # flag: modern JDKs default cacerts to PKCS12 and keytool autodetects. + cp "$src_cacerts" "$JKS_PATH" + chmod 0644 "$JKS_PATH" + keytool -importcert -noprompt \ + -alias "$CERT_BASENAME" \ + -file "$USE_CERT" \ + -keystore "$JKS_PATH" \ + -storepass "$JKS_PASSWORD" >/dev/null + + # Note on inner quoting: /etc/environment uses NAME="VALUE" format and + # treats backslashes literally — there's no way to embed `"` inside the + # value without breaking the parser. The JKS_PATH and JKS_PASSWORD are + # space-free by construction (regex on --cert-name enforces no spaces; + # JKS_DIR is /etc/ssl/package-route-jvm). Adding inner quotes would + # require an alternate persistence format and is not required for the + # current spec. The Windows sibling can quote because HKCU\Environment + # is a registry REG_SZ that stores the value byte-for-byte; macOS plist + # XML has its own escaping. The bash branch deliberately stays unquoted. + local jto_value="-Djavax.net.ssl.trustStore=${JKS_PATH} -Djavax.net.ssl.trustStorePassword=${JKS_PASSWORD}" + + echo "[2/4] Writing JAVA_TOOL_OPTIONS to $ENVIRONMENT_FILE..." + ensure_kv_in_environment_file "JAVA_TOOL_OPTIONS" "$jto_value" + + echo "[3/4] Updating target user's shell rc file..." + update_user_shell_rc "$jto_value" + + echo "[4/4] Done." + echo + echo "Truststore:" + echo " $JKS_PATH (alias: $CERT_BASENAME)" + echo "JAVA_TOOL_OPTIONS:" + echo " $jto_value" + echo + echo "Notes:" + echo " - Log out and log back in for GDM/KDM-launched IDEs to pick up JAVA_TOOL_OPTIONS." + echo " - Run 'gradle --stop' to refresh the Gradle Daemon if one was already running." + echo " - The 'Picked up JAVA_TOOL_OPTIONS:' banner on stderr is expected." + + if [[ "$RC_UPDATED" -eq 0 ]]; then + echo + echo "WARNING: per-user shell rc was NOT updated; existing shells of the developer user" + echo " will not see JAVA_TOOL_OPTIONS until they log out and back in (or source" + echo " /etc/environment manually). The system-wide change in $ENVIRONMENT_FILE" + echo " takes effect on the next fresh login." >&2 + fi +} + +main() { + require_root + parse_args "$@" + check_os + check_dependencies + validate_pem "$USE_CERT" + + local mode="$MODE_OPT" + if [[ "$mode" == "auto" ]]; then + mode="$(detect_mode)" + echo "Auto-detected mode: $mode" + else + echo "Mode (forced via --mode): $mode" + fi + + case "$mode" in + update-ca-trust) install_via_update_ca_trust ;; + java-tool-options) install_via_jto ;; + *) + echo "Error: unexpected mode after detection: $mode" >&2 + exit 1 + ;; + esac +} + +main "$@" diff --git a/testing/test_install_certs_jvm_linux.sh b/testing/test_install_certs_jvm_linux.sh new file mode 100755 index 0000000..296e1b6 --- /dev/null +++ b/testing/test_install_certs_jvm_linux.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Docker-driven smoke test matrix for install_certs_jvm_linux.sh and +# validate_certs_jvm_linux.sh. Runs the same internal probe script across +# four distro x JDK combinations in parallel and reports per-image status. +# +# Run from the repo root: +# ./testing/test_install_certs_jvm_linux.sh +# +# Each container: +# 1. installs JDK + openssl +# 2. mints a self-signed lab CA (CA:TRUE, 7-day validity) +# 3. creates a non-root devx user (so update_user_shell_rc has a target) +# 4. runs install_certs_jvm_linux.sh + validate_certs_jvm_linux.sh +# 5. exercises 7 additional invariants: +# - subject mismatch must exit 1 +# - idempotent re-install (no duplicate lines / aliases) +# - custom --cert-name round-trips through validator +# - path-traversal --cert-name is rejected +# - malformed PEM is rejected +# - expired CA is rejected +# - leaf cert (CA:FALSE) is rejected +# +# Exit 0 iff every container reports ALL SMOKE TESTS PASSED. + +set -euo pipefail + +if ! command -v docker >/dev/null 2>&1; then + echo "Error: docker is required to run this test matrix." >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PROBE="$(mktemp)" +trap 'rm -f "$PROBE"' EXIT +cat > "$PROBE" <<'PROBE_EOF' +#!/usr/bin/env bash +set -euo pipefail +fail() { echo "BUG: $1" >&2; exit 1; } + +# I5: surface openssl version per-container so a future image bump that +# drops openssl below 3.2 doesn't silently turn the expired-CA assertion +# into a SKIP that nobody notices. The expired-CA case uses -not_before/ +# -not_after, which require OpenSSL 3.2+. +openssl_version="$(openssl version)" +echo "openssl: $openssl_version" +case "$openssl_version" in + OpenSSL\ [01]*|OpenSSL\ 2*|OpenSSL\ 3.0*|OpenSSL\ 3.1*) + echo " [warn] openssl < 3.2 -- expired-CA assertion will fall back to -days -1 path or SKIP" >&2 + ;; +esac + +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/k.pem -out /tmp/ca.pem -days 7 \ + -subj "/CN=Lab JVM CA Final/O=JFrog" \ + -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null + +useradd -m -s /bin/bash devx >/dev/null 2>&1 || true + +echo "=== positive: install + validate ===" +SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem >/dev/null +./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" >/dev/null +echo " ok" + +echo "=== negative: subject mismatch must exit 1 ===" +if ./validate_certs_jvm_linux.sh --expected-subject "Microsoft Root CA NoMatch" >/dev/null 2>&1; then + fail "validator should have exited 1 on subject mismatch" +fi +echo " ok" + +echo "=== idempotency: 2nd install must produce same final state ===" +SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem >/dev/null +./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" >/dev/null + +# I4: assert -eq 1 (not -le 1). A regression that silently DROPS the line +# from /etc/environment would slip past `-le 1` with 0; the env var should +# always be present after a successful install in Path B mode. +# On Path A the env var should NOT be set at all (0 lines expected); the +# distinction is per-distro since detect_mode picks the path. +env_lines=0 +[[ -f /etc/environment ]] && env_lines=$(grep -c '^JAVA_TOOL_OPTIONS=' /etc/environment 2>/dev/null || true) +env_lines=${env_lines:-0} +rc_lines=0 +[[ -f /home/devx/.bashrc ]] && rc_lines=$(grep -c '^export JAVA_TOOL_OPTIONS=' /home/devx/.bashrc 2>/dev/null || true) +rc_lines=${rc_lines:-0} + +# Determine which path the installer took by checking which artifact exists. +if [[ -f /etc/ssl/package-route-jvm/truststore.jks ]]; then + # Path B: JKS exists, env vars must be exactly 1 + [[ "$env_lines" -eq 1 ]] || fail "/etc/environment has $env_lines JAVA_TOOL_OPTIONS lines (expected 1 on Path B)" + [[ "$rc_lines" -eq 1 ]] || fail "/home/devx/.bashrc has $rc_lines export lines (expected 1 on Path B)" + + # Path B JKS contains the JDK's default cacerts (~150 public roots) PLUS + # exactly one corporate-CA alias. After two installs, the corporate alias + # count must be exactly 1 — the JDK aliases stay constant. + corp_alias_count="$(keytool -list -keystore /etc/ssl/package-route-jvm/truststore.jks -storepass changeit 2>/dev/null | grep -cE '^package-route-custom-ca[,[:space:]]' || true)" + corp_alias_count="${corp_alias_count:-0}" + [[ "$corp_alias_count" -eq 1 ]] || fail "JKS corporate-CA alias count after 2nd install: $corp_alias_count (expected 1)" +else + # Path A: no env vars, anchor file must exist and be exactly 1 file with our basename + [[ "$env_lines" -eq 0 ]] || fail "Path A leaked JAVA_TOOL_OPTIONS to /etc/environment ($env_lines lines)" + anchor_count="$(ls -1 /etc/pki/ca-trust/source/anchors/package-route-custom-ca.crt 2>/dev/null | wc -l | tr -d ' ')" + [[ "$anchor_count" -eq 1 ]] || fail "Path A anchor count: $anchor_count (expected 1)" +fi +echo " ok (env=$env_lines rc=$rc_lines)" + +# I1: JTO replaces-not-appends sentinel. Pre-seed a junk value in +# /etc/environment, run installer, assert the junk is gone (env var was +# REPLACED, not concatenated). Only applies when detect_mode picks Path B. +if [[ -f /etc/ssl/package-route-jvm/truststore.jks ]]; then + echo "=== JTO env var REPLACES (not appends) on re-install ===" + sed -i '/^JAVA_TOOL_OPTIONS=/d' /etc/environment + echo 'JAVA_TOOL_OPTIONS="-Dpackage-reroute-test-sentinel=must-be-replaced"' >> /etc/environment + SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem >/dev/null + if grep -q 'package-reroute-test-sentinel' /etc/environment; then + fail "JTO env var was APPENDED to (sentinel survived). Re-install must replace." + fi + echo " ok" +fi + +echo "=== --cert-name custom: alias and basename honor flag ===" +SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --cert-name zscaler-root >/dev/null +./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" --cert-name zscaler-root >/dev/null +echo " ok" + +echo "=== negative: --cert-name with bad chars must exit 1 ===" +if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --cert-name '../etc/pwned' >/dev/null 2>&1; then + fail "installer should have rejected path-traversal --cert-name" +fi +echo " ok" + +echo "=== negative: malformed PEM must exit 1 ===" +echo "not a certificate" > /tmp/bad.pem +if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/bad.pem >/dev/null 2>&1; then + fail "installer should have rejected malformed PEM" +fi +echo " ok" + +echo "=== negative: expired CA must exit 1 ===" +# Generate a cert with explicit past not_before / not_after when openssl supports it, +# otherwise fall back to negative -days. Verify the result is actually expired before +# running the assertion — some openssl 3.x builds treat -days -1 as 1 day in the future. +rm -f /tmp/old-ca.pem +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/old-k.pem -out /tmp/old-ca.pem \ + -subj "/CN=Expired CA" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -not_before 20200101000000Z -not_after 20200201000000Z 2>/dev/null \ +|| openssl x509 -req -in <(openssl req -new -key /tmp/k.pem -subj "/CN=Expired") \ + -signkey /tmp/k.pem -days -1 -out /tmp/old-ca.pem 2>/dev/null \ +|| true +if [[ ! -f /tmp/old-ca.pem ]] || openssl x509 -in /tmp/old-ca.pem -checkend 0 -noout >/dev/null 2>&1; then + echo " SKIP: cannot produce a verifiably expired cert with the installed openssl ($(openssl version))" +else + if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/old-ca.pem >/dev/null 2>&1; then + fail "installer should have rejected expired CA" + fi + echo " ok" +fi + +echo "=== negative: leaf cert (not CA) must exit 1 ===" +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/leaf-k.pem -out /tmp/leaf.pem -days 7 \ + -subj "/CN=Leaf Not CA" \ + -addext "basicConstraints=critical,CA:FALSE" 2>/dev/null +if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/leaf.pem >/dev/null 2>&1; then + fail "installer should have rejected leaf cert (CA:FALSE)" +fi +echo " ok" + +echo "=== forced --mode update-ca-trust ===" +if command -v update-ca-trust >/dev/null 2>&1; then + # Reset Path B state so we can exercise Path A cleanly. + rm -rf /etc/ssl/package-route-jvm + sed -i '/^JAVA_TOOL_OPTIONS=/d' /etc/environment 2>/dev/null || true + sed -i '/^export JAVA_TOOL_OPTIONS=/d' /home/devx/.bashrc 2>/dev/null || true + + SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --mode update-ca-trust >/dev/null + [[ -f /etc/pki/ca-trust/source/anchors/package-route-custom-ca.crt ]] \ + || fail "Path A anchor file not created" + keytool -list -keystore /etc/pki/ca-trust/extracted/java/cacerts -storepass changeit 2>/dev/null \ + | grep -q "$(openssl x509 -in /tmp/ca.pem -noout -fingerprint -sha256 | sed 's/.*=//')" \ + || fail "Path A: CA fingerprint not visible in system Java cacerts" + # /etc/environment must NOT have JAVA_TOOL_OPTIONS on Path A. + if [[ -f /etc/environment ]] && grep -q '^JAVA_TOOL_OPTIONS=' /etc/environment; then + fail "Path A leaked JAVA_TOOL_OPTIONS into /etc/environment" + fi + ./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" >/dev/null + echo " ok" +else + echo " SKIP: update-ca-trust not installed on this distro" +fi + +echo "=== regression: dual-artifact validator must not silent-pass ===" +# Reproduce the iteration-1 fix's regression: both Path A anchor and Path B JKS +# present at once. detect_mode previously leaked warn-stdout into command +# substitution and exited 0 with NO checks run. +if command -v update-ca-trust >/dev/null 2>&1; then + # Both artifacts already exist from Path A run; force Path B side too. + SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --mode java-tool-options >/dev/null + [[ -f /etc/ssl/package-route-jvm/truststore.jks ]] || fail "Path B JKS not created" + [[ -f /etc/pki/ca-trust/source/anchors/package-route-custom-ca.crt ]] || fail "Path A anchor missing" + + # Validator must NOT print 'All checks passed' silently — it must actually run + # the Path B checks (positive subject match against the JKS). + out="$(./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" 2>&1)" + grep -q 'Validating Path B' <<<"$out" || fail "validator did not run Path B checks: $out" + grep -q 'All checks passed' <<<"$out" || fail "validator did not report success: $out" + echo " ok" +else + echo " SKIP: cannot construct dual-artifact state without update-ca-trust" +fi + +echo "=== validate_pem 30-day-expiry warn fires (1-day cert) ===" +# I2 backport from macOS/Windows: assert the warn is emitted but install succeeds. +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/soon-k.pem -out /tmp/soon-ca.pem -days 1 \ + -subj "/CN=Soon to Expire/O=JFrog" \ + -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null +warn_out="$(SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/soon-ca.pem 2>&1)" +if ! echo "$warn_out" | grep -q "certificate expires within 30 days"; then + echo "$warn_out" | tail -10 + fail "30-day expiry warn not emitted" +fi +echo " ok" + +echo "=== validate_pem multi-cert bundle warn fires ===" +# I2 backport: concat two certs, assert the warn is emitted but install succeeds. +cat /tmp/ca.pem /tmp/soon-ca.pem > /tmp/bundle.pem +warn_out="$(SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/bundle.pem 2>&1)" +if ! echo "$warn_out" | grep -qE "PEM file contains [0-9]+ certificates"; then + echo "$warn_out" | tail -10 + fail "multi-cert bundle warn not emitted" +fi +echo " ok" + +echo "=== negative: missing keytool fails cleanly (Path B) ===" +# I3 backport from Windows: temporarily hide every keytool on PATH (incl. the +# JDK-installed one symlinked via update-alternatives), assert the installer +# exits non-zero with a message mentioning keytool. Path A is keytool-optional +# so we only exercise Path B (--mode java-tool-options). +keytool_paths=() +while IFS= read -r p; do keytool_paths+=("$p"); done < <(command -v keytool 2>/dev/null; type -ap keytool 2>/dev/null | sort -u) +for p in "${keytool_paths[@]}"; do mv "$p" "$p.hidden_for_test" 2>/dev/null || true; done +trap 'for p in "${keytool_paths[@]}"; do [[ -f "$p.hidden_for_test" ]] && mv "$p.hidden_for_test" "$p" 2>/dev/null || true; done' EXIT +if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --mode java-tool-options >/tmp/nokey.out 2>&1; then + cat /tmp/nokey.out | head -10 + fail "installer should have rejected missing keytool" +fi +if ! grep -q -i keytool /tmp/nokey.out; then + cat /tmp/nokey.out | head -10 + fail "missing-keytool error message should mention 'keytool'" +fi +# Restore now (and clear the EXIT trap) so subsequent tests can use keytool. +for p in "${keytool_paths[@]}"; do [[ -f "$p.hidden_for_test" ]] && mv "$p.hidden_for_test" "$p" 2>/dev/null || true; done +trap - EXIT +echo " ok" + +echo "=== negative: DER cert rejected (C1 cross-platform parity) ===" +# C1 backport: convert the lab CA to DER, then attempt install — should +# fail with a hint to convert back. Mirrors Windows behavior (which now +# also rejects DER for cross-platform symmetry). +openssl x509 -in /tmp/ca.pem -outform DER -out /tmp/ca.der 2>/dev/null +if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.der >/dev/null 2>&1; then + fail "installer should have rejected DER-encoded cert" +fi +echo " ok" + +echo "=== Path B: JKS extends default cacerts (preserves public roots) ===" +# Bug 2 regression guard. -Djavax.net.ssl.trustStore in OpenJDK REPLACES the +# JVM trust source (it does not merge with $JAVA_HOME/lib/security/cacerts). +# We must therefore ship a JKS that already contains the JDK's ~150 public +# roots PLUS the corporate CA. A future change that re-shrinks the truststore +# to just the corporate CA would break every public-CA TLS handshake. +if [[ -f /etc/ssl/package-route-jvm/truststore.jks ]]; then + # Re-build via Path B so we measure the post-fix state. + SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --mode java-tool-options >/dev/null + alias_count=$(keytool -list -keystore /etc/ssl/package-route-jvm/truststore.jks \ + -storepass changeit 2>/dev/null | grep -c 'trustedCertEntry' || true) + alias_count=${alias_count:-0} + [[ "$alias_count" -ge 100 ]] || fail "Path B truststore has $alias_count aliases; expected >= 100 (JDK cacerts ~150 public roots + corporate CA)" + keytool -list -keystore /etc/ssl/package-route-jvm/truststore.jks -storepass changeit 2>/dev/null \ + | grep -qi 'digicert' \ + || fail "Path B truststore is missing the DigiCert family of public roots; the copy-from-JDK step did not run" + echo " ok ($alias_count aliases, DigiCert present)" +else + echo " SKIP: Path B not exercised on this distro (Path A only)" +fi + +echo "=== ALL SMOKE TESTS PASSED ===" +PROBE_EOF +chmod +x "$PROBE" + +# distro_id|image|setup_command +MATRIX=( + "ubuntu|ubuntu:22.04|export DEBIAN_FRONTEND=noninteractive; apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl ca-certificates default-jdk-headless >/dev/null" + "debian|debian:12|export DEBIAN_FRONTEND=noninteractive; apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl ca-certificates default-jdk-headless >/dev/null" + "rhel|redhat/ubi9:latest|dnf install -y -q java-21-openjdk-headless openssl shadow-utils >/dev/null" + "amazonlinux|amazonlinux:2023|dnf install -y -q java-21-amazon-corretto-headless openssl shadow-utils >/dev/null" +) + +LOG_DIR="$(mktemp -d)" +trap 'rm -f "$PROBE"; rm -rf "$LOG_DIR"' EXIT + +pids=() +labels=() + +for entry in "${MATRIX[@]}"; do + distro="${entry%%|*}" + rest="${entry#*|}" + image="${rest%%|*}" + setup="${rest#*|}" + + log="${LOG_DIR}/${distro}.log" + ( + docker run --rm \ + -v "${REPO_ROOT}":/lab \ + -v "${PROBE}":/probe.sh:ro \ + -w /lab "$image" bash -c "set -e; ${setup}; bash /probe.sh" + ) >"$log" 2>&1 & + pids+=("$!") + labels+=("$distro ($image)") + echo "[launched] $distro ($image) -> $log" +done + +overall_rc=0 +for i in "${!pids[@]}"; do + if wait "${pids[$i]}"; then + echo "[PASS] ${labels[$i]}" + else + echo "[FAIL] ${labels[$i]} — last 30 lines:" + tail -30 "${LOG_DIR}/${labels[$i]%% *}.log" | sed 's/^/ /' + overall_rc=1 + fi +done + +if [[ "$overall_rc" -eq 0 ]]; then + echo "All distros passed." +else + echo "One or more distros failed." +fi + +exit "$overall_rc" diff --git a/validate_certs_jvm_linux.sh b/validate_certs_jvm_linux.sh new file mode 100755 index 0000000..cc5d22c --- /dev/null +++ b/validate_certs_jvm_linux.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Validate JVM truststore installation done by install_certs_jvm_linux.sh. +# +# Detects which path the installer used (RHEL update-ca-trust vs JKS + +# JAVA_TOOL_OPTIONS), then asserts: +# 1. Anchor file (Path A) OR JKS truststore (Path B) exists +# 2. Customer CA subject matches --expected-subject (substring, case-insensitive) +# 3. (Path B) /etc/environment contains JAVA_TOOL_OPTIONS pointing at the JKS +# 4. (Path B) Current user's shell rc files reference the same value (WARN if missing) +# 5. Current process inherited JAVA_TOOL_OPTIONS (HINT if missing — open a new shell) +# +# Run: +# bash validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" +# sudo bash validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" --all-users +# +# Pass the same --cert-name that was used during install if it was non-default. +# +# Exit 0 = all checks passed (warnings tolerated). +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# validate_certs_jvm_macos.sh — LaunchAgent + launchctl getenv check +# validate_certs_jvm_windows.ps1 — HKCU\Environment + Get-JavaToolOptions check +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ ! -f "${SCRIPT_DIR}/_jvm_linux_paths.sh" ]]; then + echo "Error: _jvm_linux_paths.sh not found next to this validator (${SCRIPT_DIR})." >&2 + echo " Invoke as ./validate_certs_jvm_linux.sh — not via 'curl | bash'." >&2 + exit 1 +fi +# shellcheck disable=SC1091 +. "${SCRIPT_DIR}/_jvm_linux_paths.sh" + +ALL_USERS=0 +EXPECTED_SUBJECT="" +CERT_BASENAME="${JVM_LINUX_DEFAULT_CERT_BASENAME}" + +usage() { + cat < [--cert-name ] [--all-users] + +Options: + --expected-subject Required. Case-insensitive substring match against the cert subject. + --cert-name Base name used at install (default: ${CERT_BASENAME}). + Must match the installer's --cert-name when non-default. + --all-users For Path B (JKS+JTO), validate /home/* users' rc files (requires root). + -h, --help Show this help + +Exits 0 if all checks pass, 1 if any check fails. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --all-users) + ALL_USERS=1 + shift + ;; + --expected-subject) + EXPECTED_SUBJECT="${2:?Error: --expected-subject requires a value}" + shift 2 + ;; + --cert-name) + CERT_BASENAME="${2:?Error: --cert-name requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$EXPECTED_SUBJECT" ]]; then + echo "Error: --expected-subject is required." >&2 + usage >&2 + exit 1 + fi + + if [[ -z "$CERT_BASENAME" ]]; then + echo "Error: --cert-name cannot be empty." >&2 + exit 1 + fi + + # Symmetrical with installer: same regex constraint so the file path resolves cleanly. + if [[ ! "$CERT_BASENAME" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Error: --cert-name must match [A-Za-z0-9._-]+ (got: $CERT_BASENAME)." >&2 + exit 1 + fi + + if [[ "$ALL_USERS" -eq 1 && "$(id -u)" -ne 0 ]]; then + echo "Error: --all-users requires root. Use: sudo $0 --all-users --expected-subject ..." >&2 + exit 1 + fi +} + +FAIL=0 +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } +ok() { echo " OK: $1"; } +warn() { echo " WARN: $1"; } +skip() { echo " SKIP: $1"; } + +# Stdout contract: exactly one of {java-tool-options, update-ca-trust, none}. +# Callers MUST equality-match. Keep in sync with installer's detect_mode. +detect_mode() { + local anchor="${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt" + local jks_present=0 anchor_present=0 + [[ -f "$JKS_PATH" ]] && jks_present=1 + [[ -f "$anchor" ]] && anchor_present=1 + + if [[ "$jks_present" -eq 1 && "$anchor_present" -eq 1 ]]; then + # detect_mode is consumed via $(...), so anything on stdout that is + # NOT the mode token leaks into the caller's $mode and breaks the case + # dispatch. Route the whole warn block to stderr. + { + warn "Both Path A and Path B artifacts exist on disk:" + warn " $JKS_PATH" + warn " $anchor" + warn "Reporting Path B (more recent mode preferred). Clean up the unused path manually." + } >&2 + echo "java-tool-options" + return + fi + + if [[ "$jks_present" -eq 1 ]]; then + echo "java-tool-options" + return + fi + if [[ "$anchor_present" -eq 1 ]]; then + echo "update-ca-trust" + return + fi + echo "none" +} + +validate_pem_subject() { + local path="$1" + if [[ ! -f "$path" ]]; then + fail "file does not exist: $path" + return 1 + fi + if ! openssl x509 -in "$path" -noout >/dev/null 2>&1; then + fail "not a valid PEM certificate: $path" + return 1 + fi + local subject + subject="$(openssl x509 -in "$path" -noout -subject 2>/dev/null)" + if echo "$subject" | grep -qi "$EXPECTED_SUBJECT"; then + ok "PEM at $path has subject matching: $EXPECTED_SUBJECT" + return 0 + fi + fail "PEM at $path subject does not match '$EXPECTED_SUBJECT' (got: $subject)" + return 1 +} + +validate_keystore_contains_subject() { + local keystore="$1" storepass="$2" label="$3" + if [[ ! -f "$keystore" ]]; then + fail "$label keystore does not exist: $keystore" + return 1 + fi + if ! command -v keytool >/dev/null 2>&1; then + # Promoting to FAIL: the keystore-subject check is the validator's + # core assertion; silently passing here would mean CI is green even + # though we never verified the cert is in the store. + fail "$label keystore present but keytool is not on PATH; cannot verify subject. Install a JDK." + return 1 + fi + + # Capture keytool output explicitly so a real keytool error (wrong password, + # corrupt store, version mismatch) is reported as such — and not silently + # masked as a "subject not found" via pipefail+|| true on the pipeline. + local keytool_output rc=0 + if ! keytool_output="$(keytool -list -v -keystore "$keystore" -storepass "$storepass" 2>&1)"; then + fail "$label: keytool could not read the keystore. Output (first 3 lines):" + printf '%s\n' "$keytool_output" | head -n3 | sed 's/^/ /' + return 1 + fi + + # Two-stage filter via a variable, not a pipe-pair: under `set -o pipefail` + # the second `grep -qi` exits early on match and SIGPIPEs the first, which + # poisons the pipeline status and turns positive matches into false negatives. + local owners + owners="$(printf '%s\n' "$keytool_output" | grep -E '^Owner:' || true)" + if ! grep -qi "$EXPECTED_SUBJECT" <<<"$owners"; then + fail "$label has no cert with subject matching: $EXPECTED_SUBJECT" + return 1 + fi + + # I8: defence-in-depth — assert every alias is a `trustedCertEntry`. + # The installer only calls `keytool -importcert`, which creates + # trustedCertEntry records. A `PrivateKeyEntry` showing up here would mean + # someone (or a compromised future installer change) imported a keypair + # into this store — the password protecting that key would then be + # `changeit`, which is the well-known JDK default and unsuitable for + # private-key material. Refuse to validate such a store. + if printf '%s\n' "$keytool_output" | grep -qE '^Entry type: PrivateKeyEntry'; then + fail "$label contains a PrivateKeyEntry — this truststore must hold only trustedCertEntry records." + return 1 + fi + ok "$label contains cert with subject matching: $EXPECTED_SUBJECT" + return 0 +} + +validate_export_in_file() { + local file="$1" label="$2" + if [[ ! -f "$file" ]]; then + skip "$label not present: $file" + return 0 + fi + if [[ ! -r "$file" ]]; then + warn "$label not readable by current user; re-run as that user or with sudo --all-users." + return 0 + fi + # Accept either quoted or unquoted trustStore=. Installer now writes + # the quoted form (cross-platform symmetry with macOS/Windows); older + # installs may carry the unquoted form. Both are valid JTO values. + if grep -qE "^export JAVA_TOOL_OPTIONS=.*trustStore=\"?${JKS_PATH}\"?" "$file" 2>/dev/null; then + ok "$label contains JAVA_TOOL_OPTIONS pointing at $JKS_PATH" + return 0 + fi + if grep -qE '^export JAVA_TOOL_OPTIONS=' "$file" 2>/dev/null; then + # Wrong-target export is a real problem: it overrides /etc/environment. + fail "$label has JAVA_TOOL_OPTIONS but it does not point at $JKS_PATH" + return 1 + fi + # Missing export in user rc is not authoritative — /etc/environment is the source of truth. + warn "$label has no JAVA_TOOL_OPTIONS export (current session may need re-login or 'source $file')" + return 0 +} + +validate_environment_file() { + if [[ ! -f "$ENVIRONMENT_FILE" ]]; then + fail "$ENVIRONMENT_FILE does not exist" + return 1 + fi + # Accept either quoted or unquoted trustStore=; see validate_export_in_file. + if grep -qE "^JAVA_TOOL_OPTIONS=.*trustStore=\"?${JKS_PATH}\"?" "$ENVIRONMENT_FILE" 2>/dev/null; then + ok "$ENVIRONMENT_FILE contains JAVA_TOOL_OPTIONS pointing at $JKS_PATH" + return 0 + fi + fail "$ENVIRONMENT_FILE has no JAVA_TOOL_OPTIONS pointing at $JKS_PATH" + return 1 +} + +validate_path_a() { + echo "Validating Path A (RHEL update-ca-trust)..." + local anchor="${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt" + validate_pem_subject "$anchor" || true + validate_keystore_contains_subject "$RHEL_JAVA_CACERTS" "$JKS_PASSWORD" "Java cacerts" || true +} + +validate_path_b() { + echo "Validating Path B (JKS + JAVA_TOOL_OPTIONS)..." + validate_keystore_contains_subject "$JKS_PATH" "$JKS_PASSWORD" "Truststore $JKS_PATH" || true + validate_environment_file || true + + if [[ "$ALL_USERS" -eq 1 ]]; then + local homedir user + for homedir in /home/*; do + [[ -d "$homedir" ]] || continue + user="$(basename "$homedir")" + echo " Checking user $user ..." + validate_export_in_file "$homedir/.bashrc" "$user .bashrc" || true + validate_export_in_file "$homedir/.zshrc" "$user .zshrc" || true + done + else + echo " Checking current user ..." + [[ -z "${HOME:-}" ]] && HOME=$(eval echo "~") + validate_export_in_file "$HOME/.bashrc" "current user .bashrc" || true + validate_export_in_file "$HOME/.zshrc" "current user .zshrc" || true + fi + + # Current-process env hint: the most common "validator passed but + # Maven still fails TLS" support ticket. /etc/environment is the + # authoritative source for new login sessions, but the SHELL THIS + # VALIDATOR IS RUNNING IN inherits its env at startup. If the user + # ran install -> validate in the same shell, the env var won't be set. + if [[ -z "${JAVA_TOOL_OPTIONS:-}" ]]; then + echo " HINT: JAVA_TOOL_OPTIONS is NOT set in this shell. Open a new" + echo " login shell, or 'source $ENVIRONMENT_FILE', then re-run" + echo " the build. The next 'mvn'/'gradle' won't see the trust" + echo " store until then." + fi +} + +main() { + parse_args "$@" + + echo "Expected subject (case-insensitive substring): $EXPECTED_SUBJECT" + echo + + local mode + mode="$(detect_mode)" + echo "Detected installer path: $mode" + echo + + case "$mode" in + update-ca-trust) validate_path_a ;; + java-tool-options) validate_path_b ;; + none) + echo "Error: no install_certs_jvm_linux.sh artifacts found for cert-name '$CERT_BASENAME'." >&2 + echo " Expected one of:" >&2 + echo " $JKS_PATH (Path B)" >&2 + echo " ${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt (Path A)" >&2 + echo " If you installed with a non-default --cert-name, pass --cert-name here too." >&2 + exit 1 + ;; + esac + + echo "---------------------------------------------------" + if [[ "$FAIL" -eq 0 ]]; then + echo "Result: All checks passed." + exit 0 + else + echo "Result: $FAIL check(s) failed." + exit 1 + fi +} + +main "$@" From b2cc3f6525600fbaaf4688bdbf01aaf1f946ad2e Mon Sep 17 00:00:00 2001 From: Michael Akushsky Date: Tue, 9 Jun 2026 15:49:25 +0300 Subject: [PATCH 2/6] DFLOW-152 - JVM client setup (macOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a macOS client-side installer + validator that wires a corporate CA into the JVM trust path so Maven / Gradle / sbt / Apache Ivy traffic redirected through package-reroute validates correctly. JVM trust only — does not configure Node/npm/Python and does not touch Docker credentials. Pair with install_certs_macos.sh if you need those flows. Based on the published research wiki (DFLOW-136): https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931 Sibling to DFLOW-150 (Linux). Single path on macOS — there is no update-ca-trust fork because KeychainStore is broken per JDK-8321045: 1. Build a per-user JKS truststore at ~/Library/Application Support/JFrog/package-route-jvm/truststore.jks 2. Write a per-user LaunchAgent at ~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist calling `launchctl setenv JAVA_TOOL_OPTIONS=…` at RunAtLoad 3. Bootstrap the agent into `gui/` so Dock-launched IDEs (IntelliJ, JetBrains Toolbox, `open -a …`) inherit the env var The ~/.zshrc / ~/.bash_profile shortcut is deliberately NOT touched — verified in the research wiki to silently fail for GUI-launched IDE builds. Files: install_certs_jvm_macos.sh Installer (~540 lines). validate_certs_jvm_macos.sh Companion validator. _jvm_macos_paths.sh Shared constants sourced by both. testing/test_install_certs_jvm_macos.sh 12-invariant smoke runner. .github/workflows/ci.yml New test-macos-jvm job. README.md New "macOS (JVM)" section. Hardening fixes folded in from a round of pr-review-toolkit review (5 agents, 21 findings: 5 Critical + 13 Important + 3 Minor): Critical: - validate_pem leaf-cert (CA:FALSE) check now works on stock macOS LibreSSL. The previous `openssl x509 -ext` flag is OpenSSL 3.x-only; LibreSSL silently failed → leaf certs accepted as trust anchors. New path parses `-noout -text` output (portable across both). - chown failure capture at both call sites (JKS dir + plist). Under --all-users a silent chown would leave root-owned plists in a user's ~/Library/LaunchAgents/, which launchd silently refuses to load — exactly the phantom-success the project bans. - --cert-name semantics clarified in constants file + --help. The previous "LaunchAgent label suffix" claim was wishful thinking; the flag is alias-cosmetic only. - Test runner docstring rewritten (cleanup runs only on positive cases) and the iter_all_users "mirrors install_certs_macos.sh" claim corrected (it's a stricter filter, not a mirror). Important: - get_single_target_user filter+order matches install_certs_macos.sh (SUDO_USER → /dev/console → logname; rejects loginwindow pseudo-user). - get_user_home eval-on-username fallback replaced with dscacheutil. - bootstrap_launch_agent retry bumped 5×100ms → 20×100ms to survive EDR exec latency; warn text no longer suggests a logout dance. - Validator --help documents the absence of --cert-name. - Validator final summary qualifies "All checks passed" with a WARN count so a green exit doesn't over-promise when launchctl getenv was skipped (no GUI session). - install_as_test_user / validate_as_test_user capture combined stdout/stderr to a tempfile; dump on UNEXPECTED exit only. - Smoke matrix gained 3 new invariants: --all-users iteration (#11), plist content validation via plutil -extract (#10), validate_pem warn-paths for 30-day-expiry and multi-cert bundle (#12). - Test #9 (launchctl getenv) now retries 20×100ms to mirror the installer. - CI workflow uses explicit `sudo env JAVA_HOME=… PATH=…` instead of --preserve-env, surviving future actions/setup-java renames. PATH includes /usr/sbin so chown is resolvable under sudo. Minor: - README requirements list names plutil/launchctl/dscl/stat. - README "five launch contexts" claim scoped to what the test actually verifies. - JKS_PASSWORD "not a secret" comment scoped to trustedCertEntry-only stores (PrivateKeyEntry would change the calculus). Smoke matrix: 12/12 green locally on dev Mac, 12/12 green on CI (Test (macOS JVM) job, macos-latest runner with Temurin 21). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 23 + README.md | 98 ++++ _jvm_macos_paths.sh | 42 ++ install_certs_jvm_macos.sh | 664 ++++++++++++++++++++++++ testing/test_install_certs_jvm_macos.sh | 430 +++++++++++++++ validate_certs_jvm_macos.sh | 327 ++++++++++++ 6 files changed, 1584 insertions(+) create mode 100644 _jvm_macos_paths.sh create mode 100755 install_certs_jvm_macos.sh create mode 100755 testing/test_install_certs_jvm_macos.sh create mode 100755 validate_certs_jvm_macos.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dc8d37..fe78001 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,29 @@ jobs: - name: Run macOS tests run: sudo ./testing/test_install_certs_macos.sh + test-macos-jvm: + name: Test (macOS JVM) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + # Pass JAVA_HOME and PATH explicitly to the sudo'd test runner rather + # than relying on `sudo --preserve-env=…`: future actions/setup-java + # versions could rename or rescope the JAVA_HOME env var, silently + # breaking the preserve-by-name approach. Explicit `env VAR=…` is + # the same idiom and survives such churn. + - name: Run macOS JVM smoke matrix + run: | + sudo env \ + JAVA_HOME="$JAVA_HOME" \ + PATH="$JAVA_HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + ./testing/test_install_certs_jvm_macos.sh + test-windows: name: Test (Windows) runs-on: windows-latest diff --git a/README.md b/README.md index 5f43255..047a3ce 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ Reference: research wiki [Maven Support in package-reroute (DFLOW-136 / DFLOW-11 |--------|----------|---------| | **install_certs_macos.sh** | macOS | Install cert, set env vars (Node/Python), and clear Docker Hub credentials | | **validate_install_macos.sh** | macOS | Validate PEM and env config | +| **install_certs_jvm_macos.sh** | macOS (JVM) | Install CA for Maven/Gradle/sbt/Ivy: JKS + per-user LaunchAgent setting `JAVA_TOOL_OPTIONS` | +| **validate_certs_jvm_macos.sh** | macOS (JVM) | Validate JVM truststore install (JKS subject + plist + `launchctl getenv`) | +| **_jvm_macos_paths.sh** | macOS (JVM) | Shared constants sourced by both installer and validator. Not directly executable. | | **install_certs_debian_ubuntu.sh** | Debian/Ubuntu | Install cert into system trust + profile.d + user shell rc + Docker cleanup | | **validate_certs_debian_ubuntu.sh** | Debian/Ubuntu | Validate PEM and env config | | **install_certs_jvm_linux.sh** | Linux (JVM) | Install CA for Maven/Gradle/sbt/Ivy: RHEL family → `update-ca-trust extract` into system anchors; others → per-host JKS + `JAVA_TOOL_OPTIONS` in `/etc/environment` | @@ -319,6 +322,100 @@ Users must open a **new terminal** (or `source ~/.zshrc`) for the new environmen --- +## macOS (JVM): install_certs_jvm_macos.sh + +### Overview + +`install_certs_jvm_macos.sh` wires a custom CA certificate into the JVM trust path on macOS so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_macos.sh` if you need those. + +Single path on macOS — there is no OS-trust fallback because macOS-specific `KeychainStore` is broken per [JDK-8321045](https://bugs.openjdk.org/browse/JDK-8321045). The script: + +1. Builds a per-user JKS truststore at `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks` containing only the customer CA. +2. Writes a per-user LaunchAgent plist at `~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist` that calls `launchctl setenv JAVA_TOOL_OPTIONS=…` at `RunAtLoad`. +3. Bootstraps the agent into `gui/` via `launchctl bootstrap` so the env var becomes available to every subsequently-launched GUI process (Dock-launched IntelliJ, JetBrains Toolbox, `open -a …`). + +The `~/.zshrc` / `~/.bash_profile` shortcut is deliberately NOT used: it silently fails for Dock-launched IDE builds because GUI apps don't read the shell's interactive init. The LaunchAgent is the only recipe verified to reach Dock-launched and `open`-launched GUI applications, which inherit `JAVA_TOOL_OPTIONS` from the launchd `gui/` domain. Terminal sessions inherit transitively because Terminal.app itself is launchd-spawned. + +### Requirements + +- **macOS**. +- **Root** (`sudo`) — needed to chown per-user files and to bootstrap into other users' `gui/` domains under `--all-users`. +- **`openssl`** on `PATH` (ships with macOS as LibreSSL; full OpenSSL via Homebrew also works). +- **`keytool`** on `PATH` (provided by any JDK — Homebrew openjdk, Adoptium Temurin, JetBrains JBR, etc.). +- macOS built-ins used by the installer (all preinstalled on supported macOS versions): `plutil` (LaunchAgent plist validation), `launchctl` (bootstrap into `gui/`), `dscl` (user / home lookup), `stat` (UID-based filtering under `--all-users`). + +### Options + +| Option | Required | Description | +|--------|----------|-------------| +| `--use-cert ` | **Yes** | Path to an existing PEM/CRT certificate file. Validation: parseable X.509, not expired, `CA:TRUE` in basicConstraints. Bundles emit a warning (only the first cert imports). | +| `--cert-name ` | No (default: `package-route-custom-ca`) | Base name for the JKS alias. Must match `[A-Za-z0-9._-]+`. Pass the same value to the validator (the validator matches by subject so this is informational unless multiple CAs coexist). | +| `--all-users` | No | Iterate `/Users/*` and install the LaunchAgent + JKS for every account with UID ≥ 501. Default = only `SUDO_USER` (or the GUI console user under JAMF). | +| `-h`, `--help` | — | Usage. | + +### Examples + +```bash +# Single user (typical: install for the developer running sudo) +sudo ./install_certs_jvm_macos.sh --use-cert /tmp/ZscalerRoot0.pem + +# Fleet onboarding (shared Mac with multiple accounts) +sudo ./install_certs_jvm_macos.sh --use-cert /tmp/ZscalerRoot0.pem --all-users + +# Custom alias for the JKS +sudo ./install_certs_jvm_macos.sh --use-cert /tmp/ZscalerRoot0.pem --cert-name zscaler-root +``` + +### Validation: validate_certs_jvm_macos.sh + +**`--expected-subject` is required.** Per user, asserts: +- JKS file exists at the per-user path. +- `keytool -list -v` shows an `Owner:` line matching the substring (case-insensitive). +- LaunchAgent plist exists and passes `plutil -lint`. +- `launchctl getenv JAVA_TOOL_OPTIONS` in `gui/` returns the JKS path (warn-not-fail when the user is not in an active GUI session — the plist will load at next login). + +```bash +./validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" +sudo ./validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" --all-users +``` + +`--all-users` requires root (other users' `~/Library` is `0700`). Exit code 0 if all checks pass, 1 otherwise. + +### Caveats + +- **Already-running apps must be restarted.** macOS does not re-poll the launchd domain env on Cmd-Tab. Quit and relaunch IntelliJ / your IDE after install for the env var to take effect. +- **Gradle Daemon caching.** Run `gradle --stop` after onboarding so the daemon re-reads `JAVA_TOOL_OPTIONS` at next start. +- **`Picked up JAVA_TOOL_OPTIONS:` banner.** Every JVM startup prints this to stderr. CI parsers that strict-match empty-stderr need to tolerate it. +- **`changeit` truststore password.** OpenJDK convention; *not* a secret. The JKS holds only public CA certificates and the password protects file integrity, not contents. +- **JKS extends the JDK's bundled cacerts.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The installer copies `$JAVA_HOME/lib/security/cacerts` to `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks` first, then `keytool -importcert` appends the corporate CA. The resulting store has ~150 public roots **plus** the corporate one. +- **`JAVA_TOOL_OPTIONS` inner quoting.** The JKS path lives under `~/Library/Application Support/` which contains a space; the JVM tokenises `JAVA_TOOL_OPTIONS` on whitespace and only honours embedded `"…"` grouping. The installer therefore writes `-Djavax.net.ssl.trustStore="" -Djavax.net.ssl.trustStorePassword="changeit"` into the LaunchAgent plist so the literal quotes reach the JVM tokenizer. Older installs (pre-fix) that wrote the unquoted form produced a fatal `Unrecognized option` on every Dock-launched JVM. +- **`gui/` domain only exists for logged-in GUI users.** Under `--all-users`, accounts that are not currently logged in get the plist installed but `launchctl bootstrap` is soft-skipped; launchd loads the plist automatically at their next login. +- **JAMF / headless kiosk caveat.** On a Mac running JAMF policies *before* any user has logged in, `gui/` is not yet running, so `launchctl bootstrap gui/` either fails or loads into a non-running domain. The plist will load on first interactive login. For truly headless boxes (rack-mounted Mac mini build agents), pair this installer with a `/Library/LaunchDaemons` (system-scope) trust-bootstrap before the first user login, or run the installer interactively as part of provisioning. +- **`KeychainStore` truststoreType is rejected.** Broken per JDK-8321045 — incomplete for `SystemRootCertificates.keychain`. No OS-trust fallback on macOS. +- **`~/.zshrc` / `~/.bash_profile` are deliberately NOT touched.** They silently fail for Dock-launched IDE builds — see Overview. +- **IntelliJ per-IDE SSL store** (`~/Library/Application Support/JetBrains/IntelliJIdea/ssl/cacerts`) is a different layer (plugin marketplace, VCS). Not configured by this script. + +### Testing + +`./testing/test_install_certs_jvm_macos.sh` runs the 9-invariant smoke matrix locally and on CI. Each run targets `SUDO_USER`'s per-user files and cleans up between cases via `trap EXIT`. + +```bash +# Local +sudo ./testing/test_install_certs_jvm_macos.sh +``` + +The same matrix runs on every push and pull request via `.github/workflows/ci.yml` (`test-macos-jvm` job). + +### Summary (macOS JVM) + +- **One run as root**, single cert source via `--use-cert`. +- Per-user JKS at `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks`. +- Per-user LaunchAgent at `~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist` bootstrapped into `gui/`. +- **Idempotent**, **re-runnable**, **JDK-version-agnostic**. New JDK installs do not require re-running the script. +- Restart already-running apps; `gradle --stop` for the Gradle Daemon. + +--- + ## Linux (Debian/Ubuntu): install_certs_debian_ubuntu.sh ### Overview @@ -568,6 +665,7 @@ On **push** and **pull request** to `main` or `master`, GitHub Actions runs: | Job | Runner | Command | |-----|--------|---------| | Test (macOS) | `macos-latest` | `sudo ./testing/test_install_certs_macos.sh` | +| Test (macOS JVM) | `macos-latest` | `sudo ./testing/test_install_certs_jvm_macos.sh` | | Test (Windows) | `windows-latest` | `./testing/test_install_certs_windows.ps1` (PowerShell) | | Test (Linux JVM) | `ubuntu-latest` | `./testing/test_install_certs_jvm_linux.sh` | diff --git a/_jvm_macos_paths.sh b/_jvm_macos_paths.sh new file mode 100644 index 0000000..b7979da --- /dev/null +++ b/_jvm_macos_paths.sh @@ -0,0 +1,42 @@ +# (c) JFrog Ltd. (2026) +# Shared constants for install_certs_jvm_macos.sh and validate_certs_jvm_macos.sh. +# Sourced — must NOT be executed directly. Has no shebang on purpose. +# +# Both scripts read this file via: +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# . "${SCRIPT_DIR}/_jvm_macos_paths.sh" +# +# Keep installer and validator in lockstep by changing only this file. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# _jvm_linux_paths.sh — system anchor (Path A) / JKS+JTO (Path B) +# _jvm_windows_paths.ps1 — per-user JKS under %LOCALAPPDATA% + +# Default base name used as the JKS alias inside the per-user truststore. +# Overridable via --cert-name on the installer (affects ONLY the alias name +# visible in `keytool -list` output — the JKS file path, plist path, and +# LaunchAgent label are all fixed per-user, so a different --cert-name on +# re-run replaces the previous CA rather than installing alongside it). +JVM_MACOS_DEFAULT_CERT_BASENAME="package-route-custom-ca" + +# Per-user JKS truststore. macOS convention: per-user resources under the user's +# Library directory so each account stays isolated. Matches the existing +# install_certs_macos.sh pattern (per-user PEM under ~//). +JKS_RELATIVE_DIR="Library/Application Support/JFrog/package-route-jvm" +JKS_BASENAME="truststore.jks" + +# Per-user LaunchAgent that calls `launchctl setenv JAVA_TOOL_OPTIONS=…` at +# RunAtLoad. This is the only macOS recipe that reaches Dock-launched IDEs; +# the ~/.zshrc shortcut silently fails for GUI-spawned subprocesses. +LAUNCH_AGENT_RELATIVE_DIR="Library/LaunchAgents" +LAUNCH_AGENT_LABEL="com.jfrog.package-reroute.jto-env" +LAUNCH_AGENT_BASENAME="${LAUNCH_AGENT_LABEL}.plist" + +# OpenJDK convention for cacerts and similar truststores. NOT a secret in +# this script's use case: we import only `trustedCertEntry` records (public +# CA certs), so the password protects file *integrity* via the keystore MAC +# but not any private key material. (A JKS that ever holds a PrivateKeyEntry +# would additionally rely on this password to encrypt the key — not relevant +# here.) Persisted in the LaunchAgent plist via -Djavax.net.ssl.trustStorePassword +# so unattended JVMs can open the store. +JKS_PASSWORD="changeit" diff --git a/install_certs_jvm_macos.sh b/install_certs_jvm_macos.sh new file mode 100755 index 0000000..d29a83d --- /dev/null +++ b/install_certs_jvm_macos.sh @@ -0,0 +1,664 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Install a custom CA certificate on macOS for JVM clients (Maven, Gradle, sbt, +# Apache Ivy). +# +# Single path: build a per-user JKS truststore at +# ~/Library/Application Support/JFrog/package-route-jvm/truststore.jks +# containing only the customer CA, then install a per-user LaunchAgent at +# ~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist +# that runs `launchctl setenv JAVA_TOOL_OPTIONS=…` at RunAtLoad. This is the +# ONLY recipe that reaches Dock-launched IDE builds — the ~/.zshrc shortcut +# silently fails for GUI-spawned subprocesses, and macOS-specific +# KeychainStore is broken per JDK-8321045. +# +# Run: +# sudo bash install_certs_jvm_macos.sh --use-cert /path/to/cert.pem +# [--cert-name ] [--all-users] +# +# Notes: +# - macOS only. +# - Must run as root (so per-user files can be chown'd to the target user). +# - JVM trust only — does not configure npm/Python/HF and does not touch +# Docker credentials. Pair with install_certs_macos.sh if you need those. +# - Existing Dock-launched apps must be restarted after install: macOS does +# not re-poll the launchd domain env on app relaunch unless the agent +# was bootstrapped before app launch. +# - JAMF / kiosk caveat: gui/ is the GUI-session launchd domain. On a +# host with NO user logged in (mac-mini in a rack, fresh JAMF bootstrap +# before first login), `launchctl bootstrap gui/` either fails or +# loads into a non-running domain. In that case the agent loads on next +# interactive login. Operators provisioning headless machines should +# also seed the JKS via a separate non-GUI mechanism (e.g. system-wide +# /Library/LaunchDaemons running as root) before any user logs in. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# install_certs_jvm_linux.sh — update-ca-trust OR JKS+JAVA_TOOL_OPTIONS +# install_certs_jvm_windows.ps1 — HKCU\Environment + per-user JKS +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "${SCRIPT_DIR}/_jvm_macos_paths.sh" + +USE_CERT="" +ALL_USERS=0 +CERT_BASENAME="${JVM_MACOS_DEFAULT_CERT_BASENAME}" + +usage() { + cat < [--cert-name ] [--all-users] + +Options: + --use-cert Path to an existing PEM/CRT certificate file (required). + --cert-name Alias under which the CA is stored inside the JKS + truststore (default: ${CERT_BASENAME}). Cosmetic — affects + only \`keytool -list\` output. JKS path, plist path, and + LaunchAgent label are fixed per-user. + --all-users Iterate /Users/* (UID >= 501, skip Shared) and install + a LaunchAgent + JKS for every account. Default = only + SUDO_USER (or the console-user under JAMF). + -h, --help Show this help. + +Note: unlike the Linux sibling, macOS has only one install path (JKS + +per-user LaunchAgent setting JAVA_TOOL_OPTIONS). There is no --mode flag +because the KeychainStore truststoreType is broken (JDK-8321045) and no +OS-trust fallback exists. + +Examples: + sudo $0 --use-cert /tmp/ZscalerRoot0.pem + sudo $0 --use-cert /tmp/ca.pem --all-users + sudo $0 --use-cert /tmp/ca.pem --cert-name zscaler-root +EOF +} + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "Error: this script must be run as root." >&2 + echo "Use: sudo $0 --use-cert [--cert-name ] [--all-users]" >&2 + exit 1 + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --use-cert) + USE_CERT="${2:?Error: --use-cert requires a value}" + shift 2 + ;; + --cert-name) + CERT_BASENAME="${2:?Error: --cert-name requires a value}" + shift 2 + ;; + --all-users) + ALL_USERS=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$USE_CERT" ]]; then + echo "Error: --use-cert is required." >&2 + usage >&2 + exit 1 + fi + + if [[ ! -f "$USE_CERT" ]]; then + echo "Error: certificate file not found: $USE_CERT" >&2 + exit 1 + fi + + if [[ -z "$CERT_BASENAME" ]]; then + echo "Error: --cert-name cannot be empty." >&2 + exit 1 + fi + + # Reject path-traversal characters so $CERT_BASENAME stays a single segment + # safe to substitute into the JKS alias and the LaunchAgent label suffix. + if [[ ! "$CERT_BASENAME" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Error: --cert-name must match [A-Za-z0-9._-]+ (got: $CERT_BASENAME)." >&2 + exit 1 + fi +} + +check_os() { + local os + os="$(uname -s)" + if [[ "$os" != "Darwin" ]]; then + echo "Error: this script supports macOS only (detected: $os)." >&2 + exit 1 + fi +} + +check_dependencies() { + if ! command -v openssl >/dev/null 2>&1; then + echo "Error: openssl is required but not found on PATH." >&2 + exit 1 + fi + # keytool is needed for the JKS step; checked at build_jks entry to keep + # validate_pem reachable even when no JDK is installed yet. +} + +# Locate the JDK's default cacerts file. Mirrors the Linux sibling: see the +# header comment on install_certs_jvm_linux.sh:find_jdk_cacerts for the +# rationale (OpenJDK's -Djavax.net.ssl.trustStore replaces rather than +# extends; we must copy the bundled cacerts as the base of our merged store). +# +# Resolution: $JAVA_HOME first, then dir-of-resolved-keytool. macOS-specific +# wrinkle: BSD readlink doesn't support -f, so we walk symlinks manually +# (Apple's /usr/bin/keytool is a stub that resolves through several layers). +find_jdk_cacerts() { + local candidate="" + + # 1. Explicit JAVA_HOME. Under `sudo` macOS strips this via env_reset, so + # operators who rely on jenv / asdf / SDKMAN must `sudo -E` or `sudo env + # JAVA_HOME=…` — the test runner does this; print a hint if we fall through. + if [[ -n "${JAVA_HOME:-}" && -f "${JAVA_HOME}/lib/security/cacerts" ]]; then + candidate="${JAVA_HOME}/lib/security/cacerts" + fi + + # 2. /usr/libexec/java_home — Apple's canonical resolver, scans + # /Library/Java/JavaVirtualMachines + ~/Library/Java/JavaVirtualMachines + # and prints the highest-version JDK home. Works under sudo with a stripped + # env (no JAVA_HOME) as long as a system-visible JDK is installed. + if [[ -z "$candidate" && -x /usr/libexec/java_home ]]; then + local java_home_out + java_home_out="$(/usr/libexec/java_home 2>/dev/null || true)" + if [[ -n "$java_home_out" && -f "${java_home_out}/lib/security/cacerts" ]]; then + candidate="${java_home_out}/lib/security/cacerts" + fi + fi + + # 3. Sibling of resolved keytool. Defensive last resort — typical macOS + # PATH-keytool is the Apple stub at /usr/bin/keytool (which delegates via + # java_home internally and has no sibling cacerts), but a real JDK on PATH + # (Homebrew openjdk, manually-installed Adoptium) does sit next to a + # cacerts file. BSD readlink lacks -f, so we walk symlinks manually. + if [[ -z "$candidate" ]]; then + local keytool_path resolved link + keytool_path="$(command -v keytool 2>/dev/null || true)" + if [[ -n "$keytool_path" ]]; then + resolved="$keytool_path" + local depth=0 + while [[ -L "$resolved" && $depth -lt 16 ]]; do + link="$(readlink "$resolved")" + if [[ "$link" = /* ]]; then + resolved="$link" + else + resolved="$(dirname "$resolved")/$link" + fi + depth=$((depth + 1)) + done + local keytool_dir + keytool_dir="$(cd "$(dirname "$resolved")" 2>/dev/null && pwd -P)" + if [[ -n "$keytool_dir" && -f "${keytool_dir}/../lib/security/cacerts" ]]; then + candidate="${keytool_dir}/../lib/security/cacerts" + fi + fi + fi + + if [[ -z "$candidate" ]]; then + echo "Error: cannot locate the JDK's default cacerts file." >&2 + echo " Tried (in order): \$JAVA_HOME/lib/security/cacerts," >&2 + echo " /usr/libexec/java_home → */lib/security/cacerts," >&2 + echo " \$(dirname keytool)/../lib/security/cacerts." >&2 + echo " Install a JDK (Homebrew, Adoptium, etc.), or invoke as 'sudo -E ./install_…'" >&2 + echo " to preserve your shell's JAVA_HOME under sudo." >&2 + exit 1 + fi + echo "$candidate" +} + +validate_pem() { + local path="$1" + + # C1 cross-platform parity: require PEM text input. DER would parse via + # openssl + import via keytool here (-inform der would be implied) and + # silently succeed — but the Linux + Windows siblings reject DER, so we + # also reject it for predictable cross-platform behaviour. + if ! grep -q -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null; then + echo "Error: certificate is not PEM-encoded: $path" >&2 + echo " If it's DER, convert first:" >&2 + echo " openssl x509 -inform der -in $path -out $path.pem" >&2 + exit 1 + fi + + if ! openssl x509 -in "$path" -noout >/dev/null 2>&1; then + echo "Error: invalid PEM/CRT certificate file: $path" >&2 + exit 1 + fi + + # Reject expired anchors: keytool -importcert -noprompt accepts them silently + # and the user gets cryptic CertificateExpiredException at TLS handshake time. + if ! openssl x509 -in "$path" -checkend 0 -noout >/dev/null 2>&1; then + echo "Error: certificate has already expired: $path" >&2 + exit 1 + fi + + # Warn (don't fail) on a cert expiring within 30 days — likely operator error. + # I23 parity: the 30-day window matches the Linux JVM_LINUX_EXPIRY_WARN_SECONDS + # constant (_jvm_linux_paths.sh) and the Windows AddDays(30) sibling. Change + # all three together — there is no single source of truth across the three. + if ! openssl x509 -in "$path" -checkend 2592000 -noout >/dev/null 2>&1; then + echo "[warn] certificate expires within 30 days: $path" >&2 + fi + + # Reject leaf certs: a cert without CA:TRUE in basicConstraints will import + # into a JKS truststore but PKIX path-building won't use it as a trust anchor. + # + # Stock macOS ships LibreSSL at /usr/bin/openssl, which does NOT support + # `openssl x509 -ext` (that flag is OpenSSL 3.x+). Parse the long-form + # `-text` output instead — works on both LibreSSL and OpenSSL. + local text bc_line ca_value + text="$(openssl x509 -in "$path" -noout -text 2>/dev/null || true)" + bc_line="$(awk ' + /X509v3 Basic Constraints/ { getline; print; exit } + ' <<<"$text")" + if [[ -n "$bc_line" ]]; then + ca_value="$(grep -oE 'CA:(TRUE|FALSE)' <<<"$bc_line" | head -n1 | cut -d: -f2)" + if [[ "$ca_value" == "FALSE" ]]; then + echo "Error: certificate is not a CA (basicConstraints CA:FALSE): $path" >&2 + echo " JKS imports succeed but PKIX rejects non-CA trust anchors." >&2 + exit 1 + fi + fi + + # Warn on bundles: keytool -importcert -noprompt reads only the first cert, + # silently dropping intermediates. Users should split bundles or supply only the root. + local count + count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null || echo 0)" + if [[ "$count" -gt 1 ]]; then + echo "[warn] PEM file contains $count certificates; only the first will be imported as the JVM trust anchor." >&2 + echo " Supply only the root CA (or split the bundle) if intermediates are needed." >&2 + fi +} + +require_keytool() { + if ! command -v keytool >/dev/null 2>&1; then + echo "Error: keytool is required (provided by any JDK)." >&2 + echo " Homebrew: brew install openjdk@21" >&2 + echo " Adoptium: https://adoptium.net/temurin/releases/" >&2 + echo " Manual JDK: add \$JAVA_HOME/bin to PATH (or symlink keytool into /usr/local/bin)." >&2 + exit 1 + fi +} + +jks_path_for_user() { + local user_home="$1" + echo "${user_home}/${JKS_RELATIVE_DIR}/${JKS_BASENAME}" +} + +build_jks_for_user() { + local target_user="$1" user_home="$2" + local jks_dir="${user_home}/${JKS_RELATIVE_DIR}" + local jks_path="${jks_dir}/${JKS_BASENAME}" + + local src_cacerts + src_cacerts="$(find_jdk_cacerts)" + echo " [JKS] Building truststore at $jks_path (extending $src_cacerts)" + + # macOS mkdir -p will create the intermediate "Application Support" / + # "JFrog" / "package-route-jvm" tree if missing. Quote the path because + # "Application Support" contains a space. + mkdir -p "$jks_dir" + + # Copy the JDK's bundled cacerts (~150 public root CAs) as the base so the + # merged store keeps trusting Maven Central, Let's Encrypt, etc. Without + # this, -Djavax.net.ssl.trustStore at JTO-resolve time would REPLACE the + # JVM's trust source — a JKS containing only the corporate CA would break + # every public-CA TLS handshake. Idempotent: cp -f overwrites any prior + # JKS, so subsequent installs start from the canonical JDK cacerts again. + cp "$src_cacerts" "$jks_path" + + # Capture keytool's combined output so a real failure (wrong format, + # unreadable cert, keytool-from-broken-JDK, etc.) doesn't leave the user + # with `set -e` aborting at an unhelpful line. The success-case output + # ("Certificate was added to keystore") is informational only. No + # -storetype flag: modern JDKs default cacerts to PKCS12 and keytool + # autodetects the format from the existing file. + local keytool_out + if ! keytool_out="$(keytool -importcert -noprompt \ + -alias "$CERT_BASENAME" \ + -file "$USE_CERT" \ + -keystore "$jks_path" \ + -storepass "$JKS_PASSWORD" 2>&1)"; then + echo "Error: keytool -importcert failed for $jks_path. Output:" >&2 + printf '%s\n' "$keytool_out" | sed 's/^/ /' >&2 + exit 1 + fi + + chmod 0755 "$jks_dir" + chmod 0644 "$jks_path" + + # Hand ownership back to the target user so they (and their LaunchAgent + # running in gui/) can read it without needing sudo to manage it. + # A silent chown failure would leave root-owned files in $target_user's + # home — launchd refuses to load root-owned LaunchAgents, so the install + # would appear to succeed and never activate. + local chown_err + if ! chown_err="$(chown -R "$target_user" "$jks_dir" 2>&1)"; then + echo "Error: chown $target_user $jks_dir failed: $chown_err" >&2 + exit 1 + fi + + echo " [JKS] OK: alias=$CERT_BASENAME" +} + +# Determine the single non-root target user when --all-users is NOT set. +# Fallback order matches install_certs_macos.sh:314-328: SUDO_USER first, +# then /dev/console owner (the JAMF / GUI-elevated case), then `logname` as a +# last resort. `loginwindow` is the special user that owns /dev/console at +# the login screen — must be filtered or we'd install into a non-account. +get_single_target_user() { + local candidate + + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + candidate="$SUDO_USER" + else + candidate="$(stat -f '%Su' /dev/console 2>/dev/null || true)" + if [[ -z "$candidate" || "$candidate" == "root" || "$candidate" == "loginwindow" ]]; then + candidate="$(logname 2>/dev/null || true)" + fi + fi + + if [[ -z "$candidate" || "$candidate" == "root" || "$candidate" == "loginwindow" ]]; then + return 0 + fi + + # Reject users that don't exist on the box. + if ! id -u "$candidate" >/dev/null 2>&1; then + return 0 + fi + + echo "$candidate" +} + +get_user_home() { + local user="$1" + # `dscl . -read /Users/$user NFSHomeDirectory` is the macOS canonical + # source of truth (passwd is a synthetic view). Fall back to dscacheutil + # if dscl is unavailable (sandboxed CI images, OpenDirectory hiccups). + # Avoid `eval echo "~$user"` — even with the upstream id-validation it + # makes the next maintainer's eyes water and would be unsafe if the + # validation is ever loosened. + local home + home="$(dscl . -read "/Users/${user}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" + if [[ -z "$home" ]] && command -v dscacheutil >/dev/null 2>&1; then + home="$(dscacheutil -q user -a name "$user" 2>/dev/null | awk '/^dir:/ {print $2}')" + fi + echo "$home" +} + +launch_agent_path_for_user() { + local user_home="$1" + echo "${user_home}/${LAUNCH_AGENT_RELATIVE_DIR}/${LAUNCH_AGENT_BASENAME}" +} + +# XML-escape a single value to make it safe inside a node. +# JKS paths contain spaces ("Application Support") but spaces in XML text are +# fine; the only chars that must be escaped are &, <, >. +plist_xml_escape() { + local s="$1" + s="${s//&/&}" + s="${s///>}" + # `"` inside PCDATA is technically valid XML, but we escape it + # for defence-in-depth: the JTO value embeds literal `"` chars to quote + # the trustStore path against JVM whitespace tokenisation, and plutil + # versions on older macOS releases have been finicky about unescaped + # quotes inside . Escaping is safe across plist consumers. + s="${s//\"/"}" + printf '%s' "$s" +} + +write_launch_agent_plist() { + local target_user="$1" user_home="$2" + local plist_path + plist_path="$(launch_agent_path_for_user "$user_home")" + local plist_dir + plist_dir="$(dirname "$plist_path")" + + local jks_path + jks_path="$(jks_path_for_user "$user_home")" + + # The JKS path is under ~/Library/Application Support/ — the embedded + # space breaks unquoted JAVA_TOOL_OPTIONS at the JVM tokenizer (which + # splits on whitespace and only honours `"…"` grouping). Without these + # inner quotes a Dock-launched JVM sees two tokens, the second of which + # starts with `Support/…` and aborts with "Unrecognized option". The + # plist carries the value verbatim through launchctl setenv, + # so the literal quote characters reach the JVM tokenizer and correctly + # group the path. plist_xml_escape converts `"` → `"` for the XML. + local jto_value="-Djavax.net.ssl.trustStore=\"${jks_path}\" -Djavax.net.ssl.trustStorePassword=\"${JKS_PASSWORD}\"" + local jto_escaped + jto_escaped="$(plist_xml_escape "$jto_value")" + + local uid + uid="$(id -u "$target_user")" + local log_out="/tmp/${LAUNCH_AGENT_LABEL}-${uid}.out.log" + local log_err="/tmp/${LAUNCH_AGENT_LABEL}-${uid}.err.log" + + echo " [Agent] Writing plist: $plist_path" + + mkdir -p "$plist_dir" + + # Atomic write via mktemp + mv in the same directory so a half-written + # plist can never be picked up by launchd. + local tmp + tmp="$(mktemp "${plist_dir}/.${LAUNCH_AGENT_BASENAME}.XXXXXX")" + cat > "$tmp" < + + + + Label + ${LAUNCH_AGENT_LABEL} + ProgramArguments + + /bin/launchctl + setenv + JAVA_TOOL_OPTIONS + ${jto_escaped} + + RunAtLoad + + StandardOutPath + ${log_out} + StandardErrorPath + ${log_err} + + +PLIST + + # plutil -lint catches XML / schema errors before launchd ever sees the file. + if ! plutil -lint "$tmp" >/dev/null 2>&1; then + local lint_err + lint_err="$(plutil -lint "$tmp" 2>&1 || true)" + rm -f "$tmp" + echo "Error: generated LaunchAgent plist failed plutil -lint: $lint_err" >&2 + exit 1 + fi + + mv "$tmp" "$plist_path" + chmod 0644 "$plist_path" + + # launchd refuses to load LaunchAgents owned by root in ~/Library/LaunchAgents/, + # so a silent chown failure here turns the install into a phantom success. + local chown_err + if ! chown_err="$(chown "$target_user" "$plist_dir" "$plist_path" 2>&1)"; then + echo "Error: chown $target_user $plist_path failed: $chown_err" >&2 + exit 1 + fi + + echo " [Agent] OK" +} + +bootstrap_launch_agent() { + local target_user="$1" user_home="$2" + local plist_path + plist_path="$(launch_agent_path_for_user "$user_home")" + local uid + uid="$(id -u "$target_user")" + local domain="gui/${uid}" + + # `gui/` exists only while the user is logged into a GUI session. + # Under --all-users this is normally false for everyone except the + # currently-logged-in account. The plist we already wrote into + # ~/Library/LaunchAgents/ is loaded automatically by launchd when the + # user next logs in (RunAtLoad=true), so a missing domain is a soft-fail. + if ! launchctl print "$domain" >/dev/null 2>&1; then + echo " [Agent] $target_user is not in an active GUI session; skipping bootstrap." + echo " [Agent] Plist is installed at $plist_path and will activate at next login." + return 0 + fi + + echo " [Agent] launchctl bootout (in case already loaded; expected to fail on a fresh install)" + # `launchctl bootout` returns non-zero when the agent isn't loaded yet. + # That's the normal first-run case; swallow it. + launchctl bootout "$domain" "$plist_path" 2>/dev/null || true + + echo " [Agent] launchctl bootstrap $domain" + local bootstrap_err + if ! bootstrap_err="$(launchctl bootstrap "$domain" "$plist_path" 2>&1)"; then + echo " [Agent] [warn] launchctl bootstrap failed for $domain: $bootstrap_err" >&2 + echo " Plist is installed at $plist_path and will activate at next login." >&2 + return 0 + fi + + # Sanity-check that setenv actually fired. `launchctl bootstrap` blocks + # until the agent is *loaded* but the ProgramArguments exec + # (`launchctl setenv …`) runs asynchronously after load. Under a quiet + # box this is microseconds; under EDR agents (CrowdStrike, Spotlight + # mid-index, Time Machine) the exec latency can climb past a second. + # Retry up to ~2s (20 × 100ms) so a perfectly-healthy install doesn't + # produce a misleading warn under load. + local seen attempt + for attempt in $(seq 1 20); do + seen="$(launchctl asuser "$uid" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" + [[ -n "$seen" ]] && break + sleep 0.1 + done + + if [[ -z "$seen" ]]; then + # The env var IS set in the plist on disk and the agent IS loaded. + # We just couldn't observe the launchctl-setenv side-effect inside + # our retry window. Surface that without suggesting a logout/login + # dance the user almost certainly doesn't need. + echo " [Agent] [warn] JAVA_TOOL_OPTIONS not observable via launchctl getenv within 2s." >&2 + echo " Plist is loaded; re-run validate_certs_jvm_macos.sh in a few seconds" >&2 + echo " to confirm, or open a new Terminal and check \$JAVA_TOOL_OPTIONS." >&2 + else + echo " [Agent] launchctl getenv JAVA_TOOL_OPTIONS confirms the value reached gui/${uid}" + fi +} + +install_for_user() { + local target_user="$1" user_home="$2" + + echo "=== User: $target_user (home: $user_home) ===" + build_jks_for_user "$target_user" "$user_home" + write_launch_agent_plist "$target_user" "$user_home" + bootstrap_launch_agent "$target_user" "$user_home" + + echo " Truststore: $(jks_path_for_user "$user_home")" + echo " LaunchAgent: $(launch_agent_path_for_user "$user_home")" +} + +# Outputs `username\thome_dir` lines for every /Users/* directory that +# represents a real account with UID >= 501. +# +# Stricter filter than install_certs_macos.sh:256-261 (which skips only +# /Users/Shared and UID < 501): also skips .localized, and rejects +# directories whose owning user no longer exists in dscl (stale home dirs +# left behind by deleted accounts would otherwise crash chown/launchctl). +iter_all_users() { + local dir base uid + for dir in /Users/*; do + [[ -d "$dir" ]] || continue + base="$(basename "$dir")" + [[ "$base" == "Shared" || "$base" == ".localized" ]] && continue + uid="$(stat -f '%u' "$dir" 2>/dev/null || true)" + [[ -n "$uid" && "$uid" -ge 501 ]] || continue + # Reject stale home dirs whose owning user no longer exists in dscl. + id -u "$base" >/dev/null 2>&1 || continue + printf '%s\t%s\n' "$base" "$dir" + done +} + +print_caveats() { + cat < per installed user. New launchd-spawned + processes (Dock-launched IntelliJ, JetBrains Toolbox, 'open -a …') inherit + JAVA_TOOL_OPTIONS automatically. + - Apps that were ALREADY running before the install must be restarted to pick + up the new env var (LaunchServices does not re-poll the launchd domain on + Cmd-Tab). + - Run 'gradle --stop' to refresh the Gradle Daemon if one was already running. + - The 'Picked up JAVA_TOOL_OPTIONS:' banner on stderr is expected. +EOF +} + +main() { + require_root + parse_args "$@" + check_os + check_dependencies + validate_pem "$USE_CERT" + require_keytool + + if [[ "$ALL_USERS" -eq 1 ]]; then + local iter_count=0 user home + while IFS=$'\t' read -r user home; do + echo + install_for_user "$user" "$home" + iter_count=$((iter_count + 1)) + done < <(iter_all_users) + + if [[ "$iter_count" -eq 0 ]]; then + echo "Error: --all-users found no eligible accounts under /Users/* (UID >= 501)." >&2 + exit 1 + fi + + echo + echo "Installed for $iter_count user(s)." + print_caveats + return 0 + fi + + local target_user user_home + target_user="$(get_single_target_user)" + if [[ -z "$target_user" ]]; then + echo "Error: could not determine non-root target user." >&2 + echo " Set SUDO_USER or invoke via sudo from the developer account," >&2 + echo " or pass --all-users to iterate every eligible account." >&2 + exit 1 + fi + user_home="$(get_user_home "$target_user")" + if [[ -z "$user_home" || ! -d "$user_home" ]]; then + echo "Error: home directory not found for $target_user." >&2 + exit 1 + fi + + echo + install_for_user "$target_user" "$user_home" + print_caveats +} + +main "$@" diff --git a/testing/test_install_certs_jvm_macos.sh b/testing/test_install_certs_jvm_macos.sh new file mode 100755 index 0000000..168991b --- /dev/null +++ b/testing/test_install_certs_jvm_macos.sh @@ -0,0 +1,430 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Smoke matrix for install_certs_jvm_macos.sh + validate_certs_jvm_macos.sh. +# +# Run as root from the repo root (matches macos-latest CI usage): +# sudo ./testing/test_install_certs_jvm_macos.sh +# +# Targets the SUDO_USER's per-user files. `cleanup` runs at the start of +# cases 1, 4, 10, 11 (the positive / fresh-state cases) and via `trap EXIT`. +# Cases 2 (subject mismatch), 3 (idempotency), 5-8 (negative arg / cert), +# 9 (getenv) and 12 (validate_pem warns) deliberately reuse the prior install +# state because they assert behavior ON TOP of an installed system. +# +# Invariants exercised: +# 1. Positive install + validate (subject substring match) +# 2. Subject mismatch -> exit 1 +# 3. Idempotent re-install (single JKS alias after 2 runs; plist replaced) +# 4. Custom --cert-name round-trips (alias inside JKS = cert-name) +# 5. Path-traversal --cert-name rejected +# 6. Malformed PEM rejected +# 7. Expired CA rejected (skip if openssl can't produce one verifiably-expired) +# 8. Leaf cert (CA:FALSE) rejected +# 9. After bootstrap, launchctl getenv JAVA_TOOL_OPTIONS in gui/ returns +# the JKS path (skip on CI runners with no GUI session) +# 10. Plist content is well-formed XML and points at the expected JKS path +# (covers the install path even when launchctl can't be verified) +# 11. --all-users iterates /Users/* and installs into every eligible account +# (covers the iter_all_users filter + per-user chown contract) +# 12. validate_pem warn paths: 30-day-expiry warn + multi-cert bundle warn + +set -euo pipefail +fail_msg() { echo "BUG: $1" >&2; exit 1; } + +if [[ "$(id -u)" -ne 0 ]]; then + echo "Error: this test runner must be run as root. Use: sudo $0" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# Identify the test user. CI calls `sudo ./test_install_certs_jvm_macos.sh` +# from an interactive account, so SUDO_USER is set; locally same. Fall back +# to the GUI console user for JAMF-style flows. +if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + TEST_USER="$SUDO_USER" +else + TEST_USER="$(stat -f '%Su' /dev/console 2>/dev/null || true)" +fi +if [[ -z "$TEST_USER" || "$TEST_USER" == "root" ]]; then + fail_msg "cannot determine non-root test user (no SUDO_USER, no console user)" +fi + +TEST_HOME="$(dscl . -read "/Users/${TEST_USER}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" +[[ -d "$TEST_HOME" ]] || fail_msg "home directory not found for $TEST_USER" +TEST_UID="$(id -u "$TEST_USER")" + +JKS="${TEST_HOME}/Library/Application Support/JFrog/package-route-jvm/truststore.jks" +JKS_DIR="${TEST_HOME}/Library/Application Support/JFrog/package-route-jvm" +PLIST="${TEST_HOME}/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist" +LABEL="com.jfrog.package-reroute.jto-env" + +echo "Test user: $TEST_USER (uid=$TEST_UID, home=$TEST_HOME)" + +cleanup() { + launchctl bootout "gui/${TEST_UID}/${LABEL}" 2>/dev/null || true + rm -f "$PLIST" + rm -rf "$JKS_DIR" + launchctl asuser "${TEST_UID}" launchctl unsetenv JAVA_TOOL_OPTIONS 2>/dev/null || true +} +trap cleanup EXIT + +# Pick an OpenSSL implementation that supports `-addext` reliably. +# macos-latest CI's default `openssl` is LibreSSL, which silently mis-handles +# -addext and emits PEM bytes keytool then rejects with "Input not an X.509 +# certificate". Homebrew's openssl@3 is preinstalled on GHA macos-latest. +OPENSSL="" +for cand in /opt/homebrew/opt/openssl@3/bin/openssl /usr/local/opt/openssl@3/bin/openssl openssl; do + if command -v "$cand" >/dev/null 2>&1 && "$cand" version 2>/dev/null | grep -q '^OpenSSL '; then + OPENSSL="$cand" + break + fi +done +[[ -n "$OPENSSL" ]] || fail_msg "no real OpenSSL on PATH (need OpenSSL 3.x; LibreSSL does not support -addext)" +echo "Using openssl: $OPENSSL ($("$OPENSSL" version))" + +# Generate the lab CA used by all positive cases. +"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/jvm-mac-test-k.pem -out /tmp/jvm-mac-test-ca.pem -days 7 \ + -subj "/CN=Lab JVM mac CA Test/O=JFrog" \ + -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null + +# Capture combined stdout/stderr to a tempfile and only dump it on an +# *unexpected* exit. Negative tests need silence on the expected-fail path +# but diagnostic output when the installer surprises us. Keeps CI logs +# tight in the green-run case (the iteration-1 debug cycle showed how +# painful the silent-on-failure pattern is). +install_as_test_user() { + # The `if cmd; then …; fi` pattern reports $?=0 of the if-statement, not + # the command. `if ! cmd` also doesn't help (the `!` operator itself + # returns 0). Reliable capture: pre-set rc=0 and use `cmd || rc=$?`. + local log rc=0 + log="$(mktemp)" + SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh "$@" >"$log" 2>&1 || rc=$? + if [[ "$rc" -eq 0 ]]; then + rm -f "$log" + else + _LAST_LOG="$log" + fi + return "$rc" +} + +validate_as_test_user() { + local log rc=0 + log="$(mktemp)" + SUDO_USER="$TEST_USER" ./validate_certs_jvm_macos.sh "$@" >"$log" 2>&1 || rc=$? + if [[ "$rc" -eq 0 ]]; then + rm -f "$log" + else + _LAST_LOG="$log" + fi + return "$rc" +} + +dump_last_log() { + [[ -n "${_LAST_LOG:-}" && -f "$_LAST_LOG" ]] || return 0 + echo "--- captured output ---" + cat "$_LAST_LOG" + echo "--- end captured output ---" + rm -f "$_LAST_LOG" + unset _LAST_LOG +} + +#----------------------------------------------------------------------------- +echo +echo "=== 1. positive: install + validate ===" +cleanup +# Positive cases let stdout through so a CI failure shows a useful log; only +# negative cases (where we *expect* exit 1) silence both streams. +SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem +SUDO_USER="$TEST_USER" ./validate_certs_jvm_macos.sh --expected-subject "Lab JVM mac CA Test" +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 2. negative: subject mismatch must exit 1 ===" +if validate_as_test_user --expected-subject "Microsoft Root CA NoMatch"; then + fail_msg "validator should have exited 1 on subject mismatch" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 3. idempotency: 2nd install produces single alias / single plist ===" +install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem +validate_as_test_user --expected-subject "Lab JVM mac CA Test" + +alias_count=$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ + | grep -c "trustedCertEntry" || true) +alias_count=${alias_count:-0} +# JKS extends the JDK's bundled cacerts (~150 public roots) plus exactly one +# corporate-CA alias. After two installs, the corporate alias count must be +# exactly 1; the JDK-supplied aliases stay constant. +corp_count=$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ + | grep -cE "^${CERT_BASENAME:-package-route-custom-ca}[,[:space:]]" || true) +corp_count=${corp_count:-0} +[[ "$corp_count" -eq 1 ]] || fail_msg "expected exactly 1 corporate-CA alias after 2 installs, got $corp_count" +[[ "$alias_count" -ge 100 ]] || fail_msg "expected JKS to extend default cacerts (>=100 aliases), got $alias_count" +[[ -f "$PLIST" ]] || fail_msg "plist missing after 2nd install" +echo " ok (alias_count=$alias_count)" + +#----------------------------------------------------------------------------- +echo +echo "=== 4. custom --cert-name round-trips (alias inside JKS = cert-name) ===" +cleanup +install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem --cert-name zscaler-root +keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ + | grep -q "^zscaler-root," \ + || fail_msg "expected JKS alias 'zscaler-root' (got: $(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null | grep trustedCertEntry))" +validate_as_test_user --expected-subject "Lab JVM mac CA Test" +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 5. negative: path-traversal --cert-name rejected ===" +if install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem --cert-name '../etc/pwned'; then + dump_last_log + fail_msg "installer should have rejected path-traversal --cert-name" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 6. negative: malformed PEM rejected ===" +echo "not a certificate" > /tmp/jvm-mac-bad.pem +if install_as_test_user --use-cert /tmp/jvm-mac-bad.pem; then + dump_last_log + fail_msg "installer should have rejected malformed PEM" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 7. negative: expired CA rejected ===" +rm -f /tmp/jvm-mac-expired.pem +# Try OpenSSL 3.2+'s `-not_before / -not_after`; fall back to negative `-days`. +"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/jvm-mac-expired-k.pem -out /tmp/jvm-mac-expired.pem \ + -subj "/CN=Expired/O=JFrog" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -not_before 20200101000000Z -not_after 20200201000000Z 2>/dev/null \ +|| "$OPENSSL" x509 -req -in <("$OPENSSL" req -new -key /tmp/jvm-mac-test-k.pem -subj "/CN=Expired") \ + -signkey /tmp/jvm-mac-test-k.pem -days -1 -out /tmp/jvm-mac-expired.pem 2>/dev/null \ +|| true + +if [[ ! -f /tmp/jvm-mac-expired.pem ]] || "$OPENSSL" x509 -in /tmp/jvm-mac-expired.pem -checkend 0 -noout >/dev/null 2>&1; then + echo " SKIP: cannot produce a verifiably expired cert with the installed openssl ($(openssl version))" +else + if install_as_test_user --use-cert /tmp/jvm-mac-expired.pem; then + dump_last_log + fail_msg "installer should have rejected expired CA" + fi + echo " ok" +fi + +#----------------------------------------------------------------------------- +echo +echo "=== 8. negative: leaf cert (CA:FALSE) rejected ===" +"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/jvm-mac-leaf-k.pem -out /tmp/jvm-mac-leaf.pem -days 7 \ + -subj "/CN=Leaf Not CA" \ + -addext "basicConstraints=critical,CA:FALSE" 2>/dev/null +if install_as_test_user --use-cert /tmp/jvm-mac-leaf.pem; then + dump_last_log + fail_msg "installer should have rejected leaf cert (CA:FALSE)" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 9. launchctl getenv JAVA_TOOL_OPTIONS in gui/ ===" +cleanup +install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem +if launchctl print "gui/${TEST_UID}" >/dev/null 2>&1; then + # Mirror the installer's 20x100ms retry: bootstrap returns the moment + # the agent is loaded, but the launchctl-setenv ProgramArguments runs + # asynchronously. Without a retry the assertion would flake on GUI + # runners under load. + jto="" + # 20 retries × 100ms = ~2s total — mirrors installer's bootstrap_launch_agent + # to cover the async-setenv window even under EDR load. + # shellcheck disable=SC2034 + for _i in $(seq 1 20); do + jto="$(launchctl asuser "${TEST_UID}" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" + [[ -n "$jto" ]] && break + sleep 0.1 + done + # Accept both quoted and unquoted forms. The post-fix installer writes + # the quoted form so JVM tokenization handles the "Application Support" + # space; older installs left it unquoted. + case "$jto" in + *"trustStore=\"${JKS}\""*|*"trustStore=${JKS}"*) echo " ok" ;; + *) fail_msg "launchctl getenv mismatch (got: $jto)" ;; + esac +else + echo " SKIP: gui/${TEST_UID} not active (CI runner with no GUI session is fine — plist will load at login)" +fi + +#----------------------------------------------------------------------------- +echo +echo "=== 10. plist is well-formed XML and points at the expected JKS ===" +# Reuses the install from step 9 (no cleanup). Independent of whether +# launchctl bootstrap succeeded — validates the write_launch_agent_plist +# code path even on headless CI runners. +plutil -lint "$PLIST" >/dev/null +# Confirm the JTO value inside the plist actually references the expected +# JKS path. plutil -extract pulls the 4th element of ProgramArguments +# (index 3) which is the `-Djavax.net.ssl.trustStore=… …` arg string. +jto_in_plist="$(plutil -extract ProgramArguments.3 raw "$PLIST" 2>/dev/null)" +case "$jto_in_plist" in + *"trustStore=\"${JKS}\""*|*"trustStore=${JKS}"*) echo " ok" ;; + *) fail_msg "plist JTO arg doesn't point at $JKS (got: $jto_in_plist)" ;; +esac + +#----------------------------------------------------------------------------- +echo +echo "=== 11. --all-users iterates eligible accounts ===" +cleanup +# CI runners only have a single user (`runner`, uid 501). That's enough to +# verify iter_all_users does at least one iteration through the filter + +# per-user-chown path; multi-user is covered by the local dev Mac smoke. +# Run without SUDO_USER set so the installer takes the --all-users branch. +out="$(./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem --all-users 2>&1)" +echo "$out" | grep -q "=== User: ${TEST_USER}" \ + || { echo "$out" | tail -20; fail_msg "--all-users did not iterate ${TEST_USER}"; } +echo "$out" | grep -qE "Installed for [0-9]+ user\(s\)" \ + || { echo "$out" | tail -20; fail_msg "--all-users summary line missing"; } +# Per-user files should be owned by the target user (not root). +plist_owner="$(stat -f '%Su' "$PLIST")" +[[ "$plist_owner" == "$TEST_USER" ]] \ + || fail_msg "plist owner=$plist_owner, expected $TEST_USER (chown failed silently?)" +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 12. validate_pem warn paths: 30-day-expiry + bundle ===" +# Short-validity CA (1 day) — within 30 days, should produce the expiry warn +# but install succeed. +"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/jvm-mac-soon-k.pem -out /tmp/jvm-mac-soon.pem -days 1 \ + -subj "/CN=Soon to Expire/O=JFrog" \ + -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null +out="$(SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-soon.pem 2>&1)" +echo "$out" | grep -q "certificate expires within 30 days" \ + || { echo "$out" | tail -20; fail_msg "30-day expiry warn missing"; } + +# Multi-cert bundle: append a second cert to the test CA. The installer +# warns and imports only the first; install must still succeed. +cat /tmp/jvm-mac-test-ca.pem /tmp/jvm-mac-soon.pem > /tmp/jvm-mac-bundle.pem +out="$(SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-bundle.pem 2>&1)" +echo "$out" | grep -qE "PEM file contains [0-9]+ certificates" \ + || { echo "$out" | tail -20; fail_msg "multi-cert bundle warn missing"; } +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 13. negative: missing keytool fails cleanly ===" +# I3 cross-platform parity: build an isolated bin/ containing symlinks to +# every /usr/bin and /bin tool EXCEPT keytool, plus openssl from its real +# location. Then run the installer with PATH=. macOS-latest +# CI has /usr/bin/keytool from the Apple-bundled JavaAppletPlugin, and +# setup-java prepends $JAVA_HOME/bin — neither survives this isolated PATH. +nokey_bin="$(mktemp -d)" +for d in /usr/bin /bin /usr/sbin /sbin; do + [[ -d "$d" ]] || continue + for f in "$d"/*; do + base="$(basename "$f")" + [[ "$base" == keytool ]] && continue + ln -sf "$f" "$nokey_bin/$base" 2>/dev/null || true + done +done +# openssl may live outside /usr/bin (Homebrew on macOS GHA puts it in +# /opt/homebrew/bin). Ensure the isolated bin can find a working openssl. +ln -sf "$OPENSSL" "$nokey_bin/openssl" 2>/dev/null || true +if ! "$nokey_bin/openssl" version >/dev/null 2>&1; then + rm -rf "$nokey_bin" + fail_msg "isolated bin missing openssl — cannot exercise missing-keytool path" +fi +if [[ -e "$nokey_bin/keytool" ]] || env -i PATH="$nokey_bin" command -v keytool >/dev/null 2>&1; then + rm -rf "$nokey_bin" + fail_msg "isolated bin still has keytool — test setup broken" +fi +if env -i PATH="$nokey_bin" HOME="$HOME" SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem >/tmp/jvm-mac-nokey.out 2>&1; then + cat /tmp/jvm-mac-nokey.out | head -20 + rm -rf "$nokey_bin" + fail_msg "installer should have rejected missing keytool" +fi +if ! grep -q -i keytool /tmp/jvm-mac-nokey.out; then + cat /tmp/jvm-mac-nokey.out | head -10 + rm -rf "$nokey_bin" + fail_msg "missing-keytool error message should mention 'keytool'" +fi +rm -rf "$nokey_bin" +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 14. negative: DER cert rejected (C1 cross-platform parity) ===" +# C1 backport: convert the lab CA to DER, then attempt install — should +# fail with a hint to convert back. Mirrors Linux + Windows behavior. +"$OPENSSL" x509 -in /tmp/jvm-mac-test-ca.pem -outform DER -out /tmp/jvm-mac-test-ca.der 2>/dev/null +if SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.der >/tmp/jvm-mac-der.out 2>&1; then + cat /tmp/jvm-mac-der.out | head -10 + fail_msg "installer should have rejected DER-encoded cert" +fi +grep -q -i "PEM-encoded" /tmp/jvm-mac-der.out \ + || { cat /tmp/jvm-mac-der.out | head -10; fail_msg "DER reject message should mention 'PEM-encoded'"; } +echo " ok" + +#----------------------------------------------------------------------------- +# Re-install once so the next three invariants observe the post-fix end state. +cleanup +SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem >/dev/null + +echo +echo "=== 15. JKS extends default cacerts (preserves public roots) ===" +# Regression guard for the "trustStore replaces, not extends" footgun. +# -Djavax.net.ssl.trustStore in OpenJDK swaps the JVM's trust source; a JKS +# holding only the corporate CA would break every public-CA TLS handshake +# (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). +# Installer must therefore cp $JAVA_HOME/lib/security/cacerts first. +alias_count="$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null | grep -c 'trustedCertEntry' || true)" +alias_count="${alias_count:-0}" +[[ "$alias_count" -ge 100 ]] \ + || fail_msg "JKS has $alias_count aliases; expected >= 100 (JDK cacerts ~150 public roots + corporate CA)" +echo " ok ($alias_count aliases)" + +echo +echo "=== 16. JKS contains a well-known public root (DigiCert family) ===" +# Spot-check the merge actually happened. DigiCert root certs ship in every +# JDK's cacerts under several aliases (digicertglobalrootca, digicertglobalrootg2, +# digicerttrustedrootg4, etc.) — case-insensitive substring match catches them all. +keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ + | grep -qi 'digicert' \ + || fail_msg "JKS missing the DigiCert family of public roots; the copy-from-JDK step did not run" +echo " ok" + +echo +echo "=== 17. JAVA_TOOL_OPTIONS round-trips through JVM tokenizer ===" +# Direct repro of the "Application Support" space-tokenisation bug. Spawn a +# child java -version with the LaunchAgent's env var and assert the JVM +# does NOT print "Unrecognized option" — that's what an unquoted trustStore +# path produced before the fix. +jto_seen="$(launchctl asuser "$TEST_UID" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" +if [[ -z "$jto_seen" ]]; then + echo " SKIP: gui/$TEST_UID is not active (no logged-in GUI session) — cannot exercise the tokenizer round-trip" +else + java_out="$(JAVA_TOOL_OPTIONS="$jto_seen" java -version 2>&1 || true)" + if grep -q 'Unrecognized option' <<<"$java_out"; then + printf '%s\n' "$java_out" | head -10 + echo "JTO seen: $jto_seen" + fail_msg "java -version reported 'Unrecognized option' — JAVA_TOOL_OPTIONS tokenization is broken (likely missing inner quotes around the JKS path)" + fi + echo " ok (java -version accepted JTO=$jto_seen)" +fi + +echo +echo "=================================================================" +echo "ALL SMOKE TESTS PASSED" +echo "=================================================================" diff --git a/validate_certs_jvm_macos.sh b/validate_certs_jvm_macos.sh new file mode 100755 index 0000000..e6fa9a5 --- /dev/null +++ b/validate_certs_jvm_macos.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Validate JVM truststore installation done by install_certs_jvm_macos.sh. +# +# Asserts, per user: +# 1. JKS file exists at ~/Library/Application Support/JFrog/package-route-jvm/truststore.jks +# 2. JKS contains a cert whose subject matches --expected-subject +# 3. LaunchAgent plist exists at ~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist +# 4. launchctl getenv JAVA_TOOL_OPTIONS in gui/ returns the JKS path +# (only verifiable when the user is in an active GUI session; warn-not-fail +# otherwise — the plist still loads on next login). +# +# Run: +# bash validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" +# sudo bash validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" --all-users +# +# Exit 0 = all checks passed; 1 = at least one failure. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# validate_certs_jvm_linux.sh — system anchor OR JKS+JTO check +# validate_certs_jvm_windows.ps1 — HKCU\Environment JTO check +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ ! -f "${SCRIPT_DIR}/_jvm_macos_paths.sh" ]]; then + echo "Error: _jvm_macos_paths.sh not found next to this validator (${SCRIPT_DIR})." >&2 + echo " Invoke as ./validate_certs_jvm_macos.sh — not via 'curl | bash'." >&2 + exit 1 +fi +# shellcheck disable=SC1091 +. "${SCRIPT_DIR}/_jvm_macos_paths.sh" + +ALL_USERS=0 +EXPECTED_SUBJECT="" + +usage() { + cat < [--all-users] [--cert-name ] + +Options: + --expected-subject Required. Case-insensitive substring match against the cert subject. + --all-users Iterate /Users/* (UID >= 501). Requires root. + --cert-name Accepted for cross-platform CLI parity with the Linux validator. + Ignored here: macOS matches by subject substring, and the JKS path / + LaunchAgent label are fixed per-user regardless of cert-name. + -h, --help Show this help + +Exits 0 if all checks pass, 1 if any check fails. Result line is qualified +with a count of any non-fatal warnings (e.g. gui/ domain absent on +headless / non-active accounts). +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --all-users) + ALL_USERS=1 + shift + ;; + --expected-subject) + EXPECTED_SUBJECT="${2:?Error: --expected-subject requires a value}" + shift 2 + ;; + --cert-name) + # Cross-platform CLI parity (see usage). A fleet wrapper that + # passes --cert-name to all three validators must not fail on + # macOS. We accept and silently ignore: macOS matches by + # subject substring, not by alias name. + : "${2:?Error: --cert-name requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$EXPECTED_SUBJECT" ]]; then + echo "Error: --expected-subject is required." >&2 + usage >&2 + exit 1 + fi + + if [[ "$ALL_USERS" -eq 1 && "$(id -u)" -ne 0 ]]; then + echo "Error: --all-users requires root (other users' ~/Library is 0700)." >&2 + echo "Use: sudo $0 --all-users --expected-subject ..." >&2 + exit 1 + fi +} + +check_os() { + local os + os="$(uname -s)" + if [[ "$os" != "Darwin" ]]; then + echo "Error: this script supports macOS only (detected: $os)." >&2 + exit 1 + fi +} + +FAIL=0 +WARN=0 +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } +ok() { echo " OK: $1"; } +warn() { echo " WARN: $1"; WARN=$((WARN + 1)); } + +# When iterating --all-users, reads of /Users//Library need root. +# Wrap file tests so the same call works in both single-user and --all-users mode. +file_exists() { + local path="$1" + if [[ -f "$path" ]]; then + return 0 + fi + [[ "$ALL_USERS" -eq 1 ]] && sudo test -f "$path" +} + +get_user_home() { + local user="$1" + local home + home="$(dscl . -read "/Users/${user}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" + if [[ -z "$home" ]]; then + home="$(eval echo "~${user}")" + fi + echo "$home" +} + +iter_all_users() { + local dir base uid + for dir in /Users/*; do + [[ -d "$dir" ]] || continue + base="$(basename "$dir")" + [[ "$base" == "Shared" || "$base" == ".localized" ]] && continue + uid="$(stat -f '%u' "$dir" 2>/dev/null || true)" + [[ -n "$uid" && "$uid" -ge 501 ]] || continue + id -u "$base" >/dev/null 2>&1 || continue + printf '%s\t%s\n' "$base" "$dir" + done +} + +validate_keystore_contains_subject() { + local keystore="$1" storepass="$2" label="$3" + if ! file_exists "$keystore"; then + fail "$label keystore does not exist: $keystore" + return 1 + fi + if ! command -v keytool >/dev/null 2>&1; then + # JKS exists but we can't open it. Don't silently pass the core invariant. + fail "$label keystore present but keytool is not on PATH; cannot verify subject. Install a JDK." + return 1 + fi + + # Capture keytool output explicitly. Reading another user's keystore under + # --all-users requires sudo; the single-user case reads as the current user. + local keytool_output rc + if [[ "$ALL_USERS" -eq 1 ]]; then + keytool_output="$(sudo keytool -list -v -keystore "$keystore" -storepass "$storepass" 2>&1)" + rc=$? + else + keytool_output="$(keytool -list -v -keystore "$keystore" -storepass "$storepass" 2>&1)" + rc=$? + fi + if [[ "$rc" -ne 0 ]]; then + fail "$label: keytool could not read the keystore. Output (first 3 lines):" + printf '%s\n' "$keytool_output" | head -n3 | sed 's/^/ /' + return 1 + fi + + # Two-stage filter via a variable, not a pipe-pair: under set -o pipefail + # the second grep -qi exits early on match and SIGPIPEs the first, which + # poisons the pipeline status and turns positive matches into false negatives. + local owners + owners="$(printf '%s\n' "$keytool_output" | grep -E '^Owner:' || true)" + if ! grep -qi "$EXPECTED_SUBJECT" <<<"$owners"; then + fail "$label has no cert with subject matching: $EXPECTED_SUBJECT" + return 1 + fi + # I8 cross-platform parity (see validate_certs_jvm_linux.sh): refuse to + # validate stores that contain key material. The installer only writes + # trustedCertEntry records, so any PrivateKeyEntry here indicates drift + # — likely a future installer change or hand-edited store. The well-known + # `changeit` password is unsuitable for actual private-key protection. + if printf '%s\n' "$keytool_output" | grep -qE '^Entry type: PrivateKeyEntry'; then + fail "$label contains a PrivateKeyEntry — this truststore must hold only trustedCertEntry records." + return 1 + fi + ok "$label contains cert with subject matching: $EXPECTED_SUBJECT" + return 0 +} + +validate_launch_agent_plist() { + local plist_path="$1" label="$2" + if file_exists "$plist_path"; then + if command -v plutil >/dev/null 2>&1; then + if [[ "$ALL_USERS" -eq 1 ]]; then + if sudo plutil -lint "$plist_path" >/dev/null 2>&1; then + ok "$label plist exists and is well-formed: $plist_path" + return 0 + fi + else + if plutil -lint "$plist_path" >/dev/null 2>&1; then + ok "$label plist exists and is well-formed: $plist_path" + return 0 + fi + fi + fail "$label plist exists but plutil -lint reports it as malformed: $plist_path" + return 1 + fi + ok "$label plist exists: $plist_path" + return 0 + fi + fail "$label LaunchAgent plist not found: $plist_path" + return 1 +} + +validate_launchctl_getenv() { + local target_uid="$1" jks_path="$2" label="$3" + local domain="gui/${target_uid}" + + if ! launchctl print "$domain" >/dev/null 2>&1; then + warn "$label is not in an active GUI session (no $domain); plist will activate at next login." + return 0 + fi + + local seen + seen="$(launchctl asuser "$target_uid" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" + if [[ -z "$seen" ]]; then + fail "$label: $domain is active but launchctl getenv JAVA_TOOL_OPTIONS is empty." + return 1 + fi + + # Accept both quoted and unquoted forms. The installer writes the quoted + # form so the JVM tokenizer respects the space in "Application Support"; + # older installs (pre-bug-fix) wrote the unquoted form and we still want + # the validator to recognise their JKS pointer as valid for diagnosis. + case "$seen" in + *"trustStore=\"${jks_path}\""*|*"trustStore=${jks_path} "*|*"trustStore=${jks_path}") + ok "$label: $domain launchctl getenv JAVA_TOOL_OPTIONS points at $jks_path" + return 0 + ;; + *) + fail "$label: $domain launchctl getenv JAVA_TOOL_OPTIONS does not point at $jks_path (got: $seen)" + return 1 + ;; + esac +} + +validate_for_user() { + local user="$1" home="$2" + local uid + uid="$(id -u "$user")" + local jks="${home}/${JKS_RELATIVE_DIR}/${JKS_BASENAME}" + local plist="${home}/${LAUNCH_AGENT_RELATIVE_DIR}/${LAUNCH_AGENT_BASENAME}" + + echo "Checking user $user (uid=$uid)..." + validate_keystore_contains_subject "$jks" "$JKS_PASSWORD" "$user truststore" || true + validate_launch_agent_plist "$plist" "$user" || true + validate_launchctl_getenv "$uid" "$jks" "$user" || true +} + +main() { + parse_args "$@" + check_os + + echo "Expected subject (case-insensitive substring): $EXPECTED_SUBJECT" + echo + + if [[ "$ALL_USERS" -eq 1 ]]; then + local iter_count=0 user home + while IFS=$'\t' read -r user home; do + validate_for_user "$user" "$home" + iter_count=$((iter_count + 1)) + done < <(iter_all_users) + if [[ "$iter_count" -eq 0 ]]; then + fail "no eligible users found under /Users/* (UID >= 501)" + fi + else + # Default: validate the invoking user. If invoked via sudo, use SUDO_USER; + # otherwise the current $USER. + local user + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + user="$SUDO_USER" + else + user="$(id -un)" + fi + if [[ "$user" == "root" ]]; then + fail "cannot validate as root without --all-users (no home to inspect)" + else + local home + home="$(get_user_home "$user")" + if [[ -z "$home" || ! -d "$home" ]]; then + fail "home directory not found for $user" + else + validate_for_user "$user" "$home" + fi + fi + fi + + echo "---------------------------------------------------" + if [[ "$FAIL" -eq 0 ]]; then + if [[ "$WARN" -eq 0 ]]; then + echo "Result: All checks passed." + else + # Qualify so a green exit doesn't over-promise. The common case + # is "gui/ domain absent" (validator can't verify launchctl + # getenv without an active GUI session — plist will activate at + # next login). + echo "Result: All checks passed (with $WARN warning(s) — see above)." + fi + exit 0 + else + echo "Result: $FAIL check(s) failed (and $WARN warning(s))." + exit 1 + fi +} + +main "$@" From 4053c619fc090708aeeeafb0bdaf2009641a434b Mon Sep 17 00:00:00 2001 From: Michael Akushsky Date: Tue, 9 Jun 2026 16:59:47 +0300 Subject: [PATCH 3/6] DFLOW-151 - JVM client setup (Windows) Adds a Windows client-side installer + validator that wires a corporate CA into the JVM trust path so Maven / Gradle / sbt / Apache Ivy traffic redirected through package-reroute validates correctly. JVM trust only -- does not configure Node/npm/Python and does not touch Docker credentials. Pair with install_certs_windows.ps1 for those flows. Based on the published research wiki (DFLOW-136): https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931 Sibling to DFLOW-150 (Linux) and DFLOW-152 (macOS). Single path on Windows -- there is no Windows-ROOT trustStoreType option because the Gradle Daemon caches a stale store (gradle/gradle#6584): 1. Build a per-user JKS truststore at %LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks 2. Set JAVA_TOOL_OPTIONS at User scope via [Environment]::SetEnvironmentVariable(..., 'User') which writes HKCU\Environment AND broadcasts WM_SETTINGCHANGE. No Administrator required (User-scope writes to HKCU\Environment without elevation; %LOCALAPPDATA% is per-user). Files: install_certs_jvm_windows.ps1 Installer (~370 lines). validate_certs_jvm_windows.ps1 Companion validator. _jvm_windows_paths.ps1 Shared constants dot-sourced. testing/test_install_certs_jvm_windows.ps1 14-invariant smoke runner. .github/workflows/ci.yml New test-windows-jvm job. README.md New "Windows (JVM)" section. Hardening fixes folded in from a round of pr-review-toolkit review (5 agents, 28 findings: 2 Critical + 21 Important + 5 Minor): Critical: - SetEnvironmentVariable round-trip verify. Windows 10 1607+ silently truncates user-env values > 2047 chars rather than throwing; future JTO extensions would corrupt HKCU. Now reads back and hard-fails on mismatch. - Test runner captures $LASTEXITCODE IMMEDIATELY after the native powershell.exe call, BEFORE the Out-String pipeline. Without this an intervening pipeline error leaves $rc carrying a stale value from a previous step and -ExpectFail can phantom-pass. Important production-code: - Require-Keytool now probes `keytool -help` to reject corrupt 0-byte stubs and broken-runtime keytools. - Build-JksTruststore post-import verify: runs keytool -list and asserts at least one trustedCertEntry, catching JBR-bundled keytool cases where rc=0 hides a non-standard java.security provider list. - Build-JksTruststore precondition checks for $env:LOCALAPPDATA existence + readability (OneDrive Known-Folder-Move, roaming-profile failures get actionable diagnostics). - Test-CaCertificate basicConstraints comment documents the legacy-cert behavior (extension absent -> accept, matches Linux/macOS siblings). - Maintenance comment on $prevEAP capture / restore. - Set-JavaToolOptions now quotes the trustStorePassword value. - Build-JksTruststore surfaces keytool's non-failure stderr warnings (JKS deprecation, weak-algo notices) on the success path. - Test-UserEnvVar regex anchors both branches so a path like $JksPath.bak.pkcs12 doesn't false-positive as a prefix match. - Test-UserEnvVar warns on Machine-scope sibling env vars to flag mixed-scope misconfig. - Validator's WarnCount infrastructure now actually fires on the Machine-scope path. Important test-runner: - Invoke-Keytool helper wraps inline `keytool -list` in tests with EAP=Continue, matching the installer/validator pattern. JDK 17+ crypto-policy notices to stderr won't terminate the test. - Cleanup() switched from -ErrorAction SilentlyContinue to probe-then- warn so locked $JksDir from a leaked keytool.exe child surfaces. - openssl version banner + warning when < 3.2 (required by test #7). - 4 new tests raise the matrix from 10 to 14: JTO env var replaces on re-install (catches future append-mode regression), missing keytool fails cleanly, mandatory -UseCert no-args fails non-interactively, docstring split #10 -> #10+#11 (30-day expiry vs bundle warn). - Explicit exit 0 so child-rc from negative tests doesn't leak to the shell wrapper. Docs / future-proofing: - PowerShell 5.1+ named as a requirement in installer header. - README "JDK-version-agnostic" claim qualified ("across currently- supported JDKs; JKS format still read by JDK 8-25; future JDK dropping JKS would require a format bump"). - README "covered for free" for Gradle auto-provisioned JDKs now cross-references the Gradle Daemon caveat. - README validation row clarifies that basicConstraints absent -> accept; CA:TRUE required only when extension present. - _jvm_windows_paths.ps1 header documents the "UTF-8 BOM + ASCII-only content" guard for PowerShell 5.1's Windows-1252 default; em-dashes in source files terminate string literals mid-line under cp1252 decoding and the parser reports a baffling error 100+ lines later. Smoke matrix: 14/14 green on windows-latest in CI (Test (Windows JVM) job, Temurin 21 via actions/setup-java). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 15 + README.md | 86 ++++ _jvm_windows_paths.ps1 | 53 +++ install_certs_jvm_windows.ps1 | 440 +++++++++++++++++++ testing/test_install_certs_jvm_windows.ps1 | 473 +++++++++++++++++++++ validate_certs_jvm_windows.ps1 | 179 ++++++++ 6 files changed, 1246 insertions(+) create mode 100644 _jvm_windows_paths.ps1 create mode 100644 install_certs_jvm_windows.ps1 create mode 100644 testing/test_install_certs_jvm_windows.ps1 create mode 100644 validate_certs_jvm_windows.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe78001..090452c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,21 @@ jobs: shell: pwsh run: ./testing/test_install_certs_windows.ps1 + test-windows-jvm: + name: Test (Windows JVM) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Run Windows JVM smoke matrix + shell: pwsh + run: ./testing/test_install_certs_jvm_windows.ps1 + test-linux-jvm: name: Test (Linux JVM) runs-on: ubuntu-latest diff --git a/README.md b/README.md index 047a3ce..03687ef 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ Reference: research wiki [Maven Support in package-reroute (DFLOW-136 / DFLOW-11 | **_jvm_linux_paths.sh** | Linux (JVM) | Shared constants dot-sourced by installer + validator. Not directly executable. | | **install_certs_windows.ps1** | Windows | Install cert, set env vars (Node/Python/Ruby), and clear Docker Hub credentials | | **validate_install_windows.ps1** | Windows | Validate PEM and env config | +| **install_certs_jvm_windows.ps1** | Windows (JVM) | Install CA for Maven/Gradle/sbt/Ivy: JKS at `%LOCALAPPDATA%` + User-scope `JAVA_TOOL_OPTIONS` | +| **validate_certs_jvm_windows.ps1** | Windows (JVM) | Validate JVM truststore install (JKS subject + User-scope env var) | +| **_jvm_windows_paths.ps1** | Windows (JVM) | Shared constants dot-sourced by installer + validator. Not directly executable. | Environment variables by platform (see each section for details): @@ -658,6 +661,88 @@ Use a substring from your CA subject as `` (find it with `op --- +## Windows (JVM): install_certs_jvm_windows.ps1 + +### Overview + +`install_certs_jvm_windows.ps1` wires a custom CA certificate into the JVM trust path on Windows so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_windows.ps1` if you need those. + +Single path on Windows — there is no OS-trust fallback. `-Djavax.net.ssl.trustStoreType=Windows-ROOT` was historically broken under Gradle ([gradle/gradle#6584](https://github.com/gradle/gradle/issues/6584), fixed in Gradle 8.3) and remains less uniform than the JKS recipe across our supported toolchains. The script: + +1. Builds a per-user JKS truststore at `%LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks` containing only the customer CA. +2. Sets `JAVA_TOOL_OPTIONS` at **User** scope via `[Environment]::SetEnvironmentVariable(…, 'User')`, which writes `HKCU\Environment` and broadcasts `WM_SETTINGCHANGE`. New JVM processes started after the broadcast inherit the env var; daemons and long-running IDEs need a fresh session. + +**No Administrator required** — the User scope writes to `HKCU\Environment` without elevation, and `%LOCALAPPDATA%` is per-user. + +### Requirements + +- **Windows** with PowerShell 5.1+. +- **`keytool.exe`** reachable via `JAVA_HOME` (set by Adoptium / Corretto / Microsoft / Zulu installers and by `actions/setup-java`) or on `PATH`. +- The cert's syntactic validation (parseable X.509, expiry, `CA:TRUE`) uses .NET's `System.Security.Cryptography.X509Certificates`, so **no `openssl` dependency** for the install path itself. + +### Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `-UseCert ` | **Yes** | Path to an existing PEM/CRT certificate file. Validation: parseable X.509, not expired, and if the basicConstraints extension is present then `CA:TRUE` is required (a cert that omits the extension entirely is accepted — matches OpenSSL/keytool default behavior). Bundles emit a warning (only the first cert imports). | +| `-CertName ` | No (default: `package-route-custom-ca`) | Alias under which the CA is stored inside the JKS. Cosmetic — affects only `keytool -list` output. JKS path and env var name are fixed per-user. Must match `[A-Za-z0-9._-]+`. | + +No `-AllUsers` (User-scope env var is per-user by construction; each developer runs the installer in their own session). No `-Mode` — we ship only the JKS + `JAVA_TOOL_OPTIONS` recipe; see Caveats for why Windows-ROOT is not exposed. + +### Examples + +```powershell +# Single user +powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseCert C:\tmp\ZscalerRoot.pem + +# Custom alias +powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseCert C:\tmp\ca.pem -CertName zscaler-root +``` + +### Validation: validate_certs_jvm_windows.ps1 + +**`-ExpectedSubject` is required.** Asserts: +- JKS file exists at the per-user path. +- `keytool -list -v` shows an `Owner:` line matching the substring (case-insensitive). +- `[Environment]::GetEnvironmentVariable('JAVA_TOOL_OPTIONS', 'User')` returns a value referencing the expected JKS path. + +```powershell +powershell -ExecutionPolicy Bypass -File validate_certs_jvm_windows.ps1 -ExpectedSubject "O=Zscaler" +``` + +Exit code 0 if all checks pass, 1 otherwise. Result line is qualified with a count of any non-fatal warnings. + +### Caveats + +- **New sessions only.** `WM_SETTINGCHANGE` reaches Explorer and a few shells but most JVM-launching processes (Gradle Daemon, IntelliJ, Maven via the wrapper) cache their environment at startup. Open a new PowerShell/cmd or log off/on after install. +- **Gradle Daemon caching.** Run `gradle --stop` after onboarding so the daemon re-reads `JAVA_TOOL_OPTIONS` at next start. +- **`Picked up JAVA_TOOL_OPTIONS:` banner.** Every JVM startup prints this to stderr. CI parsers that strict-match empty-stderr need to tolerate it. +- **`changeit` truststore password.** OpenJDK convention; *not* a secret. The JKS holds only public CA certificates and the password protects file integrity, not contents. +- **JKS extends the JDK's bundled cacerts.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The installer copies `$env:JAVA_HOME\lib\security\cacerts` to `%LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks` first, then `keytool -importcert` appends the corporate CA. The resulting store has ~150 public roots **plus** the corporate one. +- **Windows-ROOT trustStoreType is excluded by design.** `-Djavax.net.ssl.trustStoreType=Windows-ROOT` would point JVMs at the system Trusted Root store directly. The Gradle Daemon stale-snapshot bug that historically made this unsafe (gradle/gradle#6584) was fixed in Gradle 8.3 via [gradle/gradle#25106](https://github.com/gradle/gradle/pull/25106), but we still ship only the JKS+`JAVA_TOOL_OPTIONS` recipe so that (a) the trust source is uniform across Linux/macOS/Windows, and (b) developers on Gradle < 8.3 are not silently affected. A future ticket could add a `-TrustStoreType Windows-ROOT` flag for organisations standardised on Gradle ≥ 8.3. +- **Machine scope is excluded.** v1 is User-scope only. Fleet/Intune rollouts that need `HKLM\Environment` should re-run the script per user via a logon script or use a future `-Scope Machine` flag (separate ticket). +- **`%USERPROFILE%\.gradle\jdks\`** is Gradle's auto-provisioned JDK location. It's covered for free because `JAVA_TOOL_OPTIONS` is read by *every* JVM the user launches, regardless of where the JDK came from — subject to the Gradle Daemon caveat above (the daemon caches its environment at startup, so a newly-provisioned toolchain JDK only picks up `JAVA_TOOL_OPTIONS` after `gradle --stop`). + +### Testing + +`./testing/test_install_certs_jvm_windows.ps1` runs the 10-invariant smoke matrix on `windows-latest` in CI and locally. Each run targets the current user's `%LOCALAPPDATA%` + `HKCU` and cleans up via try/finally. + +```powershell +powershell -ExecutionPolicy Bypass -File testing\test_install_certs_jvm_windows.ps1 +``` + +The same matrix runs on every push and pull request via `.github/workflows/ci.yml` (`test-windows-jvm` job). + +### Summary (Windows JVM) + +- **No Administrator required.** Single cert source via `-UseCert`. +- Per-user JKS at `%LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks`. +- User-scope `JAVA_TOOL_OPTIONS` in `HKCU\Environment`, broadcast via `WM_SETTINGCHANGE`. +- **Idempotent**, **re-runnable**, **JDK-version-agnostic** across currently-supported JDKs (JKS format is still read by JDK 8–25; a future JDK that drops JKS support would require an installer-side format bump). New JDK installs do not require re-running the script. +- New sessions for activation; `gradle --stop` for the Gradle Daemon. + +--- + ## Continuous integration On **push** and **pull request** to `main` or `master`, GitHub Actions runs: @@ -667,6 +752,7 @@ On **push** and **pull request** to `main` or `master`, GitHub Actions runs: | Test (macOS) | `macos-latest` | `sudo ./testing/test_install_certs_macos.sh` | | Test (macOS JVM) | `macos-latest` | `sudo ./testing/test_install_certs_jvm_macos.sh` | | Test (Windows) | `windows-latest` | `./testing/test_install_certs_windows.ps1` (PowerShell) | +| Test (Windows JVM) | `windows-latest` | `./testing/test_install_certs_jvm_windows.ps1` (PowerShell) | | Test (Linux JVM) | `ubuntu-latest` | `./testing/test_install_certs_jvm_linux.sh` | There is no CI job for the Debian/Ubuntu scripts in this workflow. diff --git a/_jvm_windows_paths.ps1 b/_jvm_windows_paths.ps1 new file mode 100644 index 0000000..f30b528 --- /dev/null +++ b/_jvm_windows_paths.ps1 @@ -0,0 +1,53 @@ +# (c) JFrog Ltd. (2026) +# Shared constants for install_certs_jvm_windows.ps1 and validate_certs_jvm_windows.ps1. +# Dot-sourced from both scripts; not directly executable. +# +# IMPORTANT for future maintainers: keep all .ps1 files in this set saved as +# UTF-8 WITH BOM and use ASCII-only content. Windows PowerShell 5.1 reads +# files without a BOM as Windows-1252; an em-dash (U+2014) bytes +# (e2 80 94) then decode as "a-tilde, euro, right-double-quote" -- the +# trailing U+201D quote terminates string literals mid-line and the parser +# reports a confusing error 100+ lines later. The BOM forces UTF-8 parsing. +# ASCII-only content avoids the issue regardless. +# +# Both scripts read this file via: +# $ScriptDir = Split-Path -Parent $PSCommandPath +# . (Join-Path $ScriptDir '_jvm_windows_paths.ps1') +# +# Keep installer and validator in lockstep by changing only this file. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# _jvm_linux_paths.sh - system anchor (Path A) / JKS+JTO (Path B) +# _jvm_macos_paths.sh - per-user JKS under ~/Library + +# Default base name used as the JKS alias inside the per-user truststore. +# Overridable via -CertName on the installer (affects ONLY the alias name +# visible in `keytool -list` output -- the JKS file path and the User-scope +# JAVA_TOOL_OPTIONS env var name are fixed, so a different -CertName on +# re-run replaces the previous CA rather than installing alongside it). +$JvmWindowsDefaultCertBasename = 'package-route-custom-ca' + +# Per-user JKS truststore under %LOCALAPPDATA% so the User-scope env var +# can point at it without crossing user boundaries. Matches the per-user +# scope of the HKCU\Environment write. +$JvmWindowsJksRelativeDir = 'JFrog\package-route-jvm' +$JvmWindowsJksBasename = 'truststore.jks' + +# Function: returns $env:LOCALAPPDATA-relative JKS path. The validator and +# installer both derive their target path through this helper so the +# shape stays in lockstep across files. +function Get-JvmWindowsJksPath { + Join-Path $env:LOCALAPPDATA (Join-Path $JvmWindowsJksRelativeDir $JvmWindowsJksBasename) +} + +# OpenJDK convention for cacerts and similar truststores. NOT a secret in +# this script's use case: we import only `trustedCertEntry` records (public +# CA certs), so the password protects file *integrity* via the keystore MAC +# but not any private key material. A JKS holding PrivateKeyEntry would +# additionally rely on this password to encrypt the key -- not relevant here. +# Persisted in JAVA_TOOL_OPTIONS via -Djavax.net.ssl.trustStorePassword so +# unattended JVMs can open the store. +$JvmWindowsJksPassword = 'changeit' + +# Environment variable name. Fixed because the install path doesn't multi-cert. +$JvmWindowsEnvVarName = 'JAVA_TOOL_OPTIONS' diff --git a/install_certs_jvm_windows.ps1 b/install_certs_jvm_windows.ps1 new file mode 100644 index 0000000..9f8e815 --- /dev/null +++ b/install_certs_jvm_windows.ps1 @@ -0,0 +1,440 @@ +# (c) JFrog Ltd. (2026) +# Install a custom CA certificate on Windows for JVM clients (Maven, Gradle, +# sbt, Apache Ivy). +# +# Single path: build a per-user JKS truststore at +# %LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks +# containing only the customer CA, then set JAVA_TOOL_OPTIONS at User scope +# (HKCU\Environment + WM_SETTINGCHANGE broadcast) so every new JVM startup +# inherits the trustStore path. +# +# Run: +# powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseCert C:\path\to\ca.pem [-CertName ] +# +# Notes: +# - Windows only. +# - Requires PowerShell 5.1+ (Windows PowerShell or PowerShell 7). +# - User scope only -- does NOT require Administrator. No Machine-scope +# option in v1 (intentional; the Wiki recommends user-scope for +# developer machines). +# - JVM trust only -- does not configure Node/npm/Python and does not +# touch Docker credentials. Pair with install_certs_windows.ps1 for those. +# - Existing processes need a logoff/logon (or to handle WM_SETTINGCHANGE) +# before they see the new env var. Most daemons don't; restart Gradle +# Daemon via `gradle --stop` and restart your IDE after install. +# - The "use the OS trust store" alternative (-Djavax.net.ssl.trustStoreType= +# Windows-ROOT) is deliberately not exposed in v1. The Gradle daemon +# stale-cache issue (gradle/gradle#6584) was fixed in Gradle 8.3 via +# gradle/gradle#25106, but the JKS+JAVA_TOOL_OPTIONS recipe stays uniform +# across Linux/macOS/Windows and works for developers on older Gradle. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# install_certs_jvm_linux.sh - update-ca-trust OR JKS+JAVA_TOOL_OPTIONS +# install_certs_jvm_macos.sh - LaunchAgent + per-user JKS +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$UseCert, + + [Parameter(Mandatory = $false)] + [string]$CertName +) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $PSCommandPath +. (Join-Path $ScriptDir '_jvm_windows_paths.ps1') + +if (-not $CertName) { + $CertName = $JvmWindowsDefaultCertBasename +} + +function Show-Usage { + @' +Usage: + powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseCert [-CertName ] + +Parameters: + -UseCert Path to an existing PEM/CRT certificate file (required). + Validation: parseable X.509, not expired, basicConstraints CA:TRUE. + -CertName Alias under which the CA is stored inside the JKS + truststore (default: package-route-custom-ca). Cosmetic -- + affects only `keytool -list` output. JKS path and env + var name are fixed per-user. + +Notes: + No -AllUsers flag -- User-scope env var is per-user by construction; each + developer runs the installer in their own session. There is no -Mode + flag (no OS-trust fallback by design: Windows-ROOT is not exposed in v1 + -- the daemon stale-cache issue gradle/gradle#6584 is fixed in Gradle 8.3, + but the JKS recipe stays uniform across platforms). + +Examples: + powershell -File install_certs_jvm_windows.ps1 -UseCert C:\tmp\ZscalerRoot.pem + powershell -File install_certs_jvm_windows.ps1 -UseCert C:\tmp\ca.pem -CertName zscaler-root +'@ +} + +function Test-CertName { + param([string]$Name) + if ($Name -notmatch '^[A-Za-z0-9._-]+$') { + Write-Error "Error: -CertName must match [A-Za-z0-9._-]+ (got: $Name). Path-traversal characters are rejected." + exit 1 + } +} + +# Port of the Linux/macOS hardened validate_pem. Uses the built-in +# System.Security.Cryptography.X509Certificates type so it works without +# openssl on stock Windows (PowerShell 5.1+ ships .NET; PowerShell 7 +# bundles its own .NET runtime). Rejects: not parseable, expired, +# CA:FALSE (leaf cert). Warns on: expiring within 30 days, multi-cert +# bundle (keytool -importcert -noprompt reads only the first cert). +function Test-CaCertificate { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Write-Error "Error: certificate file not found: $Path" + exit 1 + } + + # C1 cross-platform parity (matches Linux + macOS siblings): require PEM + # text input. X509Certificate2 happily parses DER, but the bash siblings + # reject DER for predictable cross-platform behaviour, and keytool's + # -importcert in PEM-text mode is what we exercise downstream. + $textPeek = [System.IO.File]::ReadAllText($Path) + if ($textPeek -notmatch '-----BEGIN CERTIFICATE-----') { + Write-Error "Error: certificate is not PEM-encoded: $Path. If it's DER, convert first: openssl x509 -inform der -in $Path -out $Path.pem" + exit 1 + } + + $bytes = [System.IO.File]::ReadAllBytes($Path) + $cert = $null + try { + $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$bytes) + } catch { + Write-Error "Error: invalid PEM/CRT certificate file: $Path ($($_.Exception.Message))" + exit 1 + } + + # Reject expired anchors: keytool -importcert -noprompt accepts them silently + # and the user gets cryptic CertificateExpiredException at TLS handshake time. + $now = [DateTime]::UtcNow + if ($cert.NotAfter.ToUniversalTime() -lt $now) { + Write-Error "Error: certificate has already expired: $Path (NotAfter=$($cert.NotAfter))" + exit 1 + } + + # Warn (don't fail) on a cert expiring within 30 days. + # I23 parity: the 30-day window matches Linux JVM_LINUX_EXPIRY_WARN_SECONDS + # (_jvm_linux_paths.sh, 2592000s = 30d) and the macOS `-checkend 2592000` + # sibling. Change all three together -- there is no single source of truth + # across the three platforms. + if ($cert.NotAfter.ToUniversalTime() -lt $now.AddDays(30)) { + Write-Warning ("certificate expires within 30 days: {0} (NotAfter={1})" -f $Path, $cert.NotAfter) + } + + # Reject leaf certs: a cert without CA:TRUE in basicConstraints will import + # into a JKS truststore but PKIX path-building won't use it as a trust anchor. + # + # Caveat (matches Linux/macOS siblings): a cert that OMITS the + # basicConstraints extension entirely (rare on modern roots; legal for + # some legacy self-signed CAs) is treated as "don't know, allow" -- same + # behavior as OpenSSL's `-ext basicConstraints` returning empty. PKIX + # will then accept the cert as a trust anchor based on its keyUsage / + # explicit-trust-anchor status. The hard rejection only fires when the + # extension is present and explicitly says CA:FALSE. + $bcExt = $cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.19' } | Select-Object -First 1 + if ($bcExt) { + $bc = [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]$bcExt + if (-not $bc.CertificateAuthority) { + Write-Error "Error: certificate is not a CA (basicConstraints CA:FALSE): $Path. JKS imports succeed but PKIX rejects non-CA trust anchors." + exit 1 + } + } + + # Warn on bundles: keytool -importcert -noprompt reads only the first cert, + # silently dropping intermediates. Count `BEGIN CERTIFICATE` markers in + # the file (works on both binary DER and text PEM -- DER files have 0). + $content = [System.IO.File]::ReadAllText($Path) + $count = ([regex]::Matches($content, '-----BEGIN CERTIFICATE-----')).Count + if ($count -gt 1) { + Write-Warning ("PEM file contains {0} certificates; only the first will be imported as the JVM trust anchor. Supply only the root CA (or split the bundle) if intermediates are needed." -f $count) + } +} + +# Locate keytool. Prefer JAVA_HOME\bin (set by actions/setup-java and by +# standard JDK installers); fall back to PATH for IDE-bundled JBR setups +# that aren't reflected in JAVA_HOME. +function Resolve-Keytool { + if ($env:JAVA_HOME) { + $candidate = Join-Path $env:JAVA_HOME 'bin\keytool.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + } + $cmd = Get-Command keytool.exe -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + return $null +} + +# Locate the JDK's default cacerts file. Mirrors the Linux + macOS siblings: +# -Djavax.net.ssl.trustStore in OpenJDK REPLACES the JVM trust source rather +# than extending it -- pointing JVMs at a JKS holding only the corporate CA +# would break every public-CA TLS handshake. Copy the JDK's bundled cacerts +# into the target keystore first, then keytool -importcert appends the +# corporate CA, so the merged store has ~150 public roots PLUS the corporate +# one. +# +# Resolution: $JAVA_HOME first, then dir-of-keytool/../lib/security/cacerts +# (works for stock Adoptium / Corretto / Microsoft / Zulu JDK layouts where +# bin/keytool.exe and lib/security/cacerts are siblings under the JDK home). +function Resolve-JdkCacerts { + if ($env:JAVA_HOME) { + $candidate = Join-Path $env:JAVA_HOME 'lib\security\cacerts' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + } + $keytool = Resolve-Keytool + if ($keytool) { + $keytoolDir = Split-Path -Parent $keytool + $candidate = Join-Path $keytoolDir '..\lib\security\cacerts' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return (Resolve-Path -LiteralPath $candidate).Path + } + } + Write-Error @' +Error: cannot locate the JDK's default cacerts file. + Set JAVA_HOME, or ensure keytool.exe resolves under a standard JDK bin/ layout. + Tried: $JAVA_HOME\lib\security\cacerts, \..\lib\security\cacerts +'@ + exit 1 +} + +function Require-Keytool { + $kt = Resolve-Keytool + if (-not $kt) { + Write-Error @' +Error: keytool.exe not found. + - Install a JDK (Adoptium Temurin, Amazon Corretto, Microsoft Build of OpenJDK, Azul Zulu, etc.) + - Ensure JAVA_HOME points at the JDK install dir, OR add $JAVA_HOME\bin to PATH. +'@ + exit 1 + } + + # Probe `keytool -help` to reject corrupt 0-byte stubs (leftover from a + # failed JDK uninstall) and to catch broken-runtime cases where the + # binary exists but won't execute cleanly. EAP=Continue around the call + # because some JDKs print informational lines to stderr even on -help. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $probeOutput = & $kt -help 2>&1 + } catch { + $ErrorActionPreference = $prevEAP + Write-Error "Error: keytool.exe at $kt threw on probe: $($_.Exception.Message). Reinstall the JDK." + exit 1 + } finally { + $ErrorActionPreference = $prevEAP + } + if ($LASTEXITCODE -ne 0) { + Write-Error ("Error: keytool.exe at {0} does not execute cleanly (rc={1}). Reinstall the JDK.`nProbe output (first 5 lines):`n {2}" ` + -f $kt, $LASTEXITCODE, (($probeOutput | Select-Object -First 5) -join "`n ")) + exit 1 + } + + return $kt +} + +function Build-JksTruststore { + param( + [string]$JksPath, + [string]$CertPath, + [string]$Alias, + [string]$Password + ) + + # Precondition: %LOCALAPPDATA% must exist and be writable. OneDrive + # Known-Folder-Move, roaming-profile misconfiguration, and IT GPO + # restrictions are the common failure modes; without this check the + # New-Item below would throw a generic .NET path message that doesn't + # hint at profile redirection. + if ([string]::IsNullOrEmpty($env:LOCALAPPDATA)) { + Write-Error 'Error: %LOCALAPPDATA% is empty. Cannot place the JKS truststore. Are you running under a service account or a profile that has not been provisioned?' + exit 1 + } + if (-not (Test-Path -LiteralPath $env:LOCALAPPDATA)) { + Write-Error ("Error: %LOCALAPPDATA% ({0}) does not exist on this filesystem. OneDrive Known-Folder-Move or roaming-profile failure?" -f $env:LOCALAPPDATA) + exit 1 + } + + $jksDir = Split-Path -Parent $JksPath + if (-not (Test-Path -LiteralPath $jksDir)) { + New-Item -ItemType Directory -Path $jksDir -Force | Out-Null + } + + $keytool = Require-Keytool + $srcCacerts = Resolve-JdkCacerts + Write-Host (" [JKS] Building truststore at {0} (extending {1})" -f $JksPath, $srcCacerts) + + # Copy the JDK's bundled cacerts (~150 public root CAs) as the base so the + # merged store keeps trusting Maven Central, Let's Encrypt, etc. Without + # this, -Djavax.net.ssl.trustStore would REPLACE the JVM's trust source + # and break every public-CA handshake. Copy-Item -Force overwrites any + # prior JKS, guaranteeing idempotent end-state: each run starts from the + # canonical JDK cacerts plus exactly one corporate-CA alias. + Copy-Item -LiteralPath $srcCacerts -Destination $JksPath -Force + + # keytool.exe writes "Certificate was added to keystore" to STDERR (yes, + # really -- it's been doing this since the Sun era). Under + # $ErrorActionPreference='Stop' PowerShell promotes any native-command + # stderr output to a terminating error before 2>&1 has a chance to + # capture it. Switch to Continue for the duration of the call so we can + # examine $LASTEXITCODE ourselves. + # + # Maintenance note: $prevEAP captures the script-scope EAP. Today that's + # 'Stop' (line 33). If the script-scope default ever changes, this + # restore-via-finally still restores to whatever was set -- but the + # rest of the script's stop-on-error contract would then have to be + # re-audited. + # + # No -storetype flag: modern JDKs default cacerts to PKCS12 and keytool + # autodetects the format from the existing file. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $keytoolOutput = & $keytool ` + -importcert -noprompt ` + -alias $Alias ` + -file $CertPath ` + -keystore $JksPath ` + -storepass $Password 2>&1 + } finally { + $ErrorActionPreference = $prevEAP + } + if ($LASTEXITCODE -ne 0) { + Write-Error ("Error: keytool -importcert failed for {0}.`nOutput:`n {1}" -f $JksPath, ($keytoolOutput -join "`n ")) + exit 1 + } + + # keytool emits stderr warnings (JKS deprecation on JDK 17+, weak-algo + # advisories, etc.) WITH rc=0. The success branch should surface them + # rather than silently discard, otherwise customers see no warning until + # JDK 25 makes JKS read-only. + if ($keytoolOutput) { + $kOut = ($keytoolOutput | Where-Object { $_ -and $_.ToString().Trim() }) -join "`n " + if ($kOut) { + Write-Host " [JKS] keytool output:`n $kOut" + } + } + + # Post-import verification: a JBR-bundled keytool can rc=0 without + # actually writing a trustedCertEntry if the JKS provider was stripped + # from java.security. Confirm the entry exists; on mismatch hard-fail + # so the operator can switch to a real JDK keytool. + $ErrorActionPreference = 'Continue' + try { + $listOutput = & $keytool -list -keystore $JksPath -storepass $Password 2>&1 + } finally { + $ErrorActionPreference = $prevEAP + } + if (-not ($listOutput | Select-String -Pattern 'trustedCertEntry' -Quiet)) { + Write-Error ("Error: keytool -importcert reported rc=0 but the keystore at {0} contains no trustedCertEntry. The resolved keytool ({1}) may be an IDE-bundled JBR with a non-standard provider list. Try a stock JDK (Adoptium / Corretto)." ` + -f $JksPath, $keytool) + exit 1 + } + + Write-Host (" [JKS] OK: alias={0}" -f $Alias) +} + +function Set-JavaToolOptions { + param( + [string]$JksPath, + [string]$Password + ) + + # User scope = HKCU\Environment. [Environment]::SetEnvironmentVariable + # also broadcasts WM_SETTINGCHANGE, so processes that handle the message + # (Explorer, some shells) pick up the value without a logoff. Most JVM + # toolchains do not -- daemons and IDE processes still need a fresh + # session before the env var reaches a new java -version. + # + # Both trustStore and trustStorePassword values are quoted so a future + # password change to one containing spaces doesn't tokenize wrongly when + # the JVM splits JAVA_TOOL_OPTIONS. + $jtoValue = '-Djavax.net.ssl.trustStore="{0}" -Djavax.net.ssl.trustStorePassword="{1}"' -f $JksPath, $Password + + Write-Host (" [Env] Setting User-scope {0}" -f $JvmWindowsEnvVarName) + [Environment]::SetEnvironmentVariable($JvmWindowsEnvVarName, $jtoValue, [EnvironmentVariableTarget]::User) + + # Round-trip verify: on Windows 10 1607+ a SetEnvironmentVariable value + # exceeding 2047 chars is silently truncated rather than throwing. A + # future feature that lengthens JAVA_TOOL_OPTIONS (e.g. adds -Dhttps + # proxy flags) would leave the user with a half-written value and the + # validator complaining that JKS path doesn't match -- exactly the kind + # of silent failure the project bans. + $readBack = [Environment]::GetEnvironmentVariable($JvmWindowsEnvVarName, [EnvironmentVariableTarget]::User) + if ($readBack -ne $jtoValue) { + Write-Error ("Error: HKCU\Environment round-trip verify failed for {0}.`n Wrote ({1} chars): {2}`n Read ({3} chars): {4}" ` + -f $JvmWindowsEnvVarName, $jtoValue.Length, $jtoValue, ($readBack.Length), $readBack) + exit 1 + } + Write-Host " [Env] OK" + + return $jtoValue +} + +function Show-DoneSummary { + param( + [string]$JksPath, + [string]$Alias, + [string]$JtoValue + ) + + Write-Host "" + Write-Host "Truststore:" + Write-Host (" {0} (alias: {1})" -f $JksPath, $Alias) + Write-Host ("{0}:" -f $JvmWindowsEnvVarName) + Write-Host (" {0}" -f $JtoValue) + Write-Host "" + Write-Host "Notes:" + Write-Host " - The User-scope env var is written to HKCU\Environment and broadcast" + Write-Host " via WM_SETTINGCHANGE. NEW processes started after this point inherit" + Write-Host " JAVA_TOOL_OPTIONS automatically." + Write-Host " - Existing PowerShell/cmd sessions did NOT see the value; open a new" + Write-Host " Terminal (or log off/on) so daemons and IDEs read it on startup." + Write-Host " - Run 'gradle --stop' to refresh the Gradle Daemon if one was already" + Write-Host " running -- daemons cache the env at startup." + Write-Host " - The 'Picked up JAVA_TOOL_OPTIONS:' banner on stderr is expected and" + Write-Host " indicates the JVM read the var correctly." +} + +function Main { + if (-not (Test-Path -LiteralPath $UseCert -PathType Leaf)) { + Write-Error "Error: certificate file not found: $UseCert" + exit 1 + } + Test-CertName $CertName + Test-CaCertificate -Path $UseCert + + $jksPath = Get-JvmWindowsJksPath + Build-JksTruststore ` + -JksPath $jksPath ` + -CertPath $UseCert ` + -Alias $CertName ` + -Password $JvmWindowsJksPassword + + $jtoValue = Set-JavaToolOptions ` + -JksPath $jksPath ` + -Password $JvmWindowsJksPassword + + Show-DoneSummary -JksPath $jksPath -Alias $CertName -JtoValue $jtoValue +} + +Main diff --git a/testing/test_install_certs_jvm_windows.ps1 b/testing/test_install_certs_jvm_windows.ps1 new file mode 100644 index 0000000..4d6080b --- /dev/null +++ b/testing/test_install_certs_jvm_windows.ps1 @@ -0,0 +1,473 @@ +# (c) JFrog Ltd. (2026) +# Smoke matrix for install_certs_jvm_windows.ps1 + validate_certs_jvm_windows.ps1. +# +# Run from the repo root: +# powershell -ExecutionPolicy Bypass -File testing/test_install_certs_jvm_windows.ps1 +# +# No Administrator required -- User-scope env vars and %LOCALAPPDATA% paths +# are per-user. The runner uses the *current* user's profile and HKCU. +# Idempotent end-state via try/finally cleanup. +# +# Invariants exercised: +# 1. Positive install + validate (subject substring match) +# 2. Subject mismatch -> exit 1 +# 3. Idempotent re-install (single JKS alias after 2 runs; env var replaced) +# 4. Custom -CertName round-trips (alias inside JKS = cert-name) +# 5. Path-traversal -CertName rejected +# 6. Malformed PEM rejected +# 7. Expired CA rejected (skip if openssl can't produce one verifiably-expired) +# 8. Leaf cert (CA:FALSE) rejected +# 9. After install, [Environment]::GetEnvironmentVariable('JAVA_TOOL_OPTIONS','User') +# returns a string referencing the expected JKS path +# 10. validate_pem 30-day-expiry warn fires (cert valid <30d still installs) +# 11. validate_pem multi-cert bundle warn fires +# 12. JTO env var REPLACES (not appends) on re-install: pre-seed a stale value, +# run installer, assert old value is gone +# 13. Missing keytool fails cleanly: clear PATH+JAVA_HOME and assert exit 1 +# 14. Mandatory -UseCert: invoke with no args, assert non-interactive exit 1 + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Fail-Test { param([string]$Msg) Write-Host "BUG: $Msg" -ForegroundColor Red; exit 1 } + +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +Set-Location $RepoRoot + +# Locate openssl. windows-latest GHA runners have Git for Windows preinstalled, +# which bundles openssl at C:\Program Files\Git\usr\bin\openssl.exe. Also try +# Strawberry Perl's openssl. Don't fall back silently -- `where openssl` could +# return LibreSSL-equivalent if anything in PATH is malformed. +$OpenSsl = $null +$candidates = @( + 'C:\Program Files\Git\usr\bin\openssl.exe', + 'C:\Strawberry\c\bin\openssl.exe' +) +foreach ($cand in $candidates) { + if (Test-Path -LiteralPath $cand -PathType Leaf) { + $OpenSsl = $cand; break + } +} +if (-not $OpenSsl) { + $cmd = Get-Command openssl.exe -ErrorAction SilentlyContinue + if ($cmd) { $OpenSsl = $cmd.Source } +} +if (-not $OpenSsl) { + Fail-Test 'no openssl.exe found (need Git for Windows or similar)' +} +Write-Host ("Using openssl: {0}" -f $OpenSsl) +$openSslVersionLine = (& $OpenSsl version) 2>&1 +Write-Host $openSslVersionLine + +# Test #7 (expired CA) uses OpenSSL 3.2+'s -not_before/-not_after flags. +# windows-latest currently ships 3.5.x via Git for Windows. If that ever +# regresses below 3.2 the test silently SKIPs -- surface a yellow flag now +# so a future maintainer sees the version drift in the CI run summary. +if ($openSslVersionLine -match 'OpenSSL\s+(\d+)\.(\d+)') { + $major = [int]$matches[1] + $minor = [int]$matches[2] + if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 2)) { + Write-Warning ("Detected OpenSSL {0}.{1}, but test #7 (expired CA) requires 3.2+ for -not_before / -not_after. It will SKIP." -f $major, $minor) + } +} + +. (Join-Path $RepoRoot '_jvm_windows_paths.ps1') +$JksPath = Get-JvmWindowsJksPath +$JksDir = Split-Path -Parent $JksPath +$LabSubj = 'Lab JVM Win CA Test' + +function Cleanup { + # Reset JAVA_TOOL_OPTIONS regardless of prior state. Setting to $null + # via SetEnvironmentVariable deletes the value. + [Environment]::SetEnvironmentVariable('JAVA_TOOL_OPTIONS', $null, [EnvironmentVariableTarget]::User) + if (Test-Path -LiteralPath $JksDir) { + # I12: probe-then-warn rather than -ErrorAction SilentlyContinue. + # Silent failure here hides locked files left by a leaked keytool.exe + # child from a previous test crash, which then surface as a confusing + # error inside the NEXT test's Build-JksTruststore. + try { + Remove-Item -LiteralPath $JksDir -Recurse -Force -ErrorAction Stop + } catch { + Write-Warning "Cleanup: could not remove $JksDir ($($_.Exception.Message)). A previous keytool.exe child may still hold a file handle; subsequent tests will likely fail." + } + } +} + +# Run cleanup unconditionally on exit so re-running after a partial failure +# starts from the same baseline. +try { + +Cleanup + +# --- Generate the lab CA used by all positive cases --- +$labKey = 'C:\Windows\Temp\jvm-win-test-k.pem' +$labCa = 'C:\Windows\Temp\jvm-win-test-ca.pem' +Remove-Item -LiteralPath $labKey, $labCa -ErrorAction SilentlyContinue +& $OpenSsl req -x509 -newkey rsa:2048 -nodes ` + -keyout $labKey -out $labCa -days 7 ` + -subj "/CN=$LabSubj/O=JFrog" ` + -addext 'basicConstraints=critical,CA:TRUE' 2>&1 | Out-Null +if (-not (Test-Path -LiteralPath $labCa)) { + Fail-Test "openssl req failed to produce $labCa" +} + +# Run installer/validator as a child powershell.exe and capture combined +# output via Tee-Object -- Tee writes BOTH to the file AND down the pipeline. +# We pass the pipeline through Out-String to flatten formatted records and +# return the joined string. On unexpected exit the caller can dump the +# captured output via Write-Host. +# +# Don't name the parameter $Args -- that's a PowerShell automatic variable +# that collides with @Args splat semantics inside the function body and +# silently turns into an empty array. +function Invoke-Installer { + param([string[]]$ScriptArgs, [switch]$ExpectFail) + # C1 fix: capture $LASTEXITCODE IMMEDIATELY after the native call, BEFORE + # the Out-String pipeline. With `$ErrorActionPreference='Stop'` plus + # `Set-StrictMode -Version Latest` a downstream pipeline element raising + # any error would jump past `$rc = $LASTEXITCODE` and leave $rc carrying + # the value from a previous step -- which can flip an `-ExpectFail` + # assertion into a phantom pass. + $raw = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File '.\install_certs_jvm_windows.ps1' @ScriptArgs 2>&1 + $rc = $LASTEXITCODE + $out = $raw | Out-String + if ($ExpectFail) { + if ($rc -eq 0) { + Write-Host "--- captured output ---" + Write-Host $out + Write-Host "--- end captured output ---" + Fail-Test "installer was expected to exit non-zero, got 0 (args: $($ScriptArgs -join ' '))" + } + } else { + if ($rc -ne 0) { + Write-Host "--- captured output ---" + Write-Host $out + Write-Host "--- end captured output ---" + Fail-Test "installer exited $rc unexpectedly (args: $($ScriptArgs -join ' '))" + } + } + return $out +} + +function Invoke-Validator { + param([string[]]$ScriptArgs, [switch]$ExpectFail) + # Same rc-capture-before-pipeline pattern as Invoke-Installer; see C1 + # comment there for the rationale. + $raw = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File '.\validate_certs_jvm_windows.ps1' @ScriptArgs 2>&1 + $rc = $LASTEXITCODE + $out = $raw | Out-String + if ($ExpectFail) { + if ($rc -eq 0) { + Write-Host "--- captured output ---" + Write-Host $out + Write-Host "--- end captured output ---" + Fail-Test "validator was expected to exit non-zero, got 0 (args: $($ScriptArgs -join ' '))" + } + } else { + if ($rc -ne 0) { + Write-Host "--- captured output ---" + Write-Host $out + Write-Host "--- end captured output ---" + Fail-Test "validator exited $rc unexpectedly (args: $($ScriptArgs -join ' '))" + } + } + return $out +} + +# Find keytool for direct independent checks (alias count, etc.) +function Get-Keytool { + if ($env:JAVA_HOME) { + $kt = Join-Path $env:JAVA_HOME 'bin\keytool.exe' + if (Test-Path -LiteralPath $kt) { return $kt } + } + $cmd = Get-Command keytool.exe -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + Fail-Test 'keytool.exe not on PATH (need JAVA_HOME set or actions/setup-java)' +} +$Keytool = Get-Keytool + +# I10: wrap inline keytool calls with EAP=Continue. keytool -list on JDK 17+ +# has been observed emitting crypto-policy notices and JKS-deprecation +# warnings to stderr at rc=0; under $ErrorActionPreference='Stop' those +# would terminate the test. Same pattern as install/validate scripts use. +function Invoke-Keytool { + param([string[]]$KeytoolArgs) + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $out = & $Keytool @KeytoolArgs 2>&1 + } finally { + $ErrorActionPreference = $prevEAP + } + return $out +} + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 1. positive: install + validate ===" +Cleanup +Invoke-Installer -ScriptArgs @('-UseCert', $labCa) | Out-Null +Invoke-Validator -ScriptArgs @('-ExpectedSubject', $LabSubj) | Out-Null +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 2. negative: subject mismatch must exit 1 ===" +Invoke-Validator -ScriptArgs @('-ExpectedSubject', 'Microsoft Root CA NoMatch') -ExpectFail | Out-Null +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 3. idempotency: 2nd install produces single alias / single env value ===" +Invoke-Installer -ScriptArgs @('-UseCert', $labCa) | Out-Null +Invoke-Validator -ScriptArgs @('-ExpectedSubject', $LabSubj) | Out-Null + +$listOut = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') +$aliasCount = (($listOut | Select-String 'trustedCertEntry').Matches.Count) +# JKS extends the JDK's bundled cacerts (~150 public roots) plus exactly one +# corporate-CA alias. After two installs, the corporate alias count must be +# exactly 1; the JDK-supplied aliases stay constant. +$corpCount = (($listOut | Select-String '^package-route-custom-ca[,\s]').Matches.Count) +if ($corpCount -ne 1) { + Fail-Test "expected exactly 1 corporate-CA alias after 2 installs, got $corpCount" +} +if ($aliasCount -lt 100) { + Fail-Test "expected JKS to extend default cacerts (>=100 aliases), got $aliasCount" +} +Write-Host (" ok (alias_count={0}, corp_alias_count={1})" -f $aliasCount, $corpCount) + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 4. custom -CertName round-trips (alias inside JKS = cert-name) ===" +Cleanup +Invoke-Installer -ScriptArgs @('-UseCert', $labCa, '-CertName', 'zscaler-root') | Out-Null +$listOut = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') +if (-not ($listOut | Select-String '^zscaler-root,')) { + Fail-Test "expected JKS alias 'zscaler-root', got: $($listOut -join '; ')" +} +Invoke-Validator -ScriptArgs @('-ExpectedSubject', $LabSubj) | Out-Null +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 5. negative: path-traversal -CertName rejected ===" +Invoke-Installer -ScriptArgs @('-UseCert', $labCa, '-CertName', '..\etc\pwned') -ExpectFail | Out-Null +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 6. negative: malformed PEM rejected ===" +$badPem = 'C:\Windows\Temp\jvm-win-bad.pem' +'not a certificate' | Set-Content -LiteralPath $badPem -Encoding ASCII +Invoke-Installer -ScriptArgs @('-UseCert', $badPem) -ExpectFail | Out-Null +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 7. negative: expired CA rejected ===" +$expiredPem = 'C:\Windows\Temp\jvm-win-expired.pem' +Remove-Item -LiteralPath $expiredPem -ErrorAction SilentlyContinue +# Try OpenSSL 3.2+'s -not_before/-not_after. +& $OpenSsl req -x509 -newkey rsa:2048 -nodes ` + -keyout 'C:\Windows\Temp\jvm-win-expired-k.pem' -out $expiredPem ` + -subj '/CN=Expired/O=JFrog' ` + -addext 'basicConstraints=critical,CA:TRUE' ` + -not_before 20200101000000Z -not_after 20200201000000Z 2>&1 | Out-Null + +# Verify the cert is actually expired before running the assertion. +$produced = Test-Path -LiteralPath $expiredPem +$stillValid = $false +if ($produced) { + # Re-parse via .NET to confirm NotAfter < now (sidestep openssl -checkend). + try { + $bytes = [System.IO.File]::ReadAllBytes($expiredPem) + $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$bytes) + $stillValid = ($cert.NotAfter.ToUniversalTime() -ge [DateTime]::UtcNow) + } catch { + $produced = $false + } +} +if (-not $produced -or $stillValid) { + Write-Host " SKIP: cannot produce a verifiably-expired cert with the installed openssl" +} else { + Invoke-Installer -ScriptArgs @('-UseCert', $expiredPem) -ExpectFail | Out-Null + Write-Host " ok" +} + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 8. negative: leaf cert (CA:FALSE) rejected ===" +$leafPem = 'C:\Windows\Temp\jvm-win-leaf.pem' +& $OpenSsl req -x509 -newkey rsa:2048 -nodes ` + -keyout 'C:\Windows\Temp\jvm-win-leaf-k.pem' -out $leafPem -days 7 ` + -subj '/CN=Leaf Not CA' ` + -addext 'basicConstraints=critical,CA:FALSE' 2>&1 | Out-Null +Invoke-Installer -ScriptArgs @('-UseCert', $leafPem) -ExpectFail | Out-Null +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 9. User-scope JAVA_TOOL_OPTIONS references the expected JKS ===" +Cleanup +Invoke-Installer -ScriptArgs @('-UseCert', $labCa) | Out-Null +$jto = [Environment]::GetEnvironmentVariable('JAVA_TOOL_OPTIONS', [EnvironmentVariableTarget]::User) +if (-not $jto) { + Fail-Test 'User-scope JAVA_TOOL_OPTIONS not set' +} +if (-not ($jto -like "*trustStore=*$JksPath*")) { + Fail-Test ("JAVA_TOOL_OPTIONS doesn't reference expected JKS path. got: {0}" -f $jto) +} +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 10. validate_pem 30-day-expiry warn fires ===" +# Short-validity CA (1 day) -- within 30 days, should produce the expiry warn +# but install succeed. +$soonPem = 'C:\Windows\Temp\jvm-win-soon.pem' +& $OpenSsl req -x509 -newkey rsa:2048 -nodes ` + -keyout 'C:\Windows\Temp\jvm-win-soon-k.pem' -out $soonPem -days 1 ` + -subj '/CN=Soon to Expire/O=JFrog' ` + -addext 'basicConstraints=critical,CA:TRUE' 2>&1 | Out-Null +$out = Invoke-Installer -ScriptArgs @('-UseCert', $soonPem) +if (-not ($out -match 'certificate expires within 30 days')) { + Write-Host $out + Fail-Test '30-day expiry warn missing' +} +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 11. validate_pem multi-cert bundle warn fires ===" +# Multi-cert bundle: append a second cert to the test CA. The installer +# warns and imports only the first; install must still succeed. +$bundlePem = 'C:\Windows\Temp\jvm-win-bundle.pem' +Get-Content -LiteralPath $labCa, $soonPem | Set-Content -LiteralPath $bundlePem +$out = Invoke-Installer -ScriptArgs @('-UseCert', $bundlePem) +if (-not ($out -match 'PEM file contains \d+ certificates')) { + Write-Host $out + Fail-Test 'multi-cert bundle warn missing' +} +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 12. JTO env var REPLACES (not appends) on re-install ===" +# Pre-seed a junk value, run installer, assert the junk is gone. +[Environment]::SetEnvironmentVariable('JAVA_TOOL_OPTIONS', '-Dpackage-reroute-test-sentinel=must-be-replaced', [EnvironmentVariableTarget]::User) +Invoke-Installer -ScriptArgs @('-UseCert', $labCa) | Out-Null +$post = [Environment]::GetEnvironmentVariable('JAVA_TOOL_OPTIONS', [EnvironmentVariableTarget]::User) +if ($post -match 'package-reroute-test-sentinel') { + Fail-Test "JTO env var was APPENDED to (sentinel survived). Re-install must replace. got: $post" +} +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 13. missing keytool fails cleanly ===" +# Run the installer in a child process with PATH stripped of all JDK +# locations and JAVA_HOME unset. Installer should error with our +# Require-Keytool message, not crash mid-Build-JksTruststore. +$strippedPath = 'C:\Windows;C:\Windows\System32' +$out = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -Command "`$env:JAVA_HOME=`$null; `$env:PATH='$strippedPath'; & .\install_certs_jvm_windows.ps1 -UseCert '$labCa'" 2>&1 +$rc13 = $LASTEXITCODE +if ($rc13 -eq 0) { + Write-Host $out + Fail-Test "installer should have rejected missing keytool (rc=0)" +} +if (-not ($out -match 'keytool')) { + Write-Host $out + Fail-Test "missing-keytool error message should mention 'keytool' (got rc=$rc13)" +} +Write-Host " ok (rc=$rc13)" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 14. -UseCert mandatory: no-args invocation fails ===" +# Non-interactive PS prompts for mandatory params and then errors out. +# `pwsh -NonInteractive` ensures we don't hang waiting for input. +$out = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File '.\install_certs_jvm_windows.ps1' 2>&1 +$rc14 = $LASTEXITCODE +if ($rc14 -eq 0) { + Write-Host $out + Fail-Test 'installer should have rejected no-args invocation (rc=0)' +} +Write-Host " ok (rc=$rc14)" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 15. negative: DER cert rejected (C1 cross-platform parity) ===" +# C1 backport: convert the lab CA to DER, then attempt install -- should +# fail with a hint to convert back. Mirrors Linux + macOS behavior so a fleet +# wrapper that hands the wrong format gets a uniform error across platforms. +$derPath = 'C:\Windows\Temp\jvm-win-test-ca.der' +Remove-Item -LiteralPath $derPath -ErrorAction SilentlyContinue +& openssl x509 -in $labCa -outform DER -out $derPath 2>&1 | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Host " SKIP: openssl unavailable to convert DER, can't run this test" +} else { + $out = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -Command "& .\install_certs_jvm_windows.ps1 -UseCert '$derPath'" 2>&1 + $rc15 = $LASTEXITCODE + if ($rc15 -eq 0) { + Write-Host $out + Fail-Test "installer should have rejected DER-encoded cert (rc=0)" + } + if (-not ($out -match 'PEM-encoded')) { + Write-Host $out + Fail-Test "DER reject message should mention 'PEM-encoded' (got rc=$rc15)" + } + Write-Host " ok (rc=$rc15)" +} + +#----------------------------------------------------------------------------- +# Re-install once so the next two invariants observe the post-fix end state. +Cleanup +Invoke-Installer -ScriptArgs @('-UseCert', $labCa) | Out-Null + +Write-Host "" +Write-Host "=== 16. JKS extends default cacerts (preserves public roots) ===" +# Regression guard for the "trustStore replaces, not extends" footgun. +# -Djavax.net.ssl.trustStore in OpenJDK swaps the JVM's trust source; a JKS +# holding only the corporate CA would break every public-CA TLS handshake +# (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). +# Installer must therefore copy $JAVA_HOME\lib\security\cacerts first. +$listOut16 = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') +$aliasCount = (($listOut16 | Select-String 'trustedCertEntry').Matches.Count) +if ($aliasCount -lt 100) { + Fail-Test "JKS has $aliasCount aliases; expected >= 100 (JDK cacerts ~150 public roots + corporate CA)" +} +Write-Host (" ok ({0} aliases)" -f $aliasCount) + +Write-Host "" +Write-Host "=== 17. JKS contains a well-known public root (DigiCert family) ===" +# Spot-check the merge actually happened. DigiCert root certs ship in every +# JDK's cacerts under several aliases (digicertglobalrootca, digicertglobalrootg2, +# digicerttrustedrootg4, etc.) -- case-insensitive substring match catches them all. +$listOut17 = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') +if (-not ($listOut17 | Select-String -Pattern 'digicert' -SimpleMatch -Quiet)) { + Fail-Test "JKS missing the DigiCert family of public roots; the copy-from-JDK step did not run" +} +Write-Host " ok" + +Write-Host "" +Write-Host "=================================================================" +Write-Host "ALL SMOKE TESTS PASSED" +Write-Host "=================================================================" + +# Tests #13 / #14 / #15 deliberately spawn child powershell.exe invocations +# that exit non-zero (Expected-Fail negative cases). Each leaves $LASTEXITCODE +# at the child's rc, which the outer `shell: pwsh` wrapper would inherit +# and report as a job failure. Explicit exit 0 here ensures the wrapper +# sees the runner's actual aggregate result. +$global:LASTEXITCODE = 0 + +} finally { + Cleanup +} + +exit 0 diff --git a/validate_certs_jvm_windows.ps1 b/validate_certs_jvm_windows.ps1 new file mode 100644 index 0000000..6e6dbd8 --- /dev/null +++ b/validate_certs_jvm_windows.ps1 @@ -0,0 +1,179 @@ +# (c) JFrog Ltd. (2026) +# Validate JVM truststore installation done by install_certs_jvm_windows.ps1. +# +# Asserts: +# 1. JKS file exists at %LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks +# 2. JKS contains a cert whose subject (Owner: in keytool -list -v) matches +# -ExpectedSubject (case-insensitive substring). +# 3. User-scope JAVA_TOOL_OPTIONS env var returns a value referencing the +# expected JKS path. +# +# Run: +# powershell -ExecutionPolicy Bypass -File validate_certs_jvm_windows.ps1 -ExpectedSubject "O=Zscaler" +# +# Exits 0 if all checks pass, 1 if any check fails. Result line is qualified +# with a count of any non-fatal warnings. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# validate_certs_jvm_linux.sh - system anchor OR JKS+JTO check +# validate_certs_jvm_macos.sh - LaunchAgent + launchctl getenv check +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ExpectedSubject, + + # Accepted for cross-platform CLI parity with the Linux validator. Ignored + # here: Windows matches by subject substring, and the JKS path / HKCU env + # var name are fixed regardless of cert-name. A fleet wrapper that passes + # -CertName to all three validators must not fail on Windows. + [Parameter(Mandatory = $false)] + [string]$CertName +) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $PSCommandPath +. (Join-Path $ScriptDir '_jvm_windows_paths.ps1') + +$script:FailCount = 0 +$script:WarnCount = 0 + +function Write-Fail { param([string]$Msg) Write-Host " FAIL: $Msg"; $script:FailCount++ } +function Write-Ok { param([string]$Msg) Write-Host " OK: $Msg" } +function Write-Warn { param([string]$Msg) Write-Host " WARN: $Msg"; $script:WarnCount++ } + +# Locate keytool. Same logic as the installer's Resolve-Keytool. +function Resolve-Keytool { + if ($env:JAVA_HOME) { + $candidate = Join-Path $env:JAVA_HOME 'bin\keytool.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + } + $cmd = Get-Command keytool.exe -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + return $null +} + +function Test-KeystoreContainsSubject { + param( + [string]$Keystore, + [string]$Password, + [string]$Label + ) + + if (-not (Test-Path -LiteralPath $Keystore -PathType Leaf)) { + Write-Fail "$Label keystore does not exist: $Keystore" + return + } + + $keytool = Resolve-Keytool + if (-not $keytool) { + # Promoting to FAIL: the keystore-subject check is the validator's + # core assertion; silently passing here would mean CI is green even + # though we never verified the cert is in the store. + Write-Fail "$Label keystore present but keytool.exe is not on PATH and JAVA_HOME is not set; cannot verify subject." + return + } + + # Capture combined output. PowerShell's 2>&1 merges stderr into the + # pipeline so a real keytool error (corrupt store, wrong password, etc.) + # is visible instead of being silently dropped. Switch ErrorActionPreference + # to Continue around the call: under Stop, PowerShell promotes any + # native-command stderr to a terminating error before 2>&1 captures it. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $output = & $keytool -list -v -keystore $Keystore -storepass $Password 2>&1 + } finally { + $ErrorActionPreference = $prevEAP + } + if ($LASTEXITCODE -ne 0) { + Write-Fail ("{0}: keytool could not read the keystore. Output (first 3 lines):`n {1}" -f $Label, (($output | Select-Object -First 3) -join "`n ")) + return + } + + # `Owner:` lines are how `keytool -list -v` prints each cert's subject. + # Case-insensitive substring match against the expected subject. + $owners = $output | Where-Object { $_ -match '^Owner:' } + $found = $false + foreach ($owner in $owners) { + if ($owner -match [regex]::Escape($ExpectedSubject)) { + $found = $true + break + } + } + if (-not $found) { + Write-Fail "$Label has no cert with subject matching: $ExpectedSubject" + return + } + # I8 cross-platform parity (see validate_certs_jvm_linux.sh): refuse to + # validate stores that contain key material. The installer only writes + # trustedCertEntry records, so any PrivateKeyEntry here indicates drift + # -- likely a future installer change or hand-edited store. The well-known + # `changeit` password is unsuitable for actual private-key protection. + if ($output | Where-Object { $_ -match '^Entry type: PrivateKeyEntry' }) { + Write-Fail "$Label contains a PrivateKeyEntry -- this truststore must hold only trustedCertEntry records." + return + } + Write-Ok "$Label contains cert with subject matching: $ExpectedSubject" +} + +function Test-UserEnvVar { + param([string]$JksPath) + + $envValue = [Environment]::GetEnvironmentVariable($JvmWindowsEnvVarName, [EnvironmentVariableTarget]::User) + if (-not $envValue) { + Write-Fail "User-scope $JvmWindowsEnvVarName is not set in HKCU\Environment" + return + } + + # Match either quoted or unquoted trustStore=...path against the expected JKS. + # Both branches anchor the end so a path like `$JksPath.bak.pkcs12` doesn't + # false-positive as a prefix-match of `$JksPath`. + $quotedPattern = 'trustStore="' + [regex]::Escape($JksPath) + '"' + $unquotedPattern = 'trustStore=' + [regex]::Escape($JksPath) + '(\s|$)' + if ($envValue -match $quotedPattern -or $envValue -match $unquotedPattern) { + Write-Ok ("User-scope {0} points at {1}" -f $JvmWindowsEnvVarName, $JksPath) + } else { + Write-Fail "User-scope $JvmWindowsEnvVarName does not reference $JksPath (got: $envValue)" + } + + # The JVM resolution order is process > User > Machine. A Machine-scope + # value would not override the User-scope one for the current process, + # but mixed scopes confuse onboarding ("I see two different paths in + # `setx /M JAVA_TOOL_OPTIONS`!"). Surface it as a warning rather than + # let it lurk silently. + $machineValue = [Environment]::GetEnvironmentVariable($JvmWindowsEnvVarName, [EnvironmentVariableTarget]::Machine) + if ($machineValue) { + Write-Warn ("Machine-scope {0} is ALSO set (value: {1}). v1 only manages User-scope; consider clearing the Machine-scope value if it's stale." -f $JvmWindowsEnvVarName, $machineValue) + } +} + +function Main { + Write-Host ("Expected subject (case-insensitive substring): {0}" -f $ExpectedSubject) + Write-Host "" + + $jksPath = Get-JvmWindowsJksPath + Test-KeystoreContainsSubject -Keystore $jksPath -Password $JvmWindowsJksPassword -Label ("Truststore {0}" -f $jksPath) + Test-UserEnvVar -JksPath $jksPath + + Write-Host "---------------------------------------------------" + if ($script:FailCount -eq 0) { + if ($script:WarnCount -eq 0) { + Write-Host "Result: All checks passed." + } else { + Write-Host ("Result: All checks passed (with {0} warning(s) -- see above)." -f $script:WarnCount) + } + exit 0 + } else { + Write-Host ("Result: {0} check(s) failed (and {1} warning(s))." -f $script:FailCount, $script:WarnCount) + exit 1 + } +} + +Main From 08542effa3b07ed5793b8aaccc1db8bbcf0e177e Mon Sep 17 00:00:00 2001 From: rani Date: Wed, 24 Jun 2026 11:12:50 +0300 Subject: [PATCH 4/6] DFLOW-140 - Use bundled JVM truststores on macOS and Windows Signed-off-by: rani --- README.md | 38 +-- _jvm_linux_paths.sh | 4 +- _jvm_macos_paths.sh | 42 --- _jvm_windows_paths.ps1 | 53 --- install_certs_jvm_macos.sh | 264 +++------------ install_certs_jvm_windows.ps1 | 309 +++--------------- testing/test_install_certs_jvm_macos.sh | 270 +++++++--------- testing/test_install_certs_jvm_windows.ps1 | 354 ++++++--------------- validate_certs_jvm_macos.sh | 16 +- validate_certs_jvm_windows.ps1 | 12 +- 10 files changed, 332 insertions(+), 1030 deletions(-) delete mode 100644 _jvm_macos_paths.sh delete mode 100644 _jvm_windows_paths.ps1 diff --git a/README.md b/README.md index 03687ef..3fab191 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ Reference: research wiki [Maven Support in package-reroute (DFLOW-136 / DFLOW-11 | **validate_install_macos.sh** | macOS | Validate PEM and env config | | **install_certs_jvm_macos.sh** | macOS (JVM) | Install CA for Maven/Gradle/sbt/Ivy: JKS + per-user LaunchAgent setting `JAVA_TOOL_OPTIONS` | | **validate_certs_jvm_macos.sh** | macOS (JVM) | Validate JVM truststore install (JKS subject + plist + `launchctl getenv`) | -| **_jvm_macos_paths.sh** | macOS (JVM) | Shared constants sourced by both installer and validator. Not directly executable. | | **install_certs_debian_ubuntu.sh** | Debian/Ubuntu | Install cert into system trust + profile.d + user shell rc + Docker cleanup | | **validate_certs_debian_ubuntu.sh** | Debian/Ubuntu | Validate PEM and env config | | **install_certs_jvm_linux.sh** | Linux (JVM) | Install CA for Maven/Gradle/sbt/Ivy: RHEL family → `update-ca-trust extract` into system anchors; others → per-host JKS + `JAVA_TOOL_OPTIONS` in `/etc/environment` | @@ -33,7 +32,6 @@ Reference: research wiki [Maven Support in package-reroute (DFLOW-136 / DFLOW-11 | **validate_install_windows.ps1** | Windows | Validate PEM and env config | | **install_certs_jvm_windows.ps1** | Windows (JVM) | Install CA for Maven/Gradle/sbt/Ivy: JKS at `%LOCALAPPDATA%` + User-scope `JAVA_TOOL_OPTIONS` | | **validate_certs_jvm_windows.ps1** | Windows (JVM) | Validate JVM truststore install (JKS subject + User-scope env var) | -| **_jvm_windows_paths.ps1** | Windows (JVM) | Shared constants dot-sourced by installer + validator. Not directly executable. | Environment variables by platform (see each section for details): @@ -329,11 +327,11 @@ Users must open a **new terminal** (or `source ~/.zshrc`) for the new environmen ### Overview -`install_certs_jvm_macos.sh` wires a custom CA certificate into the JVM trust path on macOS so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_macos.sh` if you need those. +`install_certs_jvm_macos.sh` wires a bundled JVM truststore into the JVM trust path on macOS so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_macos.sh` if you need those. Single path on macOS — there is no OS-trust fallback because macOS-specific `KeychainStore` is broken per [JDK-8321045](https://bugs.openjdk.org/browse/JDK-8321045). The script: -1. Builds a per-user JKS truststore at `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks` containing only the customer CA. +1. Copies the supplied JKS truststore to `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks`. 2. Writes a per-user LaunchAgent plist at `~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist` that calls `launchctl setenv JAVA_TOOL_OPTIONS=…` at `RunAtLoad`. 3. Bootstraps the agent into `gui/` via `launchctl bootstrap` so the env var becomes available to every subsequently-launched GUI process (Dock-launched IntelliJ, JetBrains Toolbox, `open -a …`). @@ -343,16 +341,13 @@ The `~/.zshrc` / `~/.bash_profile` shortcut is deliberately NOT used: it silentl - **macOS**. - **Root** (`sudo`) — needed to chown per-user files and to bootstrap into other users' `gui/` domains under `--all-users`. -- **`openssl`** on `PATH` (ships with macOS as LibreSSL; full OpenSSL via Homebrew also works). -- **`keytool`** on `PATH` (provided by any JDK — Homebrew openjdk, Adoptium Temurin, JetBrains JBR, etc.). - macOS built-ins used by the installer (all preinstalled on supported macOS versions): `plutil` (LaunchAgent plist validation), `launchctl` (bootstrap into `gui/`), `dscl` (user / home lookup), `stat` (UID-based filtering under `--all-users`). ### Options | Option | Required | Description | |--------|----------|-------------| -| `--use-cert ` | **Yes** | Path to an existing PEM/CRT certificate file. Validation: parseable X.509, not expired, `CA:TRUE` in basicConstraints. Bundles emit a warning (only the first cert imports). | -| `--cert-name ` | No (default: `package-route-custom-ca`) | Base name for the JKS alias. Must match `[A-Za-z0-9._-]+`. Pass the same value to the validator (the validator matches by subject so this is informational unless multiple CAs coexist). | +| `--use-truststore ` | **Yes** | Path to an existing JVM truststore (JKS/PKCS12-compatible). The truststore is copied as-is and must be readable by JVMs with password `changeit`. | | `--all-users` | No | Iterate `/Users/*` and install the LaunchAgent + JKS for every account with UID ≥ 501. Default = only `SUDO_USER` (or the GUI console user under JAMF). | | `-h`, `--help` | — | Usage. | @@ -360,13 +355,10 @@ The `~/.zshrc` / `~/.bash_profile` shortcut is deliberately NOT used: it silentl ```bash # Single user (typical: install for the developer running sudo) -sudo ./install_certs_jvm_macos.sh --use-cert /tmp/ZscalerRoot0.pem +sudo ./install_certs_jvm_macos.sh --use-truststore /tmp/package-route-truststore.jks # Fleet onboarding (shared Mac with multiple accounts) -sudo ./install_certs_jvm_macos.sh --use-cert /tmp/ZscalerRoot0.pem --all-users - -# Custom alias for the JKS -sudo ./install_certs_jvm_macos.sh --use-cert /tmp/ZscalerRoot0.pem --cert-name zscaler-root +sudo ./install_certs_jvm_macos.sh --use-truststore /tmp/package-route-truststore.jks --all-users ``` ### Validation: validate_certs_jvm_macos.sh @@ -390,7 +382,7 @@ sudo ./validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" --all-users - **Gradle Daemon caching.** Run `gradle --stop` after onboarding so the daemon re-reads `JAVA_TOOL_OPTIONS` at next start. - **`Picked up JAVA_TOOL_OPTIONS:` banner.** Every JVM startup prints this to stderr. CI parsers that strict-match empty-stderr need to tolerate it. - **`changeit` truststore password.** OpenJDK convention; *not* a secret. The JKS holds only public CA certificates and the password protects file integrity, not contents. -- **JKS extends the JDK's bundled cacerts.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The installer copies `$JAVA_HOME/lib/security/cacerts` to `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks` first, then `keytool -importcert` appends the corporate CA. The resulting store has ~150 public roots **plus** the corporate one. +- **The supplied JKS must include public roots.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The release process that creates the bundled truststore owns including public roots plus the corporate CA before install. - **`JAVA_TOOL_OPTIONS` inner quoting.** The JKS path lives under `~/Library/Application Support/` which contains a space; the JVM tokenises `JAVA_TOOL_OPTIONS` on whitespace and only honours embedded `"…"` grouping. The installer therefore writes `-Djavax.net.ssl.trustStore="" -Djavax.net.ssl.trustStorePassword="changeit"` into the LaunchAgent plist so the literal quotes reach the JVM tokenizer. Older installs (pre-fix) that wrote the unquoted form produced a fatal `Unrecognized option` on every Dock-launched JVM. - **`gui/` domain only exists for logged-in GUI users.** Under `--all-users`, accounts that are not currently logged in get the plist installed but `launchctl bootstrap` is soft-skipped; launchd loads the plist automatically at their next login. - **JAMF / headless kiosk caveat.** On a Mac running JAMF policies *before* any user has logged in, `gui/` is not yet running, so `launchctl bootstrap gui/` either fails or loads into a non-running domain. The plist will load on first interactive login. For truly headless boxes (rack-mounted Mac mini build agents), pair this installer with a `/Library/LaunchDaemons` (system-scope) trust-bootstrap before the first user login, or run the installer interactively as part of provisioning. @@ -665,11 +657,11 @@ Use a substring from your CA subject as `` (find it with `op ### Overview -`install_certs_jvm_windows.ps1` wires a custom CA certificate into the JVM trust path on Windows so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_windows.ps1` if you need those. +`install_certs_jvm_windows.ps1` wires a bundled JVM truststore into the JVM trust path on Windows so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_windows.ps1` if you need those. Single path on Windows — there is no OS-trust fallback. `-Djavax.net.ssl.trustStoreType=Windows-ROOT` was historically broken under Gradle ([gradle/gradle#6584](https://github.com/gradle/gradle/issues/6584), fixed in Gradle 8.3) and remains less uniform than the JKS recipe across our supported toolchains. The script: -1. Builds a per-user JKS truststore at `%LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks` containing only the customer CA. +1. Copies the supplied JKS truststore to `%LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks`. 2. Sets `JAVA_TOOL_OPTIONS` at **User** scope via `[Environment]::SetEnvironmentVariable(…, 'User')`, which writes `HKCU\Environment` and broadcasts `WM_SETTINGCHANGE`. New JVM processes started after the broadcast inherit the env var; daemons and long-running IDEs need a fresh session. **No Administrator required** — the User scope writes to `HKCU\Environment` without elevation, and `%LOCALAPPDATA%` is per-user. @@ -677,15 +669,12 @@ Single path on Windows — there is no OS-trust fallback. `-Djavax.net.ssl.trust ### Requirements - **Windows** with PowerShell 5.1+. -- **`keytool.exe`** reachable via `JAVA_HOME` (set by Adoptium / Corretto / Microsoft / Zulu installers and by `actions/setup-java`) or on `PATH`. -- The cert's syntactic validation (parseable X.509, expiry, `CA:TRUE`) uses .NET's `System.Security.Cryptography.X509Certificates`, so **no `openssl` dependency** for the install path itself. ### Parameters | Parameter | Required | Description | |-----------|----------|-------------| -| `-UseCert ` | **Yes** | Path to an existing PEM/CRT certificate file. Validation: parseable X.509, not expired, and if the basicConstraints extension is present then `CA:TRUE` is required (a cert that omits the extension entirely is accepted — matches OpenSSL/keytool default behavior). Bundles emit a warning (only the first cert imports). | -| `-CertName ` | No (default: `package-route-custom-ca`) | Alias under which the CA is stored inside the JKS. Cosmetic — affects only `keytool -list` output. JKS path and env var name are fixed per-user. Must match `[A-Za-z0-9._-]+`. | +| `-UseTruststore ` | **Yes** | Path to an existing JVM truststore (JKS/PKCS12-compatible). The truststore is copied as-is and must be readable by JVMs with password `changeit`. | No `-AllUsers` (User-scope env var is per-user by construction; each developer runs the installer in their own session). No `-Mode` — we ship only the JKS + `JAVA_TOOL_OPTIONS` recipe; see Caveats for why Windows-ROOT is not exposed. @@ -693,10 +682,7 @@ No `-AllUsers` (User-scope env var is per-user by construction; each developer r ```powershell # Single user -powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseCert C:\tmp\ZscalerRoot.pem - -# Custom alias -powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseCert C:\tmp\ca.pem -CertName zscaler-root +powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseTruststore C:\tmp\package-route-truststore.jks ``` ### Validation: validate_certs_jvm_windows.ps1 @@ -718,14 +704,14 @@ Exit code 0 if all checks pass, 1 otherwise. Result line is qualified with a cou - **Gradle Daemon caching.** Run `gradle --stop` after onboarding so the daemon re-reads `JAVA_TOOL_OPTIONS` at next start. - **`Picked up JAVA_TOOL_OPTIONS:` banner.** Every JVM startup prints this to stderr. CI parsers that strict-match empty-stderr need to tolerate it. - **`changeit` truststore password.** OpenJDK convention; *not* a secret. The JKS holds only public CA certificates and the password protects file integrity, not contents. -- **JKS extends the JDK's bundled cacerts.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The installer copies `$env:JAVA_HOME\lib\security\cacerts` to `%LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks` first, then `keytool -importcert` appends the corporate CA. The resulting store has ~150 public roots **plus** the corporate one. +- **The supplied JKS must include public roots.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The release process that creates the bundled truststore owns including public roots plus the corporate CA before install. - **Windows-ROOT trustStoreType is excluded by design.** `-Djavax.net.ssl.trustStoreType=Windows-ROOT` would point JVMs at the system Trusted Root store directly. The Gradle Daemon stale-snapshot bug that historically made this unsafe (gradle/gradle#6584) was fixed in Gradle 8.3 via [gradle/gradle#25106](https://github.com/gradle/gradle/pull/25106), but we still ship only the JKS+`JAVA_TOOL_OPTIONS` recipe so that (a) the trust source is uniform across Linux/macOS/Windows, and (b) developers on Gradle < 8.3 are not silently affected. A future ticket could add a `-TrustStoreType Windows-ROOT` flag for organisations standardised on Gradle ≥ 8.3. - **Machine scope is excluded.** v1 is User-scope only. Fleet/Intune rollouts that need `HKLM\Environment` should re-run the script per user via a logon script or use a future `-Scope Machine` flag (separate ticket). - **`%USERPROFILE%\.gradle\jdks\`** is Gradle's auto-provisioned JDK location. It's covered for free because `JAVA_TOOL_OPTIONS` is read by *every* JVM the user launches, regardless of where the JDK came from — subject to the Gradle Daemon caveat above (the daemon caches its environment at startup, so a newly-provisioned toolchain JDK only picks up `JAVA_TOOL_OPTIONS` after `gradle --stop`). ### Testing -`./testing/test_install_certs_jvm_windows.ps1` runs the 10-invariant smoke matrix on `windows-latest` in CI and locally. Each run targets the current user's `%LOCALAPPDATA%` + `HKCU` and cleans up via try/finally. +`./testing/test_install_certs_jvm_windows.ps1` runs the smoke matrix on `windows-latest` in CI and locally. Each run targets the current user's `%LOCALAPPDATA%` + `HKCU`, builds a bundled truststore fixture, and cleans up via try/finally. ```powershell powershell -ExecutionPolicy Bypass -File testing\test_install_certs_jvm_windows.ps1 diff --git a/_jvm_linux_paths.sh b/_jvm_linux_paths.sh index cb51932..611c5bd 100644 --- a/_jvm_linux_paths.sh +++ b/_jvm_linux_paths.sh @@ -9,8 +9,8 @@ # Keep installer and validator in lockstep by changing only this file. # # Cross-platform siblings (keep CLI shapes and contracts in sync): -# _jvm_macos_paths.sh — per-user JKS under ~/Library -# _jvm_windows_paths.ps1 — per-user JKS under %LOCALAPPDATA% +# install_certs_jvm_macos.sh — per-user JKS under ~/Library +# install_certs_jvm_windows.ps1 — per-user JKS under %LOCALAPPDATA% # Default base name for the installed CA file (Path A) and the JKS alias (Path B). # Overridable via --cert-name on the installer; the validator must be invoked with diff --git a/_jvm_macos_paths.sh b/_jvm_macos_paths.sh deleted file mode 100644 index b7979da..0000000 --- a/_jvm_macos_paths.sh +++ /dev/null @@ -1,42 +0,0 @@ -# (c) JFrog Ltd. (2026) -# Shared constants for install_certs_jvm_macos.sh and validate_certs_jvm_macos.sh. -# Sourced — must NOT be executed directly. Has no shebang on purpose. -# -# Both scripts read this file via: -# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# . "${SCRIPT_DIR}/_jvm_macos_paths.sh" -# -# Keep installer and validator in lockstep by changing only this file. -# -# Cross-platform siblings (keep CLI shapes and contracts in sync): -# _jvm_linux_paths.sh — system anchor (Path A) / JKS+JTO (Path B) -# _jvm_windows_paths.ps1 — per-user JKS under %LOCALAPPDATA% - -# Default base name used as the JKS alias inside the per-user truststore. -# Overridable via --cert-name on the installer (affects ONLY the alias name -# visible in `keytool -list` output — the JKS file path, plist path, and -# LaunchAgent label are all fixed per-user, so a different --cert-name on -# re-run replaces the previous CA rather than installing alongside it). -JVM_MACOS_DEFAULT_CERT_BASENAME="package-route-custom-ca" - -# Per-user JKS truststore. macOS convention: per-user resources under the user's -# Library directory so each account stays isolated. Matches the existing -# install_certs_macos.sh pattern (per-user PEM under ~//). -JKS_RELATIVE_DIR="Library/Application Support/JFrog/package-route-jvm" -JKS_BASENAME="truststore.jks" - -# Per-user LaunchAgent that calls `launchctl setenv JAVA_TOOL_OPTIONS=…` at -# RunAtLoad. This is the only macOS recipe that reaches Dock-launched IDEs; -# the ~/.zshrc shortcut silently fails for GUI-spawned subprocesses. -LAUNCH_AGENT_RELATIVE_DIR="Library/LaunchAgents" -LAUNCH_AGENT_LABEL="com.jfrog.package-reroute.jto-env" -LAUNCH_AGENT_BASENAME="${LAUNCH_AGENT_LABEL}.plist" - -# OpenJDK convention for cacerts and similar truststores. NOT a secret in -# this script's use case: we import only `trustedCertEntry` records (public -# CA certs), so the password protects file *integrity* via the keystore MAC -# but not any private key material. (A JKS that ever holds a PrivateKeyEntry -# would additionally rely on this password to encrypt the key — not relevant -# here.) Persisted in the LaunchAgent plist via -Djavax.net.ssl.trustStorePassword -# so unattended JVMs can open the store. -JKS_PASSWORD="changeit" diff --git a/_jvm_windows_paths.ps1 b/_jvm_windows_paths.ps1 deleted file mode 100644 index f30b528..0000000 --- a/_jvm_windows_paths.ps1 +++ /dev/null @@ -1,53 +0,0 @@ -# (c) JFrog Ltd. (2026) -# Shared constants for install_certs_jvm_windows.ps1 and validate_certs_jvm_windows.ps1. -# Dot-sourced from both scripts; not directly executable. -# -# IMPORTANT for future maintainers: keep all .ps1 files in this set saved as -# UTF-8 WITH BOM and use ASCII-only content. Windows PowerShell 5.1 reads -# files without a BOM as Windows-1252; an em-dash (U+2014) bytes -# (e2 80 94) then decode as "a-tilde, euro, right-double-quote" -- the -# trailing U+201D quote terminates string literals mid-line and the parser -# reports a confusing error 100+ lines later. The BOM forces UTF-8 parsing. -# ASCII-only content avoids the issue regardless. -# -# Both scripts read this file via: -# $ScriptDir = Split-Path -Parent $PSCommandPath -# . (Join-Path $ScriptDir '_jvm_windows_paths.ps1') -# -# Keep installer and validator in lockstep by changing only this file. -# -# Cross-platform siblings (keep CLI shapes and contracts in sync): -# _jvm_linux_paths.sh - system anchor (Path A) / JKS+JTO (Path B) -# _jvm_macos_paths.sh - per-user JKS under ~/Library - -# Default base name used as the JKS alias inside the per-user truststore. -# Overridable via -CertName on the installer (affects ONLY the alias name -# visible in `keytool -list` output -- the JKS file path and the User-scope -# JAVA_TOOL_OPTIONS env var name are fixed, so a different -CertName on -# re-run replaces the previous CA rather than installing alongside it). -$JvmWindowsDefaultCertBasename = 'package-route-custom-ca' - -# Per-user JKS truststore under %LOCALAPPDATA% so the User-scope env var -# can point at it without crossing user boundaries. Matches the per-user -# scope of the HKCU\Environment write. -$JvmWindowsJksRelativeDir = 'JFrog\package-route-jvm' -$JvmWindowsJksBasename = 'truststore.jks' - -# Function: returns $env:LOCALAPPDATA-relative JKS path. The validator and -# installer both derive their target path through this helper so the -# shape stays in lockstep across files. -function Get-JvmWindowsJksPath { - Join-Path $env:LOCALAPPDATA (Join-Path $JvmWindowsJksRelativeDir $JvmWindowsJksBasename) -} - -# OpenJDK convention for cacerts and similar truststores. NOT a secret in -# this script's use case: we import only `trustedCertEntry` records (public -# CA certs), so the password protects file *integrity* via the keystore MAC -# but not any private key material. A JKS holding PrivateKeyEntry would -# additionally rely on this password to encrypt the key -- not relevant here. -# Persisted in JAVA_TOOL_OPTIONS via -Djavax.net.ssl.trustStorePassword so -# unattended JVMs can open the store. -$JvmWindowsJksPassword = 'changeit' - -# Environment variable name. Fixed because the install path doesn't multi-cert. -$JvmWindowsEnvVarName = 'JAVA_TOOL_OPTIONS' diff --git a/install_certs_jvm_macos.sh b/install_certs_jvm_macos.sh index d29a83d..80a4e67 100755 --- a/install_certs_jvm_macos.sh +++ b/install_certs_jvm_macos.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # (c) JFrog Ltd. (2026) -# Install a custom CA certificate on macOS for JVM clients (Maven, Gradle, sbt, -# Apache Ivy). +# Install a bundled JVM truststore on macOS for JVM clients (Maven, Gradle, +# sbt, Apache Ivy). # -# Single path: build a per-user JKS truststore at +# Single path: copy a supplied JKS truststore to # ~/Library/Application Support/JFrog/package-route-jvm/truststore.jks -# containing only the customer CA, then install a per-user LaunchAgent at +# then install a per-user LaunchAgent at # ~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist # that runs `launchctl setenv JAVA_TOOL_OPTIONS=…` at RunAtLoad. This is the # ONLY recipe that reaches Dock-launched IDE builds — the ~/.zshrc shortcut @@ -13,8 +13,8 @@ # KeychainStore is broken per JDK-8321045. # # Run: -# sudo bash install_certs_jvm_macos.sh --use-cert /path/to/cert.pem -# [--cert-name ] [--all-users] +# sudo bash install_certs_jvm_macos.sh --use-truststore /path/to/truststore.jks +# [--all-users] # # Notes: # - macOS only. @@ -41,25 +41,29 @@ set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck disable=SC1091 -. "${SCRIPT_DIR}/_jvm_macos_paths.sh" +# Keep this installer self-contained: it is often copied/run as a standalone +# script during onboarding, so avoid requiring sibling files for constants. +JKS_RELATIVE_DIR="Library/Application Support/JFrog/package-route-jvm" +JKS_BASENAME="truststore.jks" +LAUNCH_AGENT_RELATIVE_DIR="Library/LaunchAgents" +LAUNCH_AGENT_LABEL="com.jfrog.package-reroute.jto-env" +LAUNCH_AGENT_BASENAME="${LAUNCH_AGENT_LABEL}.plist" +JKS_PASSWORD="changeit" -USE_CERT="" +USE_TRUSTSTORE="" ALL_USERS=0 -CERT_BASENAME="${JVM_MACOS_DEFAULT_CERT_BASENAME}" usage() { cat < [--cert-name ] [--all-users] + sudo $0 --use-truststore [--all-users] Options: - --use-cert Path to an existing PEM/CRT certificate file (required). - --cert-name Alias under which the CA is stored inside the JKS - truststore (default: ${CERT_BASENAME}). Cosmetic — affects - only \`keytool -list\` output. JKS path, plist path, and - LaunchAgent label are fixed per-user. + --use-truststore + Path to an existing JVM truststore (JKS/PKCS12-compatible) + to copy into each target user's fixed JKS location. + The truststore must be readable by JVMs with password + '${JKS_PASSWORD}'. --all-users Iterate /Users/* (UID >= 501, skip Shared) and install a LaunchAgent + JKS for every account. Default = only SUDO_USER (or the console-user under JAMF). @@ -71,16 +75,15 @@ because the KeychainStore truststoreType is broken (JDK-8321045) and no OS-trust fallback exists. Examples: - sudo $0 --use-cert /tmp/ZscalerRoot0.pem - sudo $0 --use-cert /tmp/ca.pem --all-users - sudo $0 --use-cert /tmp/ca.pem --cert-name zscaler-root + sudo $0 --use-truststore /tmp/package-route-truststore.jks + sudo $0 --use-truststore /tmp/package-route-truststore.jks --all-users EOF } require_root() { if [[ "$(id -u)" -ne 0 ]]; then echo "Error: this script must be run as root." >&2 - echo "Use: sudo $0 --use-cert [--cert-name ] [--all-users]" >&2 + echo "Use: sudo $0 --use-truststore [--all-users]" >&2 exit 1 fi } @@ -88,12 +91,8 @@ require_root() { parse_args() { while [[ $# -gt 0 ]]; do case "$1" in - --use-cert) - USE_CERT="${2:?Error: --use-cert requires a value}" - shift 2 - ;; - --cert-name) - CERT_BASENAME="${2:?Error: --cert-name requires a value}" + --use-truststore) + USE_TRUSTSTORE="${2:?Error: --use-truststore requires a value}" shift 2 ;; --all-users) @@ -112,26 +111,24 @@ parse_args() { esac done - if [[ -z "$USE_CERT" ]]; then - echo "Error: --use-cert is required." >&2 + if [[ -z "$USE_TRUSTSTORE" ]]; then + echo "Error: --use-truststore is required." >&2 usage >&2 exit 1 fi - if [[ ! -f "$USE_CERT" ]]; then - echo "Error: certificate file not found: $USE_CERT" >&2 + if [[ ! -f "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file not found: $USE_TRUSTSTORE" >&2 exit 1 fi - if [[ -z "$CERT_BASENAME" ]]; then - echo "Error: --cert-name cannot be empty." >&2 + if [[ ! -r "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file is not readable: $USE_TRUSTSTORE" >&2 exit 1 fi - # Reject path-traversal characters so $CERT_BASENAME stays a single segment - # safe to substitute into the JKS alias and the LaunchAgent label suffix. - if [[ ! "$CERT_BASENAME" =~ ^[A-Za-z0-9._-]+$ ]]; then - echo "Error: --cert-name must match [A-Za-z0-9._-]+ (got: $CERT_BASENAME)." >&2 + if [[ ! -s "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file is empty: $USE_TRUSTSTORE" >&2 exit 1 fi } @@ -145,202 +142,26 @@ check_os() { fi } -check_dependencies() { - if ! command -v openssl >/dev/null 2>&1; then - echo "Error: openssl is required but not found on PATH." >&2 - exit 1 - fi - # keytool is needed for the JKS step; checked at build_jks entry to keep - # validate_pem reachable even when no JDK is installed yet. -} - -# Locate the JDK's default cacerts file. Mirrors the Linux sibling: see the -# header comment on install_certs_jvm_linux.sh:find_jdk_cacerts for the -# rationale (OpenJDK's -Djavax.net.ssl.trustStore replaces rather than -# extends; we must copy the bundled cacerts as the base of our merged store). -# -# Resolution: $JAVA_HOME first, then dir-of-resolved-keytool. macOS-specific -# wrinkle: BSD readlink doesn't support -f, so we walk symlinks manually -# (Apple's /usr/bin/keytool is a stub that resolves through several layers). -find_jdk_cacerts() { - local candidate="" - - # 1. Explicit JAVA_HOME. Under `sudo` macOS strips this via env_reset, so - # operators who rely on jenv / asdf / SDKMAN must `sudo -E` or `sudo env - # JAVA_HOME=…` — the test runner does this; print a hint if we fall through. - if [[ -n "${JAVA_HOME:-}" && -f "${JAVA_HOME}/lib/security/cacerts" ]]; then - candidate="${JAVA_HOME}/lib/security/cacerts" - fi - - # 2. /usr/libexec/java_home — Apple's canonical resolver, scans - # /Library/Java/JavaVirtualMachines + ~/Library/Java/JavaVirtualMachines - # and prints the highest-version JDK home. Works under sudo with a stripped - # env (no JAVA_HOME) as long as a system-visible JDK is installed. - if [[ -z "$candidate" && -x /usr/libexec/java_home ]]; then - local java_home_out - java_home_out="$(/usr/libexec/java_home 2>/dev/null || true)" - if [[ -n "$java_home_out" && -f "${java_home_out}/lib/security/cacerts" ]]; then - candidate="${java_home_out}/lib/security/cacerts" - fi - fi - - # 3. Sibling of resolved keytool. Defensive last resort — typical macOS - # PATH-keytool is the Apple stub at /usr/bin/keytool (which delegates via - # java_home internally and has no sibling cacerts), but a real JDK on PATH - # (Homebrew openjdk, manually-installed Adoptium) does sit next to a - # cacerts file. BSD readlink lacks -f, so we walk symlinks manually. - if [[ -z "$candidate" ]]; then - local keytool_path resolved link - keytool_path="$(command -v keytool 2>/dev/null || true)" - if [[ -n "$keytool_path" ]]; then - resolved="$keytool_path" - local depth=0 - while [[ -L "$resolved" && $depth -lt 16 ]]; do - link="$(readlink "$resolved")" - if [[ "$link" = /* ]]; then - resolved="$link" - else - resolved="$(dirname "$resolved")/$link" - fi - depth=$((depth + 1)) - done - local keytool_dir - keytool_dir="$(cd "$(dirname "$resolved")" 2>/dev/null && pwd -P)" - if [[ -n "$keytool_dir" && -f "${keytool_dir}/../lib/security/cacerts" ]]; then - candidate="${keytool_dir}/../lib/security/cacerts" - fi - fi - fi - - if [[ -z "$candidate" ]]; then - echo "Error: cannot locate the JDK's default cacerts file." >&2 - echo " Tried (in order): \$JAVA_HOME/lib/security/cacerts," >&2 - echo " /usr/libexec/java_home → */lib/security/cacerts," >&2 - echo " \$(dirname keytool)/../lib/security/cacerts." >&2 - echo " Install a JDK (Homebrew, Adoptium, etc.), or invoke as 'sudo -E ./install_…'" >&2 - echo " to preserve your shell's JAVA_HOME under sudo." >&2 - exit 1 - fi - echo "$candidate" -} - -validate_pem() { - local path="$1" - - # C1 cross-platform parity: require PEM text input. DER would parse via - # openssl + import via keytool here (-inform der would be implied) and - # silently succeed — but the Linux + Windows siblings reject DER, so we - # also reject it for predictable cross-platform behaviour. - if ! grep -q -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null; then - echo "Error: certificate is not PEM-encoded: $path" >&2 - echo " If it's DER, convert first:" >&2 - echo " openssl x509 -inform der -in $path -out $path.pem" >&2 - exit 1 - fi - - if ! openssl x509 -in "$path" -noout >/dev/null 2>&1; then - echo "Error: invalid PEM/CRT certificate file: $path" >&2 - exit 1 - fi - - # Reject expired anchors: keytool -importcert -noprompt accepts them silently - # and the user gets cryptic CertificateExpiredException at TLS handshake time. - if ! openssl x509 -in "$path" -checkend 0 -noout >/dev/null 2>&1; then - echo "Error: certificate has already expired: $path" >&2 - exit 1 - fi - - # Warn (don't fail) on a cert expiring within 30 days — likely operator error. - # I23 parity: the 30-day window matches the Linux JVM_LINUX_EXPIRY_WARN_SECONDS - # constant (_jvm_linux_paths.sh) and the Windows AddDays(30) sibling. Change - # all three together — there is no single source of truth across the three. - if ! openssl x509 -in "$path" -checkend 2592000 -noout >/dev/null 2>&1; then - echo "[warn] certificate expires within 30 days: $path" >&2 - fi - - # Reject leaf certs: a cert without CA:TRUE in basicConstraints will import - # into a JKS truststore but PKIX path-building won't use it as a trust anchor. - # - # Stock macOS ships LibreSSL at /usr/bin/openssl, which does NOT support - # `openssl x509 -ext` (that flag is OpenSSL 3.x+). Parse the long-form - # `-text` output instead — works on both LibreSSL and OpenSSL. - local text bc_line ca_value - text="$(openssl x509 -in "$path" -noout -text 2>/dev/null || true)" - bc_line="$(awk ' - /X509v3 Basic Constraints/ { getline; print; exit } - ' <<<"$text")" - if [[ -n "$bc_line" ]]; then - ca_value="$(grep -oE 'CA:(TRUE|FALSE)' <<<"$bc_line" | head -n1 | cut -d: -f2)" - if [[ "$ca_value" == "FALSE" ]]; then - echo "Error: certificate is not a CA (basicConstraints CA:FALSE): $path" >&2 - echo " JKS imports succeed but PKIX rejects non-CA trust anchors." >&2 - exit 1 - fi - fi - - # Warn on bundles: keytool -importcert -noprompt reads only the first cert, - # silently dropping intermediates. Users should split bundles or supply only the root. - local count - count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null || echo 0)" - if [[ "$count" -gt 1 ]]; then - echo "[warn] PEM file contains $count certificates; only the first will be imported as the JVM trust anchor." >&2 - echo " Supply only the root CA (or split the bundle) if intermediates are needed." >&2 - fi -} - -require_keytool() { - if ! command -v keytool >/dev/null 2>&1; then - echo "Error: keytool is required (provided by any JDK)." >&2 - echo " Homebrew: brew install openjdk@21" >&2 - echo " Adoptium: https://adoptium.net/temurin/releases/" >&2 - echo " Manual JDK: add \$JAVA_HOME/bin to PATH (or symlink keytool into /usr/local/bin)." >&2 - exit 1 - fi -} - jks_path_for_user() { local user_home="$1" echo "${user_home}/${JKS_RELATIVE_DIR}/${JKS_BASENAME}" } -build_jks_for_user() { +install_truststore_for_user() { local target_user="$1" user_home="$2" local jks_dir="${user_home}/${JKS_RELATIVE_DIR}" local jks_path="${jks_dir}/${JKS_BASENAME}" - local src_cacerts - src_cacerts="$(find_jdk_cacerts)" - echo " [JKS] Building truststore at $jks_path (extending $src_cacerts)" + echo " [JKS] Installing truststore at $jks_path" # macOS mkdir -p will create the intermediate "Application Support" / # "JFrog" / "package-route-jvm" tree if missing. Quote the path because # "Application Support" contains a space. mkdir -p "$jks_dir" - # Copy the JDK's bundled cacerts (~150 public root CAs) as the base so the - # merged store keeps trusting Maven Central, Let's Encrypt, etc. Without - # this, -Djavax.net.ssl.trustStore at JTO-resolve time would REPLACE the - # JVM's trust source — a JKS containing only the corporate CA would break - # every public-CA TLS handshake. Idempotent: cp -f overwrites any prior - # JKS, so subsequent installs start from the canonical JDK cacerts again. - cp "$src_cacerts" "$jks_path" - - # Capture keytool's combined output so a real failure (wrong format, - # unreadable cert, keytool-from-broken-JDK, etc.) doesn't leave the user - # with `set -e` aborting at an unhelpful line. The success-case output - # ("Certificate was added to keystore") is informational only. No - # -storetype flag: modern JDKs default cacerts to PKCS12 and keytool - # autodetects the format from the existing file. - local keytool_out - if ! keytool_out="$(keytool -importcert -noprompt \ - -alias "$CERT_BASENAME" \ - -file "$USE_CERT" \ - -keystore "$jks_path" \ - -storepass "$JKS_PASSWORD" 2>&1)"; then - echo "Error: keytool -importcert failed for $jks_path. Output:" >&2 - printf '%s\n' "$keytool_out" | sed 's/^/ /' >&2 - exit 1 - fi + # The installer deliberately treats the supplied truststore as final. The + # release process that builds it owns root selection and CA contents. + cp "$USE_TRUSTSTORE" "$jks_path" chmod 0755 "$jks_dir" chmod 0644 "$jks_path" @@ -356,7 +177,7 @@ build_jks_for_user() { exit 1 fi - echo " [JKS] OK: alias=$CERT_BASENAME" + echo " [JKS] OK" } # Determine the single non-root target user when --all-users is NOT set. @@ -571,7 +392,7 @@ install_for_user() { local target_user="$1" user_home="$2" echo "=== User: $target_user (home: $user_home) ===" - build_jks_for_user "$target_user" "$user_home" + install_truststore_for_user "$target_user" "$user_home" write_launch_agent_plist "$target_user" "$user_home" bootstrap_launch_agent "$target_user" "$user_home" @@ -619,9 +440,6 @@ main() { require_root parse_args "$@" check_os - check_dependencies - validate_pem "$USE_CERT" - require_keytool if [[ "$ALL_USERS" -eq 1 ]]; then local iter_count=0 user home diff --git a/install_certs_jvm_windows.ps1 b/install_certs_jvm_windows.ps1 index 9f8e815..491964e 100644 --- a/install_certs_jvm_windows.ps1 +++ b/install_certs_jvm_windows.ps1 @@ -1,15 +1,15 @@ # (c) JFrog Ltd. (2026) -# Install a custom CA certificate on Windows for JVM clients (Maven, Gradle, +# Install a bundled JVM truststore on Windows for JVM clients (Maven, Gradle, # sbt, Apache Ivy). # -# Single path: build a per-user JKS truststore at +# Single path: copy a supplied JKS truststore to # %LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks -# containing only the customer CA, then set JAVA_TOOL_OPTIONS at User scope +# then set JAVA_TOOL_OPTIONS at User scope # (HKCU\Environment + WM_SETTINGCHANGE broadcast) so every new JVM startup # inherits the trustStore path. # # Run: -# powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseCert C:\path\to\ca.pem [-CertName ] +# powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseTruststore C:\path\to\truststore.jks # # Notes: # - Windows only. @@ -38,33 +38,32 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$UseCert, - - [Parameter(Mandatory = $false)] - [string]$CertName + [string]$UseTruststore ) $ErrorActionPreference = 'Stop' -$ScriptDir = Split-Path -Parent $PSCommandPath -. (Join-Path $ScriptDir '_jvm_windows_paths.ps1') +# Keep this installer self-contained: it is often copied/run as a standalone +# script during onboarding, so avoid requiring sibling files for constants. +$JvmWindowsJksRelativeDir = 'JFrog\package-route-jvm' +$JvmWindowsJksBasename = 'truststore.jks' +$JvmWindowsJksPassword = 'changeit' +$JvmWindowsEnvVarName = 'JAVA_TOOL_OPTIONS' -if (-not $CertName) { - $CertName = $JvmWindowsDefaultCertBasename +function Get-JvmWindowsJksPath { + Join-Path $env:LOCALAPPDATA (Join-Path $JvmWindowsJksRelativeDir $JvmWindowsJksBasename) } function Show-Usage { @' Usage: - powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseCert [-CertName ] + powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseTruststore Parameters: - -UseCert Path to an existing PEM/CRT certificate file (required). - Validation: parseable X.509, not expired, basicConstraints CA:TRUE. - -CertName Alias under which the CA is stored inside the JKS - truststore (default: package-route-custom-ca). Cosmetic -- - affects only `keytool -list` output. JKS path and env - var name are fixed per-user. + -UseTruststore Path to an existing JVM truststore (JKS/PKCS12-compatible) + to copy into the current user's fixed JKS location. + The truststore must be readable by JVMs with password + 'changeit'. Notes: No -AllUsers flag -- User-scope env var is per-user by construction; each @@ -74,190 +73,39 @@ Notes: but the JKS recipe stays uniform across platforms). Examples: - powershell -File install_certs_jvm_windows.ps1 -UseCert C:\tmp\ZscalerRoot.pem - powershell -File install_certs_jvm_windows.ps1 -UseCert C:\tmp\ca.pem -CertName zscaler-root + powershell -File install_certs_jvm_windows.ps1 -UseTruststore C:\tmp\package-route-truststore.jks '@ } -function Test-CertName { - param([string]$Name) - if ($Name -notmatch '^[A-Za-z0-9._-]+$') { - Write-Error "Error: -CertName must match [A-Za-z0-9._-]+ (got: $Name). Path-traversal characters are rejected." - exit 1 - } -} - -# Port of the Linux/macOS hardened validate_pem. Uses the built-in -# System.Security.Cryptography.X509Certificates type so it works without -# openssl on stock Windows (PowerShell 5.1+ ships .NET; PowerShell 7 -# bundles its own .NET runtime). Rejects: not parseable, expired, -# CA:FALSE (leaf cert). Warns on: expiring within 30 days, multi-cert -# bundle (keytool -importcert -noprompt reads only the first cert). -function Test-CaCertificate { +function Test-Truststore { param([string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { - Write-Error "Error: certificate file not found: $Path" + Write-Error "Error: truststore file not found: $Path" exit 1 } - # C1 cross-platform parity (matches Linux + macOS siblings): require PEM - # text input. X509Certificate2 happily parses DER, but the bash siblings - # reject DER for predictable cross-platform behaviour, and keytool's - # -importcert in PEM-text mode is what we exercise downstream. - $textPeek = [System.IO.File]::ReadAllText($Path) - if ($textPeek -notmatch '-----BEGIN CERTIFICATE-----') { - Write-Error "Error: certificate is not PEM-encoded: $Path. If it's DER, convert first: openssl x509 -inform der -in $Path -out $Path.pem" + $item = Get-Item -LiteralPath $Path + if ($item.Length -le 0) { + Write-Error "Error: truststore file is empty: $Path" exit 1 } - $bytes = [System.IO.File]::ReadAllBytes($Path) - $cert = $null + $stream = $null try { - $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$bytes) + $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) } catch { - Write-Error "Error: invalid PEM/CRT certificate file: $Path ($($_.Exception.Message))" - exit 1 - } - - # Reject expired anchors: keytool -importcert -noprompt accepts them silently - # and the user gets cryptic CertificateExpiredException at TLS handshake time. - $now = [DateTime]::UtcNow - if ($cert.NotAfter.ToUniversalTime() -lt $now) { - Write-Error "Error: certificate has already expired: $Path (NotAfter=$($cert.NotAfter))" - exit 1 - } - - # Warn (don't fail) on a cert expiring within 30 days. - # I23 parity: the 30-day window matches Linux JVM_LINUX_EXPIRY_WARN_SECONDS - # (_jvm_linux_paths.sh, 2592000s = 30d) and the macOS `-checkend 2592000` - # sibling. Change all three together -- there is no single source of truth - # across the three platforms. - if ($cert.NotAfter.ToUniversalTime() -lt $now.AddDays(30)) { - Write-Warning ("certificate expires within 30 days: {0} (NotAfter={1})" -f $Path, $cert.NotAfter) - } - - # Reject leaf certs: a cert without CA:TRUE in basicConstraints will import - # into a JKS truststore but PKIX path-building won't use it as a trust anchor. - # - # Caveat (matches Linux/macOS siblings): a cert that OMITS the - # basicConstraints extension entirely (rare on modern roots; legal for - # some legacy self-signed CAs) is treated as "don't know, allow" -- same - # behavior as OpenSSL's `-ext basicConstraints` returning empty. PKIX - # will then accept the cert as a trust anchor based on its keyUsage / - # explicit-trust-anchor status. The hard rejection only fires when the - # extension is present and explicitly says CA:FALSE. - $bcExt = $cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.19' } | Select-Object -First 1 - if ($bcExt) { - $bc = [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]$bcExt - if (-not $bc.CertificateAuthority) { - Write-Error "Error: certificate is not a CA (basicConstraints CA:FALSE): $Path. JKS imports succeed but PKIX rejects non-CA trust anchors." - exit 1 - } - } - - # Warn on bundles: keytool -importcert -noprompt reads only the first cert, - # silently dropping intermediates. Count `BEGIN CERTIFICATE` markers in - # the file (works on both binary DER and text PEM -- DER files have 0). - $content = [System.IO.File]::ReadAllText($Path) - $count = ([regex]::Matches($content, '-----BEGIN CERTIFICATE-----')).Count - if ($count -gt 1) { - Write-Warning ("PEM file contains {0} certificates; only the first will be imported as the JVM trust anchor. Supply only the root CA (or split the bundle) if intermediates are needed." -f $count) - } -} - -# Locate keytool. Prefer JAVA_HOME\bin (set by actions/setup-java and by -# standard JDK installers); fall back to PATH for IDE-bundled JBR setups -# that aren't reflected in JAVA_HOME. -function Resolve-Keytool { - if ($env:JAVA_HOME) { - $candidate = Join-Path $env:JAVA_HOME 'bin\keytool.exe' - if (Test-Path -LiteralPath $candidate -PathType Leaf) { - return $candidate - } - } - $cmd = Get-Command keytool.exe -ErrorAction SilentlyContinue - if ($cmd) { - return $cmd.Source - } - return $null -} - -# Locate the JDK's default cacerts file. Mirrors the Linux + macOS siblings: -# -Djavax.net.ssl.trustStore in OpenJDK REPLACES the JVM trust source rather -# than extending it -- pointing JVMs at a JKS holding only the corporate CA -# would break every public-CA TLS handshake. Copy the JDK's bundled cacerts -# into the target keystore first, then keytool -importcert appends the -# corporate CA, so the merged store has ~150 public roots PLUS the corporate -# one. -# -# Resolution: $JAVA_HOME first, then dir-of-keytool/../lib/security/cacerts -# (works for stock Adoptium / Corretto / Microsoft / Zulu JDK layouts where -# bin/keytool.exe and lib/security/cacerts are siblings under the JDK home). -function Resolve-JdkCacerts { - if ($env:JAVA_HOME) { - $candidate = Join-Path $env:JAVA_HOME 'lib\security\cacerts' - if (Test-Path -LiteralPath $candidate -PathType Leaf) { - return $candidate - } - } - $keytool = Resolve-Keytool - if ($keytool) { - $keytoolDir = Split-Path -Parent $keytool - $candidate = Join-Path $keytoolDir '..\lib\security\cacerts' - if (Test-Path -LiteralPath $candidate -PathType Leaf) { - return (Resolve-Path -LiteralPath $candidate).Path - } - } - Write-Error @' -Error: cannot locate the JDK's default cacerts file. - Set JAVA_HOME, or ensure keytool.exe resolves under a standard JDK bin/ layout. - Tried: $JAVA_HOME\lib\security\cacerts, \..\lib\security\cacerts -'@ - exit 1 -} - -function Require-Keytool { - $kt = Resolve-Keytool - if (-not $kt) { - Write-Error @' -Error: keytool.exe not found. - - Install a JDK (Adoptium Temurin, Amazon Corretto, Microsoft Build of OpenJDK, Azul Zulu, etc.) - - Ensure JAVA_HOME points at the JDK install dir, OR add $JAVA_HOME\bin to PATH. -'@ - exit 1 - } - - # Probe `keytool -help` to reject corrupt 0-byte stubs (leftover from a - # failed JDK uninstall) and to catch broken-runtime cases where the - # binary exists but won't execute cleanly. EAP=Continue around the call - # because some JDKs print informational lines to stderr even on -help. - $prevEAP = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { - $probeOutput = & $kt -help 2>&1 - } catch { - $ErrorActionPreference = $prevEAP - Write-Error "Error: keytool.exe at $kt threw on probe: $($_.Exception.Message). Reinstall the JDK." + Write-Error "Error: truststore file is not readable: $Path ($($_.Exception.Message))" exit 1 } finally { - $ErrorActionPreference = $prevEAP - } - if ($LASTEXITCODE -ne 0) { - Write-Error ("Error: keytool.exe at {0} does not execute cleanly (rc={1}). Reinstall the JDK.`nProbe output (first 5 lines):`n {2}" ` - -f $kt, $LASTEXITCODE, (($probeOutput | Select-Object -First 5) -join "`n ")) - exit 1 + if ($stream) { $stream.Dispose() } } - - return $kt } -function Build-JksTruststore { +function Install-JksTruststore { param( [string]$JksPath, - [string]$CertPath, - [string]$Alias, - [string]$Password + [string]$SourceTruststore ) # Precondition: %LOCALAPPDATA% must exist and be writable. OneDrive @@ -279,78 +127,17 @@ function Build-JksTruststore { New-Item -ItemType Directory -Path $jksDir -Force | Out-Null } - $keytool = Require-Keytool - $srcCacerts = Resolve-JdkCacerts - Write-Host (" [JKS] Building truststore at {0} (extending {1})" -f $JksPath, $srcCacerts) - - # Copy the JDK's bundled cacerts (~150 public root CAs) as the base so the - # merged store keeps trusting Maven Central, Let's Encrypt, etc. Without - # this, -Djavax.net.ssl.trustStore would REPLACE the JVM's trust source - # and break every public-CA handshake. Copy-Item -Force overwrites any - # prior JKS, guaranteeing idempotent end-state: each run starts from the - # canonical JDK cacerts plus exactly one corporate-CA alias. - Copy-Item -LiteralPath $srcCacerts -Destination $JksPath -Force - - # keytool.exe writes "Certificate was added to keystore" to STDERR (yes, - # really -- it's been doing this since the Sun era). Under - # $ErrorActionPreference='Stop' PowerShell promotes any native-command - # stderr output to a terminating error before 2>&1 has a chance to - # capture it. Switch to Continue for the duration of the call so we can - # examine $LASTEXITCODE ourselves. - # - # Maintenance note: $prevEAP captures the script-scope EAP. Today that's - # 'Stop' (line 33). If the script-scope default ever changes, this - # restore-via-finally still restores to whatever was set -- but the - # rest of the script's stop-on-error contract would then have to be - # re-audited. - # - # No -storetype flag: modern JDKs default cacerts to PKCS12 and keytool - # autodetects the format from the existing file. - $prevEAP = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { - $keytoolOutput = & $keytool ` - -importcert -noprompt ` - -alias $Alias ` - -file $CertPath ` - -keystore $JksPath ` - -storepass $Password 2>&1 - } finally { - $ErrorActionPreference = $prevEAP - } - if ($LASTEXITCODE -ne 0) { - Write-Error ("Error: keytool -importcert failed for {0}.`nOutput:`n {1}" -f $JksPath, ($keytoolOutput -join "`n ")) - exit 1 - } - - # keytool emits stderr warnings (JKS deprecation on JDK 17+, weak-algo - # advisories, etc.) WITH rc=0. The success branch should surface them - # rather than silently discard, otherwise customers see no warning until - # JDK 25 makes JKS read-only. - if ($keytoolOutput) { - $kOut = ($keytoolOutput | Where-Object { $_ -and $_.ToString().Trim() }) -join "`n " - if ($kOut) { - Write-Host " [JKS] keytool output:`n $kOut" + Write-Host (" [JKS] Installing truststore at {0}" -f $JksPath) + $sourcePath = (Resolve-Path -LiteralPath $SourceTruststore).Path + if (Test-Path -LiteralPath $JksPath -PathType Leaf) { + $destPath = (Resolve-Path -LiteralPath $JksPath).Path + if ([string]::Equals($sourcePath, $destPath, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-Host " [JKS] Source already matches destination; leaving truststore in place." + return } } - - # Post-import verification: a JBR-bundled keytool can rc=0 without - # actually writing a trustedCertEntry if the JKS provider was stripped - # from java.security. Confirm the entry exists; on mismatch hard-fail - # so the operator can switch to a real JDK keytool. - $ErrorActionPreference = 'Continue' - try { - $listOutput = & $keytool -list -keystore $JksPath -storepass $Password 2>&1 - } finally { - $ErrorActionPreference = $prevEAP - } - if (-not ($listOutput | Select-String -Pattern 'trustedCertEntry' -Quiet)) { - Write-Error ("Error: keytool -importcert reported rc=0 but the keystore at {0} contains no trustedCertEntry. The resolved keytool ({1}) may be an IDE-bundled JBR with a non-standard provider list. Try a stock JDK (Adoptium / Corretto)." ` - -f $JksPath, $keytool) - exit 1 - } - - Write-Host (" [JKS] OK: alias={0}" -f $Alias) + Copy-Item -LiteralPath $sourcePath -Destination $JksPath -Force + Write-Host " [JKS] OK" } function Set-JavaToolOptions { @@ -393,13 +180,12 @@ function Set-JavaToolOptions { function Show-DoneSummary { param( [string]$JksPath, - [string]$Alias, [string]$JtoValue ) Write-Host "" Write-Host "Truststore:" - Write-Host (" {0} (alias: {1})" -f $JksPath, $Alias) + Write-Host (" {0}" -f $JksPath) Write-Host ("{0}:" -f $JvmWindowsEnvVarName) Write-Host (" {0}" -f $JtoValue) Write-Host "" @@ -416,25 +202,18 @@ function Show-DoneSummary { } function Main { - if (-not (Test-Path -LiteralPath $UseCert -PathType Leaf)) { - Write-Error "Error: certificate file not found: $UseCert" - exit 1 - } - Test-CertName $CertName - Test-CaCertificate -Path $UseCert + Test-Truststore -Path $UseTruststore $jksPath = Get-JvmWindowsJksPath - Build-JksTruststore ` + Install-JksTruststore ` -JksPath $jksPath ` - -CertPath $UseCert ` - -Alias $CertName ` - -Password $JvmWindowsJksPassword + -SourceTruststore $UseTruststore $jtoValue = Set-JavaToolOptions ` -JksPath $jksPath ` -Password $JvmWindowsJksPassword - Show-DoneSummary -JksPath $jksPath -Alias $CertName -JtoValue $jtoValue + Show-DoneSummary -JksPath $jksPath -JtoValue $jtoValue } Main diff --git a/testing/test_install_certs_jvm_macos.sh b/testing/test_install_certs_jvm_macos.sh index 168991b..dadb696 100755 --- a/testing/test_install_certs_jvm_macos.sh +++ b/testing/test_install_certs_jvm_macos.sh @@ -6,27 +6,24 @@ # sudo ./testing/test_install_certs_jvm_macos.sh # # Targets the SUDO_USER's per-user files. `cleanup` runs at the start of -# cases 1, 4, 10, 11 (the positive / fresh-state cases) and via `trap EXIT`. -# Cases 2 (subject mismatch), 3 (idempotency), 5-8 (negative arg / cert), -# 9 (getenv) and 12 (validate_pem warns) deliberately reuse the prior install -# state because they assert behavior ON TOP of an installed system. +# fresh-state cases and via `trap EXIT`. The test runner builds a bundled +# truststore fixture from the local JDK cacerts plus a lab CA, then verifies the +# installer only copies that ready-made JKS into place and configures launchd. # # Invariants exercised: # 1. Positive install + validate (subject substring match) # 2. Subject mismatch -> exit 1 -# 3. Idempotent re-install (single JKS alias after 2 runs; plist replaced) -# 4. Custom --cert-name round-trips (alias inside JKS = cert-name) -# 5. Path-traversal --cert-name rejected -# 6. Malformed PEM rejected -# 7. Expired CA rejected (skip if openssl can't produce one verifiably-expired) -# 8. Leaf cert (CA:FALSE) rejected -# 9. After bootstrap, launchctl getenv JAVA_TOOL_OPTIONS in gui/ returns +# 3. Idempotent re-install (copied JKS checksum stable; plist replaced) +# 4. Missing --use-truststore is rejected +# 5. Missing / empty truststore paths are rejected +# 6. After bootstrap, launchctl getenv JAVA_TOOL_OPTIONS in gui/ returns # the JKS path (skip on CI runners with no GUI session) -# 10. Plist content is well-formed XML and points at the expected JKS path +# 7. Plist content is well-formed XML and points at the expected JKS path # (covers the install path even when launchctl can't be verified) -# 11. --all-users iterates /Users/* and installs into every eligible account +# 8. --all-users iterates /Users/* and installs into every eligible account # (covers the iter_all_users filter + per-user chown contract) -# 12. validate_pem warn paths: 30-day-expiry warn + multi-cert bundle warn +# 9. Installed JKS preserves public roots from the bundled truststore +# 10. JAVA_TOOL_OPTIONS round-trips through JVM tokenizer set -euo pipefail fail_msg() { echo "BUG: $1" >&2; exit 1; } @@ -59,6 +56,7 @@ JKS="${TEST_HOME}/Library/Application Support/JFrog/package-route-jvm/truststore JKS_DIR="${TEST_HOME}/Library/Application Support/JFrog/package-route-jvm" PLIST="${TEST_HOME}/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist" LABEL="com.jfrog.package-reroute.jto-env" +BUNDLE_JKS="/tmp/jvm-mac-bundled-truststore.jks" echo "Test user: $TEST_USER (uid=$TEST_UID, home=$TEST_HOME)" @@ -67,8 +65,14 @@ cleanup() { rm -f "$PLIST" rm -rf "$JKS_DIR" launchctl asuser "${TEST_UID}" launchctl unsetenv JAVA_TOOL_OPTIONS 2>/dev/null || true + rm -f /tmp/jvm-mac-empty-truststore.jks } -trap cleanup EXIT + +final_cleanup() { + cleanup + rm -f "$BUNDLE_JKS" +} +trap final_cleanup EXIT # Pick an OpenSSL implementation that supports `-addext` reliably. # macos-latest CI's default `openssl` is LibreSSL, which silently mis-handles @@ -84,12 +88,73 @@ done [[ -n "$OPENSSL" ]] || fail_msg "no real OpenSSL on PATH (need OpenSSL 3.x; LibreSSL does not support -addext)" echo "Using openssl: $OPENSSL ($("$OPENSSL" version))" +require_keytool() { + command -v keytool >/dev/null 2>&1 || fail_msg "keytool not on PATH (validator/test fixture requires a JDK)" +} + +find_jdk_cacerts() { + if [[ -n "${JAVA_HOME:-}" && -f "${JAVA_HOME}/lib/security/cacerts" ]]; then + echo "${JAVA_HOME}/lib/security/cacerts" + return 0 + fi + + if [[ -x /usr/libexec/java_home ]]; then + local java_home_out + java_home_out="$(/usr/libexec/java_home 2>/dev/null || true)" + if [[ -n "$java_home_out" && -f "${java_home_out}/lib/security/cacerts" ]]; then + echo "${java_home_out}/lib/security/cacerts" + return 0 + fi + fi + + local keytool_path resolved link + keytool_path="$(command -v keytool 2>/dev/null || true)" + if [[ -n "$keytool_path" ]]; then + resolved="$keytool_path" + local depth=0 + while [[ -L "$resolved" && $depth -lt 16 ]]; do + link="$(readlink "$resolved")" + if [[ "$link" = /* ]]; then + resolved="$link" + else + resolved="$(dirname "$resolved")/$link" + fi + depth=$((depth + 1)) + done + local keytool_dir + keytool_dir="$(cd "$(dirname "$resolved")" 2>/dev/null && pwd -P)" + if [[ -n "$keytool_dir" && -f "${keytool_dir}/../lib/security/cacerts" ]]; then + echo "${keytool_dir}/../lib/security/cacerts" + return 0 + fi + fi + + fail_msg "cannot locate JDK cacerts for bundled truststore fixture" +} + +build_bundle_truststore() { + local ca_path="$1" src_cacerts + src_cacerts="$(find_jdk_cacerts)" + rm -f "$BUNDLE_JKS" + cp "$src_cacerts" "$BUNDLE_JKS" + chmod 0644 "$BUNDLE_JKS" + keytool -importcert -noprompt \ + -alias package-route-custom-ca \ + -file "$ca_path" \ + -keystore "$BUNDLE_JKS" \ + -storepass changeit >/dev/null + echo "Bundled truststore fixture: $BUNDLE_JKS (base: $src_cacerts)" +} + # Generate the lab CA used by all positive cases. "$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ -keyout /tmp/jvm-mac-test-k.pem -out /tmp/jvm-mac-test-ca.pem -days 7 \ -subj "/CN=Lab JVM mac CA Test/O=JFrog" \ -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null +require_keytool +build_bundle_truststore /tmp/jvm-mac-test-ca.pem + # Capture combined stdout/stderr to a tempfile and only dump it on an # *unexpected* exit. Negative tests need silence on the expected-fail path # but diagnostic output when the installer surprises us. Keeps CI logs @@ -137,7 +202,7 @@ echo "=== 1. positive: install + validate ===" cleanup # Positive cases let stdout through so a CI failure shows a useful log; only # negative cases (where we *expect* exit 1) silence both streams. -SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem +SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-truststore "$BUNDLE_JKS" SUDO_USER="$TEST_USER" ./validate_certs_jvm_macos.sh --expected-subject "Lab JVM mac CA Test" echo " ok" @@ -151,96 +216,57 @@ echo " ok" #----------------------------------------------------------------------------- echo -echo "=== 3. idempotency: 2nd install produces single alias / single plist ===" -install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem +echo "=== 3. idempotency: 2nd install preserves bundled JKS / single plist ===" +install_as_test_user --use-truststore "$BUNDLE_JKS" validate_as_test_user --expected-subject "Lab JVM mac CA Test" +bundle_sha="$(shasum -a 256 "$BUNDLE_JKS" | awk '{print $1}')" +installed_sha="$(shasum -a 256 "$JKS" | awk '{print $1}')" +[[ "$installed_sha" == "$bundle_sha" ]] || fail_msg "installed JKS checksum differs from bundled truststore" alias_count=$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ | grep -c "trustedCertEntry" || true) alias_count=${alias_count:-0} -# JKS extends the JDK's bundled cacerts (~150 public roots) plus exactly one -# corporate-CA alias. After two installs, the corporate alias count must be -# exactly 1; the JDK-supplied aliases stay constant. corp_count=$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ - | grep -cE "^${CERT_BASENAME:-package-route-custom-ca}[,[:space:]]" || true) + | grep -cE "^package-route-custom-ca[,[:space:]]" || true) corp_count=${corp_count:-0} [[ "$corp_count" -eq 1 ]] || fail_msg "expected exactly 1 corporate-CA alias after 2 installs, got $corp_count" [[ "$alias_count" -ge 100 ]] || fail_msg "expected JKS to extend default cacerts (>=100 aliases), got $alias_count" [[ -f "$PLIST" ]] || fail_msg "plist missing after 2nd install" -echo " ok (alias_count=$alias_count)" +echo " ok (alias_count=$alias_count, sha=$installed_sha)" #----------------------------------------------------------------------------- echo -echo "=== 4. custom --cert-name round-trips (alias inside JKS = cert-name) ===" -cleanup -install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem --cert-name zscaler-root -keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ - | grep -q "^zscaler-root," \ - || fail_msg "expected JKS alias 'zscaler-root' (got: $(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null | grep trustedCertEntry))" -validate_as_test_user --expected-subject "Lab JVM mac CA Test" -echo " ok" - -#----------------------------------------------------------------------------- -echo -echo "=== 5. negative: path-traversal --cert-name rejected ===" -if install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem --cert-name '../etc/pwned'; then +echo "=== 4. negative: missing --use-truststore rejected ===" +if install_as_test_user; then dump_last_log - fail_msg "installer should have rejected path-traversal --cert-name" + fail_msg "installer should have rejected missing --use-truststore" fi echo " ok" #----------------------------------------------------------------------------- echo -echo "=== 6. negative: malformed PEM rejected ===" -echo "not a certificate" > /tmp/jvm-mac-bad.pem -if install_as_test_user --use-cert /tmp/jvm-mac-bad.pem; then +echo "=== 5. negative: missing truststore path rejected ===" +if install_as_test_user --use-truststore /tmp/no-such-jvm-truststore.jks; then dump_last_log - fail_msg "installer should have rejected malformed PEM" + fail_msg "installer should have rejected missing truststore path" fi echo " ok" #----------------------------------------------------------------------------- echo -echo "=== 7. negative: expired CA rejected ===" -rm -f /tmp/jvm-mac-expired.pem -# Try OpenSSL 3.2+'s `-not_before / -not_after`; fall back to negative `-days`. -"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ - -keyout /tmp/jvm-mac-expired-k.pem -out /tmp/jvm-mac-expired.pem \ - -subj "/CN=Expired/O=JFrog" \ - -addext "basicConstraints=critical,CA:TRUE" \ - -not_before 20200101000000Z -not_after 20200201000000Z 2>/dev/null \ -|| "$OPENSSL" x509 -req -in <("$OPENSSL" req -new -key /tmp/jvm-mac-test-k.pem -subj "/CN=Expired") \ - -signkey /tmp/jvm-mac-test-k.pem -days -1 -out /tmp/jvm-mac-expired.pem 2>/dev/null \ -|| true - -if [[ ! -f /tmp/jvm-mac-expired.pem ]] || "$OPENSSL" x509 -in /tmp/jvm-mac-expired.pem -checkend 0 -noout >/dev/null 2>&1; then - echo " SKIP: cannot produce a verifiably expired cert with the installed openssl ($(openssl version))" -else - if install_as_test_user --use-cert /tmp/jvm-mac-expired.pem; then - dump_last_log - fail_msg "installer should have rejected expired CA" - fi - echo " ok" -fi - -#----------------------------------------------------------------------------- -echo -echo "=== 8. negative: leaf cert (CA:FALSE) rejected ===" -"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ - -keyout /tmp/jvm-mac-leaf-k.pem -out /tmp/jvm-mac-leaf.pem -days 7 \ - -subj "/CN=Leaf Not CA" \ - -addext "basicConstraints=critical,CA:FALSE" 2>/dev/null -if install_as_test_user --use-cert /tmp/jvm-mac-leaf.pem; then +echo "=== 6. negative: empty truststore rejected ===" +: > /tmp/jvm-mac-empty-truststore.jks +if install_as_test_user --use-truststore /tmp/jvm-mac-empty-truststore.jks; then dump_last_log - fail_msg "installer should have rejected leaf cert (CA:FALSE)" + fail_msg "installer should have rejected empty truststore" fi echo " ok" #----------------------------------------------------------------------------- echo -echo "=== 9. launchctl getenv JAVA_TOOL_OPTIONS in gui/ ===" +echo "=== 7. launchctl getenv JAVA_TOOL_OPTIONS in gui/ ===" cleanup -install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem +install_as_test_user --use-truststore "$BUNDLE_JKS" if launchctl print "gui/${TEST_UID}" >/dev/null 2>&1; then # Mirror the installer's 20x100ms retry: bootstrap returns the moment # the agent is loaded, but the launchctl-setenv ProgramArguments runs @@ -268,8 +294,8 @@ fi #----------------------------------------------------------------------------- echo -echo "=== 10. plist is well-formed XML and points at the expected JKS ===" -# Reuses the install from step 9 (no cleanup). Independent of whether +echo "=== 8. plist is well-formed XML and points at the expected JKS ===" +# Reuses the install from step 7 (no cleanup). Independent of whether # launchctl bootstrap succeeded — validates the write_launch_agent_plist # code path even on headless CI runners. plutil -lint "$PLIST" >/dev/null @@ -284,13 +310,13 @@ esac #----------------------------------------------------------------------------- echo -echo "=== 11. --all-users iterates eligible accounts ===" +echo "=== 9. --all-users iterates eligible accounts ===" cleanup # CI runners only have a single user (`runner`, uid 501). That's enough to # verify iter_all_users does at least one iteration through the filter + # per-user-chown path; multi-user is covered by the local dev Mac smoke. # Run without SUDO_USER set so the installer takes the --all-users branch. -out="$(./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem --all-users 2>&1)" +out="$(./install_certs_jvm_macos.sh --use-truststore "$BUNDLE_JKS" --all-users 2>&1)" echo "$out" | grep -q "=== User: ${TEST_USER}" \ || { echo "$out" | tail -20; fail_msg "--all-users did not iterate ${TEST_USER}"; } echo "$out" | grep -qE "Installed for [0-9]+ user\(s\)" \ @@ -302,93 +328,17 @@ plist_owner="$(stat -f '%Su' "$PLIST")" echo " ok" #----------------------------------------------------------------------------- -echo -echo "=== 12. validate_pem warn paths: 30-day-expiry + bundle ===" -# Short-validity CA (1 day) — within 30 days, should produce the expiry warn -# but install succeed. -"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ - -keyout /tmp/jvm-mac-soon-k.pem -out /tmp/jvm-mac-soon.pem -days 1 \ - -subj "/CN=Soon to Expire/O=JFrog" \ - -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null -out="$(SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-soon.pem 2>&1)" -echo "$out" | grep -q "certificate expires within 30 days" \ - || { echo "$out" | tail -20; fail_msg "30-day expiry warn missing"; } - -# Multi-cert bundle: append a second cert to the test CA. The installer -# warns and imports only the first; install must still succeed. -cat /tmp/jvm-mac-test-ca.pem /tmp/jvm-mac-soon.pem > /tmp/jvm-mac-bundle.pem -out="$(SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-bundle.pem 2>&1)" -echo "$out" | grep -qE "PEM file contains [0-9]+ certificates" \ - || { echo "$out" | tail -20; fail_msg "multi-cert bundle warn missing"; } -echo " ok" - -#----------------------------------------------------------------------------- -echo -echo "=== 13. negative: missing keytool fails cleanly ===" -# I3 cross-platform parity: build an isolated bin/ containing symlinks to -# every /usr/bin and /bin tool EXCEPT keytool, plus openssl from its real -# location. Then run the installer with PATH=. macOS-latest -# CI has /usr/bin/keytool from the Apple-bundled JavaAppletPlugin, and -# setup-java prepends $JAVA_HOME/bin — neither survives this isolated PATH. -nokey_bin="$(mktemp -d)" -for d in /usr/bin /bin /usr/sbin /sbin; do - [[ -d "$d" ]] || continue - for f in "$d"/*; do - base="$(basename "$f")" - [[ "$base" == keytool ]] && continue - ln -sf "$f" "$nokey_bin/$base" 2>/dev/null || true - done -done -# openssl may live outside /usr/bin (Homebrew on macOS GHA puts it in -# /opt/homebrew/bin). Ensure the isolated bin can find a working openssl. -ln -sf "$OPENSSL" "$nokey_bin/openssl" 2>/dev/null || true -if ! "$nokey_bin/openssl" version >/dev/null 2>&1; then - rm -rf "$nokey_bin" - fail_msg "isolated bin missing openssl — cannot exercise missing-keytool path" -fi -if [[ -e "$nokey_bin/keytool" ]] || env -i PATH="$nokey_bin" command -v keytool >/dev/null 2>&1; then - rm -rf "$nokey_bin" - fail_msg "isolated bin still has keytool — test setup broken" -fi -if env -i PATH="$nokey_bin" HOME="$HOME" SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem >/tmp/jvm-mac-nokey.out 2>&1; then - cat /tmp/jvm-mac-nokey.out | head -20 - rm -rf "$nokey_bin" - fail_msg "installer should have rejected missing keytool" -fi -if ! grep -q -i keytool /tmp/jvm-mac-nokey.out; then - cat /tmp/jvm-mac-nokey.out | head -10 - rm -rf "$nokey_bin" - fail_msg "missing-keytool error message should mention 'keytool'" -fi -rm -rf "$nokey_bin" -echo " ok" - -#----------------------------------------------------------------------------- -echo -echo "=== 14. negative: DER cert rejected (C1 cross-platform parity) ===" -# C1 backport: convert the lab CA to DER, then attempt install — should -# fail with a hint to convert back. Mirrors Linux + Windows behavior. -"$OPENSSL" x509 -in /tmp/jvm-mac-test-ca.pem -outform DER -out /tmp/jvm-mac-test-ca.der 2>/dev/null -if SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.der >/tmp/jvm-mac-der.out 2>&1; then - cat /tmp/jvm-mac-der.out | head -10 - fail_msg "installer should have rejected DER-encoded cert" -fi -grep -q -i "PEM-encoded" /tmp/jvm-mac-der.out \ - || { cat /tmp/jvm-mac-der.out | head -10; fail_msg "DER reject message should mention 'PEM-encoded'"; } -echo " ok" - -#----------------------------------------------------------------------------- -# Re-install once so the next three invariants observe the post-fix end state. +# Re-install once so the next three invariants observe the final end state. cleanup -SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem >/dev/null +SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-truststore "$BUNDLE_JKS" >/dev/null echo -echo "=== 15. JKS extends default cacerts (preserves public roots) ===" +echo "=== 10. JKS extends bundled public roots ===" # Regression guard for the "trustStore replaces, not extends" footgun. # -Djavax.net.ssl.trustStore in OpenJDK swaps the JVM's trust source; a JKS # holding only the corporate CA would break every public-CA TLS handshake # (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). -# Installer must therefore cp $JAVA_HOME/lib/security/cacerts first. +# The shipped bundle must therefore include public roots before install. alias_count="$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null | grep -c 'trustedCertEntry' || true)" alias_count="${alias_count:-0}" [[ "$alias_count" -ge 100 ]] \ @@ -396,7 +346,7 @@ alias_count="${alias_count:-0}" echo " ok ($alias_count aliases)" echo -echo "=== 16. JKS contains a well-known public root (DigiCert family) ===" +echo "=== 11. JKS contains a well-known public root (DigiCert family) ===" # Spot-check the merge actually happened. DigiCert root certs ship in every # JDK's cacerts under several aliases (digicertglobalrootca, digicertglobalrootg2, # digicerttrustedrootg4, etc.) — case-insensitive substring match catches them all. @@ -406,7 +356,7 @@ keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ echo " ok" echo -echo "=== 17. JAVA_TOOL_OPTIONS round-trips through JVM tokenizer ===" +echo "=== 12. JAVA_TOOL_OPTIONS round-trips through JVM tokenizer ===" # Direct repro of the "Application Support" space-tokenisation bug. Spawn a # child java -version with the LaunchAgent's env var and assert the JVM # does NOT print "Unrecognized option" — that's what an unquoted trustStore diff --git a/testing/test_install_certs_jvm_windows.ps1 b/testing/test_install_certs_jvm_windows.ps1 index 4d6080b..7869405 100644 --- a/testing/test_install_certs_jvm_windows.ps1 +++ b/testing/test_install_certs_jvm_windows.ps1 @@ -5,26 +5,20 @@ # powershell -ExecutionPolicy Bypass -File testing/test_install_certs_jvm_windows.ps1 # # No Administrator required -- User-scope env vars and %LOCALAPPDATA% paths -# are per-user. The runner uses the *current* user's profile and HKCU. -# Idempotent end-state via try/finally cleanup. +# are per-user. The runner builds a bundled truststore fixture from the local +# JDK cacerts plus a lab CA, then verifies the installer only copies that +# ready-made JKS into place and configures HKCU\Environment. # # Invariants exercised: # 1. Positive install + validate (subject substring match) # 2. Subject mismatch -> exit 1 -# 3. Idempotent re-install (single JKS alias after 2 runs; env var replaced) -# 4. Custom -CertName round-trips (alias inside JKS = cert-name) -# 5. Path-traversal -CertName rejected -# 6. Malformed PEM rejected -# 7. Expired CA rejected (skip if openssl can't produce one verifiably-expired) -# 8. Leaf cert (CA:FALSE) rejected -# 9. After install, [Environment]::GetEnvironmentVariable('JAVA_TOOL_OPTIONS','User') -# returns a string referencing the expected JKS path -# 10. validate_pem 30-day-expiry warn fires (cert valid <30d still installs) -# 11. validate_pem multi-cert bundle warn fires -# 12. JTO env var REPLACES (not appends) on re-install: pre-seed a stale value, -# run installer, assert old value is gone -# 13. Missing keytool fails cleanly: clear PATH+JAVA_HOME and assert exit 1 -# 14. Mandatory -UseCert: invoke with no args, assert non-interactive exit 1 +# 3. Idempotent re-install (copied JKS checksum stable; env var replaced) +# 4. Missing / empty truststore paths are rejected +# 5. User-scope JAVA_TOOL_OPTIONS references the expected JKS +# 6. JTO env var REPLACES (not appends) on re-install +# 7. Mandatory -UseTruststore: no-args invocation fails non-interactively +# 8. Installed JKS preserves public roots from the bundled truststore +# 9. Installed JKS contains a well-known public root (DigiCert family) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest @@ -36,8 +30,8 @@ Set-Location $RepoRoot # Locate openssl. windows-latest GHA runners have Git for Windows preinstalled, # which bundles openssl at C:\Program Files\Git\usr\bin\openssl.exe. Also try -# Strawberry Perl's openssl. Don't fall back silently -- `where openssl` could -# return LibreSSL-equivalent if anything in PATH is malformed. +# Strawberry Perl's openssl. The test needs openssl only to mint the lab CA +# that goes into the bundled truststore fixture; the installer itself does not. $OpenSsl = $null $candidates = @( 'C:\Program Files\Git\usr\bin\openssl.exe', @@ -53,53 +47,46 @@ if (-not $OpenSsl) { if ($cmd) { $OpenSsl = $cmd.Source } } if (-not $OpenSsl) { - Fail-Test 'no openssl.exe found (need Git for Windows or similar)' + Fail-Test 'no openssl.exe found (need Git for Windows or similar to build the test fixture)' } Write-Host ("Using openssl: {0}" -f $OpenSsl) -$openSslVersionLine = (& $OpenSsl version) 2>&1 -Write-Host $openSslVersionLine - -# Test #7 (expired CA) uses OpenSSL 3.2+'s -not_before/-not_after flags. -# windows-latest currently ships 3.5.x via Git for Windows. If that ever -# regresses below 3.2 the test silently SKIPs -- surface a yellow flag now -# so a future maintainer sees the version drift in the CI run summary. -if ($openSslVersionLine -match 'OpenSSL\s+(\d+)\.(\d+)') { - $major = [int]$matches[1] - $minor = [int]$matches[2] - if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 2)) { - Write-Warning ("Detected OpenSSL {0}.{1}, but test #7 (expired CA) requires 3.2+ for -not_before / -not_after. It will SKIP." -f $major, $minor) - } +Write-Host ((& $OpenSsl version) 2>&1) + +$JvmWindowsJksRelativeDir = 'JFrog\package-route-jvm' +$JvmWindowsJksBasename = 'truststore.jks' +function Get-JvmWindowsJksPath { + Join-Path $env:LOCALAPPDATA (Join-Path $JvmWindowsJksRelativeDir $JvmWindowsJksBasename) } -. (Join-Path $RepoRoot '_jvm_windows_paths.ps1') -$JksPath = Get-JvmWindowsJksPath -$JksDir = Split-Path -Parent $JksPath -$LabSubj = 'Lab JVM Win CA Test' +$JksPath = Get-JvmWindowsJksPath +$JksDir = Split-Path -Parent $JksPath +$LabSubj = 'Lab JVM Win CA Test' +$BundleJks = 'C:\Windows\Temp\jvm-win-bundled-truststore.jks' function Cleanup { # Reset JAVA_TOOL_OPTIONS regardless of prior state. Setting to $null # via SetEnvironmentVariable deletes the value. [Environment]::SetEnvironmentVariable('JAVA_TOOL_OPTIONS', $null, [EnvironmentVariableTarget]::User) if (Test-Path -LiteralPath $JksDir) { - # I12: probe-then-warn rather than -ErrorAction SilentlyContinue. - # Silent failure here hides locked files left by a leaked keytool.exe - # child from a previous test crash, which then surface as a confusing - # error inside the NEXT test's Build-JksTruststore. try { Remove-Item -LiteralPath $JksDir -Recurse -Force -ErrorAction Stop } catch { - Write-Warning "Cleanup: could not remove $JksDir ($($_.Exception.Message)). A previous keytool.exe child may still hold a file handle; subsequent tests will likely fail." + Write-Warning "Cleanup: could not remove $JksDir ($($_.Exception.Message)). A previous process may still hold a file handle; subsequent tests will likely fail." } } + Remove-Item -LiteralPath 'C:\Windows\Temp\jvm-win-empty-truststore.jks' -ErrorAction SilentlyContinue +} + +function Final-Cleanup { + Cleanup + Remove-Item -LiteralPath $BundleJks -ErrorAction SilentlyContinue } -# Run cleanup unconditionally on exit so re-running after a partial failure -# starts from the same baseline. try { Cleanup -# --- Generate the lab CA used by all positive cases --- +# --- Generate the lab CA used by the bundled truststore fixture --- $labKey = 'C:\Windows\Temp\jvm-win-test-k.pem' $labCa = 'C:\Windows\Temp\jvm-win-test-ca.pem' Remove-Item -LiteralPath $labKey, $labCa -ErrorAction SilentlyContinue @@ -111,23 +98,8 @@ if (-not (Test-Path -LiteralPath $labCa)) { Fail-Test "openssl req failed to produce $labCa" } -# Run installer/validator as a child powershell.exe and capture combined -# output via Tee-Object -- Tee writes BOTH to the file AND down the pipeline. -# We pass the pipeline through Out-String to flatten formatted records and -# return the joined string. On unexpected exit the caller can dump the -# captured output via Write-Host. -# -# Don't name the parameter $Args -- that's a PowerShell automatic variable -# that collides with @Args splat semantics inside the function body and -# silently turns into an empty array. function Invoke-Installer { param([string[]]$ScriptArgs, [switch]$ExpectFail) - # C1 fix: capture $LASTEXITCODE IMMEDIATELY after the native call, BEFORE - # the Out-String pipeline. With `$ErrorActionPreference='Stop'` plus - # `Set-StrictMode -Version Latest` a downstream pipeline element raising - # any error would jump past `$rc = $LASTEXITCODE` and leave $rc carrying - # the value from a previous step -- which can flip an `-ExpectFail` - # assertion into a phantom pass. $raw = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` -File '.\install_certs_jvm_windows.ps1' @ScriptArgs 2>&1 $rc = $LASTEXITCODE @@ -152,8 +124,6 @@ function Invoke-Installer { function Invoke-Validator { param([string[]]$ScriptArgs, [switch]$ExpectFail) - # Same rc-capture-before-pipeline pattern as Invoke-Installer; see C1 - # comment there for the rationale. $raw = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` -File '.\validate_certs_jvm_windows.ps1' @ScriptArgs 2>&1 $rc = $LASTEXITCODE @@ -176,7 +146,6 @@ function Invoke-Validator { return $out } -# Find keytool for direct independent checks (alias count, etc.) function Get-Keytool { if ($env:JAVA_HOME) { $kt = Join-Path $env:JAVA_HOME 'bin\keytool.exe' @@ -184,31 +153,64 @@ function Get-Keytool { } $cmd = Get-Command keytool.exe -ErrorAction SilentlyContinue if ($cmd) { return $cmd.Source } - Fail-Test 'keytool.exe not on PATH (need JAVA_HOME set or actions/setup-java)' + Fail-Test 'keytool.exe not on PATH (need JAVA_HOME set or actions/setup-java for validator/test fixture)' } $Keytool = Get-Keytool -# I10: wrap inline keytool calls with EAP=Continue. keytool -list on JDK 17+ -# has been observed emitting crypto-policy notices and JKS-deprecation -# warnings to stderr at rc=0; under $ErrorActionPreference='Stop' those -# would terminate the test. Same pattern as install/validate scripts use. +function Resolve-JdkCacerts { + if ($env:JAVA_HOME) { + $candidate = Join-Path $env:JAVA_HOME 'lib\security\cacerts' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + } + $keytoolDir = Split-Path -Parent $Keytool + $candidate = Join-Path $keytoolDir '..\lib\security\cacerts' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return (Resolve-Path -LiteralPath $candidate).Path + } + Fail-Test 'cannot locate JDK cacerts for bundled truststore fixture' +} + function Invoke-Keytool { param([string[]]$KeytoolArgs) $prevEAP = $ErrorActionPreference $ErrorActionPreference = 'Continue' + $rc = 0 try { $out = & $Keytool @KeytoolArgs 2>&1 + $rc = $LASTEXITCODE } finally { $ErrorActionPreference = $prevEAP } + if ($rc -ne 0) { + Fail-Test ("keytool exited {0}: {1}" -f $rc, (($out | Select-Object -First 5) -join '; ')) + } return $out } +function Build-BundledTruststore { + param([string]$CaPath) + $srcCacerts = Resolve-JdkCacerts + Remove-Item -LiteralPath $BundleJks -ErrorAction SilentlyContinue + Copy-Item -LiteralPath $srcCacerts -Destination $BundleJks -Force + Invoke-Keytool -KeytoolArgs @( + '-importcert', '-noprompt', + '-alias', 'package-route-custom-ca', + '-file', $CaPath, + '-keystore', $BundleJks, + '-storepass', 'changeit' + ) | Out-Null + Write-Host ("Bundled truststore fixture: {0} (base: {1})" -f $BundleJks, $srcCacerts) +} + +Build-BundledTruststore -CaPath $labCa + #----------------------------------------------------------------------------- Write-Host "" Write-Host "=== 1. positive: install + validate ===" Cleanup -Invoke-Installer -ScriptArgs @('-UseCert', $labCa) | Out-Null +Invoke-Installer -ScriptArgs @('-UseTruststore', $BundleJks) | Out-Null Invoke-Validator -ScriptArgs @('-ExpectedSubject', $LabSubj) | Out-Null Write-Host " ok" @@ -220,98 +222,44 @@ Write-Host " ok" #----------------------------------------------------------------------------- Write-Host "" -Write-Host "=== 3. idempotency: 2nd install produces single alias / single env value ===" -Invoke-Installer -ScriptArgs @('-UseCert', $labCa) | Out-Null +Write-Host "=== 3. idempotency: 2nd install preserves bundled JKS / single env value ===" +Invoke-Installer -ScriptArgs @('-UseTruststore', $BundleJks) | Out-Null Invoke-Validator -ScriptArgs @('-ExpectedSubject', $LabSubj) | Out-Null +$bundleHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $BundleJks).Hash +$installedHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $JksPath).Hash +if ($installedHash -ne $bundleHash) { + Fail-Test "installed JKS checksum differs from bundled truststore" +} + $listOut = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') $aliasCount = (($listOut | Select-String 'trustedCertEntry').Matches.Count) -# JKS extends the JDK's bundled cacerts (~150 public roots) plus exactly one -# corporate-CA alias. After two installs, the corporate alias count must be -# exactly 1; the JDK-supplied aliases stay constant. $corpCount = (($listOut | Select-String '^package-route-custom-ca[,\s]').Matches.Count) if ($corpCount -ne 1) { Fail-Test "expected exactly 1 corporate-CA alias after 2 installs, got $corpCount" } if ($aliasCount -lt 100) { - Fail-Test "expected JKS to extend default cacerts (>=100 aliases), got $aliasCount" + Fail-Test "expected JKS to preserve bundled public roots (>=100 aliases), got $aliasCount" } -Write-Host (" ok (alias_count={0}, corp_alias_count={1})" -f $aliasCount, $corpCount) - -#----------------------------------------------------------------------------- -Write-Host "" -Write-Host "=== 4. custom -CertName round-trips (alias inside JKS = cert-name) ===" -Cleanup -Invoke-Installer -ScriptArgs @('-UseCert', $labCa, '-CertName', 'zscaler-root') | Out-Null -$listOut = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') -if (-not ($listOut | Select-String '^zscaler-root,')) { - Fail-Test "expected JKS alias 'zscaler-root', got: $($listOut -join '; ')" -} -Invoke-Validator -ScriptArgs @('-ExpectedSubject', $LabSubj) | Out-Null -Write-Host " ok" +Write-Host (" ok (alias_count={0}, corp_alias_count={1}, sha={2})" -f $aliasCount, $corpCount, $installedHash) #----------------------------------------------------------------------------- Write-Host "" -Write-Host "=== 5. negative: path-traversal -CertName rejected ===" -Invoke-Installer -ScriptArgs @('-UseCert', $labCa, '-CertName', '..\etc\pwned') -ExpectFail | Out-Null +Write-Host "=== 4. negative: missing truststore path rejected ===" +Invoke-Installer -ScriptArgs @('-UseTruststore', 'C:\Windows\Temp\no-such-jvm-truststore.jks') -ExpectFail | Out-Null Write-Host " ok" -#----------------------------------------------------------------------------- -Write-Host "" -Write-Host "=== 6. negative: malformed PEM rejected ===" -$badPem = 'C:\Windows\Temp\jvm-win-bad.pem' -'not a certificate' | Set-Content -LiteralPath $badPem -Encoding ASCII -Invoke-Installer -ScriptArgs @('-UseCert', $badPem) -ExpectFail | Out-Null -Write-Host " ok" - -#----------------------------------------------------------------------------- -Write-Host "" -Write-Host "=== 7. negative: expired CA rejected ===" -$expiredPem = 'C:\Windows\Temp\jvm-win-expired.pem' -Remove-Item -LiteralPath $expiredPem -ErrorAction SilentlyContinue -# Try OpenSSL 3.2+'s -not_before/-not_after. -& $OpenSsl req -x509 -newkey rsa:2048 -nodes ` - -keyout 'C:\Windows\Temp\jvm-win-expired-k.pem' -out $expiredPem ` - -subj '/CN=Expired/O=JFrog' ` - -addext 'basicConstraints=critical,CA:TRUE' ` - -not_before 20200101000000Z -not_after 20200201000000Z 2>&1 | Out-Null - -# Verify the cert is actually expired before running the assertion. -$produced = Test-Path -LiteralPath $expiredPem -$stillValid = $false -if ($produced) { - # Re-parse via .NET to confirm NotAfter < now (sidestep openssl -checkend). - try { - $bytes = [System.IO.File]::ReadAllBytes($expiredPem) - $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$bytes) - $stillValid = ($cert.NotAfter.ToUniversalTime() -ge [DateTime]::UtcNow) - } catch { - $produced = $false - } -} -if (-not $produced -or $stillValid) { - Write-Host " SKIP: cannot produce a verifiably-expired cert with the installed openssl" -} else { - Invoke-Installer -ScriptArgs @('-UseCert', $expiredPem) -ExpectFail | Out-Null - Write-Host " ok" -} - -#----------------------------------------------------------------------------- Write-Host "" -Write-Host "=== 8. negative: leaf cert (CA:FALSE) rejected ===" -$leafPem = 'C:\Windows\Temp\jvm-win-leaf.pem' -& $OpenSsl req -x509 -newkey rsa:2048 -nodes ` - -keyout 'C:\Windows\Temp\jvm-win-leaf-k.pem' -out $leafPem -days 7 ` - -subj '/CN=Leaf Not CA' ` - -addext 'basicConstraints=critical,CA:FALSE' 2>&1 | Out-Null -Invoke-Installer -ScriptArgs @('-UseCert', $leafPem) -ExpectFail | Out-Null +Write-Host "=== 5. negative: empty truststore rejected ===" +'' | Set-Content -LiteralPath 'C:\Windows\Temp\jvm-win-empty-truststore.jks' -NoNewline +Invoke-Installer -ScriptArgs @('-UseTruststore', 'C:\Windows\Temp\jvm-win-empty-truststore.jks') -ExpectFail | Out-Null Write-Host " ok" #----------------------------------------------------------------------------- Write-Host "" -Write-Host "=== 9. User-scope JAVA_TOOL_OPTIONS references the expected JKS ===" +Write-Host "=== 6. User-scope JAVA_TOOL_OPTIONS references the expected JKS ===" Cleanup -Invoke-Installer -ScriptArgs @('-UseCert', $labCa) | Out-Null +Invoke-Installer -ScriptArgs @('-UseTruststore', $BundleJks) | Out-Null $jto = [Environment]::GetEnvironmentVariable('JAVA_TOOL_OPTIONS', [EnvironmentVariableTarget]::User) if (-not $jto) { Fail-Test 'User-scope JAVA_TOOL_OPTIONS not set' @@ -323,41 +271,9 @@ Write-Host " ok" #----------------------------------------------------------------------------- Write-Host "" -Write-Host "=== 10. validate_pem 30-day-expiry warn fires ===" -# Short-validity CA (1 day) -- within 30 days, should produce the expiry warn -# but install succeed. -$soonPem = 'C:\Windows\Temp\jvm-win-soon.pem' -& $OpenSsl req -x509 -newkey rsa:2048 -nodes ` - -keyout 'C:\Windows\Temp\jvm-win-soon-k.pem' -out $soonPem -days 1 ` - -subj '/CN=Soon to Expire/O=JFrog' ` - -addext 'basicConstraints=critical,CA:TRUE' 2>&1 | Out-Null -$out = Invoke-Installer -ScriptArgs @('-UseCert', $soonPem) -if (-not ($out -match 'certificate expires within 30 days')) { - Write-Host $out - Fail-Test '30-day expiry warn missing' -} -Write-Host " ok" - -#----------------------------------------------------------------------------- -Write-Host "" -Write-Host "=== 11. validate_pem multi-cert bundle warn fires ===" -# Multi-cert bundle: append a second cert to the test CA. The installer -# warns and imports only the first; install must still succeed. -$bundlePem = 'C:\Windows\Temp\jvm-win-bundle.pem' -Get-Content -LiteralPath $labCa, $soonPem | Set-Content -LiteralPath $bundlePem -$out = Invoke-Installer -ScriptArgs @('-UseCert', $bundlePem) -if (-not ($out -match 'PEM file contains \d+ certificates')) { - Write-Host $out - Fail-Test 'multi-cert bundle warn missing' -} -Write-Host " ok" - -#----------------------------------------------------------------------------- -Write-Host "" -Write-Host "=== 12. JTO env var REPLACES (not appends) on re-install ===" -# Pre-seed a junk value, run installer, assert the junk is gone. +Write-Host "=== 7. JTO env var REPLACES (not appends) on re-install ===" [Environment]::SetEnvironmentVariable('JAVA_TOOL_OPTIONS', '-Dpackage-reroute-test-sentinel=must-be-replaced', [EnvironmentVariableTarget]::User) -Invoke-Installer -ScriptArgs @('-UseCert', $labCa) | Out-Null +Invoke-Installer -ScriptArgs @('-UseTruststore', $BundleJks) | Out-Null $post = [Environment]::GetEnvironmentVariable('JAVA_TOOL_OPTIONS', [EnvironmentVariableTarget]::User) if ($post -match 'package-reroute-test-sentinel') { Fail-Test "JTO env var was APPENDED to (sentinel survived). Re-install must replace. got: $post" @@ -366,91 +282,34 @@ Write-Host " ok" #----------------------------------------------------------------------------- Write-Host "" -Write-Host "=== 13. missing keytool fails cleanly ===" -# Run the installer in a child process with PATH stripped of all JDK -# locations and JAVA_HOME unset. Installer should error with our -# Require-Keytool message, not crash mid-Build-JksTruststore. -$strippedPath = 'C:\Windows;C:\Windows\System32' -$out = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -Command "`$env:JAVA_HOME=`$null; `$env:PATH='$strippedPath'; & .\install_certs_jvm_windows.ps1 -UseCert '$labCa'" 2>&1 -$rc13 = $LASTEXITCODE -if ($rc13 -eq 0) { - Write-Host $out - Fail-Test "installer should have rejected missing keytool (rc=0)" -} -if (-not ($out -match 'keytool')) { - Write-Host $out - Fail-Test "missing-keytool error message should mention 'keytool' (got rc=$rc13)" -} -Write-Host " ok (rc=$rc13)" - -#----------------------------------------------------------------------------- -Write-Host "" -Write-Host "=== 14. -UseCert mandatory: no-args invocation fails ===" -# Non-interactive PS prompts for mandatory params and then errors out. -# `pwsh -NonInteractive` ensures we don't hang waiting for input. +Write-Host "=== 8. -UseTruststore mandatory: no-args invocation fails ===" $out = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` -File '.\install_certs_jvm_windows.ps1' 2>&1 -$rc14 = $LASTEXITCODE -if ($rc14 -eq 0) { +$rc8 = $LASTEXITCODE +if ($rc8 -eq 0) { Write-Host $out Fail-Test 'installer should have rejected no-args invocation (rc=0)' } -Write-Host " ok (rc=$rc14)" - -#----------------------------------------------------------------------------- -Write-Host "" -Write-Host "=== 15. negative: DER cert rejected (C1 cross-platform parity) ===" -# C1 backport: convert the lab CA to DER, then attempt install -- should -# fail with a hint to convert back. Mirrors Linux + macOS behavior so a fleet -# wrapper that hands the wrong format gets a uniform error across platforms. -$derPath = 'C:\Windows\Temp\jvm-win-test-ca.der' -Remove-Item -LiteralPath $derPath -ErrorAction SilentlyContinue -& openssl x509 -in $labCa -outform DER -out $derPath 2>&1 | Out-Null -if ($LASTEXITCODE -ne 0) { - Write-Host " SKIP: openssl unavailable to convert DER, can't run this test" -} else { - $out = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -Command "& .\install_certs_jvm_windows.ps1 -UseCert '$derPath'" 2>&1 - $rc15 = $LASTEXITCODE - if ($rc15 -eq 0) { - Write-Host $out - Fail-Test "installer should have rejected DER-encoded cert (rc=0)" - } - if (-not ($out -match 'PEM-encoded')) { - Write-Host $out - Fail-Test "DER reject message should mention 'PEM-encoded' (got rc=$rc15)" - } - Write-Host " ok (rc=$rc15)" -} +Write-Host " ok (rc=$rc8)" #----------------------------------------------------------------------------- -# Re-install once so the next two invariants observe the post-fix end state. Cleanup -Invoke-Installer -ScriptArgs @('-UseCert', $labCa) | Out-Null +Invoke-Installer -ScriptArgs @('-UseTruststore', $BundleJks) | Out-Null Write-Host "" -Write-Host "=== 16. JKS extends default cacerts (preserves public roots) ===" -# Regression guard for the "trustStore replaces, not extends" footgun. -# -Djavax.net.ssl.trustStore in OpenJDK swaps the JVM's trust source; a JKS -# holding only the corporate CA would break every public-CA TLS handshake -# (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). -# Installer must therefore copy $JAVA_HOME\lib\security\cacerts first. -$listOut16 = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') -$aliasCount = (($listOut16 | Select-String 'trustedCertEntry').Matches.Count) +Write-Host "=== 9. JKS preserves bundled public roots ===" +$listOut9 = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') +$aliasCount = (($listOut9 | Select-String 'trustedCertEntry').Matches.Count) if ($aliasCount -lt 100) { - Fail-Test "JKS has $aliasCount aliases; expected >= 100 (JDK cacerts ~150 public roots + corporate CA)" + Fail-Test "JKS has $aliasCount aliases; expected >= 100 (bundled public roots + corporate CA)" } Write-Host (" ok ({0} aliases)" -f $aliasCount) Write-Host "" -Write-Host "=== 17. JKS contains a well-known public root (DigiCert family) ===" -# Spot-check the merge actually happened. DigiCert root certs ship in every -# JDK's cacerts under several aliases (digicertglobalrootca, digicertglobalrootg2, -# digicerttrustedrootg4, etc.) -- case-insensitive substring match catches them all. -$listOut17 = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') -if (-not ($listOut17 | Select-String -Pattern 'digicert' -SimpleMatch -Quiet)) { - Fail-Test "JKS missing the DigiCert family of public roots; the copy-from-JDK step did not run" +Write-Host "=== 10. JKS contains a well-known public root (DigiCert family) ===" +$listOut10 = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') +if (-not ($listOut10 | Select-String -Pattern 'digicert' -SimpleMatch -Quiet)) { + Fail-Test "JKS missing the DigiCert family of public roots; the bundled truststore fixture is incomplete" } Write-Host " ok" @@ -459,15 +318,12 @@ Write-Host "=================================================================" Write-Host "ALL SMOKE TESTS PASSED" Write-Host "=================================================================" -# Tests #13 / #14 / #15 deliberately spawn child powershell.exe invocations -# that exit non-zero (Expected-Fail negative cases). Each leaves $LASTEXITCODE -# at the child's rc, which the outer `shell: pwsh` wrapper would inherit -# and report as a job failure. Explicit exit 0 here ensures the wrapper -# sees the runner's actual aggregate result. +# Test #8 deliberately spawns a child powershell.exe invocation that exits +# non-zero. Reset LASTEXITCODE so the outer shell sees the aggregate result. $global:LASTEXITCODE = 0 } finally { - Cleanup + Final-Cleanup } exit 0 diff --git a/validate_certs_jvm_macos.sh b/validate_certs_jvm_macos.sh index e6fa9a5..3c32580 100755 --- a/validate_certs_jvm_macos.sh +++ b/validate_certs_jvm_macos.sh @@ -25,14 +25,14 @@ set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [[ ! -f "${SCRIPT_DIR}/_jvm_macos_paths.sh" ]]; then - echo "Error: _jvm_macos_paths.sh not found next to this validator (${SCRIPT_DIR})." >&2 - echo " Invoke as ./validate_certs_jvm_macos.sh — not via 'curl | bash'." >&2 - exit 1 -fi -# shellcheck disable=SC1091 -. "${SCRIPT_DIR}/_jvm_macos_paths.sh" +# Keep this validator self-contained: it is often copied/run as a standalone +# script during onboarding, so avoid requiring sibling files for constants. +JKS_RELATIVE_DIR="Library/Application Support/JFrog/package-route-jvm" +JKS_BASENAME="truststore.jks" +LAUNCH_AGENT_RELATIVE_DIR="Library/LaunchAgents" +LAUNCH_AGENT_LABEL="com.jfrog.package-reroute.jto-env" +LAUNCH_AGENT_BASENAME="${LAUNCH_AGENT_LABEL}.plist" +JKS_PASSWORD="changeit" ALL_USERS=0 EXPECTED_SUBJECT="" diff --git a/validate_certs_jvm_windows.ps1 b/validate_certs_jvm_windows.ps1 index 6e6dbd8..5885fae 100644 --- a/validate_certs_jvm_windows.ps1 +++ b/validate_certs_jvm_windows.ps1 @@ -36,8 +36,16 @@ param( $ErrorActionPreference = 'Stop' -$ScriptDir = Split-Path -Parent $PSCommandPath -. (Join-Path $ScriptDir '_jvm_windows_paths.ps1') +# Keep this validator self-contained: it is often copied/run as a standalone +# script during onboarding, so avoid requiring sibling files for constants. +$JvmWindowsJksRelativeDir = 'JFrog\package-route-jvm' +$JvmWindowsJksBasename = 'truststore.jks' +$JvmWindowsJksPassword = 'changeit' +$JvmWindowsEnvVarName = 'JAVA_TOOL_OPTIONS' + +function Get-JvmWindowsJksPath { + Join-Path $env:LOCALAPPDATA (Join-Path $JvmWindowsJksRelativeDir $JvmWindowsJksBasename) +} $script:FailCount = 0 $script:WarnCount = 0 From 83925616d7f8708354b52cc632487126d80704b0 Mon Sep 17 00:00:00 2001 From: rani Date: Wed, 24 Jun 2026 11:53:33 +0300 Subject: [PATCH 5/6] DFLOW-140 - Split Linux JVM trust installers --- README.md | 121 +++--- _jvm_linux_paths.sh | 55 --- install_certs_jvm_linux.sh | 498 ++++-------------------- install_certs_jvm_macos.sh | 3 +- install_certs_jvm_rhel.sh | 215 ++++++++++ install_certs_jvm_windows.ps1 | 3 +- testing/test_install_certs_jvm_linux.sh | 396 +++++++------------ validate_certs_jvm_linux.sh | 191 ++------- validate_certs_jvm_rhel.sh | 145 +++++++ 9 files changed, 683 insertions(+), 944 deletions(-) delete mode 100644 _jvm_linux_paths.sh create mode 100755 install_certs_jvm_rhel.sh create mode 100755 validate_certs_jvm_rhel.sh diff --git a/README.md b/README.md index 3fab191..9b101a9 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,10 @@ Reference: research wiki [Maven Support in package-reroute (DFLOW-136 / DFLOW-11 | **validate_certs_jvm_macos.sh** | macOS (JVM) | Validate JVM truststore install (JKS subject + plist + `launchctl getenv`) | | **install_certs_debian_ubuntu.sh** | Debian/Ubuntu | Install cert into system trust + profile.d + user shell rc + Docker cleanup | | **validate_certs_debian_ubuntu.sh** | Debian/Ubuntu | Validate PEM and env config | -| **install_certs_jvm_linux.sh** | Linux (JVM) | Install CA for Maven/Gradle/sbt/Ivy: RHEL family → `update-ca-trust extract` into system anchors; others → per-host JKS + `JAVA_TOOL_OPTIONS` in `/etc/environment` | -| **validate_certs_jvm_linux.sh** | Linux (JVM) | Validate JVM truststore install (auto-detects Path A vs B; checks anchor file or JKS subject + `/etc/environment` + shell-rc) | -| **_jvm_linux_paths.sh** | Linux (JVM) | Shared constants dot-sourced by installer + validator. Not directly executable. | +| **install_certs_jvm_linux.sh** | Linux (JVM) | Install bundled JVM truststore: JKS at `/etc/ssl/package-route-jvm/truststore.jks` + `JAVA_TOOL_OPTIONS` in `/etc/environment` | +| **validate_certs_jvm_linux.sh** | Linux (JVM) | Validate bundled JVM truststore install (JKS subject + `/etc/environment` + shell-rc) | +| **install_certs_jvm_rhel.sh** | RHEL (JVM) | Install PEM into RHEL-family system trust via `update-ca-trust extract` | +| **validate_certs_jvm_rhel.sh** | RHEL (JVM) | Validate RHEL JVM system-trust install (anchor PEM + extracted Java cacerts) | | **install_certs_windows.ps1** | Windows | Install cert, set env vars (Node/Python/Ruby), and clear Docker Hub credentials | | **validate_install_windows.ps1** | Windows | Validate PEM and env config | | **install_certs_jvm_windows.ps1** | Windows (JVM) | Install CA for Maven/Gradle/sbt/Ivy: JKS at `%LOCALAPPDATA%` + User-scope `JAVA_TOOL_OPTIONS` | @@ -461,83 +462,100 @@ sudo ./validate_certs_debian_ubuntu.sh --all-users --expected-subject "O=Example ### Overview -`install_certs_jvm_linux.sh` wires a custom CA certificate into the JVM trust path so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_debian_ubuntu.sh` if you need the Node/Python flows or Docker Hub credential cleanup. +`install_certs_jvm_linux.sh` wires a bundled JVM truststore into the JVM trust path so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_debian_ubuntu.sh` if you need the Node/Python flows or Docker Hub credential cleanup. -The script auto-detects between two lab-verified paths: +This generic Linux script always uses the JKS + `JAVA_TOOL_OPTIONS` recipe: -- **Path A — `update-ca-trust`** for RHEL/Fedora/CentOS/Amazon-Linux when a JDK whose `lib/security/cacerts` is symlinked to `/etc/pki/ca-trust/extracted/java/cacerts` is on `PATH` (Red Hat OpenJDK, and on some images Corretto). Drops the CA into `/etc/pki/ca-trust/source/anchors/` and runs `update-ca-trust extract`. **No env var is set.** -- **Path B — JKS + `JAVA_TOOL_OPTIONS`** for everything else (Debian/Ubuntu, manual JDK installs that don't symlink to the system store, SDKMAN, snap-confined JDKs). Builds a JKS truststore at `/etc/ssl/package-route-jvm/truststore.jks` containing only the customer CA, then sets `JAVA_TOOL_OPTIONS` in `/etc/environment`. JDK-version-agnostic by construction — one env var serves all current and future JDKs. +1. Copies the supplied truststore to `/etc/ssl/package-route-jvm/truststore.jks`. +2. Writes `JAVA_TOOL_OPTIONS` to `/etc/environment`. +3. Updates the invoking developer user's `.bashrc` or `.zshrc` when a non-root user can be determined. -Auto-detection logic (`detect_mode` in the script): - -1. If `update-ca-trust` is not on `PATH` → **Path B**. -2. If `/etc/pki/ca-trust/extracted/java/cacerts` does not exist → **Path B**. -3. If no `java` is on `PATH` → **Path A** (assumes Red Hat OpenJDK will be installed via `dnf`; the installer prints a loud end-of-run warning instructing the user to re-run with `--mode java-tool-options` if they install Corretto/Temurin/SDKMAN instead). -4. If the resolved `java`'s `lib/security/cacerts` symlinks to the RHEL system store → **Path A**; otherwise **Path B**. - -`--mode java-tool-options` or `--mode update-ca-trust` overrides the detection. - -Both scripts source a small shared file `_jvm_linux_paths.sh` for the constants block (CA basename default, anchor dir, JKS path, password, env file) so the installer and validator cannot drift. +RHEL-family hosts that intentionally use Red Hat OpenJDK system trust should use `install_certs_jvm_rhel.sh` instead. ### Requirements -- **Linux** (Debian/Ubuntu family OR RHEL/Fedora/CentOS/Amazon-Linux family). +- **Linux**. - **Root** (`sudo`). -- **`openssl`** on `PATH`. -- **`keytool`** on `PATH` (provided by any JDK) **for Path B**. On Path A the verification step uses `keytool` opportunistically but the install itself does not need it; the installer emits a warning if `keytool` is missing on Path A. +- A prebuilt JVM truststore readable with password `changeit`. ### Options | Option | Required | Description | |--------|----------|-------------| -| `--use-cert ` | **Yes** | Path to an existing PEM/CRT certificate file. Validated: must be a parseable X.509, not expired, with `CA:TRUE` in basicConstraints. Bundles emit a warning (only the first cert imports). | -| `--mode auto\|java-tool-options\|update-ca-trust` | No (default: **auto**) | Override path detection. | -| `--cert-name ` | No (default: `package-route-custom-ca`) | Base name applied to the Path A anchor file (`/etc/pki/ca-trust/source/anchors/.crt`) AND the Path B JKS alias. Must match `[A-Za-z0-9._-]+`. Pass the same value to the validator. | +| `--use-truststore ` | **Yes** | Path to an existing JVM truststore (JKS/PKCS12-compatible). The truststore is copied as-is and must be readable by JVMs with password `changeit`. | | `-h`, `--help` | — | Usage. | ### Examples ```bash -# Auto-detect; works on both RHEL family and Debian/Ubuntu -sudo ./install_certs_jvm_linux.sh --use-cert /tmp/ZscalerRoot0.pem - -# Force JAVA_TOOL_OPTIONS path even on a RHEL host -sudo ./install_certs_jvm_linux.sh --use-cert /tmp/ZscalerRoot0.pem --mode java-tool-options - -# Custom basename for the anchor file / JKS alias -sudo ./install_certs_jvm_linux.sh --use-cert /tmp/ZscalerRoot0.pem --cert-name zscaler-root +sudo ./install_certs_jvm_linux.sh --use-truststore /tmp/package-route-truststore.jks ``` ### Validation: validate_certs_jvm_linux.sh -**`--expected-subject` is required.** The validator auto-detects which path was used (by checking for the JKS file or the anchor file), then asserts the customer CA is present by case-insensitive subject substring match. `--all-users` iterates `/home/*` and is Path B-specific (requires root; checked at `parse_args` time, fails fast). Pass `--cert-name ` if the installer was invoked with a non-default value. +**`--expected-subject` is required.** The validator asserts the copied JKS contains a cert with the expected subject, `/etc/environment` points at the JKS, and the relevant shell rc files are wired. `--all-users` iterates `/home/*` and requires root. ```bash ./validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" sudo ./validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" --all-users -./validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" --cert-name zscaler-root ``` -Exit code 0 if all checks pass, 1 otherwise. Missing `JAVA_TOOL_OPTIONS` in a user rc file is a **warning** (not a failure) — `/etc/environment` is the authoritative source for system-wide config. Missing `keytool` while a keystore exists is a **failure** (cannot verify the core invariant). +Exit code 0 if all checks pass, 1 otherwise. Missing `JAVA_TOOL_OPTIONS` in a user rc file is a **warning** (not a failure) — `/etc/environment` is the authoritative source for system-wide config. Missing `keytool` while a keystore exists is a **failure** because the validator cannot verify the core invariant. ### Caveats -- **`/etc/environment` activation** (Path B). GUI-launched apps (IntelliJ from the GNOME/KDE launcher) inherit `/etc/environment` via the session manager at login. Existing sessions need a logoff/login to pick up the new env var. The script updates the SUDO_USER's `.bashrc`/`.zshrc` so the *current* shell session has it without re-login. -- **Gradle Daemon caching** (Path B). A Gradle Daemon started before the env var was set still uses its captured environment. Run `gradle --stop` after onboarding. -- **`Picked up JAVA_TOOL_OPTIONS:` banner** (Path B). Every JVM prints this to stderr at startup. CI log parsers that strict-match empty-stderr need to tolerate it. -- **`changeit` truststore password.** The JKS at `/etc/ssl/package-route-jvm/truststore.jks` uses the OpenJDK convention password `changeit`. This is **not** a secret — JKS truststores protect file integrity, not contents, and the trust anchor inside is a public CA certificate. The password is persisted in `/etc/environment` via the `-Djavax.net.ssl.trustStorePassword` flag so unattended JVMs can open the store. -- **Path B truststore extends the JDK's bundled cacerts.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The installer therefore copies `$JAVA_HOME/lib/security/cacerts` to `/etc/ssl/package-route-jvm/truststore.jks` first, then `keytool -importcert` appends the corporate CA. The resulting store has ~150 public roots **plus** the corporate one. Path A is unaffected — `update-ca-trust extract` already builds the system-wide Java cacerts by merging system anchors with the JDK's defaults. -- **Mixed-distro auto-detection.** On RHEL family with a non-Red-Hat JDK on PATH (Corretto, Temurin, SDKMAN), auto-detection lands on Path B because the JDK's `lib/security/cacerts` is not symlinked to the RHEL system store. On RHEL family with **no** JDK on PATH yet, auto-detection picks Path A on the assumption that Red Hat OpenJDK will follow via `dnf` — the installer emits a loud end-of-run warning naming this assumption. -- **Snap-confined JDKs** are not configured by Path A (read-only squashfs). Auto-detection lands on Path B because the snap cacerts is not symlinked to the system one. Path B works because `JAVA_TOOL_OPTIONS` is read by the JVM at startup regardless of how the JDK was installed. -- **Container-internal JDKs.** Maven/Gradle running inside Docker on a developer machine need the CA wired into the *container* image — host-side install does not propagate. Use a `RUN` step in the Dockerfile or pass `JAVA_TOOL_OPTIONS` via `docker run -e`. -- **`MAVEN_OPTS` clobbering.** If your shell or `~/.mavenrc` sets `MAVEN_OPTS`, those args land AFTER `JAVA_TOOL_OPTIONS` and can override the trust store flags. If `mvn` fails TLS after install, check `env | grep -E '^(JAVA_TOOL_OPTIONS|MAVEN_OPTS)='` — if both are set, ensure `MAVEN_OPTS` does NOT also include `-Djavax.net.ssl.trustStore`. -- **IntelliJ per-IDE SSL store.** `~/.config/JetBrains//ssl/cacerts` is a separate trust store used by the IDE for the plugin marketplace and VCS integration — NOT by Maven/Gradle runs spawned from IntelliJ (those use the JBR's truststore which the env var path covers). If `mvn` works in Terminal but IntelliJ Maven sync fails, add the CA via Settings → Tools → Server Certificates. -- **Idempotent re-runs.** Path B re-creates the JKS each run (single alias guaranteed) and replaces (not appends) the env var line in `/etc/environment`. Path A re-copies the anchor and re-runs `update-ca-trust extract` (with fingerprint-compare to surface a deliberate replacement vs. an idempotent re-run). Running the script twice produces the same final state. -- **Existing anchor file replacement (Path A).** A file at `/etc/pki/ca-trust/source/anchors/.crt` placed by other tooling is replaced if its SHA-256 fingerprint differs from `--use-cert`. The installer prints a `[warn] Replacing existing anchor` line with both fingerprints so the swap is auditable. +- **`/etc/environment` activation.** GUI-launched apps (IntelliJ from the GNOME/KDE launcher) inherit `/etc/environment` via the session manager at login. Existing sessions need a logoff/login to pick up the new env var. The script updates the SUDO_USER's `.bashrc`/`.zshrc` so the current shell session has it without re-login. +- **Gradle Daemon caching.** A Gradle Daemon started before the env var was set still uses its captured environment. Run `gradle --stop` after onboarding. +- **`Picked up JAVA_TOOL_OPTIONS:` banner.** Every JVM prints this to stderr at startup. CI log parsers that strict-match empty-stderr need to tolerate it. +- **The supplied JKS must include public roots.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The release process that creates the bundled truststore owns including public roots plus the corporate CA before install. +- **Container-internal JDKs.** Maven/Gradle running inside Docker on a developer machine need the CA wired into the container image — host-side install does not propagate. Use a `RUN` step in the Dockerfile or pass `JAVA_TOOL_OPTIONS` via `docker run -e`. +- **`MAVEN_OPTS` clobbering.** If your shell or `~/.mavenrc` sets `MAVEN_OPTS`, those args land AFTER `JAVA_TOOL_OPTIONS` and can override the trust store flags. If `mvn` fails TLS after install, check `env | grep -E '^(JAVA_TOOL_OPTIONS|MAVEN_OPTS)='`. +- **IntelliJ per-IDE SSL store.** `~/.config/JetBrains//ssl/cacerts` is a separate trust store used by the IDE for the plugin marketplace and VCS integration — NOT by Maven/Gradle runs spawned from IntelliJ. + +--- + +## RHEL (JVM): install_certs_jvm_rhel.sh + +### Overview + +`install_certs_jvm_rhel.sh` is for RHEL/Fedora/CentOS/Rocky/Alma/Amazon-Linux hosts where Java trust follows the system trust generated by `update-ca-trust`. + +It installs a PEM anchor into `/etc/pki/ca-trust/source/anchors/.crt` and runs `update-ca-trust extract`. It does **not** write `JAVA_TOOL_OPTIONS`. + +### Requirements + +- **RHEL-family Linux**. +- **Root** (`sudo`). +- **`openssl`** and **`update-ca-trust`** on `PATH`. +- **`keytool`** is optional for install-time verification; the validator requires it to inspect Java cacerts. + +### Options + +| Option | Required | Description | +|--------|----------|-------------| +| `--use-cert ` | **Yes** | Path to an existing PEM/CRT certificate file. Validated: parseable X.509, not expired, and `CA:TRUE` in basicConstraints. | +| `--cert-name ` | No (default: `package-route-custom-ca`) | Base name for `/etc/pki/ca-trust/source/anchors/.crt`. Must match `[A-Za-z0-9._-]+`. | +| `-h`, `--help` | — | Usage. | + +### Examples + +```bash +sudo ./install_certs_jvm_rhel.sh --use-cert /tmp/ZscalerRoot0.pem +sudo ./install_certs_jvm_rhel.sh --use-cert /tmp/ZscalerRoot0.pem --cert-name zscaler-root +``` + +### Validation: validate_certs_jvm_rhel.sh + +```bash +./validate_certs_jvm_rhel.sh --expected-subject "O=Zscaler" +./validate_certs_jvm_rhel.sh --expected-subject "O=Zscaler" --cert-name zscaler-root +``` + +The validator checks the installed anchor PEM and `/etc/pki/ca-trust/extracted/java/cacerts`. ### Testing -`./testing/test_install_certs_jvm_linux.sh` runs the Docker smoke matrix across four distro × JDK combinations in parallel from any host with Docker. Each container builds a self-signed lab CA, runs the installer, runs the validator, then exercises negative subject, idempotency, custom `--cert-name`, path-traversal rejection, malformed-PEM rejection, expired-CA rejection, and leaf-cert rejection. +`./testing/test_install_certs_jvm_linux.sh` runs the Docker smoke matrix across generic Linux and RHEL flows. Each container builds a lab CA; generic Linux tests build a bundled truststore fixture, while the RHEL test exercises the PEM/update-ca-trust path. ```bash # From repo root (any host with Docker) @@ -550,11 +568,10 @@ The same matrix runs on every push and pull request via `.github/workflows/ci.ym ### Summary (Linux JVM) -- **One run as root**, single cert source via `--use-cert`. -- **Path A**: anchor in `/etc/pki/ca-trust/source/anchors/.crt`; `update-ca-trust extract` updates the system Java trust store. No env var. -- **Path B**: JKS at `/etc/ssl/package-route-jvm/truststore.jks` with alias ``; `JAVA_TOOL_OPTIONS` in `/etc/environment` and in the developer user's `.bashrc`/`.zshrc`. -- **Idempotent** (both paths), **re-runnable**, **JDK-version-agnostic** across currently-supported JDKs — JKS format is still read by JDK 8–25 (Path B); Path A piggy-backs on the OS trust store that Red Hat OpenJDK already symlinks. A future JDK that drops JKS support would require a Path B format bump. -- Users must open a new login shell (or `source /etc/environment`) for env changes to take effect. `gradle --stop` to refresh the Gradle Daemon. +- Use `install_certs_jvm_linux.sh` for bundled JKS + `JAVA_TOOL_OPTIONS`. +- Use `install_certs_jvm_rhel.sh` for RHEL-family `update-ca-trust`. +- Linux JVM scripts are self-contained; there is no shared `_jvm_linux_paths.sh`. +- Users of the generic flow must open a new login shell (or `source /etc/environment`) for env changes to take effect. `gradle --stop` refreshes the Gradle Daemon. --- diff --git a/_jvm_linux_paths.sh b/_jvm_linux_paths.sh deleted file mode 100644 index 611c5bd..0000000 --- a/_jvm_linux_paths.sh +++ /dev/null @@ -1,55 +0,0 @@ -# (c) JFrog Ltd. (2026) -# Shared constants for install_certs_jvm_linux.sh and validate_certs_jvm_linux.sh. -# Sourced — must NOT be executed directly. Has no shebang on purpose. -# -# Both scripts read this file via: -# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# . "${SCRIPT_DIR}/_jvm_linux_paths.sh" -# -# Keep installer and validator in lockstep by changing only this file. -# -# Cross-platform siblings (keep CLI shapes and contracts in sync): -# install_certs_jvm_macos.sh — per-user JKS under ~/Library -# install_certs_jvm_windows.ps1 — per-user JKS under %LOCALAPPDATA% - -# Default base name for the installed CA file (Path A) and the JKS alias (Path B). -# Overridable via --cert-name on the installer; the validator must be invoked with -# the same --cert-name when a non-default value was used. -# -# CROSS-PLATFORM NOTE: Linux is the only sibling where --cert-name affects -# a filesystem-visible path (Path A: /etc/pki/ca-trust/source/anchors/${CERT_BASENAME}.crt). -# macOS and Windows treat --cert-name as a JKS-alias-only cosmetic. A fleet -# script that wraps all three installers with the same --cert-name must -# remember to also pass --cert-name to the LINUX validator. -JVM_LINUX_DEFAULT_CERT_BASENAME="package-route-custom-ca" - -# Path A — RHEL family with Red Hat OpenJDK. The JDK symlinks its -# lib/security/cacerts to RHEL_JAVA_CACERTS, so system trust IS Java trust. -RHEL_ANCHOR_DIR="/etc/pki/ca-trust/source/anchors" -RHEL_JAVA_CACERTS="/etc/pki/ca-trust/extracted/java/cacerts" - -# Path B — JKS truststore + JAVA_TOOL_OPTIONS. Used on Debian/Ubuntu, Amazon -# Corretto, Eclipse Temurin, SDKMAN-installed JDKs, and manual .tar.gz installs. -# -# Note: Linux uses /etc/ssl/package-route-jvm rather than nesting under -# /etc/ssl/JFrog/package-route-jvm (as macOS/Windows do under their per-user -# trees). The flat path matches /etc/ssl conventions; if a future JFrog tool -# needs a sibling dir, it can carve out /etc/ssl/jfrog-/ alongside. -JKS_DIR="/etc/ssl/package-route-jvm" -JKS_PATH="${JKS_DIR}/truststore.jks" - -# OpenJDK convention for cacerts and similar truststores. NOT a secret in -# this script's use case: we import only `trustedCertEntry` records (public -# CA certs), so the password protects file *integrity* via the keystore MAC -# but not any private key material. A JKS that ever holds a PrivateKeyEntry -# would additionally rely on this password to encrypt the key — not relevant -# here. Persisted in /etc/environment via -Djavax.net.ssl.trustStorePassword -# so unattended JVMs can open the store. -JKS_PASSWORD="changeit" - -ENVIRONMENT_FILE="/etc/environment" - -# 30-day expiry warn threshold (in seconds) — used by validate_pem. Must stay -# in lockstep with the macOS bash sibling (-checkend 2592000) and the Windows -# .NET sibling (AddDays(30)). Do not change without updating all three. -JVM_LINUX_EXPIRY_WARN_SECONDS=2592000 diff --git a/install_certs_jvm_linux.sh b/install_certs_jvm_linux.sh index 301f0c8..6d3f887 100755 --- a/install_certs_jvm_linux.sh +++ b/install_certs_jvm_linux.sh @@ -1,84 +1,57 @@ #!/usr/bin/env bash # (c) JFrog Ltd. (2026) -# Install a custom CA certificate on Linux for JVM clients (Maven, Gradle, sbt, -# Apache Ivy). Two paths depending on distro + JDK distribution: +# Install a bundled JVM truststore on Linux for JVM clients (Maven, Gradle, +# sbt, Apache Ivy). # -# Path A — RHEL/Fedora/CentOS/Amazon-Linux + Red Hat OpenJDK: -# Drop the cert into /etc/pki/ca-trust/source/anchors/ and run -# update-ca-trust extract. Red Hat's OpenJDK symlinks its cacerts to the -# system-managed copy, so system trust IS Java trust. -# -# Path B — everything else (Debian/Ubuntu, Amazon Corretto, Eclipse Temurin, -# SDKMAN-installed JDKs, manual .tar.gz installs): -# Build a JKS truststore at /etc/ssl/package-route-jvm/truststore.jks -# containing the customer CA and set JAVA_TOOL_OPTIONS in /etc/environment -# so every JDK on the box picks it up at startup. +# Single path: copy a supplied JKS truststore to +# /etc/ssl/package-route-jvm/truststore.jks +# then set JAVA_TOOL_OPTIONS in /etc/environment so every new JVM startup +# inherits the trustStore path. # # Run: -# sudo bash install_certs_jvm_linux.sh --use-cert /path/to/cert.pem -# [--mode auto|java-tool-options|update-ca-trust] [--cert-name ] +# sudo bash install_certs_jvm_linux.sh --use-truststore /path/to/truststore.jks # # Notes: -# - Linux only (Debian/Ubuntu and RHEL/Fedora/CentOS/Amazon-Linux families). +# - Linux only. # - Must run as root. # - JVM trust only — does not configure npm/Python/HF and does not touch # Docker credentials. Pair with install_certs_debian_ubuntu.sh if needed. # - GUI-launched IDEs need a logoff/login to pick up /etc/environment. -# -# Cross-platform siblings (keep CLI shapes and contracts in sync): -# install_certs_jvm_macos.sh — LaunchAgent + per-user JKS -# install_certs_jvm_windows.ps1 — HKCU\Environment + per-user JKS -# -# Research / rationale: see the JVM client-onboarding wiki page -# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ +# - RHEL-family hosts that intentionally use Red Hat OpenJDK system trust +# should use install_certs_jvm_rhel.sh instead. set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [[ ! -f "${SCRIPT_DIR}/_jvm_linux_paths.sh" ]]; then - echo "Error: _jvm_linux_paths.sh not found next to this installer (${SCRIPT_DIR})." >&2 - echo " This script reads its constants from a sibling file." >&2 - echo " Invoke as ./install_certs_jvm_linux.sh — not via 'curl | bash'" >&2 - echo " or 'sh -c \"\$(cat …)\"' (those lose BASH_SOURCE[0])." >&2 - exit 1 -fi -# shellcheck disable=SC1091 -. "${SCRIPT_DIR}/_jvm_linux_paths.sh" - -USE_CERT="" -MODE_OPT="auto" -CERT_BASENAME="${JVM_LINUX_DEFAULT_CERT_BASENAME}" - -# Tracked so install_via_jto's final summary can warn loudly if the per-user -# rc step was skipped (otherwise the [3/4] header reads like a success). +# Keep this installer self-contained: it is often copied/run as a standalone +# script during onboarding, so avoid requiring sibling files for constants. +JKS_DIR="/etc/ssl/package-route-jvm" +JKS_PATH="${JKS_DIR}/truststore.jks" +JKS_PASSWORD="changeit" +ENVIRONMENT_FILE="/etc/environment" + +USE_TRUSTSTORE="" RC_UPDATED=0 usage() { cat < [--mode auto|java-tool-options|update-ca-trust] [--cert-name ] + sudo $0 --use-truststore Options: - --use-cert Path to an existing PEM/CRT certificate file (required). - --mode auto|java-tool-options|update-ca-trust Override path detection (default: auto). - auto - detect by distro + JDK trust integration - java-tool-options - force JAVA_TOOL_OPTIONS + JKS path - update-ca-trust - force RHEL system-trust path - --cert-name Base name for installed cert (default: ${CERT_BASENAME}). - Applied to the anchor file (Path A) AND the JKS alias (Path B). - -h, --help Show this help. + --use-truststore Path to an existing JVM truststore (JKS/PKCS12-compatible) + to copy to ${JKS_PATH}. The truststore must be + readable by JVMs with password '${JKS_PASSWORD}'. + -h, --help Show this help. Examples: - sudo $0 --use-cert /tmp/ZscalerRoot0.pem - sudo $0 --use-cert /tmp/ca.pem --mode java-tool-options - sudo $0 --use-cert /tmp/ca.pem --cert-name zscaler-root + sudo $0 --use-truststore /tmp/package-route-truststore.jks EOF } require_root() { if [[ "$(id -u)" -ne 0 ]]; then echo "Error: this script must be run as root." >&2 - echo "Use: sudo $0 --use-cert [--mode auto|java-tool-options|update-ca-trust]" >&2 + echo "Use: sudo $0 --use-truststore " >&2 exit 1 fi } @@ -86,16 +59,8 @@ require_root() { parse_args() { while [[ $# -gt 0 ]]; do case "$1" in - --use-cert) - USE_CERT="${2:?Error: --use-cert requires a value}" - shift 2 - ;; - --mode) - MODE_OPT="${2:?Error: --mode requires a value}" - shift 2 - ;; - --cert-name) - CERT_BASENAME="${2:?Error: --cert-name requires a value}" + --use-truststore) + USE_TRUSTSTORE="${2:?Error: --use-truststore requires a value}" shift 2 ;; -h|--help) @@ -110,178 +75,42 @@ parse_args() { esac done - case "$MODE_OPT" in - auto|java-tool-options|update-ca-trust) ;; - *) - echo "Error: --mode must be auto, java-tool-options, or update-ca-trust (got: $MODE_OPT)." >&2 - exit 1 - ;; - esac - - if [[ -z "$USE_CERT" ]]; then - echo "Error: --use-cert is required." >&2 + if [[ -z "$USE_TRUSTSTORE" ]]; then + echo "Error: --use-truststore is required." >&2 usage >&2 exit 1 fi - - if [[ ! -f "$USE_CERT" ]]; then - echo "Error: certificate file not found: $USE_CERT" >&2 + if [[ ! -f "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file not found: $USE_TRUSTSTORE" >&2 exit 1 fi - - if [[ -z "$CERT_BASENAME" ]]; then - echo "Error: --cert-name cannot be empty." >&2 + if [[ ! -r "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file is not readable: $USE_TRUSTSTORE" >&2 exit 1 fi - - # Reject path-traversal characters so $CERT_BASENAME stays a single path segment. - if [[ ! "$CERT_BASENAME" =~ ^[A-Za-z0-9._-]+$ ]]; then - echo "Error: --cert-name must match [A-Za-z0-9._-]+ (got: $CERT_BASENAME)." >&2 + if [[ ! -s "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file is empty: $USE_TRUSTSTORE" >&2 exit 1 fi } check_os() { - if [[ ! -r /etc/os-release ]]; then - echo "Error: cannot determine OS." >&2 - exit 1 - fi - - # shellcheck disable=SC1091 - . /etc/os-release - - local id="${ID:-}" id_like="${ID_LIKE:-}" - case "$id" in - ubuntu|debian|rhel|fedora|centos|rocky|almalinux|amzn) return 0 ;; - esac - case "$id_like" in - *debian*|*rhel*|*fedora*|*centos*) return 0 ;; - esac - - echo "Error: this script supports Debian/Ubuntu and RHEL/Fedora/CentOS/Amazon-Linux families." >&2 - echo "Detected ID=${id:-unknown}, ID_LIKE=${id_like:-unknown}" >&2 - exit 1 -} - -check_dependencies() { - if ! command -v openssl >/dev/null 2>&1; then - echo "Error: openssl is required but not found." >&2 - exit 1 - fi - # keytool is needed only by Path B; checked at the install_via_jto entry point. -} - -# Locate the JDK's default cacerts file. -Djavax.net.ssl.trustStore in OpenJDK -# REPLACES the JVM trust source rather than extending it — pointing JVMs at a -# JKS containing only the corporate CA breaks every public-CA TLS handshake -# (Maven Central, Gradle plugin portal, Let's Encrypt-fronted artifact mirrors). -# We copy the JDK's bundled cacerts into the target keystore first, then -# `keytool -importcert` appends our CA, so the merged store has ~150 public -# roots PLUS the corporate one. -# -# Resolution precedence: -# 1. $JAVA_HOME/lib/security/cacerts (set by every standard JDK installer) -# 2. $(dirname $(command -v keytool))/../lib/security/cacerts (works for -# stock Adoptium / Corretto / Microsoft / Zulu / RHEL OpenJDK layouts) -# 3. Hard fail with a clear message. -# -# Echoes the resolved path on stdout; exits non-zero on failure. -find_jdk_cacerts() { - local candidate="" - if [[ -n "${JAVA_HOME:-}" && -f "${JAVA_HOME}/lib/security/cacerts" ]]; then - candidate="${JAVA_HOME}/lib/security/cacerts" - else - local keytool_path keytool_dir - keytool_path="$(command -v keytool 2>/dev/null || true)" - if [[ -n "$keytool_path" ]]; then - keytool_dir="$(dirname "$(readlink -f "$keytool_path" 2>/dev/null || echo "$keytool_path")")" - if [[ -f "${keytool_dir}/../lib/security/cacerts" ]]; then - candidate="${keytool_dir}/../lib/security/cacerts" - fi - fi - fi - if [[ -z "$candidate" ]]; then - echo "Error: cannot locate the JDK's default cacerts file." >&2 - echo " Set JAVA_HOME, or ensure keytool resolves under a standard JDK bin/ layout." >&2 - echo " Tried: \$JAVA_HOME/lib/security/cacerts, \$(dirname keytool)/../lib/security/cacerts" >&2 - exit 1 - fi - echo "$candidate" -} - -validate_pem() { - local path="$1" - - # Require PEM-encoded input (text `-----BEGIN CERTIFICATE-----` block). - # Cross-platform contract: the Windows sibling auto-detects DER via - # System.Security.Cryptography.X509Certificates and silently accepts - # it, but the bash branch only handles PEM. Keep the trilogy honest by - # rejecting DER everywhere with a clear conversion hint, so the same - # cert file produces the same outcome regardless of the platform the - # operator runs the installer on. - if ! grep -q -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null; then - echo "Error: certificate is not PEM-encoded: $path" >&2 - echo " If it's DER, convert first:" >&2 - echo " openssl x509 -inform der -in $path -out $path.pem" >&2 - echo " then re-run with --use-cert $path.pem" >&2 - exit 1 - fi - - if ! openssl x509 -in "$path" -noout >/dev/null 2>&1; then - echo "Error: invalid PEM/CRT certificate file: $path" >&2 - exit 1 - fi - - # Reject expired anchors: keytool -importcert -noprompt accepts them silently - # and the user gets cryptic CertificateExpiredException at TLS handshake time. - if ! openssl x509 -in "$path" -checkend 0 -noout >/dev/null 2>&1; then - echo "Error: certificate has already expired: $path" >&2 - exit 1 - fi - - # Warn (don't fail) on a cert expiring within JVM_LINUX_EXPIRY_WARN_SECONDS - # (30 days). Threshold is in lockstep with the macOS/Windows siblings. - if ! openssl x509 -in "$path" -checkend "$JVM_LINUX_EXPIRY_WARN_SECONDS" -noout >/dev/null 2>&1; then - echo "[warn] certificate expires within 30 days: $path" >&2 - fi - - # Reject leaf certs: a cert without CA:TRUE in basicConstraints will import - # into a JKS truststore but PKIX path-building won't use it as a trust anchor. - local bc - bc="$(openssl x509 -in "$path" -noout -ext basicConstraints 2>/dev/null || true)" - if [[ -n "$bc" ]] && ! grep -qi 'CA:TRUE' <<<"$bc"; then - echo "Error: certificate is not a CA (basicConstraints missing CA:TRUE): $path" >&2 - echo " JKS imports succeed but PKIX rejects non-CA trust anchors." >&2 + if [[ "$(uname -s)" != "Linux" ]]; then + echo "Error: this script supports Linux only." >&2 exit 1 fi - - # Warn on bundles: keytool -importcert -noprompt reads only the first cert, - # silently dropping intermediates. Users should split bundles or supply only the root. - local count - count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null || echo 0)" - if [[ "$count" -gt 1 ]]; then - echo "[warn] PEM file contains $count certificates; only the first will be imported as the JVM trust anchor." >&2 - echo " Supply only the root CA (or split the bundle) if intermediates are needed." >&2 - fi } replace_export_in_file() { - local file="$1" - local var="$2" - local value="$3" + local file="$1" var="$2" value="$3" local tmp escaped escaped="${value//\\/\\\\}" escaped="${escaped//\"/\\\"}" - # Place tmp in the same directory as the target so `mv` is rename(2) (atomic) - # and a cross-filesystem copy can't half-truncate the target on disk-full. tmp=$(mktemp -p "$(dirname "$file")") awk -v var="$var" -v val="$escaped" ' - $0 ~ "^export " var "=" { - print "export " var "=\"" val "\"" - next - } + $0 ~ "^export " var "=" { print "export " var "=\"" val "\""; next } { print } ' "$file" > "$tmp" if [[ ! -s "$tmp" && -s "$file" ]]; then @@ -293,12 +122,9 @@ replace_export_in_file() { } ensure_export_in_file() { - local file="$1" - local var="$2" - local value="$3" + local file="$1" var="$2" value="$3" touch "$file" - if grep -qE "^export ${var}=" "$file" 2>/dev/null; then replace_export_in_file "$file" "$var" "$value" else @@ -306,6 +132,34 @@ ensure_export_in_file() { fi } +ensure_kv_in_environment_file() { + local key="$1" value="$2" + local tmp escaped + + touch "$ENVIRONMENT_FILE" + + escaped="${value//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + + if grep -qE "^${key}=" "$ENVIRONMENT_FILE" 2>/dev/null; then + tmp=$(mktemp -p "$(dirname "$ENVIRONMENT_FILE")") + awk -v k="$key" -v v="$escaped" ' + $0 ~ "^" k "=" { print k "=\"" v "\""; next } + { print } + ' "$ENVIRONMENT_FILE" > "$tmp" + if [[ ! -s "$tmp" && -s "$ENVIRONMENT_FILE" ]]; then + rm -f "$tmp" + echo "Error: awk produced empty output; refusing to overwrite $ENVIRONMENT_FILE" >&2 + exit 1 + fi + mv "$tmp" "$ENVIRONMENT_FILE" + else + printf '%s="%s"\n' "$key" "$escaped" >> "$ENVIRONMENT_FILE" + fi + + chmod 0644 "$ENVIRONMENT_FILE" +} + get_target_user() { local candidate @@ -327,8 +181,6 @@ get_target_user() { if [[ -z "$candidate" || "$candidate" == "root" ]]; then return 0 fi - - # Reject users that don't exist in passwd (transient sessions etc.). if ! getent passwd "$candidate" >/dev/null 2>&1; then return 0 fi @@ -346,151 +198,6 @@ get_user_shell() { getent passwd "$user" | cut -d: -f7 } -# Stdout contract: exactly one of {java-tool-options, update-ca-trust}. -# Callers MUST equality-match the returned string. Keep in sync with the -# validator's detect_mode (which adds a "none" case when no artifacts exist). -detect_mode() { - if ! command -v update-ca-trust >/dev/null 2>&1; then - echo "java-tool-options" - return - fi - if [[ ! -e "$RHEL_JAVA_CACERTS" ]]; then - echo "java-tool-options" - return - fi - - local java_bin java_home cacerts - java_bin="$(command -v java 2>/dev/null || true)" - if [[ -z "$java_bin" ]]; then - # update-ca-trust present and the RHEL system Java cacerts exists, but - # no JDK is on PATH yet. Pick the system-trust path on the assumption - # that Red Hat OpenJDK will be installed via dnf. If the user later - # installs a non-Red-Hat JDK (Corretto, Temurin, SDKMAN), they must - # re-run with --mode java-tool-options — install_via_update_ca_trust - # emits a loud end-of-run warning to that effect. - echo "update-ca-trust" - return - fi - - java_home="$(dirname "$(dirname "$(readlink -f "$java_bin")")")" - cacerts="${java_home}/lib/security/cacerts" - if [[ "$(readlink -f "$cacerts" 2>/dev/null)" == "$RHEL_JAVA_CACERTS" ]]; then - echo "update-ca-trust" - else - echo "java-tool-options" - fi -} - -install_via_update_ca_trust() { - local anchor_path="${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt" - local java_present=0 - command -v java >/dev/null 2>&1 && java_present=1 - - echo "[1/4] Installing CA into system anchor: $anchor_path" - mkdir -p "$RHEL_ANCHOR_DIR" - - # Pre-flight: if a file already exists at the anchor path, compare - # SHA-256 fingerprints. Identical -> idempotent re-run, skip the cp. - # Different -> warn the operator that we're replacing an existing - # trust anchor so a silent overwrite (potentially clobbering a CA - # placed by other tooling under the same --cert-name basename) leaves - # a clear paper trail. - if [[ -e "$anchor_path" ]]; then - local existing_fp installer_fp - existing_fp="$(openssl x509 -in "$anchor_path" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//' || true)" - installer_fp="$(openssl x509 -in "$USE_CERT" -noout -fingerprint -sha256 | sed 's/.*=//')" - if [[ "$existing_fp" == "$installer_fp" ]]; then - echo " Anchor already present with matching fingerprint; skipping copy." - else - echo " [warn] Replacing existing anchor at $anchor_path" >&2 - echo " existing fingerprint: $existing_fp" >&2 - echo " new fingerprint: $installer_fp" >&2 - cp "$USE_CERT" "$anchor_path" - fi - else - cp "$USE_CERT" "$anchor_path" - fi - chmod 0644 "$anchor_path" - - echo "[2/4] Running update-ca-trust extract..." - update-ca-trust extract - - echo "[3/4] Verifying Java cacerts contains the CA..." - if ! command -v keytool >/dev/null 2>&1; then - echo " [warn] keytool not on PATH; skipping Java-side verification. Install a JDK and re-run validate_certs_jvm_linux.sh." >&2 - else - local fingerprint listing - fingerprint="$(openssl x509 -in "$anchor_path" -noout -fingerprint -sha256 | sed 's/.*=//')" - - # Capture keytool's output explicitly so a real keytool failure (wrong - # password, corrupt store, missing JDK at runtime) is reported as such - # and not as a misleading "fingerprint not yet visible". - if ! listing="$(keytool -list -keystore "$RHEL_JAVA_CACERTS" -storepass "$JKS_PASSWORD" 2>&1)"; then - echo " [warn] keytool could not read $RHEL_JAVA_CACERTS. Output:" >&2 - printf '%s\n' "$listing" | head -n5 >&2 - elif grep -qiF "$fingerprint" <<<"$listing"; then - echo " OK: CA fingerprint visible in $RHEL_JAVA_CACERTS" - else - echo " [warn] CA fingerprint not yet visible in $RHEL_JAVA_CACERTS — system trust may need a fresh login session or a non-Red-Hat JDK is on PATH." >&2 - fi - fi - - echo "[4/4] Done. No env var needed; Red Hat OpenJDK reads $RHEL_JAVA_CACERTS directly." - echo - echo "Installed certificate:" - echo " $anchor_path" - echo "Java trust path:" - echo " $RHEL_JAVA_CACERTS" - - if [[ "$java_present" -eq 0 ]]; then - echo - echo "WARNING: no JDK is on PATH right now. The system-trust path was picked because" - echo " /etc/pki/ca-trust/extracted/java/cacerts exists, assuming Red Hat OpenJDK" - echo " will be installed via dnf. If you instead install Corretto, Eclipse Temurin," - echo " or any JDK whose lib/security/cacerts is NOT symlinked to the system store," - echo " this installer will NOT have configured trust for it. Re-run with" - echo " --mode java-tool-options in that case." >&2 - fi -} - -require_keytool() { - if ! command -v keytool >/dev/null 2>&1; then - echo "Error: keytool is required for --mode java-tool-options (provided by any JDK)." >&2 - echo " Debian/Ubuntu: sudo apt-get install -y default-jdk-headless" >&2 - echo " RHEL/Fedora: sudo dnf install -y java-21-openjdk-headless" >&2 - echo " Manual JDK: add \$JAVA_HOME/bin to PATH (or symlink keytool into /usr/local/bin)." >&2 - exit 1 - fi -} - -ensure_kv_in_environment_file() { - local key="$1" value="$2" tmp escaped - - touch "$ENVIRONMENT_FILE" - - escaped="${value//\\/\\\\}" - escaped="${escaped//\"/\\\"}" - - if grep -qE "^${key}=" "$ENVIRONMENT_FILE" 2>/dev/null; then - # Same-directory mktemp keeps `mv` atomic and avoids cross-fs truncation risk. - tmp=$(mktemp -p "$(dirname "$ENVIRONMENT_FILE")") - awk -v k="$key" -v v="$escaped" ' - $0 ~ "^" k "=" { print k "=\"" v "\""; next } - { print } - ' "$ENVIRONMENT_FILE" > "$tmp" - if [[ ! -s "$tmp" && -s "$ENVIRONMENT_FILE" ]]; then - rm -f "$tmp" - echo "Error: awk produced empty output; refusing to overwrite $ENVIRONMENT_FILE" >&2 - exit 1 - fi - mv "$tmp" "$ENVIRONMENT_FILE" - else - printf '%s="%s"\n' "$key" "$escaped" >> "$ENVIRONMENT_FILE" - fi - - chmod 0644 "$ENVIRONMENT_FILE" -} - update_user_shell_rc() { local jto_value="$1" local target_user user_home user_shell rc_file chown_err @@ -498,9 +205,7 @@ update_user_shell_rc() { target_user="$(get_target_user)" if [[ -z "$target_user" || "$target_user" == "root" ]]; then echo " [warn] Per-user rc not updated: could not determine non-root target user." >&2 - echo " Run with sudo as the developer user (or set SUDO_USER) so the current shell" >&2 - echo " session picks up JAVA_TOOL_OPTIONS without re-login. /etc/environment is" >&2 - echo " written either way and will reach new login sessions." >&2 + echo " /etc/environment is written and will reach new login sessions." >&2 return 0 fi @@ -526,35 +231,21 @@ update_user_shell_rc() { RC_UPDATED=1 } -install_via_jto() { - require_keytool - - local src_cacerts - src_cacerts="$(find_jdk_cacerts)" - echo "[1/4] Building JKS truststore at $JKS_PATH (extending $src_cacerts)..." +install_truststore() { + echo "[1/4] Installing truststore at $JKS_PATH..." mkdir -p "$JKS_DIR" chmod 0755 "$JKS_DIR" - # Copy the JDK's bundled cacerts (~150 public root CAs) as the base so the - # merged store keeps trusting Maven Central, Let's Encrypt, etc. Then - # keytool -importcert appends the corporate CA next to them. No -storetype - # flag: modern JDKs default cacerts to PKCS12 and keytool autodetects. - cp "$src_cacerts" "$JKS_PATH" + cp "$USE_TRUSTSTORE" "$JKS_PATH" chmod 0644 "$JKS_PATH" - keytool -importcert -noprompt \ - -alias "$CERT_BASENAME" \ - -file "$USE_CERT" \ - -keystore "$JKS_PATH" \ - -storepass "$JKS_PASSWORD" >/dev/null - - # Note on inner quoting: /etc/environment uses NAME="VALUE" format and - # treats backslashes literally — there's no way to embed `"` inside the - # value without breaking the parser. The JKS_PATH and JKS_PASSWORD are - # space-free by construction (regex on --cert-name enforces no spaces; - # JKS_DIR is /etc/ssl/package-route-jvm). Adding inner quotes would - # require an alternate persistence format and is not required for the - # current spec. The Windows sibling can quote because HKCU\Environment - # is a registry REG_SZ that stores the value byte-for-byte; macOS plist - # XML has its own escaping. The bash branch deliberately stays unquoted. +} + +main() { + require_root + parse_args "$@" + check_os + + install_truststore + local jto_value="-Djavax.net.ssl.trustStore=${JKS_PATH} -Djavax.net.ssl.trustStorePassword=${JKS_PASSWORD}" echo "[2/4] Writing JAVA_TOOL_OPTIONS to $ENVIRONMENT_FILE..." @@ -566,7 +257,7 @@ install_via_jto() { echo "[4/4] Done." echo echo "Truststore:" - echo " $JKS_PATH (alias: $CERT_BASENAME)" + echo " $JKS_PATH" echo "JAVA_TOOL_OPTIONS:" echo " $jto_value" echo @@ -584,29 +275,4 @@ install_via_jto() { fi } -main() { - require_root - parse_args "$@" - check_os - check_dependencies - validate_pem "$USE_CERT" - - local mode="$MODE_OPT" - if [[ "$mode" == "auto" ]]; then - mode="$(detect_mode)" - echo "Auto-detected mode: $mode" - else - echo "Mode (forced via --mode): $mode" - fi - - case "$mode" in - update-ca-trust) install_via_update_ca_trust ;; - java-tool-options) install_via_jto ;; - *) - echo "Error: unexpected mode after detection: $mode" >&2 - exit 1 - ;; - esac -} - main "$@" diff --git a/install_certs_jvm_macos.sh b/install_certs_jvm_macos.sh index 80a4e67..784899d 100755 --- a/install_certs_jvm_macos.sh +++ b/install_certs_jvm_macos.sh @@ -33,7 +33,8 @@ # /Library/LaunchDaemons running as root) before any user logs in. # # Cross-platform siblings (keep CLI shapes and contracts in sync): -# install_certs_jvm_linux.sh — update-ca-trust OR JKS+JAVA_TOOL_OPTIONS +# install_certs_jvm_linux.sh — bundled JKS + JAVA_TOOL_OPTIONS +# install_certs_jvm_rhel.sh — RHEL update-ca-trust # install_certs_jvm_windows.ps1 — HKCU\Environment + per-user JKS # # Research / rationale: see the JVM client-onboarding wiki page diff --git a/install_certs_jvm_rhel.sh b/install_certs_jvm_rhel.sh new file mode 100755 index 0000000..2edbee1 --- /dev/null +++ b/install_certs_jvm_rhel.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Install a custom CA certificate into RHEL-family system trust for JVM clients. +# +# This script is for Red Hat OpenJDK-style hosts where Java cacerts is managed +# by update-ca-trust at /etc/pki/ca-trust/extracted/java/cacerts. +# +# Run: +# sudo bash install_certs_jvm_rhel.sh --use-cert /path/to/cert.pem [--cert-name ] + +set -euo pipefail + +DEFAULT_CERT_BASENAME="package-route-custom-ca" +RHEL_ANCHOR_DIR="/etc/pki/ca-trust/source/anchors" +RHEL_JAVA_CACERTS="/etc/pki/ca-trust/extracted/java/cacerts" +JKS_PASSWORD="changeit" +EXPIRY_WARN_SECONDS=2592000 + +USE_CERT="" +CERT_BASENAME="$DEFAULT_CERT_BASENAME" + +usage() { + cat < [--cert-name ] + +Options: + --use-cert Path to an existing PEM/CRT certificate file (required). + --cert-name Base name for the installed anchor file + (default: ${DEFAULT_CERT_BASENAME}). + -h, --help Show this help. + +Examples: + sudo $0 --use-cert /tmp/ZscalerRoot0.pem + sudo $0 --use-cert /tmp/ca.pem --cert-name zscaler-root +EOF +} + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "Error: this script must be run as root." >&2 + echo "Use: sudo $0 --use-cert [--cert-name ]" >&2 + exit 1 + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --use-cert) + USE_CERT="${2:?Error: --use-cert requires a value}" + shift 2 + ;; + --cert-name) + CERT_BASENAME="${2:?Error: --cert-name requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$USE_CERT" ]]; then + echo "Error: --use-cert is required." >&2 + usage >&2 + exit 1 + fi + if [[ ! -f "$USE_CERT" ]]; then + echo "Error: certificate file not found: $USE_CERT" >&2 + exit 1 + fi + if [[ -z "$CERT_BASENAME" ]]; then + echo "Error: --cert-name cannot be empty." >&2 + exit 1 + fi + if [[ ! "$CERT_BASENAME" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Error: --cert-name must match [A-Za-z0-9._-]+ (got: $CERT_BASENAME)." >&2 + exit 1 + fi +} + +check_os() { + if [[ ! -r /etc/os-release ]]; then + echo "Error: cannot determine OS." >&2 + exit 1 + fi + + # shellcheck disable=SC1091 + . /etc/os-release + local id="${ID:-}" id_like="${ID_LIKE:-}" + case "$id" in + rhel|fedora|centos|rocky|almalinux|amzn) return 0 ;; + esac + case "$id_like" in + *rhel*|*fedora*|*centos*) return 0 ;; + esac + + echo "Error: this script supports RHEL/Fedora/CentOS/Amazon-Linux families." >&2 + echo "Detected ID=${id:-unknown}, ID_LIKE=${id_like:-unknown}" >&2 + exit 1 +} + +check_dependencies() { + if ! command -v openssl >/dev/null 2>&1; then + echo "Error: openssl is required but not found." >&2 + exit 1 + fi + if ! command -v update-ca-trust >/dev/null 2>&1; then + echo "Error: update-ca-trust is required but not found." >&2 + exit 1 + fi +} + +validate_pem() { + local path="$1" + + if ! grep -q -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null; then + echo "Error: certificate is not PEM-encoded: $path" >&2 + echo " If it's DER, convert first:" >&2 + echo " openssl x509 -inform der -in $path -out $path.pem" >&2 + exit 1 + fi + if ! openssl x509 -in "$path" -noout >/dev/null 2>&1; then + echo "Error: invalid PEM/CRT certificate file: $path" >&2 + exit 1 + fi + if ! openssl x509 -in "$path" -checkend 0 -noout >/dev/null 2>&1; then + echo "Error: certificate has already expired: $path" >&2 + exit 1 + fi + if ! openssl x509 -in "$path" -checkend "$EXPIRY_WARN_SECONDS" -noout >/dev/null 2>&1; then + echo "[warn] certificate expires within 30 days: $path" >&2 + fi + + local bc + bc="$(openssl x509 -in "$path" -noout -ext basicConstraints 2>/dev/null || true)" + if ! grep -qi 'CA:TRUE' <<<"$bc"; then + echo "Error: certificate is not a CA (basicConstraints missing CA:TRUE): $path" >&2 + echo " Java trust stores reject non-CA trust anchors during PKIX path building." >&2 + exit 1 + fi + + local count + count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null || echo 0)" + if [[ "$count" -gt 1 ]]; then + echo "[warn] PEM file contains $count certificates; update-ca-trust will ingest the bundle as one anchor file." >&2 + fi +} + +install_anchor() { + local anchor_path="${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt" + + echo "[1/3] Installing CA into system anchor: $anchor_path" + mkdir -p "$RHEL_ANCHOR_DIR" + + if [[ -e "$anchor_path" ]]; then + local existing_fp installer_fp + existing_fp="$(openssl x509 -in "$anchor_path" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//' || true)" + installer_fp="$(openssl x509 -in "$USE_CERT" -noout -fingerprint -sha256 | sed 's/.*=//')" + if [[ "$existing_fp" == "$installer_fp" ]]; then + echo " Anchor already present with matching fingerprint; skipping copy." + else + echo " [warn] Replacing existing anchor at $anchor_path" >&2 + echo " existing fingerprint: $existing_fp" >&2 + echo " new fingerprint: $installer_fp" >&2 + cp "$USE_CERT" "$anchor_path" + fi + else + cp "$USE_CERT" "$anchor_path" + fi + chmod 0644 "$anchor_path" + + echo "[2/3] Running update-ca-trust extract..." + update-ca-trust extract + + echo "[3/3] Verifying Java cacerts contains the CA..." + if ! command -v keytool >/dev/null 2>&1; then + echo " [warn] keytool not on PATH; skipping Java-side verification. Install a JDK and re-run validate_certs_jvm_rhel.sh." >&2 + else + local fingerprint listing + fingerprint="$(openssl x509 -in "$anchor_path" -noout -fingerprint -sha256 | sed 's/.*=//')" + if ! listing="$(keytool -list -keystore "$RHEL_JAVA_CACERTS" -storepass "$JKS_PASSWORD" 2>&1)"; then + echo " [warn] keytool could not read $RHEL_JAVA_CACERTS. Output:" >&2 + printf '%s\n' "$listing" | head -n5 >&2 + elif grep -qiF "$fingerprint" <<<"$listing"; then + echo " OK: CA fingerprint visible in $RHEL_JAVA_CACERTS" + else + echo " [warn] CA fingerprint not visible in $RHEL_JAVA_CACERTS." >&2 + fi + fi + + echo + echo "Installed certificate:" + echo " $anchor_path" + echo "Java trust path:" + echo " $RHEL_JAVA_CACERTS" +} + +main() { + require_root + parse_args "$@" + check_os + check_dependencies + validate_pem "$USE_CERT" + install_anchor +} + +main "$@" diff --git a/install_certs_jvm_windows.ps1 b/install_certs_jvm_windows.ps1 index 491964e..920b6f4 100644 --- a/install_certs_jvm_windows.ps1 +++ b/install_certs_jvm_windows.ps1 @@ -29,7 +29,8 @@ # across Linux/macOS/Windows and works for developers on older Gradle. # # Cross-platform siblings (keep CLI shapes and contracts in sync): -# install_certs_jvm_linux.sh - update-ca-trust OR JKS+JAVA_TOOL_OPTIONS +# install_certs_jvm_linux.sh - bundled JKS + JAVA_TOOL_OPTIONS +# install_certs_jvm_rhel.sh - RHEL update-ca-trust # install_certs_jvm_macos.sh - LaunchAgent + per-user JKS # # Research / rationale: see the JVM client-onboarding wiki page diff --git a/testing/test_install_certs_jvm_linux.sh b/testing/test_install_certs_jvm_linux.sh index 296e1b6..e57ebcc 100755 --- a/testing/test_install_certs_jvm_linux.sh +++ b/testing/test_install_certs_jvm_linux.sh @@ -1,27 +1,10 @@ #!/usr/bin/env bash # (c) JFrog Ltd. (2026) -# Docker-driven smoke test matrix for install_certs_jvm_linux.sh and -# validate_certs_jvm_linux.sh. Runs the same internal probe script across -# four distro x JDK combinations in parallel and reports per-image status. +# Docker-driven smoke test matrix for Linux JVM trust installers. # -# Run from the repo root: -# ./testing/test_install_certs_jvm_linux.sh -# -# Each container: -# 1. installs JDK + openssl -# 2. mints a self-signed lab CA (CA:TRUE, 7-day validity) -# 3. creates a non-root devx user (so update_user_shell_rc has a target) -# 4. runs install_certs_jvm_linux.sh + validate_certs_jvm_linux.sh -# 5. exercises 7 additional invariants: -# - subject mismatch must exit 1 -# - idempotent re-install (no duplicate lines / aliases) -# - custom --cert-name round-trips through validator -# - path-traversal --cert-name is rejected -# - malformed PEM is rejected -# - expired CA is rejected -# - leaf cert (CA:FALSE) is rejected -# -# Exit 0 iff every container reports ALL SMOKE TESTS PASSED. +# Runs: +# - generic Linux bundled-JKS flow on Debian/Ubuntu/Amazon Linux +# - RHEL update-ca-trust PEM flow on UBI/RHEL set -euo pipefail @@ -37,19 +20,9 @@ trap 'rm -f "$PROBE"' EXIT cat > "$PROBE" <<'PROBE_EOF' #!/usr/bin/env bash set -euo pipefail -fail() { echo "BUG: $1" >&2; exit 1; } -# I5: surface openssl version per-container so a future image bump that -# drops openssl below 3.2 doesn't silently turn the expired-CA assertion -# into a SKIP that nobody notices. The expired-CA case uses -not_before/ -# -not_after, which require OpenSSL 3.2+. -openssl_version="$(openssl version)" -echo "openssl: $openssl_version" -case "$openssl_version" in - OpenSSL\ [01]*|OpenSSL\ 2*|OpenSSL\ 3.0*|OpenSSL\ 3.1*) - echo " [warn] openssl < 3.2 -- expired-CA assertion will fall back to -days -1 path or SKIP" >&2 - ;; -esac +MODE="${1:?mode required: generic|rhel}" +fail() { echo "BUG: $1" >&2; exit 1; } openssl req -x509 -newkey rsa:2048 -nodes \ -keyout /tmp/k.pem -out /tmp/ca.pem -days 7 \ @@ -58,246 +31,161 @@ openssl req -x509 -newkey rsa:2048 -nodes \ useradd -m -s /bin/bash devx >/dev/null 2>&1 || true -echo "=== positive: install + validate ===" -SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem >/dev/null -./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" >/dev/null -echo " ok" +find_jdk_cacerts() { + if [[ -n "${JAVA_HOME:-}" && -f "${JAVA_HOME}/lib/security/cacerts" ]]; then + echo "${JAVA_HOME}/lib/security/cacerts" + return 0 + fi + local keytool_path keytool_dir + keytool_path="$(command -v keytool 2>/dev/null || true)" + if [[ -n "$keytool_path" ]]; then + keytool_dir="$(dirname "$(readlink -f "$keytool_path" 2>/dev/null || echo "$keytool_path")")" + if [[ -f "${keytool_dir}/../lib/security/cacerts" ]]; then + echo "${keytool_dir}/../lib/security/cacerts" + return 0 + fi + fi + fail "cannot locate JDK cacerts for bundled truststore fixture" +} + +build_bundle_truststore() { + local src_cacerts + src_cacerts="$(find_jdk_cacerts)" + cp "$src_cacerts" /tmp/bundled-truststore.jks + chmod 0644 /tmp/bundled-truststore.jks + keytool -importcert -noprompt \ + -alias package-route-custom-ca \ + -file /tmp/ca.pem \ + -keystore /tmp/bundled-truststore.jks \ + -storepass changeit >/dev/null + echo "bundle base: $src_cacerts" +} + +run_generic() { + build_bundle_truststore + + echo "=== generic: positive install + validate ===" + SUDO_USER=devx ./install_certs_jvm_linux.sh --use-truststore /tmp/bundled-truststore.jks >/dev/null + ./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" >/dev/null + echo " ok" -echo "=== negative: subject mismatch must exit 1 ===" -if ./validate_certs_jvm_linux.sh --expected-subject "Microsoft Root CA NoMatch" >/dev/null 2>&1; then - fail "validator should have exited 1 on subject mismatch" -fi -echo " ok" - -echo "=== idempotency: 2nd install must produce same final state ===" -SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem >/dev/null -./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" >/dev/null - -# I4: assert -eq 1 (not -le 1). A regression that silently DROPS the line -# from /etc/environment would slip past `-le 1` with 0; the env var should -# always be present after a successful install in Path B mode. -# On Path A the env var should NOT be set at all (0 lines expected); the -# distinction is per-distro since detect_mode picks the path. -env_lines=0 -[[ -f /etc/environment ]] && env_lines=$(grep -c '^JAVA_TOOL_OPTIONS=' /etc/environment 2>/dev/null || true) -env_lines=${env_lines:-0} -rc_lines=0 -[[ -f /home/devx/.bashrc ]] && rc_lines=$(grep -c '^export JAVA_TOOL_OPTIONS=' /home/devx/.bashrc 2>/dev/null || true) -rc_lines=${rc_lines:-0} - -# Determine which path the installer took by checking which artifact exists. -if [[ -f /etc/ssl/package-route-jvm/truststore.jks ]]; then - # Path B: JKS exists, env vars must be exactly 1 - [[ "$env_lines" -eq 1 ]] || fail "/etc/environment has $env_lines JAVA_TOOL_OPTIONS lines (expected 1 on Path B)" - [[ "$rc_lines" -eq 1 ]] || fail "/home/devx/.bashrc has $rc_lines export lines (expected 1 on Path B)" - - # Path B JKS contains the JDK's default cacerts (~150 public roots) PLUS - # exactly one corporate-CA alias. After two installs, the corporate alias - # count must be exactly 1 — the JDK aliases stay constant. - corp_alias_count="$(keytool -list -keystore /etc/ssl/package-route-jvm/truststore.jks -storepass changeit 2>/dev/null | grep -cE '^package-route-custom-ca[,[:space:]]' || true)" - corp_alias_count="${corp_alias_count:-0}" - [[ "$corp_alias_count" -eq 1 ]] || fail "JKS corporate-CA alias count after 2nd install: $corp_alias_count (expected 1)" -else - # Path A: no env vars, anchor file must exist and be exactly 1 file with our basename - [[ "$env_lines" -eq 0 ]] || fail "Path A leaked JAVA_TOOL_OPTIONS to /etc/environment ($env_lines lines)" - anchor_count="$(ls -1 /etc/pki/ca-trust/source/anchors/package-route-custom-ca.crt 2>/dev/null | wc -l | tr -d ' ')" - [[ "$anchor_count" -eq 1 ]] || fail "Path A anchor count: $anchor_count (expected 1)" -fi -echo " ok (env=$env_lines rc=$rc_lines)" + echo "=== generic: subject mismatch must exit 1 ===" + if ./validate_certs_jvm_linux.sh --expected-subject "Microsoft Root CA NoMatch" >/dev/null 2>&1; then + fail "validator should have exited 1 on subject mismatch" + fi + echo " ok" + + echo "=== generic: idempotent re-install preserves bundled JKS ===" + SUDO_USER=devx ./install_certs_jvm_linux.sh --use-truststore /tmp/bundled-truststore.jks >/dev/null + bundle_sha="$(sha256sum /tmp/bundled-truststore.jks | awk '{print $1}')" + installed_sha="$(sha256sum /etc/ssl/package-route-jvm/truststore.jks | awk '{print $1}')" + [[ "$installed_sha" == "$bundle_sha" ]] || fail "installed JKS checksum differs from bundle" + env_lines="$(grep -c '^JAVA_TOOL_OPTIONS=' /etc/environment 2>/dev/null || true)" + rc_lines="$(grep -c '^export JAVA_TOOL_OPTIONS=' /home/devx/.bashrc 2>/dev/null || true)" + [[ "${env_lines:-0}" -eq 1 ]] || fail "/etc/environment has $env_lines JAVA_TOOL_OPTIONS lines (expected 1)" + [[ "${rc_lines:-0}" -eq 1 ]] || fail "/home/devx/.bashrc has $rc_lines export lines (expected 1)" + echo " ok" -# I1: JTO replaces-not-appends sentinel. Pre-seed a junk value in -# /etc/environment, run installer, assert the junk is gone (env var was -# REPLACED, not concatenated). Only applies when detect_mode picks Path B. -if [[ -f /etc/ssl/package-route-jvm/truststore.jks ]]; then - echo "=== JTO env var REPLACES (not appends) on re-install ===" + echo "=== generic: JTO env var REPLACES (not appends) on re-install ===" sed -i '/^JAVA_TOOL_OPTIONS=/d' /etc/environment echo 'JAVA_TOOL_OPTIONS="-Dpackage-reroute-test-sentinel=must-be-replaced"' >> /etc/environment - SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem >/dev/null + SUDO_USER=devx ./install_certs_jvm_linux.sh --use-truststore /tmp/bundled-truststore.jks >/dev/null if grep -q 'package-reroute-test-sentinel' /etc/environment; then fail "JTO env var was APPENDED to (sentinel survived). Re-install must replace." fi echo " ok" -fi -echo "=== --cert-name custom: alias and basename honor flag ===" -SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --cert-name zscaler-root >/dev/null -./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" --cert-name zscaler-root >/dev/null -echo " ok" + echo "=== generic: missing and empty truststores rejected ===" + if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-truststore /tmp/no-such-truststore.jks >/dev/null 2>&1; then + fail "installer should have rejected missing truststore" + fi + : > /tmp/empty-truststore.jks + if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-truststore /tmp/empty-truststore.jks >/dev/null 2>&1; then + fail "installer should have rejected empty truststore" + fi + echo " ok" -echo "=== negative: --cert-name with bad chars must exit 1 ===" -if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --cert-name '../etc/pwned' >/dev/null 2>&1; then - fail "installer should have rejected path-traversal --cert-name" -fi -echo " ok" + echo "=== generic: JKS preserves bundled public roots ===" + alias_count="$(keytool -list -keystore /etc/ssl/package-route-jvm/truststore.jks -storepass changeit 2>/dev/null | grep -c 'trustedCertEntry' || true)" + [[ "${alias_count:-0}" -ge 100 ]] || fail "truststore has $alias_count aliases; expected >= 100" + keytool -list -keystore /etc/ssl/package-route-jvm/truststore.jks -storepass changeit 2>/dev/null \ + | grep -qi 'digicert' \ + || fail "truststore is missing the DigiCert family of public roots" + echo " ok ($alias_count aliases)" +} + +run_rhel() { + echo "=== rhel: positive install + validate ===" + ./install_certs_jvm_rhel.sh --use-cert /tmp/ca.pem >/dev/null + ./validate_certs_jvm_rhel.sh --expected-subject "Lab JVM CA Final" >/dev/null + [[ -f /etc/pki/ca-trust/source/anchors/package-route-custom-ca.crt ]] || fail "RHEL anchor file not created" + echo " ok" -echo "=== negative: malformed PEM must exit 1 ===" -echo "not a certificate" > /tmp/bad.pem -if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/bad.pem >/dev/null 2>&1; then - fail "installer should have rejected malformed PEM" -fi -echo " ok" + echo "=== rhel: subject mismatch must exit 1 ===" + if ./validate_certs_jvm_rhel.sh --expected-subject "Microsoft Root CA NoMatch" >/dev/null 2>&1; then + fail "validator should have exited 1 on subject mismatch" + fi + echo " ok" -echo "=== negative: expired CA must exit 1 ===" -# Generate a cert with explicit past not_before / not_after when openssl supports it, -# otherwise fall back to negative -days. Verify the result is actually expired before -# running the assertion — some openssl 3.x builds treat -days -1 as 1 day in the future. -rm -f /tmp/old-ca.pem -openssl req -x509 -newkey rsa:2048 -nodes \ - -keyout /tmp/old-k.pem -out /tmp/old-ca.pem \ - -subj "/CN=Expired CA" \ - -addext "basicConstraints=critical,CA:TRUE" \ - -not_before 20200101000000Z -not_after 20200201000000Z 2>/dev/null \ -|| openssl x509 -req -in <(openssl req -new -key /tmp/k.pem -subj "/CN=Expired") \ - -signkey /tmp/k.pem -days -1 -out /tmp/old-ca.pem 2>/dev/null \ -|| true -if [[ ! -f /tmp/old-ca.pem ]] || openssl x509 -in /tmp/old-ca.pem -checkend 0 -noout >/dev/null 2>&1; then - echo " SKIP: cannot produce a verifiably expired cert with the installed openssl ($(openssl version))" -else - if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/old-ca.pem >/dev/null 2>&1; then - fail "installer should have rejected expired CA" + echo "=== rhel: custom --cert-name round-trips ===" + ./install_certs_jvm_rhel.sh --use-cert /tmp/ca.pem --cert-name zscaler-root >/dev/null + ./validate_certs_jvm_rhel.sh --expected-subject "Lab JVM CA Final" --cert-name zscaler-root >/dev/null + [[ -f /etc/pki/ca-trust/source/anchors/zscaler-root.crt ]] || fail "custom RHEL anchor file not created" + echo " ok" + + echo "=== rhel: path traversal cert-name rejected ===" + if ./install_certs_jvm_rhel.sh --use-cert /tmp/ca.pem --cert-name '../etc/pwned' >/dev/null 2>&1; then + fail "installer should have rejected path-traversal --cert-name" fi echo " ok" -fi -echo "=== negative: leaf cert (not CA) must exit 1 ===" -openssl req -x509 -newkey rsa:2048 -nodes \ - -keyout /tmp/leaf-k.pem -out /tmp/leaf.pem -days 7 \ - -subj "/CN=Leaf Not CA" \ - -addext "basicConstraints=critical,CA:FALSE" 2>/dev/null -if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/leaf.pem >/dev/null 2>&1; then - fail "installer should have rejected leaf cert (CA:FALSE)" -fi -echo " ok" - -echo "=== forced --mode update-ca-trust ===" -if command -v update-ca-trust >/dev/null 2>&1; then - # Reset Path B state so we can exercise Path A cleanly. - rm -rf /etc/ssl/package-route-jvm - sed -i '/^JAVA_TOOL_OPTIONS=/d' /etc/environment 2>/dev/null || true - sed -i '/^export JAVA_TOOL_OPTIONS=/d' /home/devx/.bashrc 2>/dev/null || true - - SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --mode update-ca-trust >/dev/null - [[ -f /etc/pki/ca-trust/source/anchors/package-route-custom-ca.crt ]] \ - || fail "Path A anchor file not created" - keytool -list -keystore /etc/pki/ca-trust/extracted/java/cacerts -storepass changeit 2>/dev/null \ - | grep -q "$(openssl x509 -in /tmp/ca.pem -noout -fingerprint -sha256 | sed 's/.*=//')" \ - || fail "Path A: CA fingerprint not visible in system Java cacerts" - # /etc/environment must NOT have JAVA_TOOL_OPTIONS on Path A. - if [[ -f /etc/environment ]] && grep -q '^JAVA_TOOL_OPTIONS=' /etc/environment; then - fail "Path A leaked JAVA_TOOL_OPTIONS into /etc/environment" + echo "=== rhel: malformed PEM rejected ===" + echo "not a certificate" > /tmp/bad.pem + if ./install_certs_jvm_rhel.sh --use-cert /tmp/bad.pem >/dev/null 2>&1; then + fail "installer should have rejected malformed PEM" fi - ./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" >/dev/null echo " ok" -else - echo " SKIP: update-ca-trust not installed on this distro" -fi -echo "=== regression: dual-artifact validator must not silent-pass ===" -# Reproduce the iteration-1 fix's regression: both Path A anchor and Path B JKS -# present at once. detect_mode previously leaked warn-stdout into command -# substitution and exited 0 with NO checks run. -if command -v update-ca-trust >/dev/null 2>&1; then - # Both artifacts already exist from Path A run; force Path B side too. - SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --mode java-tool-options >/dev/null - [[ -f /etc/ssl/package-route-jvm/truststore.jks ]] || fail "Path B JKS not created" - [[ -f /etc/pki/ca-trust/source/anchors/package-route-custom-ca.crt ]] || fail "Path A anchor missing" - - # Validator must NOT print 'All checks passed' silently — it must actually run - # the Path B checks (positive subject match against the JKS). - out="$(./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" 2>&1)" - grep -q 'Validating Path B' <<<"$out" || fail "validator did not run Path B checks: $out" - grep -q 'All checks passed' <<<"$out" || fail "validator did not report success: $out" + echo "=== rhel: leaf cert rejected ===" + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/leaf-k.pem -out /tmp/leaf.pem -days 7 \ + -subj "/CN=Leaf Not CA" \ + -addext "basicConstraints=critical,CA:FALSE" 2>/dev/null + if ./install_certs_jvm_rhel.sh --use-cert /tmp/leaf.pem >/dev/null 2>&1; then + fail "installer should have rejected leaf cert" + fi echo " ok" -else - echo " SKIP: cannot construct dual-artifact state without update-ca-trust" -fi -echo "=== validate_pem 30-day-expiry warn fires (1-day cert) ===" -# I2 backport from macOS/Windows: assert the warn is emitted but install succeeds. -openssl req -x509 -newkey rsa:2048 -nodes \ - -keyout /tmp/soon-k.pem -out /tmp/soon-ca.pem -days 1 \ - -subj "/CN=Soon to Expire/O=JFrog" \ - -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null -warn_out="$(SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/soon-ca.pem 2>&1)" -if ! echo "$warn_out" | grep -q "certificate expires within 30 days"; then - echo "$warn_out" | tail -10 - fail "30-day expiry warn not emitted" -fi -echo " ok" - -echo "=== validate_pem multi-cert bundle warn fires ===" -# I2 backport: concat two certs, assert the warn is emitted but install succeeds. -cat /tmp/ca.pem /tmp/soon-ca.pem > /tmp/bundle.pem -warn_out="$(SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/bundle.pem 2>&1)" -if ! echo "$warn_out" | grep -qE "PEM file contains [0-9]+ certificates"; then - echo "$warn_out" | tail -10 - fail "multi-cert bundle warn not emitted" -fi -echo " ok" - -echo "=== negative: missing keytool fails cleanly (Path B) ===" -# I3 backport from Windows: temporarily hide every keytool on PATH (incl. the -# JDK-installed one symlinked via update-alternatives), assert the installer -# exits non-zero with a message mentioning keytool. Path A is keytool-optional -# so we only exercise Path B (--mode java-tool-options). -keytool_paths=() -while IFS= read -r p; do keytool_paths+=("$p"); done < <(command -v keytool 2>/dev/null; type -ap keytool 2>/dev/null | sort -u) -for p in "${keytool_paths[@]}"; do mv "$p" "$p.hidden_for_test" 2>/dev/null || true; done -trap 'for p in "${keytool_paths[@]}"; do [[ -f "$p.hidden_for_test" ]] && mv "$p.hidden_for_test" "$p" 2>/dev/null || true; done' EXIT -if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --mode java-tool-options >/tmp/nokey.out 2>&1; then - cat /tmp/nokey.out | head -10 - fail "installer should have rejected missing keytool" -fi -if ! grep -q -i keytool /tmp/nokey.out; then - cat /tmp/nokey.out | head -10 - fail "missing-keytool error message should mention 'keytool'" -fi -# Restore now (and clear the EXIT trap) so subsequent tests can use keytool. -for p in "${keytool_paths[@]}"; do [[ -f "$p.hidden_for_test" ]] && mv "$p.hidden_for_test" "$p" 2>/dev/null || true; done -trap - EXIT -echo " ok" - -echo "=== negative: DER cert rejected (C1 cross-platform parity) ===" -# C1 backport: convert the lab CA to DER, then attempt install — should -# fail with a hint to convert back. Mirrors Windows behavior (which now -# also rejects DER for cross-platform symmetry). -openssl x509 -in /tmp/ca.pem -outform DER -out /tmp/ca.der 2>/dev/null -if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.der >/dev/null 2>&1; then - fail "installer should have rejected DER-encoded cert" -fi -echo " ok" - -echo "=== Path B: JKS extends default cacerts (preserves public roots) ===" -# Bug 2 regression guard. -Djavax.net.ssl.trustStore in OpenJDK REPLACES the -# JVM trust source (it does not merge with $JAVA_HOME/lib/security/cacerts). -# We must therefore ship a JKS that already contains the JDK's ~150 public -# roots PLUS the corporate CA. A future change that re-shrinks the truststore -# to just the corporate CA would break every public-CA TLS handshake. -if [[ -f /etc/ssl/package-route-jvm/truststore.jks ]]; then - # Re-build via Path B so we measure the post-fix state. - SUDO_USER=devx ./install_certs_jvm_linux.sh --use-cert /tmp/ca.pem --mode java-tool-options >/dev/null - alias_count=$(keytool -list -keystore /etc/ssl/package-route-jvm/truststore.jks \ - -storepass changeit 2>/dev/null | grep -c 'trustedCertEntry' || true) - alias_count=${alias_count:-0} - [[ "$alias_count" -ge 100 ]] || fail "Path B truststore has $alias_count aliases; expected >= 100 (JDK cacerts ~150 public roots + corporate CA)" - keytool -list -keystore /etc/ssl/package-route-jvm/truststore.jks -storepass changeit 2>/dev/null \ - | grep -qi 'digicert' \ - || fail "Path B truststore is missing the DigiCert family of public roots; the copy-from-JDK step did not run" - echo " ok ($alias_count aliases, DigiCert present)" -else - echo " SKIP: Path B not exercised on this distro (Path A only)" -fi + echo "=== rhel: DER cert rejected ===" + openssl x509 -in /tmp/ca.pem -outform DER -out /tmp/ca.der 2>/dev/null + if ./install_certs_jvm_rhel.sh --use-cert /tmp/ca.der >/dev/null 2>&1; then + fail "installer should have rejected DER-encoded cert" + fi + echo " ok" + + echo "=== rhel: no JAVA_TOOL_OPTIONS written ===" + if [[ -f /etc/environment ]] && grep -q '^JAVA_TOOL_OPTIONS=' /etc/environment; then + fail "RHEL flow should not write JAVA_TOOL_OPTIONS" + fi + echo " ok" +} + +case "$MODE" in + generic) run_generic ;; + rhel) run_rhel ;; + *) fail "unknown mode: $MODE" ;; +esac -echo "=== ALL SMOKE TESTS PASSED ===" +echo "=== ALL SMOKE TESTS PASSED ($MODE) ===" PROBE_EOF chmod +x "$PROBE" -# distro_id|image|setup_command +# distro_id|mode|image|setup_command MATRIX=( - "ubuntu|ubuntu:22.04|export DEBIAN_FRONTEND=noninteractive; apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl ca-certificates default-jdk-headless >/dev/null" - "debian|debian:12|export DEBIAN_FRONTEND=noninteractive; apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl ca-certificates default-jdk-headless >/dev/null" - "rhel|redhat/ubi9:latest|dnf install -y -q java-21-openjdk-headless openssl shadow-utils >/dev/null" - "amazonlinux|amazonlinux:2023|dnf install -y -q java-21-amazon-corretto-headless openssl shadow-utils >/dev/null" + "ubuntu|generic|ubuntu:22.04|export DEBIAN_FRONTEND=noninteractive; apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl ca-certificates default-jdk-headless >/dev/null" + "debian|generic|debian:12|export DEBIAN_FRONTEND=noninteractive; apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl ca-certificates default-jdk-headless >/dev/null" + "amazonlinux|generic|amazonlinux:2023|dnf install -y -q java-21-amazon-corretto-headless openssl shadow-utils >/dev/null" + "rhel|rhel|redhat/ubi9:latest|dnf install -y -q java-21-openjdk-headless openssl shadow-utils >/dev/null" ) LOG_DIR="$(mktemp -d)" @@ -305,10 +193,13 @@ trap 'rm -f "$PROBE"; rm -rf "$LOG_DIR"' EXIT pids=() labels=() +log_names=() for entry in "${MATRIX[@]}"; do distro="${entry%%|*}" rest="${entry#*|}" + mode="${rest%%|*}" + rest="${rest#*|}" image="${rest%%|*}" setup="${rest#*|}" @@ -317,11 +208,12 @@ for entry in "${MATRIX[@]}"; do docker run --rm \ -v "${REPO_ROOT}":/lab \ -v "${PROBE}":/probe.sh:ro \ - -w /lab "$image" bash -c "set -e; ${setup}; bash /probe.sh" + -w /lab "$image" bash -c "set -e; ${setup}; bash /probe.sh ${mode}" ) >"$log" 2>&1 & pids+=("$!") - labels+=("$distro ($image)") - echo "[launched] $distro ($image) -> $log" + labels+=("$distro/$mode ($image)") + log_names+=("$log") + echo "[launched] $distro/$mode ($image) -> $log" done overall_rc=0 @@ -330,7 +222,7 @@ for i in "${!pids[@]}"; do echo "[PASS] ${labels[$i]}" else echo "[FAIL] ${labels[$i]} — last 30 lines:" - tail -30 "${LOG_DIR}/${labels[$i]%% *}.log" | sed 's/^/ /' + tail -30 "${log_names[$i]}" | sed 's/^/ /' overall_rc=1 fi done diff --git a/validate_certs_jvm_linux.sh b/validate_certs_jvm_linux.sh index cc5d22c..3262775 100755 --- a/validate_certs_jvm_linux.sh +++ b/validate_certs_jvm_linux.sh @@ -2,55 +2,32 @@ # (c) JFrog Ltd. (2026) # Validate JVM truststore installation done by install_certs_jvm_linux.sh. # -# Detects which path the installer used (RHEL update-ca-trust vs JKS + -# JAVA_TOOL_OPTIONS), then asserts: -# 1. Anchor file (Path A) OR JKS truststore (Path B) exists -# 2. Customer CA subject matches --expected-subject (substring, case-insensitive) -# 3. (Path B) /etc/environment contains JAVA_TOOL_OPTIONS pointing at the JKS -# 4. (Path B) Current user's shell rc files reference the same value (WARN if missing) -# 5. Current process inherited JAVA_TOOL_OPTIONS (HINT if missing — open a new shell) -# -# Run: -# bash validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" -# sudo bash validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" --all-users -# -# Pass the same --cert-name that was used during install if it was non-default. -# -# Exit 0 = all checks passed (warnings tolerated). -# -# Cross-platform siblings (keep CLI shapes and contracts in sync): -# validate_certs_jvm_macos.sh — LaunchAgent + launchctl getenv check -# validate_certs_jvm_windows.ps1 — HKCU\Environment + Get-JavaToolOptions check -# -# Research / rationale: see the JVM client-onboarding wiki page -# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ +# Asserts: +# 1. JKS truststore exists at /etc/ssl/package-route-jvm/truststore.jks +# 2. JKS contains a cert whose subject matches --expected-subject +# 3. /etc/environment contains JAVA_TOOL_OPTIONS pointing at the JKS +# 4. Current user's shell rc files reference the same value (WARN if missing) +# 5. Current process inherited JAVA_TOOL_OPTIONS (HINT if missing) set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [[ ! -f "${SCRIPT_DIR}/_jvm_linux_paths.sh" ]]; then - echo "Error: _jvm_linux_paths.sh not found next to this validator (${SCRIPT_DIR})." >&2 - echo " Invoke as ./validate_certs_jvm_linux.sh — not via 'curl | bash'." >&2 - exit 1 -fi -# shellcheck disable=SC1091 -. "${SCRIPT_DIR}/_jvm_linux_paths.sh" +JKS_DIR="/etc/ssl/package-route-jvm" +JKS_PATH="${JKS_DIR}/truststore.jks" +JKS_PASSWORD="changeit" +ENVIRONMENT_FILE="/etc/environment" ALL_USERS=0 EXPECTED_SUBJECT="" -CERT_BASENAME="${JVM_LINUX_DEFAULT_CERT_BASENAME}" usage() { cat < [--cert-name ] [--all-users] + $0 --expected-subject [--all-users] Options: --expected-subject Required. Case-insensitive substring match against the cert subject. - --cert-name Base name used at install (default: ${CERT_BASENAME}). - Must match the installer's --cert-name when non-default. - --all-users For Path B (JKS+JTO), validate /home/* users' rc files (requires root). - -h, --help Show this help + --all-users Validate /home/* users' rc files (requires root). + -h, --help Show this help. Exits 0 if all checks pass, 1 if any check fails. EOF @@ -67,10 +44,6 @@ parse_args() { EXPECTED_SUBJECT="${2:?Error: --expected-subject requires a value}" shift 2 ;; - --cert-name) - CERT_BASENAME="${2:?Error: --cert-name requires a value}" - shift 2 - ;; -h|--help) usage exit 0 @@ -89,17 +62,6 @@ parse_args() { exit 1 fi - if [[ -z "$CERT_BASENAME" ]]; then - echo "Error: --cert-name cannot be empty." >&2 - exit 1 - fi - - # Symmetrical with installer: same regex constraint so the file path resolves cleanly. - if [[ ! "$CERT_BASENAME" =~ ^[A-Za-z0-9._-]+$ ]]; then - echo "Error: --cert-name must match [A-Za-z0-9._-]+ (got: $CERT_BASENAME)." >&2 - exit 1 - fi - if [[ "$ALL_USERS" -eq 1 && "$(id -u)" -ne 0 ]]; then echo "Error: --all-users requires root. Use: sudo $0 --all-users --expected-subject ..." >&2 exit 1 @@ -112,59 +74,6 @@ ok() { echo " OK: $1"; } warn() { echo " WARN: $1"; } skip() { echo " SKIP: $1"; } -# Stdout contract: exactly one of {java-tool-options, update-ca-trust, none}. -# Callers MUST equality-match. Keep in sync with installer's detect_mode. -detect_mode() { - local anchor="${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt" - local jks_present=0 anchor_present=0 - [[ -f "$JKS_PATH" ]] && jks_present=1 - [[ -f "$anchor" ]] && anchor_present=1 - - if [[ "$jks_present" -eq 1 && "$anchor_present" -eq 1 ]]; then - # detect_mode is consumed via $(...), so anything on stdout that is - # NOT the mode token leaks into the caller's $mode and breaks the case - # dispatch. Route the whole warn block to stderr. - { - warn "Both Path A and Path B artifacts exist on disk:" - warn " $JKS_PATH" - warn " $anchor" - warn "Reporting Path B (more recent mode preferred). Clean up the unused path manually." - } >&2 - echo "java-tool-options" - return - fi - - if [[ "$jks_present" -eq 1 ]]; then - echo "java-tool-options" - return - fi - if [[ "$anchor_present" -eq 1 ]]; then - echo "update-ca-trust" - return - fi - echo "none" -} - -validate_pem_subject() { - local path="$1" - if [[ ! -f "$path" ]]; then - fail "file does not exist: $path" - return 1 - fi - if ! openssl x509 -in "$path" -noout >/dev/null 2>&1; then - fail "not a valid PEM certificate: $path" - return 1 - fi - local subject - subject="$(openssl x509 -in "$path" -noout -subject 2>/dev/null)" - if echo "$subject" | grep -qi "$EXPECTED_SUBJECT"; then - ok "PEM at $path has subject matching: $EXPECTED_SUBJECT" - return 0 - fi - fail "PEM at $path subject does not match '$EXPECTED_SUBJECT' (got: $subject)" - return 1 -} - validate_keystore_contains_subject() { local keystore="$1" storepass="$2" label="$3" if [[ ! -f "$keystore" ]]; then @@ -172,26 +81,17 @@ validate_keystore_contains_subject() { return 1 fi if ! command -v keytool >/dev/null 2>&1; then - # Promoting to FAIL: the keystore-subject check is the validator's - # core assertion; silently passing here would mean CI is green even - # though we never verified the cert is in the store. fail "$label keystore present but keytool is not on PATH; cannot verify subject. Install a JDK." return 1 fi - # Capture keytool output explicitly so a real keytool error (wrong password, - # corrupt store, version mismatch) is reported as such — and not silently - # masked as a "subject not found" via pipefail+|| true on the pipeline. - local keytool_output rc=0 + local keytool_output if ! keytool_output="$(keytool -list -v -keystore "$keystore" -storepass "$storepass" 2>&1)"; then fail "$label: keytool could not read the keystore. Output (first 3 lines):" printf '%s\n' "$keytool_output" | head -n3 | sed 's/^/ /' return 1 fi - # Two-stage filter via a variable, not a pipe-pair: under `set -o pipefail` - # the second `grep -qi` exits early on match and SIGPIPEs the first, which - # poisons the pipeline status and turns positive matches into false negatives. local owners owners="$(printf '%s\n' "$keytool_output" | grep -E '^Owner:' || true)" if ! grep -qi "$EXPECTED_SUBJECT" <<<"$owners"; then @@ -199,13 +99,6 @@ validate_keystore_contains_subject() { return 1 fi - # I8: defence-in-depth — assert every alias is a `trustedCertEntry`. - # The installer only calls `keytool -importcert`, which creates - # trustedCertEntry records. A `PrivateKeyEntry` showing up here would mean - # someone (or a compromised future installer change) imported a keypair - # into this store — the password protecting that key would then be - # `changeit`, which is the well-known JDK default and unsuitable for - # private-key material. Refuse to validate such a store. if printf '%s\n' "$keytool_output" | grep -qE '^Entry type: PrivateKeyEntry'; then fail "$label contains a PrivateKeyEntry — this truststore must hold only trustedCertEntry records." return 1 @@ -224,19 +117,14 @@ validate_export_in_file() { warn "$label not readable by current user; re-run as that user or with sudo --all-users." return 0 fi - # Accept either quoted or unquoted trustStore=. Installer now writes - # the quoted form (cross-platform symmetry with macOS/Windows); older - # installs may carry the unquoted form. Both are valid JTO values. if grep -qE "^export JAVA_TOOL_OPTIONS=.*trustStore=\"?${JKS_PATH}\"?" "$file" 2>/dev/null; then ok "$label contains JAVA_TOOL_OPTIONS pointing at $JKS_PATH" return 0 fi if grep -qE '^export JAVA_TOOL_OPTIONS=' "$file" 2>/dev/null; then - # Wrong-target export is a real problem: it overrides /etc/environment. fail "$label has JAVA_TOOL_OPTIONS but it does not point at $JKS_PATH" return 1 fi - # Missing export in user rc is not authoritative — /etc/environment is the source of truth. warn "$label has no JAVA_TOOL_OPTIONS export (current session may need re-login or 'source $file')" return 0 } @@ -246,7 +134,6 @@ validate_environment_file() { fail "$ENVIRONMENT_FILE does not exist" return 1 fi - # Accept either quoted or unquoted trustStore=; see validate_export_in_file. if grep -qE "^JAVA_TOOL_OPTIONS=.*trustStore=\"?${JKS_PATH}\"?" "$ENVIRONMENT_FILE" 2>/dev/null; then ok "$ENVIRONMENT_FILE contains JAVA_TOOL_OPTIONS pointing at $JKS_PATH" return 0 @@ -255,18 +142,7 @@ validate_environment_file() { return 1 } -validate_path_a() { - echo "Validating Path A (RHEL update-ca-trust)..." - local anchor="${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt" - validate_pem_subject "$anchor" || true - validate_keystore_contains_subject "$RHEL_JAVA_CACERTS" "$JKS_PASSWORD" "Java cacerts" || true -} - -validate_path_b() { - echo "Validating Path B (JKS + JAVA_TOOL_OPTIONS)..." - validate_keystore_contains_subject "$JKS_PATH" "$JKS_PASSWORD" "Truststore $JKS_PATH" || true - validate_environment_file || true - +validate_shell_rc_files() { if [[ "$ALL_USERS" -eq 1 ]]; then local homedir user for homedir in /home/*; do @@ -282,18 +158,6 @@ validate_path_b() { validate_export_in_file "$HOME/.bashrc" "current user .bashrc" || true validate_export_in_file "$HOME/.zshrc" "current user .zshrc" || true fi - - # Current-process env hint: the most common "validator passed but - # Maven still fails TLS" support ticket. /etc/environment is the - # authoritative source for new login sessions, but the SHELL THIS - # VALIDATOR IS RUNNING IN inherits its env at startup. If the user - # ran install -> validate in the same shell, the env var won't be set. - if [[ -z "${JAVA_TOOL_OPTIONS:-}" ]]; then - echo " HINT: JAVA_TOOL_OPTIONS is NOT set in this shell. Open a new" - echo " login shell, or 'source $ENVIRONMENT_FILE', then re-run" - echo " the build. The next 'mvn'/'gradle' won't see the trust" - echo " store until then." - fi } main() { @@ -302,23 +166,16 @@ main() { echo "Expected subject (case-insensitive substring): $EXPECTED_SUBJECT" echo - local mode - mode="$(detect_mode)" - echo "Detected installer path: $mode" - echo + validate_keystore_contains_subject "$JKS_PATH" "$JKS_PASSWORD" "Truststore $JKS_PATH" || true + validate_environment_file || true + validate_shell_rc_files - case "$mode" in - update-ca-trust) validate_path_a ;; - java-tool-options) validate_path_b ;; - none) - echo "Error: no install_certs_jvm_linux.sh artifacts found for cert-name '$CERT_BASENAME'." >&2 - echo " Expected one of:" >&2 - echo " $JKS_PATH (Path B)" >&2 - echo " ${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt (Path A)" >&2 - echo " If you installed with a non-default --cert-name, pass --cert-name here too." >&2 - exit 1 - ;; - esac + if [[ -z "${JAVA_TOOL_OPTIONS:-}" ]]; then + echo " HINT: JAVA_TOOL_OPTIONS is NOT set in this shell. Open a new" + echo " login shell, or 'source $ENVIRONMENT_FILE', then re-run" + echo " the build. The next 'mvn'/'gradle' won't see the trust" + echo " store until then." + fi echo "---------------------------------------------------" if [[ "$FAIL" -eq 0 ]]; then diff --git a/validate_certs_jvm_rhel.sh b/validate_certs_jvm_rhel.sh new file mode 100755 index 0000000..f14bbdc --- /dev/null +++ b/validate_certs_jvm_rhel.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Validate JVM trust installation done by install_certs_jvm_rhel.sh. + +set -euo pipefail + +DEFAULT_CERT_BASENAME="package-route-custom-ca" +RHEL_ANCHOR_DIR="/etc/pki/ca-trust/source/anchors" +RHEL_JAVA_CACERTS="/etc/pki/ca-trust/extracted/java/cacerts" +JKS_PASSWORD="changeit" + +EXPECTED_SUBJECT="" +CERT_BASENAME="$DEFAULT_CERT_BASENAME" + +usage() { + cat < [--cert-name ] + +Options: + --expected-subject Required. Case-insensitive substring match against the cert subject. + --cert-name Base name used at install (default: ${DEFAULT_CERT_BASENAME}). + -h, --help Show this help. + +Exits 0 if all checks pass, 1 if any check fails. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --expected-subject) + EXPECTED_SUBJECT="${2:?Error: --expected-subject requires a value}" + shift 2 + ;; + --cert-name) + CERT_BASENAME="${2:?Error: --cert-name requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$EXPECTED_SUBJECT" ]]; then + echo "Error: --expected-subject is required." >&2 + usage >&2 + exit 1 + fi + if [[ -z "$CERT_BASENAME" ]]; then + echo "Error: --cert-name cannot be empty." >&2 + exit 1 + fi + if [[ ! "$CERT_BASENAME" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Error: --cert-name must match [A-Za-z0-9._-]+ (got: $CERT_BASENAME)." >&2 + exit 1 + fi +} + +FAIL=0 +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } +ok() { echo " OK: $1"; } + +validate_pem_subject() { + local path="$1" + if [[ ! -f "$path" ]]; then + fail "file does not exist: $path" + return 1 + fi + if ! openssl x509 -in "$path" -noout >/dev/null 2>&1; then + fail "not a valid PEM certificate: $path" + return 1 + fi + local subject + subject="$(openssl x509 -in "$path" -noout -subject 2>/dev/null)" + if echo "$subject" | grep -qi "$EXPECTED_SUBJECT"; then + ok "PEM at $path has subject matching: $EXPECTED_SUBJECT" + return 0 + fi + fail "PEM at $path subject does not match '$EXPECTED_SUBJECT' (got: $subject)" + return 1 +} + +validate_keystore_contains_subject() { + local keystore="$1" storepass="$2" label="$3" + if [[ ! -f "$keystore" ]]; then + fail "$label keystore does not exist: $keystore" + return 1 + fi + if ! command -v keytool >/dev/null 2>&1; then + fail "$label keystore present but keytool is not on PATH; cannot verify subject. Install a JDK." + return 1 + fi + + local keytool_output + if ! keytool_output="$(keytool -list -v -keystore "$keystore" -storepass "$storepass" 2>&1)"; then + fail "$label: keytool could not read the keystore. Output (first 3 lines):" + printf '%s\n' "$keytool_output" | head -n3 | sed 's/^/ /' + return 1 + fi + + local owners + owners="$(printf '%s\n' "$keytool_output" | grep -E '^Owner:' || true)" + if ! grep -qi "$EXPECTED_SUBJECT" <<<"$owners"; then + fail "$label has no cert with subject matching: $EXPECTED_SUBJECT" + return 1 + fi + if printf '%s\n' "$keytool_output" | grep -qE '^Entry type: PrivateKeyEntry'; then + fail "$label contains a PrivateKeyEntry — this truststore must hold only trustedCertEntry records." + return 1 + fi + ok "$label contains cert with subject matching: $EXPECTED_SUBJECT" + return 0 +} + +main() { + parse_args "$@" + + local anchor="${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt" + + echo "Expected subject (case-insensitive substring): $EXPECTED_SUBJECT" + echo + echo "Validating RHEL update-ca-trust JVM install..." + + validate_pem_subject "$anchor" || true + validate_keystore_contains_subject "$RHEL_JAVA_CACERTS" "$JKS_PASSWORD" "Java cacerts" || true + + echo "---------------------------------------------------" + if [[ "$FAIL" -eq 0 ]]; then + echo "Result: All checks passed." + exit 0 + else + echo "Result: $FAIL check(s) failed." + exit 1 + fi +} + +main "$@" From 952b9a76b30517911fb984fe1d3e5698e2afbf51 Mon Sep 17 00:00:00 2001 From: rani Date: Wed, 24 Jun 2026 12:24:33 +0300 Subject: [PATCH 6/6] DFLOW-140 - Add JVM truststore bundle builders --- README.md | 47 ++++- build_jvm_truststore_linux.sh | 222 +++++++++++++++++++++ build_jvm_truststore_macos.sh | 222 +++++++++++++++++++++ build_jvm_truststore_windows.ps1 | 206 +++++++++++++++++++ testing/test_install_certs_jvm_linux.sh | 37 +--- testing/test_install_certs_jvm_macos.sh | 71 ++----- testing/test_install_certs_jvm_windows.ps1 | 45 ++--- 7 files changed, 731 insertions(+), 119 deletions(-) create mode 100755 build_jvm_truststore_linux.sh create mode 100755 build_jvm_truststore_macos.sh create mode 100644 build_jvm_truststore_windows.ps1 diff --git a/README.md b/README.md index 9b101a9..8f3dc6f 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,10 @@ Reference: research wiki [Maven Support in package-reroute (DFLOW-136 / DFLOW-11 | **validate_install_macos.sh** | macOS | Validate PEM and env config | | **install_certs_jvm_macos.sh** | macOS (JVM) | Install CA for Maven/Gradle/sbt/Ivy: JKS + per-user LaunchAgent setting `JAVA_TOOL_OPTIONS` | | **validate_certs_jvm_macos.sh** | macOS (JVM) | Validate JVM truststore install (JKS subject + plist + `launchctl getenv`) | +| **build_jvm_truststore_macos.sh** | macOS (JVM) | Build installer-ready JKS from macOS system roots plus a supplied PEM CA | | **install_certs_debian_ubuntu.sh** | Debian/Ubuntu | Install cert into system trust + profile.d + user shell rc + Docker cleanup | | **validate_certs_debian_ubuntu.sh** | Debian/Ubuntu | Validate PEM and env config | +| **build_jvm_truststore_linux.sh** | Linux (JVM) | Build installer-ready JKS from Linux system CA bundle plus a supplied PEM CA | | **install_certs_jvm_linux.sh** | Linux (JVM) | Install bundled JVM truststore: JKS at `/etc/ssl/package-route-jvm/truststore.jks` + `JAVA_TOOL_OPTIONS` in `/etc/environment` | | **validate_certs_jvm_linux.sh** | Linux (JVM) | Validate bundled JVM truststore install (JKS subject + `/etc/environment` + shell-rc) | | **install_certs_jvm_rhel.sh** | RHEL (JVM) | Install PEM into RHEL-family system trust via `update-ca-trust extract` | @@ -33,6 +35,7 @@ Reference: research wiki [Maven Support in package-reroute (DFLOW-136 / DFLOW-11 | **validate_install_windows.ps1** | Windows | Validate PEM and env config | | **install_certs_jvm_windows.ps1** | Windows (JVM) | Install CA for Maven/Gradle/sbt/Ivy: JKS at `%LOCALAPPDATA%` + User-scope `JAVA_TOOL_OPTIONS` | | **validate_certs_jvm_windows.ps1** | Windows (JVM) | Validate JVM truststore install (JKS subject + User-scope env var) | +| **build_jvm_truststore_windows.ps1** | Windows (JVM) | Build installer-ready JKS from Windows LocalMachine roots plus a supplied PEM CA | Environment variables by platform (see each section for details): @@ -391,6 +394,18 @@ sudo ./validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" --all-users - **`~/.zshrc` / `~/.bash_profile` are deliberately NOT touched.** They silently fail for Dock-launched IDE builds — see Overview. - **IntelliJ per-IDE SSL store** (`~/Library/Application Support/JetBrains/IntelliJIdea/ssl/cacerts`) is a different layer (plugin marketplace, VCS). Not configured by this script. +### Building the bundled truststore + +`build_jvm_truststore_macos.sh` creates the `--use-truststore` input for the installer. It exports macOS system certificates from the system keychains, imports them into a JKS truststore, then imports your supplied PEM CA under alias `package-route-custom-ca`. + +```bash +./build_jvm_truststore_macos.sh \ + --use-cert /tmp/ZscalerRoot0.pem \ + --output /tmp/package-route-truststore.jks + +sudo ./install_certs_jvm_macos.sh --use-truststore /tmp/package-route-truststore.jks +``` + ### Testing `./testing/test_install_certs_jvm_macos.sh` runs the 9-invariant smoke matrix locally and on CI. Each run targets `SUDO_USER`'s per-user files and cleans up between cases via `trap EXIT`. @@ -404,7 +419,7 @@ The same matrix runs on every push and pull request via `.github/workflows/ci.ym ### Summary (macOS JVM) -- **One run as root**, single cert source via `--use-cert`. +- **One run as root**, single truststore source via `--use-truststore`. - Per-user JKS at `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks`. - Per-user LaunchAgent at `~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist` bootstrapped into `gui/`. - **Idempotent**, **re-runnable**, **JDK-version-agnostic**. New JDK installs do not require re-running the script. @@ -472,6 +487,20 @@ This generic Linux script always uses the JKS + `JAVA_TOOL_OPTIONS` recipe: RHEL-family hosts that intentionally use Red Hat OpenJDK system trust should use `install_certs_jvm_rhel.sh` instead. +### Building the bundled truststore + +`build_jvm_truststore_linux.sh` creates the `--use-truststore` input for the generic Linux installer. It imports certificates from the host Linux system CA bundle, then imports your supplied PEM CA under alias `package-route-custom-ca`. + +```bash +./build_jvm_truststore_linux.sh \ + --use-cert /tmp/ZscalerRoot0.pem \ + --output /tmp/package-route-truststore.jks + +sudo ./install_certs_jvm_linux.sh --use-truststore /tmp/package-route-truststore.jks +``` + +If the host uses a non-standard CA bundle path, pass `--system-bundle `. + ### Requirements - **Linux**. @@ -570,6 +599,7 @@ The same matrix runs on every push and pull request via `.github/workflows/ci.ym - Use `install_certs_jvm_linux.sh` for bundled JKS + `JAVA_TOOL_OPTIONS`. - Use `install_certs_jvm_rhel.sh` for RHEL-family `update-ca-trust`. +- Use `build_jvm_truststore_linux.sh` only for the generic bundled-JKS flow; RHEL uses the PEM directly. - Linux JVM scripts are self-contained; there is no shared `_jvm_linux_paths.sh`. - Users of the generic flow must open a new login shell (or `source /etc/environment`) for env changes to take effect. `gradle --stop` refreshes the Gradle Daemon. @@ -715,6 +745,19 @@ powershell -ExecutionPolicy Bypass -File validate_certs_jvm_windows.ps1 -Expecte Exit code 0 if all checks pass, 1 otherwise. Result line is qualified with a count of any non-fatal warnings. +### Building the bundled truststore + +`build_jvm_truststore_windows.ps1` creates the `-UseTruststore` input for the Windows JVM installer. It imports Windows `LocalMachine\Root` certificates into a JKS truststore, then imports your supplied PEM CA under alias `package-route-custom-ca`. + +```powershell +powershell -ExecutionPolicy Bypass -File .\build_jvm_truststore_windows.ps1 ` + -UseCert C:\tmp\ZscalerRoot0.pem ` + -Output C:\tmp\package-route-truststore.jks + +powershell -ExecutionPolicy Bypass -File .\install_certs_jvm_windows.ps1 ` + -UseTruststore C:\tmp\package-route-truststore.jks +``` + ### Caveats - **New sessions only.** `WM_SETTINGCHANGE` reaches Explorer and a few shells but most JVM-launching processes (Gradle Daemon, IntelliJ, Maven via the wrapper) cache their environment at startup. Open a new PowerShell/cmd or log off/on after install. @@ -738,7 +781,7 @@ The same matrix runs on every push and pull request via `.github/workflows/ci.ym ### Summary (Windows JVM) -- **No Administrator required.** Single cert source via `-UseCert`. +- **No Administrator required.** Single truststore source via `-UseTruststore`. - Per-user JKS at `%LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks`. - User-scope `JAVA_TOOL_OPTIONS` in `HKCU\Environment`, broadcast via `WM_SETTINGCHANGE`. - **Idempotent**, **re-runnable**, **JDK-version-agnostic** across currently-supported JDKs (JKS format is still read by JDK 8–25; a future JDK that drops JKS support would require an installer-side format bump). New JDK installs do not require re-running the script. diff --git a/build_jvm_truststore_linux.sh b/build_jvm_truststore_linux.sh new file mode 100755 index 0000000..8737df4 --- /dev/null +++ b/build_jvm_truststore_linux.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Build a JVM truststore from the Linux system CA bundle plus one custom CA PEM. +# +# This is a build-time helper for the JVM installers. It does not install +# anything and does not require root. +# +# Run: +# ./build_jvm_truststore_linux.sh --use-cert /path/to/company-ca.pem --output /tmp/package-route-truststore.jks + +set -euo pipefail + +JKS_PASSWORD="changeit" +DEFAULT_CERT_ALIAS="package-route-custom-ca" + +USE_CERT="" +OUTPUT="" +CERT_ALIAS="$DEFAULT_CERT_ALIAS" +SYSTEM_BUNDLE="" +OPENSSL_BIN="${OPENSSL:-openssl}" + +usage() { + cat < --output [--cert-alias ] [--system-bundle ] + +Options: + --use-cert PEM certificate to add to the truststore. Must contain + exactly one non-expired CA certificate with CA:TRUE. + --output Destination truststore path. Replaced atomically after + successful build. + --cert-alias Alias for the custom CA (default: ${DEFAULT_CERT_ALIAS}). + --system-bundle Override the detected Linux system CA PEM bundle. + -h, --help Show this help. + +The generated truststore uses password '${JKS_PASSWORD}'. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --use-cert) + USE_CERT="${2:?Error: --use-cert requires a value}" + shift 2 + ;; + --output) + OUTPUT="${2:?Error: --output requires a value}" + shift 2 + ;; + --cert-alias) + CERT_ALIAS="${2:?Error: --cert-alias requires a value}" + shift 2 + ;; + --system-bundle) + SYSTEM_BUNDLE="${2:?Error: --system-bundle requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + [[ -n "$USE_CERT" ]] || { echo "Error: --use-cert is required." >&2; usage >&2; exit 1; } + [[ -n "$OUTPUT" ]] || { echo "Error: --output is required." >&2; usage >&2; exit 1; } + [[ "$CERT_ALIAS" =~ ^[A-Za-z0-9._-]+$ ]] || { + echo "Error: --cert-alias must match [A-Za-z0-9._-]+ (got: $CERT_ALIAS)." >&2 + exit 1 + } +} + +check_dependencies() { + command -v keytool >/dev/null 2>&1 || { echo "Error: keytool is required." >&2; exit 1; } + command -v "$OPENSSL_BIN" >/dev/null 2>&1 || { echo "Error: openssl is required." >&2; exit 1; } +} + +detect_system_bundle() { + if [[ -n "$SYSTEM_BUNDLE" ]]; then + [[ -f "$SYSTEM_BUNDLE" && -r "$SYSTEM_BUNDLE" && -s "$SYSTEM_BUNDLE" ]] || { + echo "Error: --system-bundle must point to a readable non-empty file: $SYSTEM_BUNDLE" >&2 + exit 1 + } + echo "$SYSTEM_BUNDLE" + return 0 + fi + + local candidate + for candidate in \ + /etc/ssl/certs/ca-certificates.crt \ + /etc/pki/tls/certs/ca-bundle.crt \ + /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem \ + /etc/ssl/ca-bundle.pem; do + if [[ -f "$candidate" && -r "$candidate" && -s "$candidate" ]]; then + echo "$candidate" + return 0 + fi + done + + echo "Error: could not find a Linux system CA bundle. Pass --system-bundle ." >&2 + exit 1 +} + +validate_custom_pem() { + local path="$1" count bc + + [[ -f "$path" && -r "$path" && -s "$path" ]] || { + echo "Error: --use-cert must point to a readable non-empty file: $path" >&2 + exit 1 + } + + count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null || true)" + if [[ "$count" -ne 1 ]]; then + echo "Error: --use-cert must contain exactly one PEM certificate (found $count): $path" >&2 + exit 1 + fi + "$OPENSSL_BIN" x509 -in "$path" -noout >/dev/null 2>&1 || { + echo "Error: invalid PEM certificate: $path" >&2 + exit 1 + } + "$OPENSSL_BIN" x509 -in "$path" -checkend 0 -noout >/dev/null 2>&1 || { + echo "Error: certificate has already expired: $path" >&2 + exit 1 + } + bc="$("$OPENSSL_BIN" x509 -in "$path" -noout -ext basicConstraints 2>/dev/null || true)" + if ! grep -qi 'CA:TRUE' <<<"$bc"; then + echo "Error: certificate is not a CA (basicConstraints missing CA:TRUE): $path" >&2 + exit 1 + fi +} + +split_pem_bundle() { + local bundle="$1" out_dir="$2" + awk -v dir="$out_dir" ' + /-----BEGIN CERTIFICATE-----/ { n++; file=sprintf("%s/cert-%05d.pem", dir, n) } + file != "" { print > file } + /-----END CERTIFICATE-----/ { file="" } + ' "$bundle" +} + +cert_fingerprint() { + "$OPENSSL_BIN" x509 -in "$1" -noout -fingerprint -sha256 \ + | sed 's/.*=//' | tr -d ':' | tr '[:upper:]' '[:lower:]' +} + +import_cert() { + local cert="$1" alias="$2" truststore="$3" keytool_out + + if ! keytool_out="$(keytool -importcert -noprompt -storetype JKS \ + -alias "$alias" \ + -file "$cert" \ + -keystore "$truststore" \ + -storepass "$JKS_PASSWORD" 2>&1)"; then + echo "Error: keytool failed while importing $cert as $alias. Output:" >&2 + printf '%s\n' "$keytool_out" | sed 's/^/ /' >&2 + exit 1 + fi +} + +build_truststore() { + local system_bundle="$1" tmpdir cert fp imported_count=0 tmp_store seen + + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + mkdir -p "$tmpdir/system" + seen="$tmpdir/seen-fingerprints.txt" + : > "$seen" + tmp_store="$tmpdir/truststore.jks" + + split_pem_bundle "$system_bundle" "$tmpdir/system" + for cert in "$tmpdir"/system/*.pem; do + [[ -s "$cert" ]] || continue + if ! "$OPENSSL_BIN" x509 -in "$cert" -noout >/dev/null 2>&1; then + continue + fi + fp="$(cert_fingerprint "$cert")" + if grep -qx "$fp" "$seen"; then + continue + fi + printf '%s\n' "$fp" >> "$seen" + import_cert "$cert" "system-$fp" "$tmp_store" + imported_count=$((imported_count + 1)) + done + + if [[ "$imported_count" -eq 0 ]]; then + echo "Error: no certificates could be imported from system bundle: $system_bundle" >&2 + exit 1 + fi + + import_cert "$USE_CERT" "$CERT_ALIAS" "$tmp_store" + + mkdir -p "$(dirname "$OUTPUT")" + mv "$tmp_store" "$OUTPUT" + chmod 0644 "$OUTPUT" + + echo "Built JVM truststore:" + echo " $OUTPUT" + echo "System bundle:" + echo " $system_bundle" + echo "Imported system certificates:" + echo " $imported_count" + echo "Custom CA alias:" + echo " $CERT_ALIAS" +} + +main() { + parse_args "$@" + check_dependencies + validate_custom_pem "$USE_CERT" + + local system_bundle + system_bundle="$(detect_system_bundle)" + build_truststore "$system_bundle" +} + +main "$@" diff --git a/build_jvm_truststore_macos.sh b/build_jvm_truststore_macos.sh new file mode 100755 index 0000000..66796cf --- /dev/null +++ b/build_jvm_truststore_macos.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Build a JVM truststore from macOS system keychains plus one custom CA PEM. +# +# This is a build-time helper for the JVM installers. It does not install +# anything and does not require root. +# +# Run: +# ./build_jvm_truststore_macos.sh --use-cert /path/to/company-ca.pem --output /tmp/package-route-truststore.jks + +set -euo pipefail + +JKS_PASSWORD="changeit" +DEFAULT_CERT_ALIAS="package-route-custom-ca" + +USE_CERT="" +OUTPUT="" +CERT_ALIAS="$DEFAULT_CERT_ALIAS" +OPENSSL_BIN="${OPENSSL:-openssl}" + +usage() { + cat < --output [--cert-alias ] + +Options: + --use-cert PEM certificate to add to the truststore. Must contain + exactly one non-expired CA certificate with CA:TRUE. + --output Destination truststore path. Replaced atomically after + successful build. + --cert-alias Alias for the custom CA (default: ${DEFAULT_CERT_ALIAS}). + -h, --help Show this help. + +The generated truststore uses password '${JKS_PASSWORD}'. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --use-cert) + USE_CERT="${2:?Error: --use-cert requires a value}" + shift 2 + ;; + --output) + OUTPUT="${2:?Error: --output requires a value}" + shift 2 + ;; + --cert-alias) + CERT_ALIAS="${2:?Error: --cert-alias requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + [[ -n "$USE_CERT" ]] || { echo "Error: --use-cert is required." >&2; usage >&2; exit 1; } + [[ -n "$OUTPUT" ]] || { echo "Error: --output is required." >&2; usage >&2; exit 1; } + [[ "$CERT_ALIAS" =~ ^[A-Za-z0-9._-]+$ ]] || { + echo "Error: --cert-alias must match [A-Za-z0-9._-]+ (got: $CERT_ALIAS)." >&2 + exit 1 + } +} + +check_os() { + if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Error: this script supports macOS only." >&2 + exit 1 + fi +} + +check_dependencies() { + command -v keytool >/dev/null 2>&1 || { echo "Error: keytool is required." >&2; exit 1; } + command -v "$OPENSSL_BIN" >/dev/null 2>&1 || { echo "Error: openssl is required." >&2; exit 1; } + command -v security >/dev/null 2>&1 || { echo "Error: macOS security tool is required." >&2; exit 1; } +} + +validate_custom_pem() { + local path="$1" count bc + + [[ -f "$path" && -r "$path" && -s "$path" ]] || { + echo "Error: --use-cert must point to a readable non-empty file: $path" >&2 + exit 1 + } + + count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null || true)" + if [[ "$count" -ne 1 ]]; then + echo "Error: --use-cert must contain exactly one PEM certificate (found $count): $path" >&2 + exit 1 + fi + "$OPENSSL_BIN" x509 -in "$path" -noout >/dev/null 2>&1 || { + echo "Error: invalid PEM certificate: $path" >&2 + exit 1 + } + "$OPENSSL_BIN" x509 -in "$path" -checkend 0 -noout >/dev/null 2>&1 || { + echo "Error: certificate has already expired: $path" >&2 + exit 1 + } + bc="$("$OPENSSL_BIN" x509 -in "$path" -noout -ext basicConstraints 2>/dev/null || true)" + if ! grep -qi 'CA:TRUE' <<<"$bc"; then + echo "Error: certificate is not a CA (basicConstraints missing CA:TRUE): $path" >&2 + exit 1 + fi +} + +export_system_keychains() { + local output="$1" + local keychains=( + "/System/Library/Keychains/SystemRootCertificates.keychain" + "/Library/Keychains/System.keychain" + ) + local existing=() keychain + + for keychain in "${keychains[@]}"; do + if [[ -e "$keychain" ]]; then + existing+=("$keychain") + fi + done + if [[ "${#existing[@]}" -eq 0 ]]; then + echo "Error: no macOS system keychains found." >&2 + exit 1 + fi + + security find-certificate -a -p "${existing[@]}" > "$output" + if [[ ! -s "$output" ]]; then + echo "Error: macOS system keychain export produced no certificates." >&2 + exit 1 + fi +} + +split_pem_bundle() { + local bundle="$1" out_dir="$2" + awk -v dir="$out_dir" ' + /-----BEGIN CERTIFICATE-----/ { n++; file=sprintf("%s/cert-%05d.pem", dir, n) } + file != "" { print > file } + /-----END CERTIFICATE-----/ { file="" } + ' "$bundle" +} + +cert_fingerprint() { + "$OPENSSL_BIN" x509 -in "$1" -noout -fingerprint -sha256 \ + | sed 's/.*=//' | tr -d ':' | tr '[:upper:]' '[:lower:]' +} + +import_cert() { + local cert="$1" alias="$2" truststore="$3" keytool_out + + if ! keytool_out="$(keytool -importcert -noprompt -storetype JKS \ + -alias "$alias" \ + -file "$cert" \ + -keystore "$truststore" \ + -storepass "$JKS_PASSWORD" 2>&1)"; then + echo "Error: keytool failed while importing $cert as $alias. Output:" >&2 + printf '%s\n' "$keytool_out" | sed 's/^/ /' >&2 + exit 1 + fi +} + +build_truststore() { + local tmpdir cert fp imported_count=0 tmp_store seen system_bundle + + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + mkdir -p "$tmpdir/system" + seen="$tmpdir/seen-fingerprints.txt" + : > "$seen" + system_bundle="$tmpdir/macos-system-certs.pem" + tmp_store="$tmpdir/truststore.jks" + + export_system_keychains "$system_bundle" + split_pem_bundle "$system_bundle" "$tmpdir/system" + + for cert in "$tmpdir"/system/*.pem; do + [[ -s "$cert" ]] || continue + if ! "$OPENSSL_BIN" x509 -in "$cert" -noout >/dev/null 2>&1; then + continue + fi + fp="$(cert_fingerprint "$cert")" + if grep -qx "$fp" "$seen"; then + continue + fi + printf '%s\n' "$fp" >> "$seen" + import_cert "$cert" "system-$fp" "$tmp_store" + imported_count=$((imported_count + 1)) + done + + if [[ "$imported_count" -eq 0 ]]; then + echo "Error: no certificates could be imported from macOS system keychains." >&2 + exit 1 + fi + + import_cert "$USE_CERT" "$CERT_ALIAS" "$tmp_store" + + mkdir -p "$(dirname "$OUTPUT")" + mv "$tmp_store" "$OUTPUT" + chmod 0644 "$OUTPUT" + + echo "Built JVM truststore:" + echo " $OUTPUT" + echo "Imported macOS system certificates:" + echo " $imported_count" + echo "Custom CA alias:" + echo " $CERT_ALIAS" +} + +main() { + parse_args "$@" + check_os + check_dependencies + validate_custom_pem "$USE_CERT" + build_truststore +} + +main "$@" diff --git a/build_jvm_truststore_windows.ps1 b/build_jvm_truststore_windows.ps1 new file mode 100644 index 0000000..84c8802 --- /dev/null +++ b/build_jvm_truststore_windows.ps1 @@ -0,0 +1,206 @@ +# (c) JFrog Ltd. (2026) +# Build a JVM truststore from Windows LocalMachine root certificates plus one +# custom CA PEM. +# +# This is a build-time helper for the JVM installers. It does not install +# anything and does not require Administrator rights for normal LocalMachine +# Root read access. +# +# Run: +# powershell -ExecutionPolicy Bypass -File .\build_jvm_truststore_windows.ps1 -UseCert C:\path\company-ca.pem -Output C:\Temp\package-route-truststore.jks + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$UseCert, + + [Parameter(Mandatory = $true)] + [string]$Output, + + [Parameter(Mandatory = $false)] + [string]$CertAlias = 'package-route-custom-ca' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$JksPassword = 'changeit' + +function Fail { + param([string]$Message) + Write-Error $Message + exit 1 +} + +function Get-Keytool { + if ($env:JAVA_HOME) { + $candidate = Join-Path $env:JAVA_HOME 'bin\keytool.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + } + + $cmd = Get-Command keytool.exe -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + + Fail 'keytool.exe is required. Install a JDK or set JAVA_HOME.' +} + +function Read-PemCertificate { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Fail "Certificate file not found: $Path" + } + + $raw = Get-Content -LiteralPath $Path -Raw + $matches = [regex]::Matches( + $raw, + '-----BEGIN CERTIFICATE-----\s*(?[A-Za-z0-9+/=\r\n]+?)\s*-----END CERTIFICATE-----' + ) + if ($matches.Count -ne 1) { + Fail "UseCert must contain exactly one PEM certificate (found $($matches.Count)): $Path" + } + + try { + $base64 = ($matches[0].Groups['body'].Value -replace '\s', '') + $bytes = [Convert]::FromBase64String($base64) + return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes) + } catch { + Fail "Invalid PEM certificate: $Path ($($_.Exception.Message))" + } +} + +function Test-CustomCaCertificate { + param( + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert, + [string]$Path + ) + + if ($Cert.NotAfter -le (Get-Date)) { + Fail "Certificate has already expired: $Path" + } + + $basic = $Cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.19' } | Select-Object -First 1 + if (-not $basic) { + Fail "Certificate is not a CA (basicConstraints missing CA:TRUE): $Path" + } + + $constraints = [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new( + $basic, + $basic.Critical + ) + if (-not $constraints.CertificateAuthority) { + Fail "Certificate is not a CA (basicConstraints missing CA:TRUE): $Path" + } +} + +function Invoke-Keytool { + param( + [string]$Keytool, + [string[]]$KeytoolArgs + ) + + $prevEap = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $rc = 0 + try { + $out = & $Keytool @KeytoolArgs 2>&1 + $rc = $LASTEXITCODE + } finally { + $ErrorActionPreference = $prevEap + } + + if ($rc -ne 0) { + $head = ($out | Select-Object -First 5) -join '; ' + Fail "keytool exited $rc. Output: $head" + } +} + +function Import-CertToTruststore { + param( + [string]$Keytool, + [string]$CertPath, + [string]$Alias, + [string]$TruststorePath + ) + + Invoke-Keytool -Keytool $Keytool -KeytoolArgs @( + '-importcert', '-noprompt', + '-storetype', 'JKS', + '-alias', $Alias, + '-file', $CertPath, + '-keystore', $TruststorePath, + '-storepass', $JksPassword + ) +} + +function Get-SystemRootCertificates { + $store = [System.Security.Cryptography.X509Certificates.X509Store]::new( + [System.Security.Cryptography.X509Certificates.StoreName]::Root, + [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine + ) + + try { + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + return @($store.Certificates | Where-Object { $_.NotAfter -gt (Get-Date) }) + } finally { + $store.Close() + } +} + +if ($CertAlias -notmatch '^[A-Za-z0-9._-]+$') { + Fail "CertAlias must match [A-Za-z0-9._-]+ (got: $CertAlias)." +} + +$customCert = Read-PemCertificate -Path $UseCert +Test-CustomCaCertificate -Cert $customCert -Path $UseCert + +$keytool = Get-Keytool +$outputParent = Split-Path -Parent $Output +if ($outputParent) { + New-Item -ItemType Directory -Path $outputParent -Force | Out-Null +} + +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("package-route-jvm-build-{0}" -f ([Guid]::NewGuid().ToString('N'))) +New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + +try { + $tempStore = Join-Path $tempDir 'truststore.jks' + $seen = New-Object 'System.Collections.Generic.HashSet[string]' + $imported = 0 + + foreach ($cert in (Get-SystemRootCertificates)) { + $thumb = $cert.Thumbprint.ToLowerInvariant() + if (-not $seen.Add($thumb)) { + continue + } + + $certPath = Join-Path $tempDir ("system-{0}.cer" -f $thumb) + [System.IO.File]::WriteAllBytes( + $certPath, + $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) + ) + Import-CertToTruststore -Keytool $keytool -CertPath $certPath -Alias "system-$thumb" -TruststorePath $tempStore + $imported++ + } + + if ($imported -eq 0) { + Fail 'No certificates could be imported from LocalMachine\Root.' + } + + Import-CertToTruststore -Keytool $keytool -CertPath $UseCert -Alias $CertAlias -TruststorePath $tempStore + Move-Item -LiteralPath $tempStore -Destination $Output -Force + + Write-Host 'Built JVM truststore:' + Write-Host " $Output" + Write-Host 'Imported Windows LocalMachine root certificates:' + Write-Host " $imported" + Write-Host 'Custom CA alias:' + Write-Host " $CertAlias" + Write-Host "Truststore password: $JksPassword" +} finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/testing/test_install_certs_jvm_linux.sh b/testing/test_install_certs_jvm_linux.sh index e57ebcc..5d6d63b 100755 --- a/testing/test_install_certs_jvm_linux.sh +++ b/testing/test_install_certs_jvm_linux.sh @@ -3,7 +3,8 @@ # Docker-driven smoke test matrix for Linux JVM trust installers. # # Runs: -# - generic Linux bundled-JKS flow on Debian/Ubuntu/Amazon Linux +# - generic Linux bundled-JKS flow on Debian/Ubuntu/Amazon Linux, using +# build_jvm_truststore_linux.sh to create the installer input # - RHEL update-ca-trust PEM flow on UBI/RHEL set -euo pipefail @@ -31,34 +32,10 @@ openssl req -x509 -newkey rsa:2048 -nodes \ useradd -m -s /bin/bash devx >/dev/null 2>&1 || true -find_jdk_cacerts() { - if [[ -n "${JAVA_HOME:-}" && -f "${JAVA_HOME}/lib/security/cacerts" ]]; then - echo "${JAVA_HOME}/lib/security/cacerts" - return 0 - fi - local keytool_path keytool_dir - keytool_path="$(command -v keytool 2>/dev/null || true)" - if [[ -n "$keytool_path" ]]; then - keytool_dir="$(dirname "$(readlink -f "$keytool_path" 2>/dev/null || echo "$keytool_path")")" - if [[ -f "${keytool_dir}/../lib/security/cacerts" ]]; then - echo "${keytool_dir}/../lib/security/cacerts" - return 0 - fi - fi - fail "cannot locate JDK cacerts for bundled truststore fixture" -} - build_bundle_truststore() { - local src_cacerts - src_cacerts="$(find_jdk_cacerts)" - cp "$src_cacerts" /tmp/bundled-truststore.jks - chmod 0644 /tmp/bundled-truststore.jks - keytool -importcert -noprompt \ - -alias package-route-custom-ca \ - -file /tmp/ca.pem \ - -keystore /tmp/bundled-truststore.jks \ - -storepass changeit >/dev/null - echo "bundle base: $src_cacerts" + ./build_jvm_truststore_linux.sh \ + --use-cert /tmp/ca.pem \ + --output /tmp/bundled-truststore.jks >/dev/null } run_generic() { @@ -108,7 +85,7 @@ run_generic() { echo "=== generic: JKS preserves bundled public roots ===" alias_count="$(keytool -list -keystore /etc/ssl/package-route-jvm/truststore.jks -storepass changeit 2>/dev/null | grep -c 'trustedCertEntry' || true)" [[ "${alias_count:-0}" -ge 100 ]] || fail "truststore has $alias_count aliases; expected >= 100" - keytool -list -keystore /etc/ssl/package-route-jvm/truststore.jks -storepass changeit 2>/dev/null \ + keytool -list -v -keystore /etc/ssl/package-route-jvm/truststore.jks -storepass changeit 2>/dev/null \ | grep -qi 'digicert' \ || fail "truststore is missing the DigiCert family of public roots" echo " ok ($alias_count aliases)" @@ -184,7 +161,7 @@ chmod +x "$PROBE" MATRIX=( "ubuntu|generic|ubuntu:22.04|export DEBIAN_FRONTEND=noninteractive; apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl ca-certificates default-jdk-headless >/dev/null" "debian|generic|debian:12|export DEBIAN_FRONTEND=noninteractive; apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl ca-certificates default-jdk-headless >/dev/null" - "amazonlinux|generic|amazonlinux:2023|dnf install -y -q java-21-amazon-corretto-headless openssl shadow-utils >/dev/null" + "amazonlinux|generic|amazonlinux:2023|dnf install -y -q java-21-amazon-corretto-headless openssl shadow-utils ca-certificates >/dev/null" "rhel|rhel|redhat/ubi9:latest|dnf install -y -q java-21-openjdk-headless openssl shadow-utils >/dev/null" ) diff --git a/testing/test_install_certs_jvm_macos.sh b/testing/test_install_certs_jvm_macos.sh index dadb696..87509d3 100755 --- a/testing/test_install_certs_jvm_macos.sh +++ b/testing/test_install_certs_jvm_macos.sh @@ -7,8 +7,8 @@ # # Targets the SUDO_USER's per-user files. `cleanup` runs at the start of # fresh-state cases and via `trap EXIT`. The test runner builds a bundled -# truststore fixture from the local JDK cacerts plus a lab CA, then verifies the -# installer only copies that ready-made JKS into place and configures launchd. +# truststore fixture from macOS system certificates plus a lab CA, then verifies +# the installer only copies that ready-made JKS into place and configures launchd. # # Invariants exercised: # 1. Positive install + validate (subject substring match) @@ -92,58 +92,13 @@ require_keytool() { command -v keytool >/dev/null 2>&1 || fail_msg "keytool not on PATH (validator/test fixture requires a JDK)" } -find_jdk_cacerts() { - if [[ -n "${JAVA_HOME:-}" && -f "${JAVA_HOME}/lib/security/cacerts" ]]; then - echo "${JAVA_HOME}/lib/security/cacerts" - return 0 - fi - - if [[ -x /usr/libexec/java_home ]]; then - local java_home_out - java_home_out="$(/usr/libexec/java_home 2>/dev/null || true)" - if [[ -n "$java_home_out" && -f "${java_home_out}/lib/security/cacerts" ]]; then - echo "${java_home_out}/lib/security/cacerts" - return 0 - fi - fi - - local keytool_path resolved link - keytool_path="$(command -v keytool 2>/dev/null || true)" - if [[ -n "$keytool_path" ]]; then - resolved="$keytool_path" - local depth=0 - while [[ -L "$resolved" && $depth -lt 16 ]]; do - link="$(readlink "$resolved")" - if [[ "$link" = /* ]]; then - resolved="$link" - else - resolved="$(dirname "$resolved")/$link" - fi - depth=$((depth + 1)) - done - local keytool_dir - keytool_dir="$(cd "$(dirname "$resolved")" 2>/dev/null && pwd -P)" - if [[ -n "$keytool_dir" && -f "${keytool_dir}/../lib/security/cacerts" ]]; then - echo "${keytool_dir}/../lib/security/cacerts" - return 0 - fi - fi - - fail_msg "cannot locate JDK cacerts for bundled truststore fixture" -} - build_bundle_truststore() { - local ca_path="$1" src_cacerts - src_cacerts="$(find_jdk_cacerts)" + local ca_path="$1" rm -f "$BUNDLE_JKS" - cp "$src_cacerts" "$BUNDLE_JKS" - chmod 0644 "$BUNDLE_JKS" - keytool -importcert -noprompt \ - -alias package-route-custom-ca \ - -file "$ca_path" \ - -keystore "$BUNDLE_JKS" \ - -storepass changeit >/dev/null - echo "Bundled truststore fixture: $BUNDLE_JKS (base: $src_cacerts)" + OPENSSL="$OPENSSL" ./build_jvm_truststore_macos.sh \ + --use-cert "$ca_path" \ + --output "$BUNDLE_JKS" >/dev/null + echo "Bundled truststore fixture: $BUNDLE_JKS" } # Generate the lab CA used by all positive cases. @@ -230,7 +185,7 @@ corp_count=$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ | grep -cE "^package-route-custom-ca[,[:space:]]" || true) corp_count=${corp_count:-0} [[ "$corp_count" -eq 1 ]] || fail_msg "expected exactly 1 corporate-CA alias after 2 installs, got $corp_count" -[[ "$alias_count" -ge 100 ]] || fail_msg "expected JKS to extend default cacerts (>=100 aliases), got $alias_count" +[[ "$alias_count" -ge 100 ]] || fail_msg "expected JKS to include macOS system roots (>=100 aliases), got $alias_count" [[ -f "$PLIST" ]] || fail_msg "plist missing after 2nd install" echo " ok (alias_count=$alias_count, sha=$installed_sha)" @@ -342,17 +297,17 @@ echo "=== 10. JKS extends bundled public roots ===" alias_count="$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null | grep -c 'trustedCertEntry' || true)" alias_count="${alias_count:-0}" [[ "$alias_count" -ge 100 ]] \ - || fail_msg "JKS has $alias_count aliases; expected >= 100 (JDK cacerts ~150 public roots + corporate CA)" + || fail_msg "JKS has $alias_count aliases; expected >= 100 (macOS system roots + corporate CA)" echo " ok ($alias_count aliases)" echo echo "=== 11. JKS contains a well-known public root (DigiCert family) ===" # Spot-check the merge actually happened. DigiCert root certs ship in every -# JDK's cacerts under several aliases (digicertglobalrootca, digicertglobalrootg2, -# digicerttrustedrootg4, etc.) — case-insensitive substring match catches them all. -keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ +# macOS system trust bundle under several names; case-insensitive substring +# match catches the family. +keytool -list -v -keystore "$JKS" -storepass changeit 2>/dev/null \ | grep -qi 'digicert' \ - || fail_msg "JKS missing the DigiCert family of public roots; the copy-from-JDK step did not run" + || fail_msg "JKS missing the DigiCert family of public roots; the system-root bundle is incomplete" echo " ok" echo diff --git a/testing/test_install_certs_jvm_windows.ps1 b/testing/test_install_certs_jvm_windows.ps1 index 7869405..675f19e 100644 --- a/testing/test_install_certs_jvm_windows.ps1 +++ b/testing/test_install_certs_jvm_windows.ps1 @@ -5,9 +5,9 @@ # powershell -ExecutionPolicy Bypass -File testing/test_install_certs_jvm_windows.ps1 # # No Administrator required -- User-scope env vars and %LOCALAPPDATA% paths -# are per-user. The runner builds a bundled truststore fixture from the local -# JDK cacerts plus a lab CA, then verifies the installer only copies that -# ready-made JKS into place and configures HKCU\Environment. +# are per-user. The runner builds a bundled truststore fixture from Windows +# LocalMachine root certificates plus a lab CA, then verifies the installer only +# copies that ready-made JKS into place and configures HKCU\Environment. # # Invariants exercised: # 1. Positive install + validate (subject substring match) @@ -157,21 +157,6 @@ function Get-Keytool { } $Keytool = Get-Keytool -function Resolve-JdkCacerts { - if ($env:JAVA_HOME) { - $candidate = Join-Path $env:JAVA_HOME 'lib\security\cacerts' - if (Test-Path -LiteralPath $candidate -PathType Leaf) { - return $candidate - } - } - $keytoolDir = Split-Path -Parent $Keytool - $candidate = Join-Path $keytoolDir '..\lib\security\cacerts' - if (Test-Path -LiteralPath $candidate -PathType Leaf) { - return (Resolve-Path -LiteralPath $candidate).Path - } - Fail-Test 'cannot locate JDK cacerts for bundled truststore fixture' -} - function Invoke-Keytool { param([string[]]$KeytoolArgs) $prevEAP = $ErrorActionPreference @@ -191,17 +176,19 @@ function Invoke-Keytool { function Build-BundledTruststore { param([string]$CaPath) - $srcCacerts = Resolve-JdkCacerts Remove-Item -LiteralPath $BundleJks -ErrorAction SilentlyContinue - Copy-Item -LiteralPath $srcCacerts -Destination $BundleJks -Force - Invoke-Keytool -KeytoolArgs @( - '-importcert', '-noprompt', - '-alias', 'package-route-custom-ca', - '-file', $CaPath, - '-keystore', $BundleJks, - '-storepass', 'changeit' - ) | Out-Null - Write-Host ("Bundled truststore fixture: {0} (base: {1})" -f $BundleJks, $srcCacerts) + $raw = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File '.\build_jvm_truststore_windows.ps1' ` + -UseCert $CaPath ` + -Output $BundleJks 2>&1 + $rc = $LASTEXITCODE + if ($rc -ne 0) { + Write-Host "--- captured output ---" + Write-Host ($raw | Out-String) + Write-Host "--- end captured output ---" + Fail-Test "build_jvm_truststore_windows.ps1 exited $rc" + } + Write-Host ("Bundled truststore fixture: {0}" -f $BundleJks) } Build-BundledTruststore -CaPath $labCa @@ -307,7 +294,7 @@ Write-Host (" ok ({0} aliases)" -f $aliasCount) Write-Host "" Write-Host "=== 10. JKS contains a well-known public root (DigiCert family) ===" -$listOut10 = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') +$listOut10 = Invoke-Keytool -KeytoolArgs @('-list', '-v', '-keystore', $JksPath, '-storepass', 'changeit') if (-not ($listOut10 | Select-String -Pattern 'digicert' -SimpleMatch -Quiet)) { Fail-Test "JKS missing the DigiCert family of public roots; the bundled truststore fixture is incomplete" }