CheckPQC.psm1
|
# CheckPQC.psm1 — PowerShell wrapper around @aegyrix/check-pqc CLI. # # Design notes: # * No reimplementation of the TLS handshake. .NET's SslStream does not # expose negotiated groups before .NET 10, and ML-KEM support is not # yet stable across runtimes. Wrapping the npm CLI is the only honest # option that delivers correct verdicts today. # * On first use, if `check-pqc` is not on PATH but Node 18+ is, we # install the npm package on demand into the user's npm prefix (no # sudo). Failure surfaces a precise next-step. # * All cmdlets honor -ErrorAction. Non-success verdicts are returned # as objects (with .Verdict and .ExitCode), not exceptions, so they # compose with `|? { $_.ExitCode -ne 0 }` style pipelines. #Requires -Version 5.1 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # --------------------------------------------------------------------------- # Internal helpers (not exported) # --------------------------------------------------------------------------- function Get-PQCCliPath { $cmd = Get-Command -Name 'check-pqc' -ErrorAction SilentlyContinue if ($cmd) { return $cmd.Source } return $null } function Test-NodeAvailable { $node = Get-Command -Name 'node' -ErrorAction SilentlyContinue if (-not $node) { return $false } try { $version = (& node --version 2>$null) -replace '^v','' $major = [int]($version.Split('.')[0]) return ($major -ge 18) } catch { return $false } } function Invoke-PQCCli { [CmdletBinding()] param( [Parameter(Mandatory)] [string[]]$Arguments, [switch]$AsJson ) $cli = Get-PQCCliPath if (-not $cli) { throw @" check-pqc CLI not found on PATH. Install with one of: Install-PQCCli # auto via npm (this module) iwr -useb https://checkpqc.com/install.ps1 | iex npm install -g @aegyrix/check-pqc docker run --rm ghcr.io/aegyrix/check-pqc:latest <host> See https://checkpqc.com/cli for full docs. "@ } # Capture stdout, stderr, and exit code separately. PowerShell merges # them by default; we want the parsed JSON / text untouched. $psi = [System.Diagnostics.ProcessStartInfo]::new() $psi.FileName = $cli foreach ($a in $Arguments) { $psi.ArgumentList.Add($a) } $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $proc = [System.Diagnostics.Process]::Start($psi) $stdout = $proc.StandardOutput.ReadToEnd() $stderr = $proc.StandardError.ReadToEnd() $proc.WaitForExit() $exit = $proc.ExitCode $result = [pscustomobject]@{ ExitCode = $exit Stdout = $stdout Stderr = $stderr } if ($AsJson) { try { $parsed = $stdout | ConvertFrom-Json -ErrorAction Stop # Surface ExitCode + Verdict at the top of the object for piping. Add-Member -InputObject $parsed -NotePropertyName ExitCode -NotePropertyValue $exit -Force return $parsed } catch { Write-Verbose "Failed to parse CLI JSON: $_" return $result } } return $result } # --------------------------------------------------------------------------- # Install-PQCCli # --------------------------------------------------------------------------- function Install-PQCCli { <# .SYNOPSIS Install or update the check-pqc CLI via npm. .DESCRIPTION Installs the @aegyrix/check-pqc npm package globally into the current user's npm prefix. Requires Node.js 18+. Idempotent — re-runs upgrade in place. .PARAMETER Version Pin a specific CLI version (e.g. '0.2.5'). Defaults to latest. .EXAMPLE Install-PQCCli .EXAMPLE Install-PQCCli -Version 0.2.5 #> [CmdletBinding()] param( [string]$Version ) if (-not (Test-NodeAvailable)) { throw @" Node.js 18+ is required. Install via: winget install OpenJS.NodeJS.LTS # Windows brew install node # macOS https://nodejs.org/en/download "@ } $pkg = if ($Version) { "@aegyrix/check-pqc@$Version" } else { '@aegyrix/check-pqc@latest' } Write-Verbose "Installing $pkg via npm" $npmArgs = @('install', '-g', $pkg) & npm @npmArgs if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit $LASTEXITCODE). On Windows, run PowerShell as Administrator, or use a Node version manager (nvm-windows, fnm)." } $cli = Get-PQCCliPath if (-not $cli) { throw "check-pqc installed but not on PATH. Add `$(npm config get prefix)` (or its bin/ subfolder) to your PATH and restart the shell." } Write-Output ([pscustomobject]@{ Path = $cli Version = (& $cli --version 2>$null).Trim() }) } # --------------------------------------------------------------------------- # Test-PQC # --------------------------------------------------------------------------- function Test-PQC { <# .SYNOPSIS Probe a host for post-quantum TLS readiness. .DESCRIPTION Performs a twin-probe TLS 1.3 handshake against the target host. Returns the same seven verdicts as the web scanner at https://checkpqc.app: HYBRID_ENABLED, PQC_ENABLED, AVAILABLE_NOT_ACTIVE, CLIENT_ONLY, SERVER_ONLY, NOT_READY, UNKNOWN. .PARAMETER HostName DNS name or IP of the server to probe. Required. .PARAMETER Port TCP port (default: 443). .PARAMETER AsJson Return the structured CLI output as a parsed object (with .Verdict, .NamedGroup, .ExitCode, ...). Without this, returns the raw stdout/stderr/exit triple. .PARAMETER TimeoutSeconds Per-probe socket timeout passed through to the CLI. Default: 10. .EXAMPLE Test-PQC -HostName google.com .EXAMPLE Test-PQC -HostName api.example.com -Port 8443 -AsJson | Where-Object { $_.Verdict -ne 'HYBRID_ENABLED' } .LINK https://checkpqc.com/cli #> [CmdletBinding()] param( [Parameter(Mandatory, Position=0, ValueFromPipeline)] [Alias('Host','Target')] [string]$HostName, [int]$Port = 443, [switch]$AsJson, [int]$TimeoutSeconds = 10 ) process { $target = if ($Port -ne 443) { "${HostName}:${Port}" } else { $HostName } $cliArgs = @($target) if ($AsJson) { $cliArgs += '--json' } if ($TimeoutSeconds) { $cliArgs += @('--timeout', "$TimeoutSeconds") } Invoke-PQCCli -Arguments $cliArgs -AsJson:$AsJson } } # --------------------------------------------------------------------------- # Test-PQCOffline # --------------------------------------------------------------------------- function Test-PQCOffline { <# .SYNOPSIS Check the local OpenSSL stack for ML-KEM hybrid TLS support. .DESCRIPTION No network calls. Reports whether the local environment is capable of offering hybrid PQC groups in a TLS 1.3 client handshake. Useful for pre-flighting CI runners and air-gapped hosts. .EXAMPLE Test-PQCOffline .EXAMPLE if ((Test-PQCOffline -AsJson).Capable) { 'Ready' } #> [CmdletBinding()] param( [switch]$AsJson ) $cliArgs = @('--check-offline') if ($AsJson) { $cliArgs += '--json' } Invoke-PQCCli -Arguments $cliArgs -AsJson:$AsJson } # --------------------------------------------------------------------------- # Get-PQCVerdict # --------------------------------------------------------------------------- function Get-PQCVerdict { <# .SYNOPSIS Return only the verdict string for a host. .DESCRIPTION Convenience wrapper that runs Test-PQC -AsJson and returns just the verdict (HYBRID_ENABLED / PQC_ENABLED / AVAILABLE_NOT_ACTIVE / CLIENT_ONLY / SERVER_ONLY / NOT_READY / UNKNOWN). .EXAMPLE 'google.com','example.com' | Get-PQCVerdict .EXAMPLE @('a.example','b.example','c.example') | ForEach-Object { [pscustomobject]@{ Host=$_; Verdict=(Get-PQCVerdict $_) } } #> [CmdletBinding()] param( [Parameter(Mandatory, Position=0, ValueFromPipeline)] [Alias('Host','Target')] [string]$HostName, [int]$Port = 443 ) process { $r = Test-PQC -HostName $HostName -Port $Port -AsJson if ($null -ne $r -and $r.PSObject.Properties.Match('Verdict').Count -gt 0) { $r.Verdict } else { 'UNKNOWN' } } } Export-ModuleMember -Function 'Test-PQC','Test-PQCOffline','Get-PQCVerdict','Install-PQCCli' |