Public/Publish-Fido2SshKeyToAzureVM.ps1

function Publish-Fido2SshKeyToAzureVM {
    <#
    .SYNOPSIS
        Publishes a FIDO2 SSH public key to an Azure VM via Run Command.
 
    .DESCRIPTION
        Uses `az vm run-command invoke` to write the contents of `-PublicKeyPath`
        (or an auto-detected `id_*_sk_rk*.pub` in `%USERPROFILE%\.ssh`) to the
        target user's `~/.ssh/authorized_keys` on the VM. Requires only Azure
        RBAC on the VM resource — no inbound SSH connectivity needed.
 
    .PARAMETER ResourceGroupName
        Resource group containing the VM.
 
    .PARAMETER VMName
        Azure VM name.
 
    .PARAMETER UserName
        Linux user on the VM. Defaults to `azureuser`.
 
    .PARAMETER PublicKeyPath
        Optional. Specific `.pub` file. If omitted, auto-detects FIDO2 keys
        in `%USERPROFILE%\.ssh` and prompts when multiple match.
 
    .PARAMETER SubscriptionId
        Optional. Falls back to the active `az` subscription if omitted.
 
    .PARAMETER WipeExistingKeys
        Replace `authorized_keys` with this key only.
 
    .PARAMETER AllowDuplicate
        Append unconditionally (skip dedupe).
 
    .EXAMPLE
        Publish-Fido2SshKeyToAzureVM -ResourceGroupName my-rg -VMName my-vm
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "Medium")]
    param(
        [Parameter(Mandatory = $true)][string]$ResourceGroupName,
        [Parameter(Mandatory = $true)][string]$VMName,
        [string]$UserName = "azureuser",
        [string]$PublicKeyPath,
        [string]$SubscriptionId,
        [switch]$WipeExistingKeys,
        [switch]$AllowDuplicate
    )

    if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
        throw "Azure CLI (az) was not found. Install it from https://aka.ms/installazurecli."
    }

    if ([string]::IsNullOrWhiteSpace($PublicKeyPath)) {
        $PublicKeyPath = Resolve-Fido2PublicKeyPath -SshDirectory (Get-Fido2DefaultSshDirectory)
    }
    if (-not (Test-Path -LiteralPath $PublicKeyPath -PathType Leaf)) {
        throw "Public key file was not found: $PublicKeyPath"
    }

    $keyLine = (Get-Content -LiteralPath $PublicKeyPath -Raw).Trim()
    if ([string]::IsNullOrWhiteSpace($keyLine)) {
        throw "Public key file is empty: $PublicKeyPath"
    }

    $mode = if ($WipeExistingKeys) { "wipe" } elseif ($AllowDuplicate) { "append" } else { "dedupe" }

    # UserName is interpolated directly into the remote script; reject anything that
    # isn't a simple Linux username to avoid shell injection.
    if ($UserName -notmatch '^[A-Za-z0-9._-]+$') {
        throw "UserName '$UserName' contains characters that are unsafe to embed in a shell script."
    }

    # Base64 the key: `az vm run-command --parameters` mangles values containing
    # spaces (SSH keys always have them), so we embed everything into the script
    # body via string substitution and decode it on the VM.
    $keyBase64 = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($keyLine))

    # Bash shebang is required: RunShellScript defaults to /bin/sh, which doesn't
    # support `set -o pipefail`.
    $remoteScript = @'
#!/bin/bash
set -euo pipefail
 
TARGET_USER="__USER__"
MODE="__MODE__"
KEY_B64="__KEY_B64__"
 
KEY="$(printf '%s' "$KEY_B64" | base64 -d)"
[ -n "$KEY" ] || { echo "Decoded key is empty." >&2; exit 2; }
 
HOME_DIR=$(getent passwd "$TARGET_USER" | cut -d: -f6)
[ -n "$HOME_DIR" ] && [ -d "$HOME_DIR" ] || { echo "User '$TARGET_USER' was not found on this VM." >&2; exit 3; }
 
SSH_DIR="$HOME_DIR/.ssh"
AUTH_FILE="$SSH_DIR/authorized_keys"
 
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
 
case "$MODE" in
    wipe) printf '%s\n' "$KEY" > "$AUTH_FILE" ;;
    append) touch "$AUTH_FILE"; printf '%s\n' "$KEY" >> "$AUTH_FILE" ;;
    dedupe)
        touch "$AUTH_FILE"
        if ! grep -qxF "$KEY" "$AUTH_FILE" 2>/dev/null; then
            printf '%s\n' "$KEY" >> "$AUTH_FILE"
            echo "Key added."
        else
            echo "Key already present; no change."
        fi
        ;;
    *) echo "Unknown mode '$MODE'." >&2; exit 4 ;;
esac
 
chmod 600 "$AUTH_FILE"
chown -R "$TARGET_USER:$TARGET_USER" "$SSH_DIR"
 
echo "Done: $TARGET_USER mode=$MODE file=$AUTH_FILE"
wc -l "$AUTH_FILE"
'@


    $remoteScript = $remoteScript.
        Replace("__USER__", $UserName).
        Replace("__MODE__", $mode).
        Replace("__KEY_B64__", $keyBase64)

    # Force LF line endings; CRLF breaks bash parsing inside Run Command.
    $remoteScript = $remoteScript -replace "`r`n", "`n"

    $target = "$VMName (resource group '$ResourceGroupName')"
    $actionDescription = "Publish SSH public key from '$PublicKeyPath' to $target via Azure VM Run Command"
    if ($WipeExistingKeys) { $actionDescription += " (wipe existing authorized_keys first)" }

    if ($PSCmdlet.ShouldProcess($target, $actionDescription)) {
        # Write the script to a UTF-8 (no BOM) LF temp file and pass `--scripts @<path>`.
        # Inline script bodies get line-ending/encoding mangled through the
        # PowerShell -> az (Python) -> ARM -> VM pipeline.
        $tempScriptPath = Join-Path ([System.IO.Path]::GetTempPath()) ("publish-fido2-ssh-key-{0}.sh" -f ([System.Guid]::NewGuid().ToString("N")))

        try {
            [System.IO.File]::WriteAllText($tempScriptPath, $remoteScript, (New-Object System.Text.UTF8Encoding($false)))

            $azArgs = @(
                "vm", "run-command", "invoke",
                "--resource-group", $ResourceGroupName,
                "--name", $VMName,
                "--command-id", "RunShellScript",
                "--scripts", ("@" + $tempScriptPath),
                "--output", "json"
            )
            if (-not [string]::IsNullOrWhiteSpace($SubscriptionId)) {
                $azArgs += @("--subscription", $SubscriptionId)
            }

            $rawResult = & az @azArgs
            if ($LASTEXITCODE -ne 0) { throw "az vm run-command invoke failed with exit code $LASTEXITCODE." }
        }
        finally {
            Remove-Item -LiteralPath $tempScriptPath -Force -ErrorAction SilentlyContinue
        }

        $resultJson = ($rawResult -join "`n")
        try { $result = $resultJson | ConvertFrom-Json }
        catch { Write-Host $resultJson; throw "Unable to parse Azure CLI response as JSON: $($_.Exception.Message)" }

        $stdout = ""
        $stderr = ""
        $entries = if ($result.PSObject.Properties.Name -contains "value") { @($result.value) } else { @() }
        foreach ($entry in $entries) {
            $code = [string]$entry.code
            $message = [string]$entry.message

            if ($code -like "*StdOut*") { $stdout = $message; continue }
            if ($code -like "*StdErr*") { $stderr = $message; continue }

            # Newer az format: a single entry whose message embeds both
            # "[stdout]\n...\n[stderr]\n..." sections.
            if ($message -match '(?s)\[stdout\]\s*\n(.*?)\n\[stderr\]\s*\n(.*)$') {
                $stdout = $Matches[1]
                $stderr = $Matches[2]
            }
        }

        Write-Host "--- Remote stdout ---"
        if ([string]::IsNullOrWhiteSpace($stdout)) { Write-Host "(empty)" } else { Write-Host $stdout.TrimEnd() }

        if (-not [string]::IsNullOrWhiteSpace($stderr)) {
            Write-Warning "--- Remote stderr ---"
            Write-Warning $stderr.TrimEnd()
            throw "Remote script reported errors on $VMName."
        }
    }

    Write-Host "Key published to $UserName@$VMName using $PublicKeyPath"
    if ($WipeExistingKeys) {
        Write-Host "Existing remote authorized_keys entries were replaced."
    }
    else {
        Write-Host "Existing remote authorized_keys entries were preserved."
    }
}