Private/New-RotationSshKeyPair.ps1

function New-RotationSshKeyPair {
    <#
    .SYNOPSIS
        Generates an RSA key pair in OpenSSH-compatible form.

    .DESCRIPTION
        Produces a PKCS#8 PEM private key and an "ssh-rsa" public key.

        Two details matter and are easy to get wrong:

        1. PEM line endings. Base64FormattingOptions::InsertLineBreaks emits CRLF.
           Mixing that with LF-terminated header lines produces a file that some
           OpenSSH clients reject outright. This function wraps at 64 characters
           with LF only.

        2. The public key wire format. "ssh-rsa" is a sequence of length-prefixed
           big-endian fields, and each integer needs a leading zero byte when its
           most significant bit is set, or it reads as negative. Building that by
           appending to a PowerShell array happens to work for RSA and silently
           breaks the moment anything changes, so it is written explicitly here.

        RSA rather than Ed25519 because the .NET 6 runtime behind PowerShell 7.2
        runbooks has no Ed25519 primitive. If you run this somewhere with a newer
        runtime, Ed25519 is the better default.

    .OUTPUTS
        PSCustomObject with PrivateKey (SecureString, PEM) and PublicKey (String).
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [ValidateSet(2048, 3072, 4096)]
        [int]$KeySize = 4096
    )

    $rsa = [System.Security.Cryptography.RSA]::Create($KeySize)
    try {
        $pem = ConvertTo-PemBlock -Der $rsa.ExportPkcs8PrivateKey() -Label 'PRIVATE KEY'

        $parameters = $rsa.ExportParameters($false)
        $publicKey = ConvertTo-OpenSshPublicKey -Exponent $parameters.Exponent -Modulus $parameters.Modulus

        return [pscustomobject]@{
            PrivateKey = ConvertTo-SecureString -String $pem -AsPlainText -Force
            PublicKey  = $publicKey
        }
    }
    finally {
        $rsa.Dispose()
        $pem = $null
        $parameters = $null
    }
}

function ConvertFrom-PemToSshPublicKey {
    <#
    .SYNOPSIS
        Derives the "ssh-rsa" public key from a PKCS#8 private key PEM.

    .DESCRIPTION
        Used when resuming an interrupted rotation. The staged secret holds only the
        private key, so the public key has to be recomputed rather than stored
        alongside it - a 4096-bit ssh-rsa key is roughly 700 characters and Key Vault
        caps a tag value at 256, so keeping it as a tag silently worked in testing and
        failed against a real vault with "Property has invalid value".
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][securestring]$PrivateKeyPem
    )

    $pem = ConvertFrom-SecureString -SecureString $PrivateKeyPem -AsPlainText
    $rsa = [System.Security.Cryptography.RSA]::Create()
    try {
        $rsa.ImportFromPem($pem)
        $parameters = $rsa.ExportParameters($false)
        return ConvertTo-OpenSshPublicKey -Exponent $parameters.Exponent -Modulus $parameters.Modulus
    }
    finally {
        $rsa.Dispose()
        $pem = $null
        [System.GC]::Collect()
    }
}

function ConvertTo-PemBlock {
    <#
    .SYNOPSIS
        Wraps DER bytes in a PEM block with LF line endings.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][byte[]]$Der,
        [Parameter(Mandatory)][string]$Label
    )

    $base64 = [Convert]::ToBase64String($Der)

    $builder = [System.Text.StringBuilder]::new()
    [void]$builder.Append("-----BEGIN $Label-----`n")
    for ($i = 0; $i -lt $base64.Length; $i += 64) {
        $take = [Math]::Min(64, $base64.Length - $i)
        [void]$builder.Append($base64.Substring($i, $take)).Append("`n")
    }
    [void]$builder.Append("-----END $Label-----`n")

    return $builder.ToString()
}

function ConvertTo-OpenSshPublicKey {
    <#
    .SYNOPSIS
        Encodes RSA public parameters as an "ssh-rsa" public key string.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][byte[]]$Exponent,
        [Parameter(Mandatory)][byte[]]$Modulus
    )

    $stream = [System.IO.MemoryStream]::new()
    try {
        Write-SshString -Stream $stream -Value ([System.Text.Encoding]::ASCII.GetBytes('ssh-rsa'))
        Write-SshMpint -Stream $stream -Value $Exponent
        Write-SshMpint -Stream $stream -Value $Modulus

        return 'ssh-rsa ' + [Convert]::ToBase64String($stream.ToArray())
    }
    finally {
        $stream.Dispose()
    }
}

function Write-SshString {
    <#
    .SYNOPSIS
        Writes a length-prefixed byte string in SSH wire format (RFC 4251).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.IO.Stream]$Stream,
        [Parameter(Mandatory)][byte[]]$Value
    )

    $length = [BitConverter]::GetBytes([uint32]$Value.Length)
    if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($length) }

    $Stream.Write($length, 0, 4)
    $Stream.Write($Value, 0, $Value.Length)
}

function Write-SshMpint {
    <#
    .SYNOPSIS
        Writes a multiple-precision integer in SSH wire format (RFC 4251).

    .DESCRIPTION
        mpint values are two's complement. A leading zero byte is required when the
        most significant bit is set, otherwise the value reads as negative. An RSA
        modulus always has that bit set; a public exponent of 65537 does not.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.IO.Stream]$Stream,
        [Parameter(Mandatory)][byte[]]$Value
    )

    # Strip any leading zero bytes the provider may have included.
    $offset = 0
    while ($offset -lt ($Value.Length - 1) -and $Value[$offset] -eq 0) { $offset++ }
    $trimmed = $Value[$offset..($Value.Length - 1)]

    if ($trimmed[0] -band 0x80) {
        $padded = [byte[]]::new($trimmed.Length + 1)
        $padded[0] = 0
        [Array]::Copy($trimmed, 0, $padded, 1, $trimmed.Length)
        $trimmed = $padded
    }

    Write-SshString -Stream $Stream -Value $trimmed
}