Public/Ssh/Test-VmSshCredential.ps1

# ---------------------------------------------------------------------------
# Test-VmSshCredential
# Single-shot authentication probe: opens one SSH session with the given
# credentials, then disposes it. Returns $true if the server ACCEPTED the
# password, $false if it DEFINITIVELY REJECTED it, and throws on any other
# (transient / unreachable) failure.
#
# Why three outcomes instead of a bool:
# "rejected" and "could not connect" are different facts and callers
# act on them differently. A rejection is a verdict about the account
# (the password is wrong, or - upstream of this probe - the account was
# never created); a timeout / connection-refused / KEX failure is a
# transport problem that says nothing about the credentials. Collapsing
# both into $false would force every caller to re-derive the difference.
# So a definitive rejection returns $false, while a transient error is
# rethrown UNCHANGED to keep its own diagnostic surface.
#
# Relationship to the other Ssh probes:
# - Test-VmSshPort answers "is the port open?" (TCP).
# - Test-SshBanner answers "is sshd actually replying?" (banner bytes).
# - Test-VmSshCredential answers "does this login work?" (auth) - the
# last rung, reached only once the lower probes pass. A banner-
# reachable host can still have zero usable logins (e.g. first-boot
# user provisioning failed), which only an auth attempt reveals.
#
# Classification is by message/inner-exception shape rather than a hard
# Renci type reference: SSH.NET surfaces a rejected password as a
# "Permission denied" message (wrapped by PowerShell's method-call
# machinery into a MethodInvocationException whose message still carries
# that text, with the SshAuthenticationException as InnerException). Keying
# on that keeps the function unit-testable without SSH.NET loaded.
#
# Connect policy (host-key acceptance, KEX set, keepalive, timeout) is
# inherited wholesale from New-VmSshClient so this probe behaves exactly
# like every other session in the module. NOTE: a rejection therefore
# pays New-VmSshClient's connect retries before SSH.NET gives up, so the
# $false path is not instantaneous; the $true path returns as soon as the
# handshake completes.
#
# Security:
# - Password is required as a plain string by SSH.NET (same contract as
# New-VmSshClient). Source it from SecretManagement and never log it.
# ---------------------------------------------------------------------------

function Test-VmSshCredential {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSAvoidUsingPlainTextForPassword', 'Password')]
    # SSH.NET takes the username/password as plaintext strings; the pair is
    # intrinsic to the connect contract (see New-VmSshClient), so suppress
    # the paired-param rule (function-scoped: the suppression ID is empty).
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSAvoidUsingUsernameAndPasswordParams', '',
        Justification = 'SSH.NET requires a plaintext username/password pair')]
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [string] $IpAddress,

        [Parameter(Mandatory)]
        [string] $Username,

        # Plain string required by SSH.NET PasswordAuthenticationMethod.
        [Parameter(Mandatory)]
        [string] $Password,

        # TCP port sshd is reachable on. Defaults to 22; callers probing
        # through a local-forward tunnel pass the ephemeral loopback port.
        [int] $Port = 22,

        # Total Connect() wall-clock budget. Mirrors New-VmSshClient's
        # default so this probe and a real session time out identically.
        [TimeSpan] $Timeout = [TimeSpan]::FromSeconds(30)
    )

    $client = $null
    try {
        # New-VmSshClient owns the connect + password-auth contract for the
        # whole stack; reuse it so this probe is indistinguishable from a
        # real session at the transport layer.
        $client = New-VmSshClient -IpAddress $IpAddress `
                                  -Username  $Username `
                                  -Password  $Password `
                                  -Port      $Port `
                                  -Timeout   $Timeout
        return $true
    }
    catch {
        $ex = $_.Exception

        # A definitive authentication rejection is the only failure that
        # answers the question with "no". Anything else (timeout, refused,
        # KEX mismatch) is a transport problem that says nothing about the
        # credentials, so rethrow it with its original surface intact.
        $isAuthFailure =
            ($ex.Message -match 'Permission denied') -or
            ($null -ne $ex.InnerException -and
             $ex.InnerException.GetType().Name -match 'Authentication')

        if ($isAuthFailure) { return $false }
        throw
    }
    finally {
        if ($null -ne $client) {
            if ($client.IsConnected) { $client.Disconnect() }
            $client.Dispose()
        }
    }
}