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 `
            -UserAgent 'CheckPQC-PowerShell/0.2.8'
        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'