diff --git a/Test-Login.ps1 b/Test-Login.ps1 index 9e63689..f0457d1 100644 --- a/Test-Login.ps1 +++ b/Test-Login.ps1 @@ -83,53 +83,158 @@ try { exit 1 } +function Read-Number($question, $min, $max, $default) { + while ($true) { + Write-Host "$question " -NoNewline -ForegroundColor White + Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray + Write-Host ": " -NoNewline -ForegroundColor White + $a = Read-Host + if ([string]::IsNullOrWhiteSpace($a)) { $a = [string]$default } + $n = 0 + if ([int]::TryParse(([string]$a).Trim(), [ref]$n) -and $n -ge $min -and $n -le $max) { return $n } + Write-Host " (a number between $min and $max)" -ForegroundColor Yellow + } +} + +# FileZilla keeps site passwords Base64-encoded, so the value sitting in the +# exported XML is not the password - it has to be decoded first. Copying it +# out verbatim fails against the server every time, which is exactly what a +# working FileZilla login plus a failing script looks like. +function Import-FileZillaSite { + Write-Host "" + Write-Host "In FileZilla: File > Export, tick 'Site Manager entries', save the .xml." -ForegroundColor Gray + $path = Read-Answer "Path to the exported .xml" $null + $path = $path.Trim('"').Trim() + if (-not (Test-Path $path)) { + Write-Host " Nothing at that path." -ForegroundColor Red + return $null + } + + try { + $xml = [xml](Get-Content $path -Raw) + } catch { + Write-Host " That file is not readable XML - $($_.Exception.Message)" -ForegroundColor Red + return $null + } + + $servers = @($xml.SelectNodes("//Server")) + if ($servers.Count -eq 0) { + Write-Host " No sites found in that file." -ForegroundColor Red + return $null + } + + $node = $servers[0] + if ($servers.Count -gt 1) { + Write-Host "" + Write-Host "Sites in that file:" -ForegroundColor White + for ($i = 0; $i -lt $servers.Count; $i++) { + $n = $servers[$i] + Write-Host (" {0}. {1} {2}@{3}:{4}" -f ($i + 1), $n.Name, $n.User, $n.Host, $n.Port) + } + $node = $servers[(Read-Number "Which one" 1 $servers.Count 1) - 1] + } + + $pass = $null + $passNode = $node.SelectSingleNode("Pass") + if ($passNode) { + $encoding = $passNode.GetAttribute("encoding") + if ($encoding -eq "base64") { + try { + $pass = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($passNode.InnerText)) + Write-Host "" + Write-Host " Decoded the password from Base64." -ForegroundColor Green + Write-Host " The XML holds $($passNode.InnerText.Length) characters; the real" -ForegroundColor Gray + Write-Host " password is $($pass.Length). Using the decoded one." -ForegroundColor Gray + } catch { + Write-Host " The password in the XML is not valid Base64." -ForegroundColor Yellow + } + } elseif ($encoding -eq "crypt") { + Write-Host "" + Write-Host " This export is protected by a FileZilla master password," -ForegroundColor Yellow + Write-Host " so the password cannot be read out of it. Type it instead." -ForegroundColor Yellow + } else { + $pass = $passNode.InnerText + } + } + + # FileZilla's protocol numbering: 1 is SFTP, 0 is plain FTP, and the + # TLS variants use higher values. Only used as the menu default. + $proto = 1 + switch ([string]$node.Protocol) { + "0" { $proto = 3 } + "1" { $proto = 1 } + "3" { $proto = 2 } + "4" { $proto = 2 } + } + + return @{ + Host = [string]$node.Host + Port = [string]$node.Port + User = [string]$node.User + Password = $pass + Proto = $proto + } +} + +$fromXml = $null Write-Host "" -$serverHost = Read-Answer "Server" $null +Write-Host "Where are the details coming from?" -ForegroundColor White +Write-Host " 1. I will type them in" +Write-Host " 2. Read them from a FileZilla site export (.xml)" +if ((Read-Number "Choose 1-2" 1 2 1) -eq 2) { + $fromXml = Import-FileZillaSite + if (-not $fromXml) { + Write-Host "" + Write-Host "Falling back to typing them in." -ForegroundColor Yellow + } +} + +Write-Host "" +$serverHost = Read-Answer "Server" $(if ($fromXml) { $fromXml.Host } else { $null }) $serverHost = ($serverHost -replace '^\w+://', '').Split('/')[0].Trim() -$protoChoice = 0 Write-Host "" Write-Host "Protocol:" -ForegroundColor White Write-Host " 1. SFTP" Write-Host " 2. FTPS" Write-Host " 3. FTP" -while ($protoChoice -lt 1 -or $protoChoice -gt 3) { - Write-Host "Choose 1-3 " -NoNewline -ForegroundColor White - Write-Host "[1]" -NoNewline -ForegroundColor DarkGray - Write-Host ": " -NoNewline -ForegroundColor White - $a = Read-Host - if ([string]::IsNullOrWhiteSpace($a)) { $a = "1" } - [int]::TryParse($a.Trim(), [ref]$protoChoice) | Out-Null -} +$protoDefault = if ($fromXml) { $fromXml.Proto } else { 1 } +$protoChoice = Read-Number "Choose 1-3" 1 3 $protoDefault $protocol = @("sftp", "ftps", "ftp")[$protoChoice - 1] -$defaultPort = if ($protocol -eq "sftp") { 22 } else { 21 } -$port = [int](Read-Answer "Port" $defaultPort) -$username = Read-Answer "Username" $null -Write-Host "" -Write-Host "Paste the password. It is not echoed and not saved anywhere." -ForegroundColor DarkGray -Write-Host "Password: " -NoNewline -ForegroundColor White -$secure = Read-Host -AsSecureString -$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) -$password = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) -[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) +$defaultPort = if ($fromXml -and $fromXml.Port) { $fromXml.Port } + elseif ($protocol -eq "sftp") { 22 } else { 21 } +$port = Read-Number "Port" 1 65535 $defaultPort +$username = Read-Answer "Username" $(if ($fromXml) { $fromXml.User } else { $null }) -# Nothing echoes while typing, so a slipped paste looks identical to a good -# one. Typing it twice is the only way to be sure of what actually went in. -Write-Host "Type it once more to check: " -NoNewline -ForegroundColor White -$secure2 = Read-Host -AsSecureString -$bstr2 = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure2) -$password2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr2) -[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr2) - -if ($password -ne $password2) { +if ($fromXml -and $fromXml.Password) { + $password = $fromXml.Password +} else { Write-Host "" - Write-Host " !! The two did not match." -ForegroundColor Red - Write-Host " First entry: $($password.Length) characters. Second: $($password2.Length)." -ForegroundColor Red - Write-Host " One of them is wrong, so this test would prove nothing." -ForegroundColor Red - Write-Host " Start over and paste carefully, or type it by hand." -ForegroundColor Yellow - Write-Host "" - exit 1 + Write-Host "Paste the password. It is not echoed and not saved anywhere." -ForegroundColor DarkGray + Write-Host "Password: " -NoNewline -ForegroundColor White + $secure = Read-Host -AsSecureString + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) + $password = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + + # Nothing echoes while typing, so a slipped paste looks identical to a good + # one. Typing it twice is the only way to be sure of what actually went in. + Write-Host "Type it once more to check: " -NoNewline -ForegroundColor White + $secure2 = Read-Host -AsSecureString + $bstr2 = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure2) + $password2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr2) + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr2) + + if ($password -ne $password2) { + Write-Host "" + Write-Host " !! The two did not match." -ForegroundColor Red + Write-Host " First entry: $($password.Length) characters. Second: $($password2.Length)." -ForegroundColor Red + Write-Host " One of them is wrong, so this test would prove nothing." -ForegroundColor Red + Write-Host " Start over and paste carefully, or type it by hand." -ForegroundColor Yellow + Write-Host "" + exit 1 + } } # -- What the credentials actually contain ------------------------------------ @@ -163,6 +268,19 @@ if ($password -match '[^\x20-\x7E]') { Write-Host " !! The password contains a non-standard character." -ForegroundColor Yellow $warned = $true } +# A password copied straight out of a FileZilla export is still Base64. +if (-not $fromXml -and $password.Length -ge 8 -and $password -cmatch '^[A-Za-z0-9+/]+={0,2}$' -and ($password.Length % 4) -eq 0) { + $decoded = $null + try { $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($password)) } catch { } + if ($decoded -and $decoded -match '^[\x20-\x7E]+$') { + Write-Host " !! This looks like Base64, not a password." -ForegroundColor Yellow + Write-Host " FileZilla stores passwords Base64-encoded in its exported" -ForegroundColor Yellow + Write-Host " XML. Decoded, this is $($decoded.Length) characters - that is" -ForegroundColor Yellow + Write-Host " probably the real password. Re-run and choose option 2 to" -ForegroundColor Yellow + Write-Host " read the export directly, or paste the decoded value." -ForegroundColor Yellow + $warned = $true + } +} if (-not $warned) { Write-Host " Nothing unusual about either string." -ForegroundColor Green }