<# .SYNOPSIS Sets up SSH for Git on Windows: creates a key in ~/.ssh, loads it into the Windows ssh-agent, adds a host entry to ~/.ssh/config, points Git at Windows' OpenSSH so pushes (and Git LFS) use the agent instead of asking for the passphrase every time, and signs Gitea's key-verification token. .DESCRIPTION Safe to re-run: an existing key is reused (unless -Force), a key already in the agent is not added again, and an existing Host entry in ~/.ssh/config is never modified. Run it as your normal user, NOT "Run as administrator". It asks for admin rights (UAC prompt) only if the ssh-agent service needs to be enabled. Execution policy: start it through powershell.exe with -ExecutionPolicy Bypass as in the examples, or double-click git-ssh-setup.cmd. Either way the bypass applies to this one run only; the system setting is not changed. .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File .\git-ssh-setup.ps1 .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File .\git-ssh-setup.ps1 -Comment "you@example.com" .EXAMPLE # Verify the key later: click Verify next to it in Gitea and pass the token it shows. powershell -NoProfile -ExecutionPolicy Bypass -File .\git-ssh-setup.ps1 -NoTest -Token d0d4b8e7... .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File .\git-ssh-setup.ps1 -HostName github.com -KeyName id_ed25519_github -NoVerify #> [CmdletBinding()] param( # Host to add to ~/.ssh/config and test against. Use -HostName "" to skip both. [string]$HostName = "tea.lenrui.net", # SSH user for the host (Gitea, GitHub and GitLab all use "git"). [string]$User = "git", [ValidateRange(1, 65535)] [int]$Port = 22, # File name of the key inside ~/.ssh. [ValidatePattern('^[A-Za-z0-9._-]+$')] [string]$KeyName = "id_ed25519_gitea", # Label stored in the key; it shows up in Gitea's key list. [ValidateNotNullOrEmpty()] [string]$Comment = "$env:USERNAME@$env:COMPUTERNAME", # Token from Gitea's Verify button. If omitted, the script asks for it. [string]$Token, # Replace an existing key with the same name instead of reusing it. [switch]$Force, # Skip the Gitea key-verification step. [switch]$NoVerify, # Skip the connection test at the end. [switch]$NoTest ) function Write-Step([string]$Message) { Write-Host "`n==> $Message" -ForegroundColor Cyan } function Write-Ok([string]$Message) { Write-Host " $Message" -ForegroundColor Green } function Write-Note([string]$Message) { Write-Host " $Message" -ForegroundColor Yellow } function Stop-Setup([string]$Message) { Write-Host "`nERROR: $Message" -ForegroundColor Red exit 1 } # Signs $Text exactly as given and returns the armored signature, or $null if # ssh-keygen failed. The text is written to a file with no trailing newline and # no BOM: piping it into ssh-keygen from PowerShell would add a newline and # change what gets signed. function Get-SshSignature([string]$Text, [string]$Keygen, [string]$KeyFile, [string]$Namespace = 'gitea') { $file = Join-Path ([IO.Path]::GetTempPath()) ("gitea-verify-" + [guid]::NewGuid().ToString('N')) $sigFile = "$file.sig" try { [IO.File]::WriteAllText($file, $Text, (New-Object Text.UTF8Encoding($false))) # A .pub key file makes ssh-keygen sign through ssh-agent (no passphrase prompt). & $Keygen -Y sign -n $Namespace -f $KeyFile $file | Out-Host if ($LASTEXITCODE -eq 0 -and (Test-Path $sigFile)) { return (Get-Content -Path $sigFile -Raw).Trim() } return $null } finally { Remove-Item -LiteralPath $file, $sigFile -Force -ErrorAction SilentlyContinue } } # Accept the token with stray spaces or quotes, but nothing that isn't hex. $Token = "$Token" -replace '[\s''"]', '' if ($Token -and $Token -notmatch '^[0-9a-fA-F]+$') { Stop-Setup "-Token should be just the hex string from Gitea's Token box." } $openSshDir = Join-Path $env:WINDIR 'System32\OpenSSH' $sshExe = Join-Path $openSshDir 'ssh.exe' $keygenExe = Join-Path $openSshDir 'ssh-keygen.exe' $addExe = Join-Path $openSshDir 'ssh-add.exe' $sshDir = Join-Path $HOME '.ssh' $keyPath = Join-Path $sshDir $KeyName $pubPath = "$keyPath.pub" $configPath = Join-Path $sshDir 'config' # --- 1. ssh-agent service ---------------------------------------------------- # Windows ships it disabled. Enabling it (and installing the OpenSSH client if # it is missing) needs admin, so only that part runs elevated. Write-Step "Checking the ssh-agent service" $agent = Get-Service ssh-agent -ErrorAction SilentlyContinue if (-not $agent -or $agent.StartType -ne 'Automatic' -or $agent.Status -ne 'Running') { $fixAgent = @' $ErrorActionPreference = 'Stop' if (-not (Get-Service ssh-agent -ErrorAction SilentlyContinue)) { Write-Host 'Installing the Windows OpenSSH client (this can take a few minutes)...' Add-WindowsCapability -Online -Name 'OpenSSH.Client~~~~0.0.1.0' | Out-Null } Set-Service ssh-agent -StartupType Automatic Start-Service ssh-agent '@ $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator) if ($isAdmin) { try { & ([scriptblock]::Create($fixAgent)) } catch { Stop-Setup "Could not enable ssh-agent: $($_.Exception.Message)" } } else { Write-Note "Enabling ssh-agent needs admin rights - approve the UAC prompt." $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($fixAgent)) try { Start-Process powershell.exe -Verb RunAs -Wait ` -ArgumentList "-NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded" } catch { Stop-Setup "The admin prompt was cancelled. Re-run the script and approve it." } } $agent = Get-Service ssh-agent -ErrorAction SilentlyContinue if (-not $agent -or $agent.Status -ne 'Running') { Stop-Setup "ssh-agent still isn't running. In an admin PowerShell run:`n Set-Service ssh-agent -StartupType Automatic; Start-Service ssh-agent" } } Write-Ok "ssh-agent is running (startup type: $($agent.StartType))" foreach ($exe in @($sshExe, $keygenExe, $addExe)) { if (-not (Test-Path $exe)) { Stop-Setup "Windows OpenSSH tool not found: $exe" } } # --- 2. Key ------------------------------------------------------------------ Write-Step "Preparing the key $keyPath" New-Item -ItemType Directory -Path $sshDir -Force | Out-Null if ((Test-Path $keyPath) -and -not $Force) { Write-Ok "Reusing the existing key (pass -Force to replace it)." if (-not (Test-Path $pubPath)) { Write-Note "Public key file is missing - recreating it from the private key." $recreated = & $keygenExe -y -f $keyPath if ($LASTEXITCODE -ne 0 -or -not $recreated) { Stop-Setup "Could not read $keyPath." } Set-Content -Path $pubPath -Value $recreated -Encoding Ascii } } else { if (Test-Path $keyPath) { Write-Note "-Force given: replacing the existing key." Remove-Item -Path $keyPath, $pubPath -Force -ErrorAction SilentlyContinue } Write-Note "Choose a passphrase when asked (or press Enter twice for none)." & $keygenExe -t ed25519 -C $Comment -f $keyPath if ($LASTEXITCODE -ne 0 -or -not (Test-Path $pubPath)) { Stop-Setup "ssh-keygen failed." } Write-Ok "Created $keyPath" } # --- 3. Load into the agent -------------------------------------------------- Write-Step "Adding the key to ssh-agent" $fingerprint = ((& $keygenExe -lf $pubPath) -split '\s+')[1] if (-not $fingerprint) { Stop-Setup "Could not read the fingerprint of $pubPath." } $loaded = (& $addExe -l) -join "`n" if ($loaded -match [regex]::Escape($fingerprint)) { Write-Ok "Already loaded ($fingerprint)." } else { & $addExe $keyPath if ($LASTEXITCODE -ne 0) { Stop-Setup "ssh-add failed." } Write-Ok "Loaded $fingerprint. Windows keeps it in the agent across reboots." } # --- 4. ~/.ssh/config entry -------------------------------------------------- if ($HostName) { Write-Step "Adding '$HostName' to $configPath" $block = @( "Host $HostName", " HostName $HostName", " User $User", " Port $Port", " IdentityFile ~/.ssh/$KeyName", " IdentitiesOnly yes", " AddKeysToAgent yes" ) -join "`n" $existing = '' if (Test-Path $configPath) { $existing = Get-Content -Path $configPath -Raw if ($null -eq $existing) { $existing = '' } } $hostPattern = '(?im)^[ \t]*Host[ \t]+([^\r\n]*[ \t])?' + [regex]::Escape($HostName) + '([ \t]|\r?$)' if ($existing -match $hostPattern) { Write-Note "A 'Host $HostName' entry already exists, so it was left alone." Write-Note "Check that it matches this:" Write-Host "`n$block" } else { $prefix = '' if ($existing.Length -gt 0) { $prefix = if ($existing.EndsWith("`n")) { "`n" } else { "`n`n" } } # Write UTF-8 *without* a BOM; OpenSSH can choke on a BOM in its config. [IO.File]::AppendAllText($configPath, "$prefix$block`n", (New-Object Text.UTF8Encoding($false))) Write-Ok "Added." } } # --- 5. Git ------------------------------------------------------------------ Write-Step "Configuring Git to use Windows OpenSSH (and its agent)" if (Get-Command git -ErrorAction SilentlyContinue) { $sshForGit = $sshExe -replace '\\', '/' git config --global core.sshCommand $sshForGit # Works around a git-lfs bug that can make SSH transfers hang. # Harmless if you never use Git LFS. git config --global lfs.ssh.automultiplex false Write-Ok "core.sshCommand = $sshForGit" Write-Ok "lfs.ssh.automultiplex = false" if ($env:GIT_SSH_COMMAND) { Write-Note "GIT_SSH_COMMAND is set ($env:GIT_SSH_COMMAND) and overrides core.sshCommand." Write-Note "Remove it from your environment variables if pushes still ask for a passphrase." } } else { Write-Note "Git is not on PATH, so Git was not configured. Install Git for Windows and re-run." } # --- 6. Public key ----------------------------------------------------------- Write-Step "Your public key" $pub = (Get-Content -Path $pubPath -Raw).Trim() Write-Host "`n$pub`n" try { Set-Clipboard -Value $pub Write-Ok "Copied to the clipboard." } catch { Write-Note "Couldn't copy to the clipboard - copy the line above." } Write-Host " Add it in Gitea: your avatar > Settings > SSH / GPG Keys > Add Key" # --- 7. Verify the key in Gitea (optional) ----------------------------------- # Gitea's Verify button shows a token. Signing it with this key proves you own # the key, and Gitea then marks the key as verified. if (-not $NoVerify) { Write-Step "Verifying the key in Gitea (optional)" if (-not $Token) { Write-Host " 1. Add the key in Gitea first (it's on your clipboard)." Write-Host " 2. Click Verify next to the key and copy the token Gitea shows." while ($true) { $Token = "$(Read-Host ' Paste the token here, or press Enter to skip')" -replace '[\s''"]', '' if (-not $Token -or $Token -match '^[0-9a-fA-F]+$') { break } Write-Note "That doesn't look like a token. Paste only the hex string from the Token box." } } if (-not $Token) { Write-Note "Skipped. To verify later, click Verify in Gitea and re-run with -NoTest -Token ." } else { $signature = Get-SshSignature -Text $Token -Keygen $keygenExe -KeyFile $pubPath if (-not $signature) { # Windows OpenSSH older than 8.1 has no 'ssh-keygen -Y'; Git for Windows ships a newer one. $gitKeygen = $null $gitCmd = Get-Command git -ErrorAction SilentlyContinue if ($gitCmd) { $dir = Split-Path $gitCmd.Source foreach ($i in 1..3) { if (-not $dir) { break } $candidate = Join-Path $dir 'usr\bin\ssh-keygen.exe' if (Test-Path $candidate) { $gitKeygen = $candidate; break } $dir = Split-Path $dir } } if ($gitKeygen) { Write-Note "Retrying with Git for Windows' ssh-keygen (it may ask for the key's passphrase)." $signature = Get-SshSignature -Text $Token -Keygen $gitKeygen -KeyFile $keyPath } } if ($signature) { Write-Host "`n$signature`n" try { Set-Clipboard -Value $signature Write-Ok "Signature copied to the clipboard." } catch { Write-Note "Couldn't copy to the clipboard - copy the block above." } Write-Host " Paste it into Gitea's 'Armored SSH signature' box and click Verify." Write-Note "The token only stays valid for a short time. If Gitea rejects the signature," Write-Note "click Verify again for a new token and re-run with -NoTest -Token ." } else { Write-Note "Couldn't sign the token, so the key wasn't verified. Everything else is set up." } } } # --- 8. Test ----------------------------------------------------------------- if ($HostName -and -not $NoTest) { Write-Step "Testing the connection to $HostName" if ($NoVerify) { Read-Host "Press Enter once the key is added in Gitea (Ctrl+C to skip)" | Out-Null } & $sshExe -T -p $Port "$User@$HostName" Write-Host "" Write-Note "A 'Hi there, ! You've successfully authenticated' message means it worked." } Write-Step "Done"