From adb51bb80482a07e5937b4fc28d6b84202e6c4ca Mon Sep 17 00:00:00 2001 From: Michael Akushsky Date: Tue, 9 Jun 2026 16:59:47 +0300 Subject: [PATCH] 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 6bdaa67..8d650d0 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), 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): @@ -654,6 +657,88 @@ Users must start a **new terminal** for env changes to take effect. --- +## 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: @@ -663,6 +748,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