CheckPQC.psm1
|
# CheckPQC.psm1 — PowerShell cmdlets for CheckPQC. # # Design notes: # * Default online checks call the hosted CheckPQC API. This keeps the # managed-fleet path dependency-light: PowerShell + HTTPS only. # * Offline / air-gapped checks still use the npm CLI because local # ML-KEM handshakes require OpenSSL 3.5+ or an equivalent TLS stack. # * If `check-pqc` is not on PATH but Node 18+ is, offline mode installs # the npm package on demand into the user's npm prefix. 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' $script:CheckPQCApiBaseUrl = 'https://api.checkpqc.app' # --------------------------------------------------------------------------- # Internal helpers (not exported) # --------------------------------------------------------------------------- function Get-PQCCliPath { $cmd = Get-Command -Name 'check-pqc' -ErrorAction SilentlyContinue if ($cmd) { return $cmd.Source } $npm = Get-Command -Name 'npm' -ErrorAction SilentlyContinue if (-not $npm) { return $null } try { $prefix = (& npm config get prefix 2>$null).Trim() if (-not $prefix) { return $null } $isWindowsVariable = Get-Variable -Name IsWindows -ValueOnly -ErrorAction SilentlyContinue $isWindowsRuntime = ($PSVersionTable.PSEdition -eq 'Desktop') -or ($true -eq $isWindowsVariable) $candidateNames = if ($isWindowsRuntime) { @('check-pqc.cmd', 'check-pqc.exe', 'check-pqc') } else { @('check-pqc') } $candidateDirs = @($prefix, (Join-Path $prefix 'bin')) | Select-Object -Unique foreach ($candidateDir in $candidateDirs) { foreach ($candidateName in $candidateNames) { $candidate = Join-Path $candidateDir $candidateName if (Test-Path -LiteralPath $candidate) { return $candidate } } } } catch { Write-Verbose "Unable to inspect npm global prefix: $_" } return $null } function Enable-CheckPQCModernProtocol { try { $protocols = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 try { $protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13 } catch { Write-Verbose "TLS 1.3 SecurityProtocolType is not available in this PowerShell runtime: $_" } [Net.ServicePointManager]::SecurityProtocol = $protocols } catch { Write-Verbose "Unable to adjust PowerShell HTTPS protocol defaults: $_" } } function Get-PQCExitCode { param([AllowNull()] [string]$Verdict) switch ($Verdict) { 'PQC_ENABLED' { return 0 } 'HYBRID_ENABLED' { return 0 } 'AVAILABLE_NOT_ACTIVE' { return 2 } 'CLIENT_ONLY' { return 2 } 'SERVER_ONLY' { return 2 } 'NOT_READY' { return 1 } default { return 3 } } } function Add-PQCExitCode { param([Parameter(Mandatory)] [psobject]$Result) $verdictProperty = $Result.PSObject.Properties['verdict'] if (-not $verdictProperty) { $verdictProperty = $Result.PSObject.Properties['Verdict'] } $verdict = if ($verdictProperty) { $verdictProperty.Value } else { $null } $exitCode = Get-PQCExitCode -Verdict $verdict Add-Member -InputObject $Result -NotePropertyName ExitCode -NotePropertyValue $exitCode -Force return $Result } function Invoke-PQCApi { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$HostName, [int]$Port = 443, [int]$TimeoutSeconds = 30 ) if ($Port -ne 443) { throw 'The hosted CheckPQC API supports public HTTPS on port 443 only. For a custom port, install the CLI and run Test-PQC -Offline.' } Enable-CheckPQCModernProtocol $apiBase = $script:CheckPQCApiBaseUrl.TrimEnd('/') $uri = "$apiBase/v1/probe" $payload = @{ hostname = $HostName; port = $Port } | ConvertTo-Json -Compress $apiTimeout = [Math]::Max($TimeoutSeconds, 10) try { $response = Invoke-RestMethod ` -Uri $uri ` -Method Post ` -ContentType 'application/json' ` -Body $payload ` -TimeoutSec $apiTimeout ` -Headers @{ 'User-Agent' = 'CheckPQC-PowerShell/0.2.7' } return Add-PQCExitCode -Result $response } catch { $message = $_.Exception.Message $responseStream = $null $responseProperty = $_.Exception.PSObject.Properties['Response'] if ($responseProperty -and $responseProperty.Value) { $responseStream = $responseProperty.Value.GetResponseStream() } if ($responseStream) { try { $reader = [System.IO.StreamReader]::new($responseStream) $body = $reader.ReadToEnd() if ($body) { $parsed = $body | ConvertFrom-Json -ErrorAction SilentlyContinue if ($parsed -and $parsed.error -and $parsed.error.message) { $message = $parsed.error.message } } } catch { Write-Verbose "Unable to parse CheckPQC API error response: $_" } } throw "CheckPQC API probe failed: $message. For blocked networks or private hosts, install the CLI and run Test-PQC -Offline." } } 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) { if (Test-NodeAvailable) { Write-Verbose 'check-pqc CLI not found; installing @aegyrix/check-pqc via npm for offline mode.' $null = Install-PQCCli $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.8'). Defaults to latest. .EXAMPLE Install-PQCCli .EXAMPLE Install-PQCCli -Version 0.2.8 #> [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() }) } # --------------------------------------------------------------------------- # Update-PQCCli # --------------------------------------------------------------------------- function Update-PQCCli { <# .SYNOPSIS Update the underlying check-pqc CLI to the latest npm package. #> [CmdletBinding(SupportsShouldProcess)] param() if ($PSCmdlet.ShouldProcess('check-pqc CLI', 'Update to the latest npm package')) { Install-PQCCli } } # --------------------------------------------------------------------------- # Uninstall-PQCCli # --------------------------------------------------------------------------- function Uninstall-PQCCli { <# .SYNOPSIS Remove the underlying check-pqc npm CLI. #> [CmdletBinding(SupportsShouldProcess)] param() if ($PSCmdlet.ShouldProcess('check-pqc CLI', 'Uninstall npm packages @aegyrix/check-pqc and check-pqc')) { & npm uninstall -g @aegyrix/check-pqc check-pqc if ($LASTEXITCODE -ne 0) { throw "npm uninstall failed (exit $LASTEXITCODE)." } } } # --------------------------------------------------------------------------- # Test-PQC # --------------------------------------------------------------------------- function Test-PQC { <# .SYNOPSIS Probe a host for post-quantum TLS readiness. .DESCRIPTION By default, calls the hosted CheckPQC API and requires only PowerShell + HTTPS. Use -Offline for air-gapped or private-host scans; offline mode uses the local check-pqc CLI and OpenSSL 3.5+. 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). The hosted API supports port 443 only; use -Offline for custom ports. .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. .PARAMETER Offline Run the full probe locally with no CheckPQC API call. Requires a local OpenSSL 3.5+ binary for the hybrid attempt. .EXAMPLE Test-PQC -HostName google.com .EXAMPLE Test-PQC -HostName api.example.com -AsJson | Where-Object { $_.Verdict -ne 'HYBRID_ENABLED' } .EXAMPLE Test-PQC -HostName internal.example.com -Port 8443 -Offline -AsJson .LINK https://checkpqc.com/cli #> [CmdletBinding()] param( [Parameter(Mandatory, Position=0, ValueFromPipeline)] [Alias('Host','Target')] [string]$HostName, [int]$Port = 443, [switch]$AsJson, [switch]$Offline, [int]$TimeoutSeconds = 10 ) process { $target = if ($Port -ne 443) { "${HostName}:${Port}" } else { $HostName } $cliArgs = @($target) if ($AsJson) { $cliArgs += '--json' } if ($Offline) { $cliArgs += '--offline' } if ($TimeoutSeconds) { $cliArgs += @('--timeout', "$TimeoutSeconds") } if ($Offline) { Invoke-PQCCli -Arguments $cliArgs -AsJson:$AsJson } else { Invoke-PQCApi -HostName $HostName -Port $Port -TimeoutSeconds $TimeoutSeconds } } } # --------------------------------------------------------------------------- # 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','Update-PQCCli','Uninstall-PQCCli' |