Private/SshHelpers.ps1

# SshHelpers.ps1
# Shared helpers for New-SshAccess / Remove-SshAccess.
# Pure, testable logic (config blocks, key generation, permissions) plus a
# thin remote-exec wrapper. No business logic lives in the cmdlets that is not here.

# Cross-version Windows detection ($IsWindows is PS6+ only; psd1 targets 5.1+).
$script:OnWindows = ($env:OS -eq 'Windows_NT')

<#
.SYNOPSIS
Returns the absolute path to the current user's .ssh directory, creating it if missing.
#>

function Get-SshUserDir {
    [CmdletBinding()]
    param()
    $home = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
    $dir = Join-Path $home '.ssh'
    if (-not (Test-Path $dir)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
        if (-not $script:OnWindows) { & chmod 700 $dir }
    }
    return $dir
}

<#
.SYNOPSIS
Builds an ~/.ssh/config Host block (pure string, no I/O).
#>

function New-SshConfigEntry {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Alias,
        [Parameter(Mandatory)][string]$HostName,
        [Parameter(Mandatory)][string]$User,
        [int]$Port = 22,
        [Parameter(Mandatory)][string]$IdentityFile
    )
    return @(
        "Host $Alias"
        " HostName $HostName"
        " User $User"
        " Port $Port"
        " IdentityFile $IdentityFile"
        " IdentitiesOnly yes"
    ) -join "`n"
}

<#
.SYNOPSIS
Adds (or replaces, with -Force) a Host block in an ssh config file. Idempotent.
Returns an object with the action taken: created | appended | replaced.
#>

function Add-SshConfigHost {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ConfigPath,
        [Parameter(Mandatory)][string]$Alias,
        [Parameter(Mandatory)][string]$HostName,
        [Parameter(Mandatory)][string]$User,
        [int]$Port = 22,
        [Parameter(Mandatory)][string]$IdentityFile,
        [switch]$Force
    )
    $block = New-SshConfigEntry -Alias $Alias -HostName $HostName -User $User -Port $Port -IdentityFile $IdentityFile

    if (-not (Test-Path $ConfigPath)) {
        $dir = Split-Path -Parent $ConfigPath
        if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        Set-Content -Path $ConfigPath -Value ($block + "`n") -Encoding UTF8
        return [pscustomobject]@{ Action = 'created'; Alias = $Alias }
    }

    $content = Get-Content $ConfigPath -Raw
    $pattern = "(?ms)^Host[ \t]+$([regex]::Escape($Alias))[ \t]*\r?$.*?(?=^Host[ \t]|\z)"
    if ($content -match $pattern) {
        if (-not $Force) {
            throw "Host '$Alias' already exists in $ConfigPath. Use -Force to replace it."
        }
        $content = [regex]::Replace($content, $pattern, ($block + "`n"))
        Set-Content -Path $ConfigPath -Value $content -Encoding UTF8
        return [pscustomobject]@{ Action = 'replaced'; Alias = $Alias }
    }
    Add-Content -Path $ConfigPath -Value ("`n" + $block + "`n")
    return [pscustomobject]@{ Action = 'appended'; Alias = $Alias }
}

<#
.SYNOPSIS
Removes a Host block (by alias) from an ssh config file. Returns $true if removed.
#>

function Remove-SshConfigHost {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ConfigPath,
        [Parameter(Mandatory)][string]$Alias
    )
    if (-not (Test-Path $ConfigPath)) { return $false }
    $content = Get-Content $ConfigPath -Raw
    $pattern = "(?ms)^Host[ \t]+$([regex]::Escape($Alias))[ \t]*\r?$.*?(?=^Host[ \t]|\z)"
    if ($content -notmatch $pattern) { return $false }
    $content = [regex]::Replace($content, $pattern, '')
    Set-Content -Path $ConfigPath -Value ($content.TrimEnd() + "`n") -Encoding UTF8
    return $true
}

<#
.SYNOPSIS
Restricts permissions on a private key file (chmod 600 on Linux, ACL reset on Windows).
OpenSSH refuses to use a private key with loose permissions.
#>

function Protect-SshPrivateKey {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    if ($script:OnWindows) {
        $me = "$env:USERNAME"
        & icacls $Path /inheritance:r *> $null
        & icacls $Path /grant:r "${me}:F" *> $null
    } else {
        & chmod 600 $Path
    }
}

<#
.SYNOPSIS
Generates an SSH key pair with ssh-keygen. Returns paths, public key and fingerprint.
Empty -Passphrase produces an unattended (deploy) key.
#>

function New-SshKeyPair {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [string]$Type = 'ed25519',
        [string]$Comment = '',
        [string]$Passphrase = '',
        [switch]$Force
    )
    if (Test-Path $Path) {
        if (-not $Force) { throw "A key already exists at $Path. Use -Force to overwrite." }
        Remove-Item -LiteralPath $Path, "$Path.pub" -Force -ErrorAction SilentlyContinue
    }
    $dir = Split-Path -Parent $Path
    if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }

    # Windows PowerShell 5.1 silently DROPS empty-string arguments to native commands, so an
    # empty `-N ''` reaches ssh-keygen without a value and it PROMPTS for a passphrase. Route the
    # empty-passphrase case through cmd.exe (Windows-only, where 5.1 runs) so `-N ""` survives.
    # PowerShell 7 (Windows/Linux) passes empty args correctly and uses the normal call.
    if (($PSVersionTable.PSVersion.Major -lt 6) -and [string]::IsNullOrEmpty($Passphrase)) {
        & cmd.exe /c "ssh-keygen -t $Type -q -f `"$Path`" -C `"$Comment`" -N `"`""
    } else {
        & ssh-keygen -t $Type -f $Path -C $Comment -N $Passphrase -q
    }
    if ($LASTEXITCODE -ne 0) { throw "ssh-keygen failed (exit $LASTEXITCODE)" }

    Protect-SshPrivateKey -Path $Path

    return [pscustomobject]@{
        PrivateKeyPath = $Path
        PublicKeyPath  = "$Path.pub"
        PublicKey      = (Get-Content "$Path.pub" -Raw).Trim()
        Fingerprint    = (Get-SshKeyFingerprint -PublicKeyPath "$Path.pub")
    }
}

<#
.SYNOPSIS
Returns the SHA256 fingerprint of a public key file.
#>

function Get-SshKeyFingerprint {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$PublicKeyPath)
    $line = & ssh-keygen -lf $PublicKeyPath
    if ($LASTEXITCODE -ne 0) { throw "ssh-keygen -lf failed for $PublicKeyPath" }
    return (($line -split '\s+')[1])
}

<#
.SYNOPSIS
Reads and validates a public key file (someone else's .pub, typically). Returns the
normalized key line, its parts and SHA256 fingerprint. Rejects private keys and
anything that is not a single "<type> <base64> [comment]" line.
#>

function Read-SshPublicKeyFile {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) { throw "Public key file not found: $Path" }
    $raw = (Get-Content -LiteralPath $Path -Raw)
    if ($raw -match 'PRIVATE KEY') {
        throw "'$Path' is a PRIVATE key. Ask for the .pub file; the private key must never leave its owner's machine."
    }
    $lines = @($raw -split "`r?`n" | Where-Object { $_.Trim() -ne '' })
    if ($lines.Count -ne 1) { throw "'$Path' must contain exactly one public key line (found $($lines.Count))." }
    $parts = $lines[0].Trim() -split '\s+'
    if ($parts.Count -lt 2 -or $parts[0] -notmatch '^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com)$' -or $parts[1] -notmatch '^AAAA[A-Za-z0-9+/=]+$') {
        throw "'$Path' does not look like an OpenSSH public key (expected '<type> AAAA... [comment]')."
    }
    return [pscustomobject]@{
        PublicKey   = ($parts -join ' ')
        Type        = $parts[0]
        Blob        = $parts[1]
        Comment     = ($(if ($parts.Count -gt 2) { ($parts[2..($parts.Count - 1)] -join ' ') } else { '' }))
        Fingerprint = (Get-SshKeyFingerprint -PublicKeyPath $Path)
    }
}

<#
.SYNOPSIS
New-SshAccess -PublicKeyFile flow: install a colleague's .pub into the target user's
authorized_keys (via the bootstrap login, sudo when needed). No key generation, no local
~/.ssh/config change, no login verification (the private key is not ours). Prints the
Host block the colleague must add on their side and returns a summary object.
#>

function Install-SshForeignPublicKey {
    [CmdletBinding()]
    param(
        [string]$Server,
        [Parameter(Mandatory)][string]$HostName,
        [Parameter(Mandatory)][string]$User,
        [int]$Port = 22,
        [string]$BootstrapUser,
        [string]$BootstrapIdentityFile,
        [switch]$Sudo,
        [Parameter(Mandatory)][string]$PublicKeyFile
    )
    if (-not $Server) { $Server = $HostName }

    Write-Host ""
    Write-Host " New-SshAccess - install foreign public key for $User@$HostName" -ForegroundColor Cyan

    $key = Read-SshPublicKeyFile -Path $PublicKeyFile
    Write-Host " Public key : $($key.Type) $($key.Fingerprint) $($key.Comment)" -ForegroundColor DarkGray
    if (-not $key.Comment) {
        Write-Host " WARNING: the key has no comment. Ask for one (e.g. <user>@<server>-<who>) so it can be identified and revoked later." -ForegroundColor Yellow
    }

    $bootstrap = if ($BootstrapUser) { $BootstrapUser } else { $User }
    $useSudo = ($Sudo -or ($bootstrap -ne $User))
    Write-Host " Installing public key for '$User' via '$bootstrap' (sudo=$useSudo)..." -ForegroundColor DarkGray
    if ($useSudo -and -not $BootstrapIdentityFile) { Write-Host " You may be prompted for your password up to 3 times (scp, ssh, sudo)." -ForegroundColor Yellow }

    $installScript = Get-BashScript -ScriptName 'Install-AuthorizedKey.sh' -Placeholders @{
        '__TARGET_USER__' = $User
        '__PUBKEY__'      = $key.PublicKey
        '__USE_SUDO__'    = ($(if ($useSudo) { '1' } else { '0' }))
    }
    Invoke-RemoteBash -ScriptContent $installScript -User $bootstrap -HostName $HostName -Port $Port -IdentityFile $BootstrapIdentityFile -Tty:$useSudo -Prefix 'macss_authkey_'
    $rc = $LASTEXITCODE
    if ($rc -ne 0) { throw "Public key install failed (exit $rc)." }

    $block = New-SshConfigEntry -Alias $Server -HostName $HostName -User $User -Port $Port -IdentityFile "~/.ssh/$Server"
    Write-Host ""
    Write-Host " OK: public key installed for $User@$HostName. Not verified (the private key is not on this machine)." -ForegroundColor Green
    Write-Host " Send this block to the key owner to append to THEIR ~/.ssh/config (adjust IdentityFile to where they saved the private key):" -ForegroundColor DarkGray
    Write-Host ""
    Write-Host ($block -replace '(?m)^', ' ')
    Write-Host ""
    Write-Host " Then they can run: ssh $Server" -ForegroundColor DarkGray

    return [pscustomobject]@{
        Server        = $Server
        HostName      = $HostName
        User          = $User
        KeyPath       = $null
        PublicKeyFile = (Resolve-Path -LiteralPath $PublicKeyFile).Path
        Fingerprint   = $key.Fingerprint
        Comment       = $key.Comment
        Verified      = $false
        ConfigBlock   = $block
    }
}

<#
.SYNOPSIS
Extracts the base64 blob (second field) of a public key line. Used to match keys
in a server's authorized_keys regardless of the trailing comment.
#>

function Get-SshPublicKeyBlob {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$PublicKey)
    return (($PublicKey.Trim() -split '\s+')[1])
}

<#
.SYNOPSIS
Runs a bash script on a remote host over SSH. If -IdentityFile is given it is used
(-i); otherwise SSH falls back to its default auth (password/agent) - this is how
the first-time bootstrap install works before any key is authorized.
Output is written live to the host (the ssh call is not piped, so an interactive -tt
sudo prompt renders in real time). The ssh exit code is left in $LASTEXITCODE; the
caller must read it right after the call.
#>

function Invoke-RemoteBash {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ScriptContent,
        [Parameter(Mandatory)][string]$User,
        [Parameter(Mandatory)][string]$HostName,
        [int]$Port = 22,
        [string]$IdentityFile,
        [switch]$Tty,
        [string]$Prefix = 'macss_ssh_'
    )
    # Normalize to LF and write a UTF-8 (no BOM) temp script.
    $unix = $ScriptContent -replace "`r`n", "`n" -replace "`r", "`n"
    $tmp = [IO.Path]::Combine([IO.Path]::GetTempPath(), ("{0}{1}.sh" -f $Prefix, ([guid]::NewGuid().ToString())))
    [System.IO.File]::WriteAllText($tmp, $unix, (New-Object System.Text.UTF8Encoding($false)))

    $idArgs = @()
    if ($IdentityFile) { $idArgs = @('-i', $IdentityFile, '-o', 'IdentitiesOnly=yes') }
    $common = @('-o', 'StrictHostKeyChecking=accept-new', '-P', "$Port") + $idArgs

    $remote = "/tmp/" + [IO.Path]::GetFileName($tmp)
    try {
        Write-Host " [scp] uploading script to $HostName ..." -ForegroundColor DarkGray
        # scp is piped to Out-Host: its stdout is consumed (kept out of the return value)
        # while the password prompt, written to the local tty, still shows.
        & scp @common $tmp "$($User)@$($HostName):$remote" 2>&1 | Out-Host
        if ($LASTEXITCODE -ne 0) { throw "scp failed (exit $LASTEXITCODE)" }

        # ssh uses -p (lowercase) for port; rebuild without the scp-style -P.
        # -tt forces a pseudo-tty so an in-script `sudo` can prompt for its password
        # (a service-account install/revoke runs via sudo over a non-interactive ssh).
        $ttyArgs = @(); if ($Tty) { $ttyArgs = @('-tt') }
        $sshCommon = @('-o', 'StrictHostKeyChecking=accept-new', '-p', "$Port") + $ttyArgs + $idArgs
        Write-Host " [ssh] running remote script (enter password / sudo if prompted) ..." -ForegroundColor DarkGray
        # NOT piped on purpose: with -tt the forwarded sudo prompt must reach the console
        # live. The caller reads $LASTEXITCODE (the ssh exit code) immediately after.
        & ssh @sshCommon "$($User)@$($HostName)" "bash $remote; rc=`$?; rm -f $remote; exit `$rc"
    }
    finally {
        Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue
    }
}