diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2b74cb8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Normalize line endings: LF in repository +* text=auto eol=lf + +# Shell scripts: LF always +*.sh text eol=lf + +# PowerShell scripts: CRLF on checkout +*.ps1 text eol=crlf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1023d36 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,63 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "0 2 1 * *" + +jobs: + # ── Linux / Bash ───────────────────────────────────────────────────────────── + test-bash: + name: BATS (Linux - ${{ matrix.distro }}) + runs-on: ubuntu-latest + if: ${{ github.event_name != 'schedule' || github.ref == 'refs/heads/main' }} + strategy: + fail-fast: false + matrix: + include: + - distro: ubuntu + dockerfile: tests/Dockerfile.ubuntu + tag: install-ca-bats-ubuntu:ci + - distro: debian + dockerfile: tests/Dockerfile.debian + tag: install-ca-bats-debian:ci + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build test image + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ matrix.dockerfile }} + load: true + tags: ${{ matrix.tag }} + cache-from: type=gha,scope=bats-${{ matrix.distro }} + cache-to: type=gha,scope=bats-${{ matrix.distro }},mode=max + + - name: Run BATS tests + run: docker run --rm ${{ matrix.tag }} + + # ── Windows / Native ──────────────────────────────────────────────────────── + test-windows: + name: Pester (Windows) + runs-on: windows-latest + if: ${{ github.event_name != 'schedule' || github.ref == 'refs/heads/main' }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Pester + shell: powershell + run: Install-Module -Name Pester -MinimumVersion 5.0 -Force -Scope CurrentUser + + - name: Run Windows tests + shell: powershell + run: Invoke-Pester tests/windows.ps1 -Output Detailed -CI diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..9c7fa44 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,74 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Test: Linux All", + "type": "node-terminal", + "request": "launch", + "command": "bash tests/run-tests.sh", + "cwd": "${workspaceFolder}", + "presentation": { + "group": "linux", + "order": 1 + } + }, + { + "name": "Test: Linux Ubuntu", + "type": "node-terminal", + "request": "launch", + "command": "bash tests/run-tests.sh linux-ubuntu", + "cwd": "${workspaceFolder}", + "presentation": { + "group": "linux", + "order": 2 + } + }, + { + "name": "Test: Linux Debian", + "type": "node-terminal", + "request": "launch", + "command": "bash tests/run-tests.sh linux-debian", + "cwd": "${workspaceFolder}", + "presentation": { + "group": "linux", + "order": 3 + } + }, + { + "name": "Debug: install-ca.sh", + "type": "node-terminal", + "request": "launch", + "command": "bash install-ca.sh", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "presentation": { + "group": "linux", + "order": 4 + } + }, + { + "name": "Test: Windows", + "type": "node-terminal", + "request": "launch", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"Invoke-Pester '${workspaceFolder}/tests/windows.ps1' -Output Detailed\"", + "cwd": "${workspaceFolder}", + "presentation": { + "group": "windows", + "order": 1 + } + }, + { + "name": "Debug: install-ca.ps1", + "type": "PowerShell", + "request": "launch", + "script": "${workspaceFolder}/install-ca.ps1", + "args": [], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "presentation": { + "group": "---", + "order": 2 + } + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..9620b3f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,74 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Test: Linux Ubuntu", + "type": "shell", + "command": "bash tests/run-tests.sh linux-ubuntu", + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": false, + "clear": true + }, + "problemMatcher": [] + }, + { + "label": "Test: Linux Debian", + "type": "shell", + "command": "bash tests/run-tests.sh linux-debian", + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": false, + "clear": true + }, + "problemMatcher": [] + }, + { + "label": "Test: Linux All", + "type": "shell", + "command": "bash tests/run-tests.sh", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": false, + "clear": true + }, + "problemMatcher": [] + }, + { + "label": "Test: Windows", + "type": "shell", + "command": "Invoke-Pester tests/windows.ps1 -Output Detailed", + "options": { + "shell": { + "executable": "powershell", + "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"] + } + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": false, + "clear": true + }, + "problemMatcher": [] + } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3b83811 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ilmars Kluss + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..979ed2e --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# install-ca + +[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows-blue)](https://github.com/IlmLV/install-ca) +[![Tests](https://github.com/IlmLV/install-ca/actions/workflows/test.yml/badge.svg)](https://github.com/IlmLV/install-ca/actions/workflows/test.yml) +[![Bash](https://img.shields.io/badge/bash-4.0%2B-4EAA25?logo=gnubash&logoColor=white)](install-ca.sh) +[![PowerShell](https://img.shields.io/badge/powershell-5.1%2B-5391FE?logo=powershell&logoColor=white)](install-ca.ps1) +[![License](https://img.shields.io/github/license/IlmLV/install-ca)](LICENSE) +[![Stars](https://img.shields.io/github/stars/IlmLV/install-ca?style=flat)](https://github.com/IlmLV/install-ca/stargazers) + +Modern systems maintain multiple independent certificate trust stores — one for the OS, and separate ones for each browser. **install-ca** handles all of them in a single run on Linux (Debian/Ubuntu) and Windows, so a custom CA is trusted everywhere without manual per-store setup. + +--- + +## Features + +- Accepts a CA certificate as a **URL** or **local file path** — or prompts interactively +- Derives the CA name and filename automatically from the certificate subject +- Compares the certificate against any existing installation before making changes — reports fingerprint, expiry, and whether an update is needed +- Exits early without changes if the certificate is already up-to-date (override with `-f`) +- Prompts for confirmation before each store is modified (suppress with `-y`) +- Verifies the installation at the end + +--- + +## Platform support + +| Platform | Script | Requirements | +| -------- | ------ | ------------ | +| ![Linux](https://img.shields.io/badge/Debian%20%7C%20Ubuntu-FCC624?logo=linux&logoColor=black) | `install-ca.sh` | `bash`, `curl`, `openssl`, `sudo`, `libnss3-tools` (auto-installed if missing) | +| ![Windows](https://img.shields.io/badge/Windows_10%2B-0078D4?logo=windows&logoColor=white) | `install-ca.ps1` | Windows 10+, PowerShell 5.1+, Administrator privileges | + +--- + +## Browser coverage + +| Browser | 🐧 Linux | 🪟 Windows | +| -------------- | ------------------------------------------------------------------ | ----------------------------------------- | +| Google Chrome | Shared NSS `~/.pki/nssdb` | Windows Certificate Store¹ | +| Chromium | Shared NSS `~/.pki/nssdb` (deb) · snap NSS `~/snap/chromium/` | Windows Certificate Store¹ | +| Microsoft Edge | Shared NSS `~/.pki/nssdb` | Windows Certificate Store¹ | +| Brave | Shared NSS `~/.pki/nssdb` (deb) · snap NSS `~/snap/brave/` (snap) | Windows Certificate Store¹ | +| Firefox | Per-profile `cert9.db` (`~/.mozilla/firefox/` · `~/snap/firefox/`) | Per-profile `cert9.db` via `certutil.exe` | + +> ¹ `LocalMachine\Root` — one write covers all Chromium-based browsers on Windows. +> +> The shared NSS database `~/.pki/nssdb` is created automatically on Linux if it does not exist. + +--- + +## Usage + +| Flag | Long form | Description | +| ------------------ | --------------------- | ------------------------------------------------------- | +| `-u ` | `--url ` | Certificate URL or local file path | +| `-y` | `--yes` | Auto-approve all prompts | +| `-f` | `--force` | Reinstall even if the certificate is already up-to-date | + +### 🐧 Linux + +**Interactive** — prompts for the certificate URL or file path: +```bash +curl -fsSL https://raw.githubusercontent.com/IlmLV/install-ca/main/install-ca.sh | bash +``` + +**Non-interactive** — certificate URL and auto-approve provided upfront: +```bash +curl -fsSL https://raw.githubusercontent.com/IlmLV/install-ca/main/install-ca.sh | bash -s -- https://example.com/ca.crt -y +``` + +> `sudo` is required for writing to `/usr/local/share/ca-certificates/`. The script will prompt for your password at that step. + +### 🪟 Windows + +**Interactive** — prompts for the certificate URL or file path: +```powershell +irm https://raw.githubusercontent.com/IlmLV/install-ca/main/install-ca.ps1 | iex +``` + +**Non-interactive** — certificate URL and auto-approve provided upfront: +```powershell +irm https://raw.githubusercontent.com/IlmLV/install-ca/main/install-ca.ps1 | iex; Install 'https://example.com/ca.crt' -y +``` + +> Requires Windows 10+, PowerShell 5.1+, and Administrator privileges. + +--- + +## How it works + +### Certificate comparison + +Before modifying any trust store, the script checks whether the certificate is already installed: + +1. Fetches or copies the certificate from the provided source +2. Validates it is a well-formed certificate (PEM on Linux; PEM or DER where supported by the platform implementation) +3. Looks up any existing certificate with the same subject in the system trust store +4. Compares SHA-256 fingerprints and expiry dates, and reports one of: + - **Already up-to-date** — exits without changes + - **Remote is newer** — recommends update, proceeds to install + - **Installed is newer** — warns that the remote cert expires sooner than what is installed + - **Fresh install** — no existing certificate found + +### Trust store locations + +#### 🐧 Linux system store + +The certificate is copied to `/usr/local/share/ca-certificates/.crt` and registered with `update-ca-certificates`. The filename is derived automatically from the certificate's Common Name (CN). + +#### 🐧 Linux NSS databases + +NSS (`cert9.db`) databases are located by scanning known directories for each browser. Each discovered database is listed before the user is asked to confirm. The CA is added using `certutil` from the `libnss3-tools` package. + +#### 🪟 Windows Certificate Store + +The certificate is added to `LocalMachine\Root` using the .NET `X509Store` API. This single store is read by all Chromium-based browsers on Windows. + +#### 🦊 Firefox (both platforms) + +Firefox maintains its own NSS databases independent of the OS store. All profiles under the standard Firefox profile directory are discovered and listed. On Linux, `certutil` from `libnss3-tools` is used. On Windows, `certutil.exe` bundled with the Firefox installation is used. + +--- + +## Files + +``` +install-ca/ +├── install-ca.sh # Bash script for Linux +├── install-ca.ps1 # PowerShell script for Windows +└── tests/ + ├── run-tests.sh # Runs Docker-containerized test suites + ├── linux.bats # Bats test suite for install-ca.sh + ├── windows.ps1 # Pester test suite for install-ca.ps1 + ├── Dockerfile.debian # Debian test container image + ├── Dockerfile.ubuntu # Ubuntu test container image + ├── docker-linux-setup.sh # Installs browsers and tooling inside the test container + ├── entrypoint.linux.sh # Container entry point — runs cert generation and Bats suite + ├── generate-certs.sh # Generates test certificates at runtime (Bash) + └── generate-certs.ps1 # Generates test certificates at runtime (PowerShell) +``` + +--- + +## Running tests + +### 🐧 Linux + +Requires Docker. + +```bash +# Run all suites (Debian + Ubuntu) +bash tests/run-tests.sh + +# Run a specific suite +bash tests/run-tests.sh linux-ubuntu +bash tests/run-tests.sh linux-debian +``` + +### 🪟 Windows + +Requires [Pester](https://pester.dev) and PowerShell 5.1+. + +```powershell +Invoke-Pester tests/windows.ps1 +``` + +--- + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/install-ca.ps1 b/install-ca.ps1 new file mode 100755 index 0000000..4683556 --- /dev/null +++ b/install-ca.ps1 @@ -0,0 +1,542 @@ +# Install a CA certificate into system and browser trust stores +# +# Browsers handled: +# - System trust store (Windows Certificate Store - LocalMachine\Root) +# - Google Chrome uses Windows Certificate Store +# - Microsoft Edge uses Windows Certificate Store +# - Brave uses Windows Certificate Store +# - Chromium uses Windows Certificate Store +# - Firefox cert9.db via certutil.exe, or ImportEnterpriseRoots registry policy +# +# Usage (file): powershell -File install-ca.ps1 [-Url|-u ] [-Force|-f] [-Yes|-y] +# Usage (iex): irm https://raw.githubusercontent.com/IlmLV/install-ca/main/install-ca.ps1 | iex +# Usage (iex non-interactive): irm https://raw.githubusercontent.com/IlmLV/install-ca/main/install-ca.ps1 | iex; Install '' -Yes +# Usage (iex define-only): . { irm https://raw.githubusercontent.com/IlmLV/install-ca/main/install-ca.ps1 | iex }; Install '' -Force + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Install { +param( + [Alias('u')][string]$Url = "", + [Alias('f')][switch]$Force, + [Alias('y')][switch]$Yes +) + +$global:__Install_InstallCalled = $true + + +if ($PSVersionTable.PSVersion -lt [version]"5.1") { + Write-Error "PowerShell 5.1+ is required." -ErrorAction Continue + return 1 +} + +# PowerShell 5.x compatibility - $IsWindows is not defined in Windows PowerShell 5.x +if (-not (Get-Variable 'IsWindows' -Scope Global -ErrorAction SilentlyContinue)) { + $IsWindows = $true # Windows PowerShell 5.x runs only on Windows +} + +# Ensure TLS 1.2 is available (PowerShell 5.x / .NET Framework defaults to TLS 1.0) +# Save and restore so irm | iex usage doesn't leave the caller's session mutated. +$originalSecurityProtocol = $null +if ($PSVersionTable.PSVersion.Major -lt 6) { + $originalSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol + [Net.ServicePointManager]::SecurityProtocol = $originalSecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +} + +# -- Elevation check ----------------------------------------------------------- +if ($IsWindows) { + $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object System.Security.Principal.WindowsPrincipal($id) + if (-not $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Host "ERROR: This script must be run as Administrator." -ForegroundColor Red + Write-Host " Right-click PowerShell and select 'Run as Administrator', then try again." -ForegroundColor Red + return 1 + } +} + +$tempDir = [IO.Path]::GetTempPath() +$caFileName = "ca_{0}.crt" -f ([guid]::NewGuid().ToString("N")) +$CA_FILE = Join-Path $tempDir $caFileName + +# -- Helpers --------------------------------------------------------------- + +function Confirm-Action([string]$Prompt) { + if ($Yes) { + Write-Host "$Prompt [y/N] y" + return $true + } + try { + $reply = Read-Host "$Prompt [y/N]" + } catch { + # Non-interactive or input unavailable - treat as a declined confirmation. + return $false + } + return $reply -match '^[Yy]$' +} + +function Invoke-CompatWebRequest([string]$Uri, [string]$OutFile, [switch]$SkipCertificateCheck) { + if ($PSVersionTable.PSVersion.Major -ge 6) { + if ($SkipCertificateCheck) { + Invoke-WebRequest -Uri $Uri -OutFile $OutFile -SkipCertificateCheck -TimeoutSec 30 + } else { + Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec 30 + } + } else { + Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing -TimeoutSec 30 + } +} + +# Download without validating server TLS (the CA is not yet trusted) +function Invoke-InsecureDownload([string]$Uri, [string]$OutFile) { + if ($PSVersionTable.PSVersion.Major -ge 6) { + Invoke-CompatWebRequest -Uri $Uri -OutFile $OutFile -SkipCertificateCheck + } else { + # PowerShell 5.x: bypass certificate validation via ServicePointManager + $origCallback = [System.Net.ServicePointManager]::ServerCertificateValidationCallback + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } + try { + Invoke-CompatWebRequest -Uri $Uri -OutFile $OutFile + } finally { + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $origCallback + } + } +} + +# Add CA to a single NSS sql: database directory using Firefox's certutil.exe +function Add-ToNssDb([string]$CertUtil, [string]$DbDir, [string]$CaName, [string]$CaFile) { + & $CertUtil -d "sql:$DbDir" -D -n $CaName 2>$null + & $CertUtil -d "sql:$DbDir" -A -n $CaName -t "CT,," -i $CaFile + if ($LASTEXITCODE -ne 0) { throw "certutil failed for $DbDir" } +} + +# -- 1. Resolve CA source ------------------------------------------------------ +$cert = $null +try { +if (-not [string]::IsNullOrWhiteSpace($Url)) { + $CA_SOURCE = $Url +} else { + try { + $CA_SOURCE = Read-Host "Enter CA certificate URL or file path" + } catch { + # In non-interactive sessions, Read-Host can throw a terminating error. + # Treat this as if no input was provided so we can emit a friendly message. + $CA_SOURCE = "" + } +} + +if ([string]::IsNullOrWhiteSpace($CA_SOURCE)) { + Write-Error "No CA source provided." -ErrorAction Continue + return 1 +} + +# -- 2. Fetch or copy the CA certificate --------------------------------------- + +Write-Host "" +if ($CA_SOURCE -match '^https?://') { + Write-Host "==> Fetching CA certificate from $CA_SOURCE ..." + $downloadOk = $false + try { + Invoke-CompatWebRequest -Uri $CA_SOURCE -OutFile $CA_FILE + $downloadOk = $true + } catch { + Write-Host " WARNING: Secure download failed. The server's TLS certificate may be invalid or self-signed." + Write-Host " Detail : $($_.Exception.Message)" + } + if (-not $downloadOk) { + if (Confirm-Action " Retry without TLS certificate validation (insecure)?") { + Invoke-InsecureDownload -Uri $CA_SOURCE -OutFile $CA_FILE + } else { + Write-Error "Download aborted." -ErrorAction Continue + return 1 + } + } +} else { + Write-Host "==> Copying CA certificate from $CA_SOURCE ..." + Copy-Item -LiteralPath $CA_SOURCE -Destination $CA_FILE -Force +} + +try { + # X509Certificate2(string) on both .NET Framework (PS 5.1) and .NET 5+ uses the + # Windows CryptQueryObject API on Windows, which handles both PEM and DER and is + # lenient about line endings and encoding variants. This is more compatible than + # CreateFromPemFile whose managed PEM parser is stricter about line-ending consistency. + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CA_FILE) +} catch { + Write-Error "File is not a valid certificate." -ErrorAction Continue + return 1 +} + +Write-Host " Subject : $($cert.Subject)" +Write-Host " NotAfter : $($cert.NotAfter)" + +# -- Verify the certificate is a CA certificate ------------------------------- +$basicConstraintsExtensionRaw = $cert.Extensions | Where-Object { + $_.Oid.Value -eq '2.5.29.19' +} | Select-Object -First 1 +if ($null -eq $basicConstraintsExtensionRaw) { + Write-Error "The provided certificate does not contain a BasicConstraints extension and cannot be used as a CA certificate." -ErrorAction Continue + return 1 +} +$basicConstraintsExtension = $basicConstraintsExtensionRaw -as [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension] +if ($null -eq $basicConstraintsExtension) { + $basicConstraintsExtension = New-Object System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension $basicConstraintsExtensionRaw, $basicConstraintsExtensionRaw.Critical +} +if (-not $basicConstraintsExtension.CertificateAuthority) { + Write-Error "The provided certificate is not a CA certificate (BasicConstraints CA=FALSE). Only CA certificates can be installed into the root trust store." -ErrorAction Continue + return 1 +} + +# Advisory KeyUsage check - warn if keyCertSign is absent but do not block installation. +# BasicConstraints CA=TRUE is the authoritative check; real-world root CAs sometimes omit +# or encode KeyUsage differently, so a hard failure here breaks legitimate use-cases. +$keyUsageExtensionRaw = $cert.Extensions | Where-Object { + $_.Oid.Value -eq '2.5.29.15' +} | Select-Object -First 1 +if ($null -eq $keyUsageExtensionRaw) { + Write-Warning "The provided certificate does not have a KeyUsage extension. Proceeding, but verify the certificate is a suitable CA certificate." +} else { + $keyUsageExtension = $keyUsageExtensionRaw -as [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension] + if ($null -eq $keyUsageExtension) { + $keyUsageExtension = New-Object System.Security.Cryptography.X509Certificates.X509KeyUsageExtension $keyUsageExtensionRaw, $keyUsageExtensionRaw.Critical + } + $requiredKeyUsage = [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign + if (($keyUsageExtension.KeyUsages -band $requiredKeyUsage) -eq 0) { + Write-Warning "The provided certificate's KeyUsage does not include keyCertSign. Proceeding, but verify the certificate is a suitable CA certificate." + } +} + +# Derive CA_NAME from the CN field of the subject +$CA_NAME = if ($cert.Subject -match 'CN=([^,]+)') { $Matches[1].Trim() } else { $cert.Subject } + +Write-Host " CA Name : $CA_NAME" + +# -- Non-Windows short-circuit ------------------------------------------------ +if (-not $IsWindows) { + if ($env:INSTALL_CA_CERT_TEST_LINUX -eq '1') { + $safeName = ($CA_NAME.ToLower() -replace '[^a-z0-9]+', '-').Trim('-') + if ([string]::IsNullOrWhiteSpace($safeName)) { $safeName = 'custom-ca' } + $systemCaFile = "/usr/local/share/ca-certificates/$safeName.crt" + + Write-Host "" + Write-Host "==> Linux system trust store (test mode)" + Copy-Item -LiteralPath $CA_FILE -Destination $systemCaFile -Force + $ucOutput = & update-ca-certificates 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "update-ca-certificates failed (exit $LASTEXITCODE): $ucOutput" -ErrorAction Continue + return 1 + } + Write-Host " Installed: $systemCaFile" + } else { + Write-Host "" + Write-Host "==> Windows-specific steps skipped (non-Windows platform)." + } + Write-Host "" + Write-Host "==> All done. Fully quit and restart any open browsers for changes to take effect." + return 0 +} + +# -- 3. Check existing certificate in system store ---------------------------- + +Write-Host "" +Write-Host "==> Checking for existing certificate in LocalMachine\Root ..." + +$checkStore = [System.Security.Cryptography.X509Certificates.X509Store]::new( + [System.Security.Cryptography.X509Certificates.StoreName]::Root, + [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine +) +$checkStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) +$existing = $null +try { + # Prefer an exact thumbprint match (certificate already installed), + # and only fall back to Subject/NotAfter for "newer/older" comparisons. + $existing = $checkStore.Certificates | + Where-Object { $_.Thumbprint -eq $cert.Thumbprint } | + Select-Object -First 1 + + if (-not $existing) { + $existing = @($checkStore.Certificates | Where-Object { $_.Subject -eq $cert.Subject }) | + Sort-Object NotAfter -Descending | Select-Object -First 1 + } +} finally { + $checkStore.Close() +} + +if ($existing) { + Write-Host " Found : $($existing.Thumbprint)" + Write-Host " expires : $($existing.NotAfter)" + Write-Host " Remote : $($cert.Thumbprint)" + Write-Host " expires : $($cert.NotAfter)" + + if ($existing.Thumbprint -eq $cert.Thumbprint) { + if ($Force) { + Write-Host " Status : Already up-to-date but -Force was specified, continuing." + } else { + Write-Host " Status : Already up-to-date (same certificate). Nothing to do." + return 0 + } + } elseif ($cert.NotAfter -gt $existing.NotAfter) { + $days = [int]($cert.NotAfter - $existing.NotAfter).TotalDays + Write-Host " Status : Remote certificate is newer by $days day(s) - update recommended." + } elseif ($cert.NotAfter -lt $existing.NotAfter) { + $days = [int]($existing.NotAfter - $cert.NotAfter).TotalDays + Write-Host " Status : WARNING - Installed certificate expires $days day(s) LATER than the remote one." + } else { + Write-Host " Status : Different certificate with the same expiry date." + } +} else { + Write-Host " Status : No existing certificate found - fresh install." +} + +# -- 4. System trust store (Windows Certificate Store) ------------------------ +# +# Adding to LocalMachine\Root covers all Chromium-based browsers on Windows +# (Chrome, Edge, Brave, Chromium) because they delegate to the OS store. + +Write-Host "" +Write-Host "==> Windows Certificate Store - LocalMachine\Root" +Write-Host " (covers Chrome, Edge, Brave, Chromium)" + +$systemStoreInstallAttempted = $false +if (Confirm-Action " Add '$CA_NAME' to the Windows Root CA store?") { + $systemStoreInstallAttempted = $true + $store = [System.Security.Cryptography.X509Certificates.X509Store]::new( + [System.Security.Cryptography.X509Certificates.StoreName]::Root, + [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine + ) + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + try { + # First, check for an existing certificate with the same thumbprint + $existingThumbprintCerts = $store.Certificates | Where-Object { $_.Thumbprint -eq $cert.Thumbprint } + if ($existingThumbprintCerts) { + Write-Host " Certificate with the same thumbprint is already present in LocalMachine\Root. Skipping add to avoid duplicate." + } else { + # Do not remove certificates by subject: subjects are not guaranteed to be unique. + $subjectMatches = $store.Certificates | Where-Object { $_.Subject -eq $cert.Subject } + if ($subjectMatches) { + Write-Host " Warning: Existing certificate(s) with the same subject are present in LocalMachine\Root." + Write-Host " No existing certificates will be removed automatically because subject matches are not a safe identifier." + if ($Force) { + Write-Host " -Force does not remove same-subject certificates; use exact thumbprints for any manual cleanup." + } + } + $store.Add($cert) + Write-Host " Done." + } + } finally { + $store.Close() + } +} else { + Write-Host " Skipped." +} + +# -- 5. Firefox ---------------------------------------------------------------- +# +# Two approaches, tried in order: +# a) certutil.exe (ships with most Firefox installs) - updates the NSS cert9.db directly. +# b) ImportEnterpriseRoots policy - a registry key that tells Firefox to delegate +# trust to the Windows Certificate Store. + +Write-Host "" +Write-Host "==> Firefox" + +$ffCertRegKey = 'HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates' +$hasEnterpriseRoots = (Test-Path $ffCertRegKey) -and + ((Get-ItemProperty $ffCertRegKey -Name 'ImportEnterpriseRoots' -ErrorAction SilentlyContinue).ImportEnterpriseRoots -eq 1) + +if ($hasEnterpriseRoots) { + Write-Host " ImportEnterpriseRoots policy is set - Firefox trusts the Windows store." + Write-Host " No additional action needed." +} else { + # Only proceed with Firefox-specific steps if Firefox is actually installed. + $ffExe = @( + "$env:ProgramFiles\Mozilla Firefox\firefox.exe", + "${env:ProgramFiles(x86)}\Mozilla Firefox\firefox.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + + if (-not $ffExe) { + Write-Host " Firefox is not installed - skipping." + } else { + # Try certutil first + $certutil = $null + $ffInstallPaths = @( + "$env:ProgramFiles\Mozilla Firefox\certutil.exe", + "${env:ProgramFiles(x86)}\Mozilla Firefox\certutil.exe" + ) + foreach ($p in $ffInstallPaths) { + if (Test-Path $p) { $certutil = $p; break } + } + + if ($certutil) { + Write-Host " Using certutil: $certutil" + + $ffDirs = @() + $ffProfileRoot = "$env:APPDATA\Mozilla\Firefox\Profiles" + if (Test-Path $ffProfileRoot) { + $ffDirs = @(Get-ChildItem -Path $ffProfileRoot -Filter "cert9.db" -Recurse -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty DirectoryName | + Sort-Object -Unique) + } + + if ($ffDirs.Count -eq 0) { + Write-Host " No Firefox profiles found - skipping." + } else { + Write-Host " Found profiles:" + $ffDirs | ForEach-Object { Write-Host " $_" } + + if (Confirm-Action " Add '$CA_NAME' to the above Firefox profiles?") { + foreach ($db in $ffDirs) { + Add-ToNssDb -CertUtil $certutil -DbDir $db -CaName $CA_NAME -CaFile $CA_FILE + Write-Host " OK: $db" + } + } else { + Write-Host " Skipped." + } + } + } else { + # certutil not available - fall back to the enterprise-roots registry policy + Write-Host " certutil.exe not found in Firefox install directories." + Write-Host " Falling back to ImportEnterpriseRoots policy (makes Firefox trust the Windows store)." + + if (Confirm-Action " Set ImportEnterpriseRoots policy so Firefox trusts the Windows store?") { + New-Item -Path $ffCertRegKey -Force | Out-Null + New-ItemProperty -Path $ffCertRegKey -Name 'ImportEnterpriseRoots' -Value 1 -PropertyType DWord -Force | Out-Null + Write-Host " Done - Firefox will now import roots from the Windows Certificate Store." + } else { + Write-Host " Skipped." + } + } + } +} + +# -- 6. Verify ----------------------------------------------------------------- + +Write-Host "" +Write-Host "==> Verifying system trust ..." + +$verifyStore = [System.Security.Cryptography.X509Certificates.X509Store]::new( + [System.Security.Cryptography.X509Certificates.StoreName]::Root, + [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine +) +$verifyStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) +$found = $null +try { + $found = $verifyStore.Certificates | Where-Object { $_.Thumbprint -eq $cert.Thumbprint } +} finally { + $verifyStore.Close() +} + +if ($found) { + Write-Host " System trust: OK (found in LocalMachine\Root)" +} else { + Write-Host " System trust: NOT FOUND in LocalMachine\Root" + if ($systemStoreInstallAttempted) { + Write-Error "Certificate was not found in LocalMachine\Root after install." -ErrorAction Continue + return 1 + } +} + +Write-Host "" +Write-Host "==> All done. Fully quit and restart any open browsers for changes to take effect." +return 0 +} finally { + if ($null -ne $originalSecurityProtocol) { + try { + [Net.ServicePointManager]::SecurityProtocol = $originalSecurityProtocol + } catch { + # Ignore failures restoring SecurityProtocol + } + } + + if ($null -ne $cert) { + try { $cert.Dispose() } catch { } + } + + Remove-Item -LiteralPath $CA_FILE -Force -ErrorAction SilentlyContinue +} +} + +function ConvertTo-InstallArguments { + param( + [string[]]$Arguments + ) + + $result = @{ + Url = "" + Force = $false + Yes = $false + } + $urlSet = $false + + for ($i = 0; $i -lt $Arguments.Count; $i++) { + $arg = $Arguments[$i] + switch ($arg) { + { $_ -in '-Url','--url','-u' } { + if ($i + 1 -ge $Arguments.Count) { throw "Missing value for $arg" } + $i++ + if ($urlSet) { throw "Multiple CA sources provided: '$($result.Url)' and '$($Arguments[$i])'" } + $result.Url = $Arguments[$i] + $urlSet = $true + } + '--force' { $result.Force = $true } + '-f' { $result.Force = $true } + '-Force' { $result.Force = $true } + '--yes' { $result.Yes = $true } + '-y' { $result.Yes = $true } + '-Yes' { $result.Yes = $true } + default { + if ($arg -like '-*') { throw "Unknown option: $arg" } + if ($urlSet) { throw "Multiple CA sources provided: '$($result.Url)' and '$arg'" } + $result.Url = $arg + $urlSet = $true + } + } + } + + return $result +} + +$invokedAsDotSource = $MyInvocation.InvocationName -eq '.' +$runningFromFile = -not [string]::IsNullOrWhiteSpace($PSCommandPath) +$shouldAutoRun = $runningFromFile -or ($args.Count -gt 0) + +if (-not $shouldAutoRun) { + if (-not $invokedAsDotSource) { + $global:__Install_InstallCalled = $false + $global:__Install_SavedPrompt = $( + $__p = Get-Item function:prompt -ErrorAction SilentlyContinue + if ($__p) { $__p.ScriptBlock } else { $null } + ) + + function global:prompt { + $saved = $global:__Install_SavedPrompt + $global:__Install_SavedPrompt = $null + if ($saved) { Set-Item function:global:prompt $saved } + else { Remove-Item function:global:prompt -ErrorAction SilentlyContinue } + + if (-not $global:__Install_InstallCalled) { + try { + $code = Install + if ($null -eq $code) { $code = 0 } + $global:LASTEXITCODE = [int]$code + } catch { + Write-Host "ERROR: $_" -ForegroundColor Red + } + } + + if ($saved) { & $saved } else { "PS $($ExecutionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " } + } + } + return +} + +$parsed = ConvertTo-InstallArguments -Arguments $args +$exitCode = Install -Url $parsed.Url -Force:$parsed.Force -Yes:$parsed.Yes +if ($null -eq $exitCode) { $exitCode = 0 } + +if ($runningFromFile -and -not $invokedAsDotSource) { + exit ([int]$exitCode) +} + +$global:LASTEXITCODE = [int]$exitCode +return diff --git a/install-ca.sh b/install-ca.sh new file mode 100755 index 0000000..d5f0dff --- /dev/null +++ b/install-ca.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# Install a CA certificate into system and browser trust stores +# +# Browsers handled: +# - System trust store (/usr/local/share/ca-certificates + update-ca-certificates) +# - Google Chrome (deb) uses shared NSS at ~/.pki/nssdb +# - Chromium (deb/snap) uses shared NSS at ~/.pki/nssdb / snap-isolated .pki/nssdb +# - Microsoft Edge (deb) uses shared NSS at ~/.pki/nssdb +# - Brave (snap) snap-isolated .pki/nssdb per version +# - Firefox (deb/non-snap) per-profile cert9.db under ~/.mozilla/firefox/ +# - Firefox (snap) per-profile cert9.db under ~/snap/firefox/ +# +# Usage: bash install-ca.sh [CA-URL-or-path] [--force|-f] [--yes|-y] +# or: curl -fsSL https://raw.githubusercontent.com/IlmLV/install-ca/main/install-ca.sh | bash -s -- -y + +set -euo pipefail + +# ── Argument parsing ────────────────────────────────────────────────────────── +FORCE=false +YES=false +CA_SOURCE_ARG="" + +i=1 +while [[ $i -le $# ]]; do + arg="${!i}" + case "$arg" in + --force|-f) FORCE=true ;; + --yes|-y) YES=true ;; + --url|-u) + i=$((i + 1)) + if [[ $i -gt $# ]]; then + echo "ERROR: ${arg} requires a value" >&2; exit 1 + fi + if [[ -n "$CA_SOURCE_ARG" ]]; then + echo "ERROR: Multiple CA sources provided: '$CA_SOURCE_ARG' and '${!i}'" >&2 + echo "Usage: bash install-ca.sh [CA-URL-or-path] [--url|-u ] [--force|-f] [--yes|-y]" >&2 + exit 1 + fi + CA_SOURCE_ARG="${!i}" + ;; + --*|-*) echo "ERROR: Unknown option: $arg" >&2; exit 1 ;; + *) + if [[ -n "$CA_SOURCE_ARG" ]]; then + echo "ERROR: Multiple positional arguments provided: '$CA_SOURCE_ARG' and '$arg'" >&2 + echo "Usage: bash install-ca.sh [CA-URL-or-path] [--url|-u ] [--force|-f] [--yes|-y]" >&2 + exit 1 + fi + CA_SOURCE_ARG="$arg" + ;; + esac + i=$((i + 1)) +done + +WORK_DIR="$(mktemp -d)" +SYSTEM_CA_DIR="/usr/local/share/ca-certificates" + +cleanup() { + if [[ -n "${WORK_DIR:-}" && -d "$WORK_DIR" ]]; then + rm -rf "$WORK_DIR" + fi +} + +on_interrupt() { + echo "" + echo "Interrupted — exiting." + exit 130 +} + +on_term() { + echo "" + echo "Terminated — exiting." + exit 143 +} + +trap cleanup EXIT +trap on_interrupt INT +trap on_term TERM + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +confirm() { + if [[ "$YES" == true ]]; then + printf '%s [y/N] y\n' "$1" + return 0 + fi + reply="" + if ! read -r -p "$1 [y/N] " reply /dev/null || true + certutil -d "sql:$db_dir" -A -n "$CA_NAME" -t "CT,," -i "$CA_FILE" +} + +# Install CA into a list of NSS database directories with a confirmation prompt +install_to_nss_dbs() { + local label="$1"; shift + local dirs=("$@") + + echo "" + echo "==> $label" + + if [[ ${#dirs[@]} -eq 0 ]]; then + echo " No NSS databases found — skipping." + return + fi + + for db in "${dirs[@]}"; do + echo " $db" + done + + if confirm " Add '$CA_NAME' to the above databases?"; then + for db in "${dirs[@]}"; do + add_to_nss_db "$db" + echo " OK: $db" + done + else + echo " Skipped." + fi +} + +# Collect cert9.db parent dirs from a list of search roots (deduped, sorted) +find_nss_dbs() { + local results=() + for root in "$@"; do + [[ -d "$root" ]] || continue + while IFS= read -r d; do + [[ -n "$d" ]] && results+=("$d") + done < <(find "$root" -name "cert9.db" -printf '%h\n' 2>/dev/null) + done + [[ ${#results[@]} -eq 0 ]] && return + printf '%s\n' "${results[@]}" | sort -u +} + +# ── 1. Resolve CA source ────────────────────────────────────────────────────── + +if [[ -n "$CA_SOURCE_ARG" ]]; then + CA_SOURCE="$CA_SOURCE_ARG" +else + if ! read -r -p "Enter CA certificate URL or file path: " CA_SOURCE &2 + exit 1 +fi + +# ── 2. Fetch or copy the CA certificate ─────────────────────────────────────── + +CA_FILE="$WORK_DIR/ca.crt" + +if [[ "$CA_SOURCE" =~ ^https?:// ]]; then + echo "==> Fetching CA certificate from $CA_SOURCE ..." + if ! curl_err=$(curl -fSsL --max-time 30 --connect-timeout 10 "$CA_SOURCE" -o "$CA_FILE" 2>&1); then + echo " WARNING: Secure download failed. The server's TLS certificate may be invalid or self-signed." + echo " Detail : $curl_err" + if confirm " Retry without TLS certificate validation (insecure)?"; then + curl -kfSsL --max-time 30 --connect-timeout 10 "$CA_SOURCE" -o "$CA_FILE" + else + echo "ERROR: Download aborted." >&2 + exit 1 + fi + fi +else + LOCAL_CA_SOURCE="$CA_SOURCE" + if [[ "$LOCAL_CA_SOURCE" == "~" ]]; then + LOCAL_CA_SOURCE="$HOME" + elif [[ "$LOCAL_CA_SOURCE" == "~/"* ]]; then + LOCAL_CA_SOURCE="$HOME/${LOCAL_CA_SOURCE#~/}" + fi + echo "==> Copying CA certificate from $LOCAL_CA_SOURCE ..." + cp -- "$LOCAL_CA_SOURCE" "$CA_FILE" +fi + +if ! openssl x509 -in "$CA_FILE" -noout 2>/dev/null; then + if openssl x509 -inform DER -in "$CA_FILE" -noout 2>/dev/null; then + CA_PEM_FILE="$WORK_DIR/ca.pem" + openssl x509 -inform DER -in "$CA_FILE" -out "$CA_PEM_FILE" + CA_FILE="$CA_PEM_FILE" + else + echo "ERROR: File is not a valid PEM or DER certificate." >&2 + exit 1 + fi +fi + +# Verify the certificate has BasicConstraints CA:TRUE +_cert_text=$(openssl x509 -in "$CA_FILE" -noout -text 2>/dev/null) +if ! printf '%s' "$_cert_text" | grep -q "X509v3 Basic Constraints"; then + echo "ERROR: The provided certificate does not contain a BasicConstraints extension and cannot be used as a CA certificate." >&2 + exit 1 +fi +if ! printf '%s' "$_cert_text" | grep -qE "CA:(TRUE|true)"; then + echo "ERROR: The provided certificate is not a CA certificate (BasicConstraints CA=FALSE). Only CA certificates can be installed into the root trust store." >&2 + exit 1 +fi +unset _cert_text + +echo " Subject : $(openssl x509 -in "$CA_FILE" -noout -subject 2>/dev/null | sed 's/^subject[[:space:]]*=[[:space:]]*//')" +echo " NotAfter : $(openssl x509 -in "$CA_FILE" -noout -enddate 2>/dev/null | sed 's/^notAfter=//')" + +# Derive CA_NAME from the certificate CN, fall back to full subject. +# Strip the leading "subject=" prefix emitted by OpenSSL and any leading "/" +# from old-style slash-delimited subjects (OpenSSL 1.x). +CA_SUBJECT=$(openssl x509 -in "$CA_FILE" -noout -subject 2>/dev/null \ + | sed 's/^subject[[:space:]]*=[[:space:]]*//' \ + | sed 's|^/||') +# sed -n with /p only prints when the CN pattern matches, so CA_CN is empty +# when there is no CN field — the fallback then uses the full stripped subject. +CA_CN=$(printf '%s' "$CA_SUBJECT" | sed -n 's/.*CN[[:space:]]*=[[:space:]]*\([^,/]*\).*/\1/p' | sed 's/[[:space:]]*$//') +CA_NAME="${CA_CN:-$CA_SUBJECT}" + +# Derive a safe filename from CA_NAME +_safe_name="$(printf '%s\n' "$CA_NAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/-\+/-/g; s/^-//; s/-$//')" +CA_FILENAME="${_safe_name:-custom-ca}.crt" +SYSTEM_CA_FILE="$SYSTEM_CA_DIR/$CA_FILENAME" + +echo " CA Name : $CA_NAME" +echo " CA File : $SYSTEM_CA_FILE" + +# ── 3. Check existing certificate in system store ──────────────────────────── +echo "" +echo "==> Checking for existing certificate at $SYSTEM_CA_FILE ..." + +if [[ -f "$SYSTEM_CA_FILE" ]]; then + existing_end=$(openssl x509 -in "$SYSTEM_CA_FILE" -noout -enddate 2>/dev/null | cut -d= -f2) + remote_end=$(openssl x509 -in "$CA_FILE" -noout -enddate 2>/dev/null | cut -d= -f2) + + existing_ts=$(date -d "$existing_end" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$existing_end" +%s 2>/dev/null || true) + remote_ts=$(date -d "$remote_end" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$remote_end" +%s 2>/dev/null || true) + + existing_fp=$(openssl x509 -in "$SYSTEM_CA_FILE" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2) + remote_fp=$(openssl x509 -in "$CA_FILE" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2) + + echo " Found : $existing_fp" + echo " expires : $existing_end" + echo " Remote : $remote_fp" + echo " expires : $remote_end" + + if [[ -z "$existing_ts" || -z "$remote_ts" ]]; then + echo " Status : Cannot compare certificate dates — date parsing failed." + elif [[ "$existing_fp" == "$remote_fp" ]]; then + if [[ "$FORCE" == true ]]; then + echo " Status : Already up-to-date but --force was specified, continuing." + else + echo " Status : Already up-to-date (same certificate). Nothing to do." + exit 0 + fi + elif (( remote_ts > existing_ts )); then + days=$(( (remote_ts - existing_ts) / 86400 )) + echo " Status : Remote certificate is newer by $days day(s) — update recommended." + elif (( remote_ts < existing_ts )); then + days=$(( (existing_ts - remote_ts) / 86400 )) + echo " Status : WARNING — installed certificate expires $days day(s) LATER than the remote one." + else + echo " Status : Different certificate with the same expiry date." + fi +else + echo " Status : No existing certificate found — fresh install." +fi + +# ── 4. System trust store ───────────────────────────────────────────────────── +echo "" +echo "==> System trust store" +echo " sudo cp $CA_FILE $SYSTEM_CA_FILE" +echo " sudo update-ca-certificates" + +system_store_install_attempted=0 +if confirm " Proceed?"; then + system_store_install_attempted=1 + sudo cp "$CA_FILE" "$SYSTEM_CA_FILE" + sudo update-ca-certificates + echo " Done." +else + echo " Skipped." +fi + +# ── 5. Ensure certutil is available ────────────────────────────────────────── +if ! command -v certutil &>/dev/null; then + echo "" + echo "==> certutil not found — required for NSS database updates." + echo " sudo apt-get install -y libnss3-tools" + if confirm " Proceed?"; then + sudo apt-get install -y libnss3-tools + else + echo " Cannot continue without certutil." >&2 + exit 1 + fi +fi + +# ── 6. Shared NSS database ──────────────────────────────────────────────────── +# +# Used by deb-installed browsers that delegate to the OS NSS store: +# - Google Chrome +# - Chromium +# - Microsoft Edge +# +SHARED_NSS="$HOME/.pki/nssdb" +_shared_nss_ready=true + +if [[ ! -d "$SHARED_NSS" ]]; then + echo "" + echo "==> Shared NSS database" + echo " No shared NSS database found at $SHARED_NSS." + if confirm " Create it? (required for Chrome, Chromium, Edge — deb installs)"; then + mkdir -p "$SHARED_NSS" + certutil -d "sql:$SHARED_NSS" -N --empty-password + echo " Created." + else + echo " Skipped — Chrome, Chromium, and Edge (deb) trust store will not be updated." + _shared_nss_ready=false + fi +fi + +if [[ "$_shared_nss_ready" == true ]]; then + install_to_nss_dbs \ + "Shared NSS database (Google Chrome, Chromium, Edge — deb installs)" \ + "$SHARED_NSS" +fi + +# ── 7. Brave (snap) ─────────────────────────────────────────────────────────── +# +# Brave snap is isolated from the shared NSS database and maintains its own +# .pki/nssdb per installed snap version under ~/snap/brave/. +# +mapfile -t BRAVE_DIRS < <(find_nss_dbs "$HOME/snap/brave") + +install_to_nss_dbs "Brave (snap)" "${BRAVE_DIRS[@]}" + +# ── 8. Chromium (snap) ──────────────────────────────────────────────────────── +mapfile -t CHROMIUM_SNAP_DIRS < <(find_nss_dbs "$HOME/snap/chromium") + +install_to_nss_dbs "Chromium (snap)" "${CHROMIUM_SNAP_DIRS[@]}" + +# ── 9. Firefox ──────────────────────────────────────────────────────────────── +# +# Firefox stores the CA in each profile's cert9.db rather than a shared store. +# Both the deb install (~/.mozilla/firefox/) and the snap install +# (~/snap/firefox/) are handled. +# +mapfile -t FIREFOX_DIRS < <(find_nss_dbs \ + "$HOME/.mozilla/firefox" \ + "$HOME/snap/firefox") + +install_to_nss_dbs "Firefox (all profiles — deb + snap)" "${FIREFOX_DIRS[@]}" + +# ── 10. Verify ──────────────────────────────────────────────────────────────── +echo "" +echo "==> Verifying system trust ..." +SYSTEM_CA_PATH="/etc/ssl/certs" +if openssl verify -CApath "$SYSTEM_CA_PATH" "$CA_FILE" &>/dev/null; then + echo " System trust: OK" +else + echo " System trust: FAILED (check that update-ca-certificates succeeded and that the CA is present in $SYSTEM_CA_PATH)" + if (( system_store_install_attempted )); then + echo "ERROR: Certificate was not found in system trust store after install." >&2 + exit 1 + fi +fi + +echo "" +echo "==> All done. Fully quit and restart any open browsers for changes to take effect." diff --git a/tests/Dockerfile.debian b/tests/Dockerfile.debian new file mode 100644 index 0000000..d503a77 --- /dev/null +++ b/tests/Dockerfile.debian @@ -0,0 +1,15 @@ +FROM debian:latest + +ENV DEBIAN_FRONTEND=noninteractive + +COPY tests/docker-linux-setup.sh /tmp/docker-linux-setup.sh +RUN bash /tmp/docker-linux-setup.sh && rm -f /tmp/docker-linux-setup.sh + +# Copy repo so BASH_SOURCE[0] resolves to a real file, giving SCRIPT_DIR=/workspace +COPY . /workspace + +WORKDIR /workspace + +RUN chmod +x /workspace/tests/entrypoint.linux.sh + +ENTRYPOINT ["/workspace/tests/entrypoint.linux.sh"] diff --git a/tests/Dockerfile.ubuntu b/tests/Dockerfile.ubuntu new file mode 100644 index 0000000..a7e20b6 --- /dev/null +++ b/tests/Dockerfile.ubuntu @@ -0,0 +1,15 @@ +FROM ubuntu:latest + +ENV DEBIAN_FRONTEND=noninteractive + +COPY tests/docker-linux-setup.sh /tmp/docker-linux-setup.sh +RUN bash /tmp/docker-linux-setup.sh && rm -f /tmp/docker-linux-setup.sh + +# Copy repo so BASH_SOURCE[0] resolves to a real file, giving SCRIPT_DIR=/workspace +COPY . /workspace + +WORKDIR /workspace + +RUN chmod +x /workspace/tests/entrypoint.linux.sh + +ENTRYPOINT ["/workspace/tests/entrypoint.linux.sh"] diff --git a/tests/docker-linux-setup.sh b/tests/docker-linux-setup.sh new file mode 100644 index 0000000..3b6ca04 --- /dev/null +++ b/tests/docker-linux-setup.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive +export TZ=UTC + +ARCH="$(dpkg --print-architecture)" + +apt-get update +apt-get install -y --no-install-recommends \ + bats \ + ca-certificates \ + curl \ + gnupg \ + debian-archive-keyring \ + libnss3-tools \ + openssl \ + sudo \ + wget +rm -rf /var/lib/apt/lists/* + +install -d /etc/apt/keyrings + +download_and_verify_gpg_key() { + local url="$1" + local expected_fpr="$2" + local target="$3" + + local tmp + tmp="$(mktemp)" + + curl -fsSL "$url" -o "$tmp" + + # Extract the first fingerprint from the key file + local actual_fpr + actual_fpr="$(gpg --show-keys --with-colons "$tmp" | awk -F: '/^fpr:/ {print $10; exit}')" + + if [ -z "$actual_fpr" ]; then + echo "ERROR: Unable to extract fingerprint from key downloaded from $url" >&2 + rm -f "$tmp" + exit 1 + fi + + if [ "$actual_fpr" != "$expected_fpr" ]; then + echo "ERROR: Fingerprint mismatch for key from $url" >&2 + echo " Expected: $expected_fpr" >&2 + echo " Actual: $actual_fpr" >&2 + rm -f "$tmp" + exit 1 + fi + + # Install a keyring suitable for APT. Some sources provide ASCII-armored + # keys that need dearmoring, while others already provide a binary .gpg + # keyring. Try dearmoring first and fall back to copying the verified file. + if ! gpg --dearmor -o "$target" "$tmp"; then + rm -f "$target" + cp "$tmp" "$target" + fi + rm -f "$tmp" +} + +# Install Google Chrome (deb) +# Google Linux package signing key fingerprint (from official documentation) +GOOGLE_LINUX_KEY_FPR="EB4C1BFD4F042F6DDDCCEC917721F63BD38B4796" +download_and_verify_gpg_key \ + "https://dl.google.com/linux/linux_signing_key.pub" \ + "$GOOGLE_LINUX_KEY_FPR" \ + "/etc/apt/keyrings/google-linux.gpg" +echo "deb [arch=$ARCH signed-by=/etc/apt/keyrings/google-linux.gpg] https://dl.google.com/linux/chrome/deb/ stable main" \ + > /etc/apt/sources.list.d/google-chrome.list + +# Install Microsoft Edge (deb) +# Microsoft package repository key fingerprint (from official documentation) +MICROSOFT_EDGE_KEY_FPR="BC528686B50D79E339D3721CEB3E94ADBE1229CF" +download_and_verify_gpg_key \ + "https://packages.microsoft.com/keys/microsoft.asc" \ + "$MICROSOFT_EDGE_KEY_FPR" \ + "/etc/apt/keyrings/microsoft.gpg" +echo "deb [arch=$ARCH signed-by=/etc/apt/keyrings/microsoft.gpg] https://packages.microsoft.com/repos/edge stable main" \ + > /etc/apt/sources.list.d/microsoft-edge.list + +# Install Brave (deb) +# Brave browser APT archive key fingerprint (from official documentation) +BRAVE_BROWSER_KEY_FPR="DBF1A116C220B8C7164F98230686B78420038257" +download_and_verify_gpg_key \ + "https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg" \ + "$BRAVE_BROWSER_KEY_FPR" \ + "/etc/apt/keyrings/brave-browser-archive-keyring.gpg" +echo "deb [arch=$ARCH signed-by=/etc/apt/keyrings/brave-browser-archive-keyring.gpg] https://brave-browser-apt-release.s3.brave.com/ stable main" \ + > /etc/apt/sources.list.d/brave-browser-release.list + +# Install Firefox (Mozilla APT repo) +# Mozilla APT repository signing key fingerprint (from official documentation) +MOZILLA_APT_KEY_FPR="35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3" +download_and_verify_gpg_key \ + "https://packages.mozilla.org/apt/repo-signing-key.gpg" \ + "$MOZILLA_APT_KEY_FPR" \ + "/etc/apt/keyrings/mozilla.gpg" +echo "deb [signed-by=/etc/apt/keyrings/mozilla.gpg] https://packages.mozilla.org/apt mozilla main" \ + > /etc/apt/sources.list.d/mozilla.list +cat >/etc/apt/preferences.d/mozilla-firefox <<'EOF' +Package: firefox* +Pin: origin packages.mozilla.org +Pin-Priority: 1001 +EOF + +apt-get update + +if [[ "$ARCH" == "amd64" ]]; then + apt-get install -y --no-install-recommends \ + google-chrome-stable \ + microsoft-edge-stable \ + brave-browser +else + echo "INFO: Google Chrome, Microsoft Edge, and Brave are only available for amd64 — skipping (current arch: $ARCH)." +fi + +if ! apt-get install -y --no-install-recommends firefox; then + apt-get install -y --no-install-recommends firefox-esr +fi + +# Chromium (deb). Ubuntu noble provides a snap stub, so pull a real deb from Debian. +cat >/etc/apt/sources.list.d/debian-bookworm.list <<'EOF' +deb [signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] https://deb.debian.org/debian bookworm main +deb [signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] https://deb.debian.org/debian-security bookworm-security main +deb [signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] https://deb.debian.org/debian bookworm-updates main +EOF +cat >/etc/apt/preferences.d/chromium <<'EOF' +Package: chromium* +Pin: release n=bookworm +Pin-Priority: 1001 +EOF +apt-get update +if ! apt-get install -y --no-install-recommends chromium chromium-common chromium-sandbox; then + echo "WARNING: debian chromium install failed; falling back to Ubuntu stub" >&2 + apt-get install -y --no-install-recommends chromium || true + apt-get install -y --no-install-recommends chromium-browser || true +fi +rm -f /etc/apt/sources.list.d/debian-bookworm.list /etc/apt/preferences.d/chromium + +rm -rf /var/lib/apt/lists/* + +# Script runs as root inside the container — allow sudo without a password +echo "root ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/root-nopasswd +chmod 0440 /etc/sudoers.d/root-nopasswd diff --git a/tests/entrypoint.linux.sh b/tests/entrypoint.linux.sh new file mode 100644 index 0000000..c9919e4 --- /dev/null +++ b/tests/entrypoint.linux.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +CERTS_DIR="/workspace/tests/runtime-certs" + +# Generate all test certificates at runtime +bash /workspace/tests/generate-certs.sh "$CERTS_DIR" + +# Export paths for BATS tests +export TEST_CERT="$CERTS_DIR/test-ca.crt" +export HTTPS_CA="$CERTS_DIR/https-ca.crt" +export LEAF_CERT="$CERTS_DIR/leaf.crt" + +openssl s_server -quiet -accept 8443 \ + -cert "$CERTS_DIR/https-server.crt" \ + -key "$CERTS_DIR/https-server.key" \ + -www < <(tail -f /dev/null) >/dev/null 2>&1 & +https_pid=$! + +trap 'kill "$https_pid" 2>/dev/null || true' EXIT + +for _ in {1..50}; do + (exec 3<>/dev/tcp/127.0.0.1/8443) 2>/dev/null && break + kill -0 "$https_pid" 2>/dev/null || { echo "ERROR: HTTPS server process exited unexpectedly." >&2; exit 1; } + sleep 0.1 +done + +if ! (exec 3<>/dev/tcp/127.0.0.1/8443) 2>/dev/null; then + echo "ERROR: HTTPS server on 127.0.0.1:8443 did not become reachable after 50 attempts; aborting tests." >&2 + exit 1 +fi +bats /workspace/tests/linux.bats diff --git a/tests/generate-certs.ps1 b/tests/generate-certs.ps1 new file mode 100755 index 0000000..de5fb76 --- /dev/null +++ b/tests/generate-certs.ps1 @@ -0,0 +1,91 @@ +# Generate test certificates at runtime - no private keys stored in the repo. +# Usage: powershell -File generate-certs.ps1 -OutputDir +# OutputDir defaults to $env:TEMP\test-certs- +param( + [string]$OutputDir = (Join-Path ([IO.Path]::GetTempPath()) "test-certs-$([guid]::NewGuid().ToString('N'))") +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + +# -- Test CA (used by install-ca.ps1 tests) ------------------------------------ +$testCert = New-SelfSignedCertificate ` + -Type Custom ` + -Subject "CN=Test CA, O=Test Org" ` + -CertStoreLocation "Cert:\CurrentUser\My" ` + -NotAfter (Get-Date).AddYears(1) ` + -KeyUsage CertSign, CRLSign ` + -TextExtension @("2.5.29.19={critical}{text}CA=true") + +try { + $certBytes = $testCert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) + # Use None to avoid InsertLineBreaks inserting \r\n; manually wrap at 64 chars with \n. + $b64 = [Convert]::ToBase64String($certBytes, [System.Base64FormattingOptions]::None) + $b64Lines = ($b64 -split '(.{1,64})' | Where-Object { $_ }) -join "`n" + $pemContent = "-----BEGIN CERTIFICATE-----`n$b64Lines`n-----END CERTIFICATE-----`n" + [IO.File]::WriteAllText((Join-Path $OutputDir 'test-ca.crt'), $pemContent, [Text.Encoding]::ASCII) +} finally { + # Always remove from cert store - it was only needed for export + Remove-Item "Cert:\CurrentUser\My\$($testCert.Thumbprint)" -Force -ErrorAction SilentlyContinue +} + +# -- Leaf certificate (no CA extensions) - used to verify non-CA cert rejection - +$leafCert = New-SelfSignedCertificate ` + -Type Custom ` + -Subject "CN=Test Leaf" ` + -CertStoreLocation "Cert:\CurrentUser\My" ` + -NotAfter (Get-Date).AddYears(1) ` + -TextExtension @("2.5.29.19={text}CA=false") # 2.5.29.19 = BasicConstraints OID + +try { + $leafBytes = $leafCert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) + $leafB64 = [Convert]::ToBase64String($leafBytes, [System.Base64FormattingOptions]::None) + $leafB64Lines = ($leafB64 -split '(.{1,64})' | Where-Object { $_ }) -join "`n" + $leafPemContent = "-----BEGIN CERTIFICATE-----`n$leafB64Lines`n-----END CERTIFICATE-----`n" + [IO.File]::WriteAllText((Join-Path $OutputDir 'leaf.crt'), $leafPemContent, [Text.Encoding]::ASCII) +} finally { + Remove-Item "Cert:\CurrentUser\My\$($leafCert.Thumbprint)" -Force -ErrorAction SilentlyContinue +} + +# -- HTTPS test CA + server cert (requires openssl in PATH) ------------------- +if (Get-Command openssl -ErrorAction SilentlyContinue) { + $ext = $null + # In Windows PowerShell 5.1, redirecting an external command's stderr with 2>&1 + # writes each stderr line as an ErrorRecord into the output stream AND the error + # stream. With $ErrorActionPreference = 'Stop', the first such record (openssl's + # random-seed progress dots) becomes a terminating error before $LASTEXITCODE is + # checked. Save and restore the preference around the openssl calls so progress + # output is treated as non-fatal; $LASTEXITCODE still catches real failures. + $savedEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $out = & openssl req -x509 -newkey rsa:2048 -keyout "$OutputDir\https-ca.key" ` + -out "$OutputDir\https-ca.crt" -days 365 -nodes ` + -subj "/CN=Test HTTPS CA" ` + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" ` + -addext "keyUsage=critical,keyCertSign,cRLSign" 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "openssl failed to generate https-ca.crt (exit $LASTEXITCODE)`n$out" } + + $out = & openssl req -newkey rsa:2048 -keyout "$OutputDir\https-server.key" ` + -out "$OutputDir\https-server.csr" -nodes ` + -subj "/CN=localhost" 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "openssl failed to generate https-server.csr (exit $LASTEXITCODE)`n$out" } + + $ext = [IO.Path]::GetTempFileName() + Set-Content $ext "subjectAltName=DNS:localhost,IP:127.0.0.1`nextendedKeyUsage=serverAuth`nkeyUsage=digitalSignature,keyEncipherment`nbasicConstraints=CA:FALSE" -Encoding ASCII + + $out = & openssl x509 -req -in "$OutputDir\https-server.csr" ` + -CA "$OutputDir\https-ca.crt" -CAkey "$OutputDir\https-ca.key" ` + -CAcreateserial -out "$OutputDir\https-server.crt" -days 365 ` + -extfile $ext 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "openssl failed to sign https-server.crt (exit $LASTEXITCODE)`n$out" } + } finally { + $ErrorActionPreference = $savedEAP + if ($ext) { Remove-Item $ext -Force -ErrorAction SilentlyContinue } + Remove-Item "$OutputDir\https-server.csr", "$OutputDir\https-ca.key", "$OutputDir\https-ca.srl" -Force -ErrorAction SilentlyContinue + } +} + +Write-Host "Certificates generated in $OutputDir" diff --git a/tests/generate-certs.sh b/tests/generate-certs.sh new file mode 100755 index 0000000..4f55a20 --- /dev/null +++ b/tests/generate-certs.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Generate all test certificates at runtime — no private keys stored in the repo. +# Usage: bash generate-certs.sh [output-dir] +# output-dir defaults to /workspace/tests/runtime-certs +# set GENERATE_CERTS_QUIET=0 to print OpenSSL output + +set -euo pipefail + +OUT="${1:-/workspace/tests/runtime-certs}" +QUIET="${GENERATE_CERTS_QUIET:-1}" +mkdir -p "$OUT" + +run_openssl() { + if [[ "$QUIET" == "1" ]]; then + local out + if ! out="$(openssl "$@" 2>&1)"; then + printf '%s\n' "$out" >&2 + return 1 + fi + return 0 + fi + + openssl "$@" +} + +# ── 1. Test CA (used by install-ca.sh / install-ca.ps1 tests) ───────────────── +run_openssl req -x509 -newkey rsa:2048 -keyout "$OUT/test-ca.key" \ + -out "$OUT/test-ca.crt" -days 365 -nodes \ + -subj "/CN=Test CA/O=Test Org" \ + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -addext "subjectKeyIdentifier=hash" + +# ── 2. HTTPS test CA (signs the local HTTPS test server) ────────────────────── +run_openssl req -x509 -newkey rsa:2048 -keyout "$OUT/https-ca.key" \ + -out "$OUT/https-ca.crt" -days 365 -nodes \ + -subj "/CN=Test HTTPS CA" \ + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -addext "subjectKeyIdentifier=hash" + +# ── 3. HTTPS test server certificate signed by the HTTPS CA ────────────────── +run_openssl req -newkey rsa:2048 -keyout "$OUT/https-server.key" \ + -out "$OUT/https-server.csr" -nodes \ + -subj "/CN=localhost" + +HTTPS_SERVER_EXT="$OUT/https-server.ext" +cat >"$HTTPS_SERVER_EXT" <<'EOF' +subjectAltName=DNS:localhost,IP:127.0.0.1 +extendedKeyUsage=serverAuth +keyUsage=digitalSignature,keyEncipherment +basicConstraints=CA:FALSE +EOF + +run_openssl x509 -req -in "$OUT/https-server.csr" \ + -CA "$OUT/https-ca.crt" -CAkey "$OUT/https-ca.key" \ + -CAcreateserial -out "$OUT/https-server.crt" -days 365 \ + -extfile "$HTTPS_SERVER_EXT" + +# ── 4. Leaf certificate (CA:FALSE) — used to verify non-CA cert rejection ────── +run_openssl req -x509 -newkey rsa:2048 -keyout "$OUT/leaf.key" \ + -out "$OUT/leaf.crt" -days 365 -nodes \ + -subj "/CN=Test Leaf" \ + -addext "basicConstraints=CA:FALSE" + +rm -f "$OUT/https-server.csr" "$OUT/https-ca.srl" "$OUT/https-ca.key" "$HTTPS_SERVER_EXT" "$OUT/test-ca.key" "$OUT/leaf.key" + +if [[ "$QUIET" != "1" ]]; then + echo "Certificates generated in $OUT" +fi diff --git a/tests/linux.bats b/tests/linux.bats new file mode 100644 index 0000000..62f5793 --- /dev/null +++ b/tests/linux.bats @@ -0,0 +1,284 @@ +#!/usr/bin/env bats +# Tests for install-ca.sh + +SCRIPT="/workspace/install-ca.sh" +CERT="${TEST_CERT:-/workspace/tests/runtime-certs/test-ca.crt}" +HTTPS_CA="${HTTPS_CA:-/workspace/tests/runtime-certs/https-ca.crt}" +LEAF_CERT="${LEAF_CERT:-/workspace/tests/runtime-certs/leaf.crt}" +SYSTEM_CA_DIR="/usr/local/share/ca-certificates" +SHARED_NSS_DIR="$HOME/.pki/nssdb" +BRAVE_NSS_DIR="$HOME/snap/brave/current/.pki/nssdb" +CHROMIUM_NSS_DIR="$HOME/snap/chromium/current/.pki/nssdb" +FIREFOX_DEB_NSS_DIR="$HOME/.mozilla/firefox/test.default" +FIREFOX_SNAP_NSS_DIR="$HOME/snap/firefox/current/.mozilla/firefox/test.default" + +init_nss_db() { + local dir="$1" + mkdir -p "$dir" + certutil -d "sql:$dir" -N --empty-password +} + +install_https_ca() { + run bash "$SCRIPT" -y "$HTTPS_CA" + [ "$status" -eq 0 ] +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || skip "$2" +} + +run_timeout() { + local duration="${CMD_TIMEOUT_SECS:-10s}" + run timeout "$duration" "$@" +} + +is_snap_stub() { + local bin="$1" + local out + out="$("$bin" --version 2>&1 || true)" + [[ "$out" == *"requires the "*snap* ]] +} + +pick_firefox_deb_bin() { + local candidate + for candidate in firefox firefox-esr; do + command -v "$candidate" >/dev/null 2>&1 || continue + is_snap_stub "$candidate" && continue + echo "$candidate" + return 0 + done + return 1 +} + +pick_chromium_deb_bin() { + local candidate + for candidate in chromium chromium-browser; do + command -v "$candidate" >/dev/null 2>&1 || continue + is_snap_stub "$candidate" && continue + echo "$candidate" + return 0 + done + return 1 +} + +pick_edge_bin() { + local candidate + for candidate in microsoft-edge microsoft-edge-stable; do + command -v "$candidate" >/dev/null 2>&1 && echo "$candidate" && return 0 + done + return 1 +} + +run_headless() { + local label="$1"; shift + run_timeout "$@" + if [[ "$status" -eq 124 ]]; then + skip "$label timed out in this container environment" + fi +} + +preinstall_test_cert() { + cp "$CERT" "$SYSTEM_CA_DIR/test-ca.crt" +} + +run_oneliner() { + # Simulate: curl -fsSL | bash -s -- [args...] + run bash -c 'cat "$1" | bash -s -- "${@:2}"' -- "$SCRIPT" "$@" +} + +setup() { + rm -f "$SYSTEM_CA_DIR/test-ca.crt" "$SYSTEM_CA_DIR/test-https-ca.crt" + update-ca-certificates --fresh >/dev/null 2>&1 || true + rm -rf "$SHARED_NSS_DIR" "$BRAVE_NSS_DIR" "$CHROMIUM_NSS_DIR" "$FIREFOX_DEB_NSS_DIR" "$FIREFOX_SNAP_NSS_DIR" + rm -rf /tmp/chrome-profile /tmp/chromium-profile /tmp/edge-profile + rm -f /tmp/firefox-test.png +} + +teardown() { + rm -f "$SYSTEM_CA_DIR/test-ca.crt" "$SYSTEM_CA_DIR/test-https-ca.crt" + update-ca-certificates --fresh >/dev/null 2>&1 || true + rm -rf "$SHARED_NSS_DIR" "$BRAVE_NSS_DIR" "$CHROMIUM_NSS_DIR" "$FIREFOX_DEB_NSS_DIR" "$FIREFOX_SNAP_NSS_DIR" + rm -rf /tmp/chrome-profile /tmp/chromium-profile /tmp/edge-profile + rm -f /tmp/firefox-test.png +} + +@test "empty input exits with error" { + run bash -c "bash '$SCRIPT' | bash -s -- +# When piped, bash reads the script from stdin; interactive reads inside the +# script redirect to /dev/tty so arguments must be passed via -s -- . + +@test "oneliner: piped script with positional cert arg installs cert" { + run_oneliner "$CERT" -y + [ "$status" -eq 0 ] + [[ "$output" == *"CA Name : Test CA"* ]] + [[ "$output" == *"System trust: OK"* ]] +} + +@test "oneliner: piped script with --url flag installs cert" { + run_oneliner --url "$CERT" -y + [ "$status" -eq 0 ] + [[ "$output" == *"CA Name : Test CA"* ]] + [[ "$output" == *"System trust: OK"* ]] +} + +@test "oneliner: piped script with no args fails with error" { + run_oneliner + [ "$status" -eq 1 ] + [[ "$output" == *"No CA source provided"* ]] +} + +@test "oneliner: piped script with --force reinstalls already-present cert" { + preinstall_test_cert + run_oneliner "$CERT" -y --force + [ "$status" -eq 0 ] + [[ "$output" == *"--force was specified, continuing"* ]] + [[ "$output" == *"System trust: OK"* ]] +} + +@test "oneliner: piped script skips already-installed cert" { + preinstall_test_cert + run_oneliner "$CERT" -y + [ "$status" -eq 0 ] + [[ "$output" == *"Already up-to-date"* ]] +} + +@test "updates all browser NSS databases" { + init_nss_db "$SHARED_NSS_DIR" + init_nss_db "$BRAVE_NSS_DIR" + init_nss_db "$CHROMIUM_NSS_DIR" + init_nss_db "$FIREFOX_DEB_NSS_DIR" + init_nss_db "$FIREFOX_SNAP_NSS_DIR" + + run bash "$SCRIPT" -y "$CERT" + [ "$status" -eq 0 ] + + run certutil -d "sql:$SHARED_NSS_DIR" -L -n "Test CA" + [ "$status" -eq 0 ] + run certutil -d "sql:$BRAVE_NSS_DIR" -L -n "Test CA" + [ "$status" -eq 0 ] + run certutil -d "sql:$CHROMIUM_NSS_DIR" -L -n "Test CA" + [ "$status" -eq 0 ] + run certutil -d "sql:$FIREFOX_DEB_NSS_DIR" -L -n "Test CA" + [ "$status" -eq 0 ] + run certutil -d "sql:$FIREFOX_SNAP_NSS_DIR" -L -n "Test CA" + [ "$status" -eq 0 ] +} + +@test "HTTPS URL trusts system CA after install" { + install_https_ca + run_timeout curl -sSf https://127.0.0.1:8443/ + [ "$status" -eq 0 ] +} + +@test "Chrome headless loads HTTPS page after trust install" { + require_cmd google-chrome "google-chrome not installed" + install_https_ca + run_headless "Chrome" bash -c "google-chrome --headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-component-update --user-data-dir=/tmp/chrome-profile --dump-dom https://127.0.0.1:8443/ >/dev/null" + [ "$status" -eq 0 ] +} + +@test "Chromium headless loads HTTPS page after trust install" { + local bin + if ! bin="$(pick_chromium_deb_bin)"; then + if command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1; then + skip "chromium deb not installed (snap stub detected)" + else + skip "chromium not installed" + fi + fi + install_https_ca + run_headless "Chromium" bash -c "$bin --headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-component-update --user-data-dir=/tmp/chromium-profile --dump-dom https://127.0.0.1:8443/ >/dev/null" + [ "$status" -eq 0 ] +} + +@test "Microsoft Edge headless loads HTTPS page after trust install" { + local bin + if ! bin="$(pick_edge_bin)"; then + skip "microsoft-edge not installed" + fi + install_https_ca + run_headless "Microsoft Edge" bash -c "$bin --headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-component-update --user-data-dir=/tmp/edge-profile --dump-dom https://127.0.0.1:8443/ >/dev/null" + [ "$status" -eq 0 ] +} + +@test "Firefox headless loads HTTPS page after trust install" { + local bin + if ! bin="$(pick_firefox_deb_bin)"; then + if command -v firefox >/dev/null 2>&1 || command -v firefox-esr >/dev/null 2>&1; then + skip "firefox deb not installed (snap stub detected)" + else + skip "firefox not installed" + fi + fi + # Use a deterministic profile DB that install_https_ca can populate. + init_nss_db "$FIREFOX_DEB_NSS_DIR" + install_https_ca + run_headless "Firefox" bash -c "$bin --headless --no-remote --profile \"$FIREFOX_DEB_NSS_DIR\" --screenshot /tmp/firefox-test.png https://127.0.0.1:8443/ >/dev/null 2>&1" + [ "$status" -eq 0 ] +} + +@test "Brave (deb) headless loads HTTPS page after trust install" { + # TODO: implement — binary candidates: brave-browser, brave (skip snap stubs) + # Uses NSS shared db at ~/.pki/nssdb; same headless flags as Chrome/Chromium/Edge. + skip "not yet implemented" +} + +# TODO: add headless TLS verification tests for snap-installed browsers listed in README: +# - Chromium (snap) — NSS at ~/snap/chromium/current/.pki/nssdb +# - Firefox (snap) — per-profile cert9.db under ~/snap/firefox/current/.mozilla/firefox/ +# - Brave (snap) — NSS at ~/snap/brave/current/.pki/nssdb +# Note: Brave headless mode hangs in Docker containers (dbus-dependent initialization +# never completes without a running dbus session). Needs investigation before adding. + +@test "Chromium (snap) headless loads HTTPS page after trust install" { + # TODO: implement — NSS at ~/snap/chromium/current/.pki/nssdb + skip "not yet implemented" +} + +@test "Firefox (snap) headless loads HTTPS page after trust install" { + # TODO: implement — per-profile cert9.db under ~/snap/firefox/current/.mozilla/firefox/ + skip "not yet implemented" +} + +@test "Brave (snap) headless loads HTTPS page after trust install" { + # TODO: implement — NSS at ~/snap/brave/current/.pki/nssdb + # Note: Brave headless hangs in Docker (dbus-dependent init never completes without a + # running dbus session). Needs investigation before implementing. + skip "not yet implemented" +} + diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..4860f3b --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Run containerized tests locally. +# Usage: bash tests/run-tests.sh [linux-ubuntu] [linux-debian] +# With no arguments, all suites are run. + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +on_interrupt() { + echo "" + echo "Interrupted (Ctrl+C). Exiting test runner." + exit 130 +} +trap on_interrupt INT + +SUITES=("$@") +if [[ ${#SUITES[@]} -eq 0 ]]; then + SUITES=(linux-ubuntu linux-debian) +fi + +PASS=() +FAIL=() + +run_suite() { + local name="$1" + local tag="install-ca-test-$name" + local dockerfile="" + + case "$name" in + linux-ubuntu) dockerfile="tests/Dockerfile.ubuntu" ;; + linux-debian) dockerfile="tests/Dockerfile.debian" ;; + esac + + echo "" + local status_prefix="== $name == " + printf '%sbuilding... ' "$status_prefix" + if build_out="$(docker build -q -f "$dockerfile" -t "$tag" . 2>&1)"; then + echo "OK" + else + local rc=$? + [[ "$rc" -eq 130 ]] && on_interrupt + echo "FAIL" + echo "ERROR: docker build failed for $name" >&2 + printf '%s\n' "$build_out" >&2 + FAIL+=("$name (build failed)") + return + fi + if docker run --rm "$tag"; then + PASS+=("$name") + echo "run: OK" + else + local rc=$? + if [[ "$rc" -eq 130 ]]; then + on_interrupt + fi + FAIL+=("$name") + echo "run: FAIL" + echo "ERROR: docker run failed for $name" >&2 + fi +} + +for suite in "${SUITES[@]}"; do + case "$suite" in + linux-ubuntu|linux-debian) run_suite "$suite" ;; + *) echo "Unknown suite: $suite (valid: linux-ubuntu, linux-debian)" >&2; exit 1 ;; + esac +done + +echo "" +echo "== results ==" +for s in "${PASS[@]+"${PASS[@]}"}"; do echo " PASS $s"; done +for s in "${FAIL[@]+"${FAIL[@]}"}"; do echo " FAIL $s"; done + +[[ ${#FAIL[@]} -eq 0 ]] diff --git a/tests/windows.ps1 b/tests/windows.ps1 new file mode 100644 index 0000000..704a3cf --- /dev/null +++ b/tests/windows.ps1 @@ -0,0 +1,418 @@ +# Pester tests for install-ca.ps1 on Windows runners +# +# Each test invokes install-ca.ps1 directly as a child PowerShell process, +# passing -Url / -Yes / -Force as named parameters - the same pattern +# used by the bash tests (e.g. "bash install-ca.sh -y $CERT"). + +BeforeAll { + # Elevation check - LocalMachine\Root writes require Administrator privileges. + $currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $currentPrincipal = New-Object System.Security.Principal.WindowsPrincipal($currentIdentity) + if (-not $currentPrincipal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "These tests modify LocalMachine\Root and must be run from an elevated (Administrator) PowerShell session." + } + + $global:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + $global:ScriptPath = Join-Path $global:RepoRoot 'install-ca.ps1' + $script:PowerShellExe = (Get-Process -Id $PID).Path + if (-not $script:PowerShellExe) { + $script:PowerShellExe = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh' } else { 'powershell' } + } + + $rawTimeout = if ($env:CMD_TIMEOUT_SECS) { $env:CMD_TIMEOUT_SECS } else { '10' } + $script:CmdTimeoutMs = [int]($rawTimeout -replace 's$', '') * 1000 + + # Generate test certificates via the dedicated script (cert gen is not inline here) + $script:TmpCertDir = Join-Path ([IO.Path]::GetTempPath()) "test-certs-$([guid]::NewGuid().ToString('N'))" + & (Join-Path $PSScriptRoot 'generate-certs.ps1') -OutputDir $script:TmpCertDir + $script:CertFile = Join-Path $script:TmpCertDir 'test-ca.crt' + $script:LeafCertFile = Join-Path $script:TmpCertDir 'leaf.crt' + $script:HttpsCaFile = Join-Path $script:TmpCertDir 'https-ca.crt' + $script:HttpsServerCrt = Join-Path $script:TmpCertDir 'https-server.crt' + $script:HttpsServerKey = Join-Path $script:TmpCertDir 'https-server.key' + + # Invoke install-ca.ps1 directly with named parameters - same pattern as bash tests. + # The child process is started non-interactively; if the script reaches Read-Host, the prompt + # is not serviced via stdin and will typically fail in non-interactive mode, which the script + # then treats as a "no input" / error path. Stdin is still redirected and closed as part of + # process setup, but that is not what drives Read-Host here. Both stdout and stderr are read + # asynchronously to avoid the deadlock that sequential ReadToEnd() can cause when the child + # process fills one pipe while we are blocked draining the other. + function Join-ProcessArguments { + param([string[]]$Argument) + + $quoted = foreach ($item in $Argument) { + if ($null -eq $item) { + '""' + } elseif ($item -match '[\s"]') { + '"' + ($item -replace '"', '\"') + '"' + } else { + $item + } + } + + return ($quoted -join ' ') + } + + function global:Add-CertToStore([Security.Cryptography.X509Certificates.X509Certificate2]$Cert) { + $store = [Security.Cryptography.X509Certificates.X509Store]::new('Root', 'LocalMachine') + try { + $store.Open('ReadWrite') + $store.Add($Cert) + } finally { + $store.Close() + $store.Dispose() + } + } + + function global:Remove-CertFromStore([Security.Cryptography.X509Certificates.X509Certificate2]$Cert) { + $store = [Security.Cryptography.X509Certificates.X509Store]::new('Root', 'LocalMachine') + try { + $store.Open('ReadWrite') + $store.Certificates | Where-Object Thumbprint -eq $Cert.Thumbprint | ForEach-Object { $store.Remove($_) } + } finally { + $store.Close() + $store.Dispose() + } + } + + # Simulate: irm | iex; Install [args] + # Invoke-Expression on the local script file mirrors what iex does when piped. + function global:Invoke-Oneliner { + param( + [string]$Url = '', + [switch]$Force, + [switch]$Yes + ) + + $escapedPath = $ScriptPath -replace "'", "''" + $installParts = [Collections.Generic.List[string]]::new() + $installParts.Add('Install') + if ($Url) { $installParts.Add("-Url '$($Url -replace "'","''")'") } + if ($Force) { $installParts.Add('-Force') } + if ($Yes) { $installParts.Add('-Yes') } + $installCall = $installParts -join ' ' + + $command = "Invoke-Expression (Get-Content '$escapedPath' -Raw); `$__ec = $installCall; if (`$null -ne `$__ec) { exit [int]`$__ec }" + $argList = @('-NoProfile', '-NonInteractive', '-Command', $command) + + $psi = [Diagnostics.ProcessStartInfo]@{ + FileName = $script:PowerShellExe + RedirectStandardInput = $true + RedirectStandardOutput = $true + RedirectStandardError = $true + UseShellExecute = $false + } + $psi.Arguments = Join-ProcessArguments -Argument $argList + $p = [Diagnostics.Process]::Start($psi) + $p.StandardInput.Close() + $stdoutTask = $p.StandardOutput.ReadToEndAsync() + $stderrTask = $p.StandardError.ReadToEndAsync() + $finished = $p.WaitForExit($script:CmdTimeoutMs) + if (-not $finished) { + try { if (-not $p.HasExited) { $p.Kill() } } catch { } + try { $null = $p.WaitForExit([Math]::Min($script:CmdTimeoutMs, 2000)) } catch { } + } + if ($finished) { + $out = $stdoutTask.GetAwaiter().GetResult() + $stderrTask.GetAwaiter().GetResult() + return [PSCustomObject]@{ ExitCode = $p.ExitCode; Output = $out.Trim() } + } + return [PSCustomObject]@{ ExitCode = 124; Output = 'Command timed out' } + } + + function global:Invoke-Script { + param( + [string]$Url = '', + [switch]$Force, + [switch]$Yes + ) + + $argList = [Collections.Generic.List[string]]::new() + $argList.Add('-NoProfile') + $argList.Add('-NonInteractive') + $argList.Add('-File') + $argList.Add($ScriptPath) + if ($Url) { $argList.Add('-Url'); $argList.Add($Url) } + if ($Force) { $argList.Add('-Force') } + if ($Yes) { $argList.Add('-Yes') } + + $psi = [Diagnostics.ProcessStartInfo]@{ + FileName = $script:PowerShellExe + RedirectStandardInput = $true + RedirectStandardOutput = $true + RedirectStandardError = $true + UseShellExecute = $false + } + $psi.Arguments = Join-ProcessArguments -Argument $argList.ToArray() + $p = [Diagnostics.Process]::Start($psi) + # Always close stdin immediately so any Read-Host call receives EOF and returns null + $p.StandardInput.Close() + $stdoutTask = $p.StandardOutput.ReadToEndAsync() + $stderrTask = $p.StandardError.ReadToEndAsync() + $finished = $p.WaitForExit($script:CmdTimeoutMs) + if (-not $finished) { + try { + if (-not $p.HasExited) { + $p.Kill() + } + } catch { + # Ignore failures from Kill() in the timeout path (process may have already exited) + } + try { + # Use a bounded wait after attempting to kill the process to avoid blocking indefinitely + $null = $p.WaitForExit([Math]::Min($script:CmdTimeoutMs, 2000)) + } catch { + # Ignore failures from WaitForExit() after attempting to kill the process + } + } + # Collect output; use GetAwaiter().GetResult() so individual task exceptions surface cleanly + if ($finished) { + $out = $stdoutTask.GetAwaiter().GetResult() + $stderrTask.GetAwaiter().GetResult() + return [PSCustomObject]@{ ExitCode = $p.ExitCode; Output = $out.Trim() } + } + # On timeout, do not wait on the read tasks to avoid hanging if the process is still running + return [PSCustomObject]@{ ExitCode = 124; Output = 'Command timed out' } + } +} + +AfterAll { + if ($script:TmpCertDir -and (Test-Path $script:TmpCertDir)) { + Remove-Item $script:TmpCertDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Describe 'install-ca.ps1 (Windows)' { + + It 'empty input exits with error' { + $r = Invoke-Script + $r.ExitCode | Should -Be 1 + $r.Output | Should -Match 'No CA source provided' + } + + It 'non-CA leaf cert is rejected with exit code 1' { + $r = Invoke-Script -Url $script:LeafCertFile -Yes + $r.ExitCode | Should -Be 1 + $r.Output | Should -Match 'not a CA certificate|BasicConstraints' + } + + It 'local cert file: installs and verifies' { + $cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new($script:CertFile) + try { + $r = Invoke-Script -Url $script:CertFile -Yes + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'CA Name\s+:\s+Test CA' + $r.Output | Should -Match 'System trust: OK' + } + finally { + Remove-CertFromStore $cert + } + } + + It 'already installed cert exits cleanly' { + $cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new($script:CertFile) + Add-CertToStore $cert + try { + $r = Invoke-Script -Url $script:CertFile + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'Already up-to-date' + } + finally { + Remove-CertFromStore $cert + } + } + + It '-Force: already installed cert continues and reinstalls' { + $cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new($script:CertFile) + try { + Add-CertToStore $cert + $r = Invoke-Script -Url $script:CertFile -Yes -Force + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match '-Force was specified, continuing' + $r.Output | Should -Match 'System trust: OK' + } + finally { + Remove-CertFromStore $cert + } + } + + # TODO: add headless TLS verification tests for browsers on Windows: + # - Chrome - uses Windows cert store; should trust CA after system install + # - Edge - uses Windows cert store; should trust CA after system install + # - Firefox - uses its own NSS profile store; requires profile setup like Linux tests + # - Brave - uses Windows cert store; should trust CA after system install + # - Chromium - uses Windows cert store; should trust CA after system install + + It 'Chrome headless loads HTTPS page after trust install' { + # TODO: implement - Chrome uses the Windows cert store, so trust is implicit after + # system install. Spawn: chrome --headless=new --no-sandbox --dump-dom https://... + Set-ItResult -Skipped -Because 'not yet implemented' + } + + It 'Microsoft Edge headless loads HTTPS page after trust install' { + # TODO: implement - Edge uses the Windows cert store, so trust is implicit after + # system install. Spawn: msedge --headless=new --no-sandbox --dump-dom https://... + Set-ItResult -Skipped -Because 'not yet implemented' + } + + It 'Firefox headless loads HTTPS page after trust install' { + # TODO: implement - Firefox uses its own NSS profile store on Windows. + # Requires profile directory setup similar to the Linux $FIREFOX_DEB_NSS_DIR tests, + # then: firefox --headless --no-remote --profile --screenshot ... https://... + Set-ItResult -Skipped -Because 'not yet implemented' + } + + It 'Brave headless loads HTTPS page after trust install' { + # TODO: implement - Brave uses the Windows cert store, so trust is implicit after + # system install. Spawn: brave --headless=new --no-sandbox --dump-dom https://... + Set-ItResult -Skipped -Because 'not yet implemented' + } + + # -- Oneliner (irm | iex) syntax ----------------------------------------------- + # + # Simulates: irm | iex; Install '' [-Yes] [-Force] + # Invoke-Expression on the local script file mirrors what iex does when piped. + + It 'oneliner: Install with cert path installs cert' { + $cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new($script:CertFile) + try { + $r = Invoke-Oneliner -Url $script:CertFile -Yes + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'CA Name\s+:\s+Test CA' + $r.Output | Should -Match 'System trust: OK' + } finally { + Remove-CertFromStore $cert + } + } + + It 'oneliner: Install with no args fails with error' { + $r = Invoke-Oneliner + $r.ExitCode | Should -Be 1 + $r.Output | Should -Match 'No CA source provided' + } + + It 'oneliner: Install -Force reinstalls already-present cert' { + $cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new($script:CertFile) + try { + Add-CertToStore $cert + $r = Invoke-Oneliner -Url $script:CertFile -Yes -Force + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match '-Force was specified, continuing' + $r.Output | Should -Match 'System trust: OK' + } finally { + Remove-CertFromStore $cert + } + } + + It 'oneliner: Install skips already-installed cert' { + $cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new($script:CertFile) + try { + Add-CertToStore $cert + $r = Invoke-Oneliner -Url $script:CertFile + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'Already up-to-date' + } finally { + Remove-CertFromStore $cert + } + } + + It 'HTTPS URL trusts system CA after install' { + if (-not (Test-Path $script:HttpsCaFile)) { + Set-ItResult -Skipped -Because 'openssl not available - HTTPS certs not generated' + return + } + + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + $listener.Start() + $port = $listener.LocalEndpoint.Port + $listener.Stop() + + $opensslPsi = [Diagnostics.ProcessStartInfo]@{ + FileName = 'openssl' + UseShellExecute = $false + # Redirect stdin so openssl does not inherit a closed pipe from the CI runner. + # openssl s_server monitors stdin in its select() loop and exits on EOF; keeping + # the pipe open (but empty) prevents it from dying during the cert-install step. + RedirectStandardInput = $true + } + $opensslArgs = @( + 's_server' + '-quiet' + '-accept' + $port.ToString() + '-cert' + $script:HttpsServerCrt + '-key' + $script:HttpsServerKey + '-www' + ) + $opensslPsi.Arguments = Join-ProcessArguments -Argument $opensslArgs + $opensslProc = [Diagnostics.Process]::Start($opensslPsi) + + $installed = $false + try { + $serverReady = $false + $sw = [Diagnostics.Stopwatch]::StartNew() + while ($sw.Elapsed.TotalSeconds -lt 5) { + try { + $tcp = [Net.Sockets.TcpClient]::new('127.0.0.1', $port) + $tcp.Close() + $serverReady = $true + break + } catch { + Start-Sleep -Milliseconds 100 + } + } + $sw.Stop() + if (-not $serverReady) { + throw "HTTPS test server on port $port was not reachable within $([math]::Round($sw.Elapsed.TotalSeconds, 2)) seconds; aborting test before Invoke-WebRequest." + } + + $r = Invoke-Script -Url $script:HttpsCaFile -Yes + $r.ExitCode | Should -Be 0 + $installed = $true + + # Verify the server is still alive after the cert install step; if it exited + # (e.g., stdin EOF on CI), fail with a clear message rather than a TLS error. + if ($opensslProc.HasExited) { + throw "openssl s_server exited unexpectedly (exit code $($opensslProc.ExitCode)) before the HTTPS trust check could run." + } + + $psi = [Diagnostics.ProcessStartInfo]@{ + FileName = $script:PowerShellExe + RedirectStandardOutput = $true + RedirectStandardError = $true + UseShellExecute = $false + } + $childArgs = @( + '-NoProfile' + '-NonInteractive' + '-Command' + "if (`$PSVersionTable.PSVersion.Major -lt 6) { Invoke-WebRequest https://127.0.0.1:$port/ -UseBasicParsing | Out-Null } else { Invoke-WebRequest https://127.0.0.1:$port/ | Out-Null }" + ) + $psi.Arguments = Join-ProcessArguments -Argument $childArgs + $p = [Diagnostics.Process]::Start($psi) + $stdoutTask = $p.StandardOutput.ReadToEndAsync() + $stderrTask = $p.StandardError.ReadToEndAsync() + $fin = $p.WaitForExit($script:CmdTimeoutMs) + if (-not $fin) { + try { $p.Kill() } catch { } + $finAfterKill = $p.WaitForExit($script:CmdTimeoutMs) + if (-not $finAfterKill) { + throw "Child PowerShell process for HTTPS Invoke-WebRequest did not exit within $($script:CmdTimeoutMs) ms even after Kill(); aborting test to avoid hang." + } + } + [void]$stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $p.ExitCode | Should -Be 0 -Because "Invoke-WebRequest stderr: $($stderr.Trim())" + } + finally { + if ($null -ne $opensslProc -and -not $opensslProc.HasExited) { + try { $opensslProc.Kill(); $opensslProc.WaitForExit() } catch { } + } + if ($installed) { + $httpsCaCert = [Security.Cryptography.X509Certificates.X509Certificate2]::new($script:HttpsCaFile) + try { Remove-CertFromStore $httpsCaCert } finally { $httpsCaCert.Dispose() } + } + } + } +}