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.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
        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.

    .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 -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,

        [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") }

        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','Update-PQCCli','Uninstall-PQCCli'