忘了 Tabby 保存的 SSH 密码?从 Windows 凭据管理器里找回来

Tabby 把 SSH 密码存在 Windows 凭据管理器里,界面上看不到明文。记录找回过程,附一个可按序号快速选择账号的 PowerShell 交互式脚本,以及粘贴脚本到 PowerShell 时内容错位的原因。

0w0 用户最后更新于 2026/10/31
本文目录7 节

起因

之前在 Tabby 里连服务器时勾了「保存密码」,后来一直靠它自动登录,密码本身早就忘了。某天要在别的机器上登录同一台服务器,才发现 Tabby 的界面里没有任何地方能看到已保存的密码。

折腾了半天才搞清楚:Tabby 在 Windows 上通过 keytar 把密码交给系统的 Windows 凭据管理器保存,自己的配置文件里并没有明文。

密码存在哪

打开「控制面板 → 凭据管理器 → Windows 凭据」,在「普通凭据」里能看到一串形如下面的条目:

text
ssh@192.168.1.10:22/deploy

格式是 ssh@主机:端口/用户名。命令行里也能列出来:

powershell
cmdkey /list

但不管是控制面板还是 cmdkey,都只能看到目标名,看不到密码。要拿到明文,得调用 Win32 的 CredRead。凭据只对保存它的那个 Windows 用户可读,所以必须用同一个账户登录运行。

最初的脚本

一开始写的版本是把目标名写死,Add-Type 包一层 CredRead,跑一次读一个。能用,但每次都得先 cmdkey /list 找到完整目标名再改脚本,一个字符不对就报「找不到元素」。

优化后的交互式脚本

改进点:

  • 用 CredEnumerate 列出当前用户的全部凭据,默认筛选 Tabby 的 ssh@ 条目,按序号选择即可;
  • 输入 /关键字 随时重新筛选(例如 /192.168),单独输入 / 显示全部;
  • 默认把密码复制到剪贴板而不是打印到屏幕,粘贴完按回车自动清空剪贴板;需要直接显示时用 Show-WinCred -Show;
  • 自动识别编码:Tabby(keytar)保存的是 UTF-8,Windows 自己保存的通用凭据多是 UTF-16;
  • 不使用 here-string、不使用 Tab 缩进,整段粘贴进 PowerShell 也不会错位(原因见下一节)。
powershell
# Tabby / Windows 凭据快速查看:列出凭据,按序号复制密码。
# 用法:powershell -ExecutionPolicy Bypass -File .\tabby-cred.ps1
# 也可整段粘贴到 PowerShell;之后可直接调用 Show-WinCred -Filter 192.168 / -Show
# 注意:脚本只用空格缩进、不用 here-string,避免粘贴时 Tab 补全和 '@ 必须顶格导致错位。

if (-not ('WinCredTool' -as [type])) {
    $src = @(
        'using System;'
        'using System.Collections.Generic;'
        'using System.ComponentModel;'
        'using System.Runtime.InteropServices;'
        'using System.Text;'
        'public class WinCredEntry {'
        '    public string Target; public string UserName; public int Type; public DateTime LastWritten;'
        '}'
        'public static class WinCredTool {'
        '    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]'
        '    struct CREDENTIAL {'
        '        public int Flags; public int Type; public string TargetName; public string Comment;'
        '        public long LastWritten; public int CredentialBlobSize; public IntPtr CredentialBlob;'
        '        public int Persist; public int AttributeCount; public IntPtr Attributes;'
        '        public string TargetAlias; public string UserName;'
        '    }'
        '    [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]'
        '    static extern bool CredEnumerate(string filter, int flags, out int count, out IntPtr creds);'
        '    [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]'
        '    static extern bool CredRead(string target, int type, int flags, out IntPtr cred);'
        '    [DllImport("advapi32.dll")]'
        '    static extern void CredFree(IntPtr buffer);'
        '    public static List<WinCredEntry> List() {'
        '        var result = new List<WinCredEntry>();'
        '        int count; IntPtr creds;'
        '        if (!CredEnumerate(null, 0, out count, out creds)) {'
        '            int err = Marshal.GetLastWin32Error();'
        '            if (err == 1168) return result;'
        '            throw new Win32Exception(err);'
        '        }'
        '        try {'
        '            for (int i = 0; i < count; i++) {'
        '                IntPtr p = Marshal.ReadIntPtr(creds, i * IntPtr.Size);'
        '                var c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));'
        '                result.Add(new WinCredEntry { Target = c.TargetName, UserName = c.UserName, Type = c.Type,'
        '                    LastWritten = DateTime.FromFileTime(c.LastWritten) });'
        '            }'
        '        } finally { CredFree(creds); }'
        '        return result;'
        '    }'
        '    public static string Read(string target, int type) {'
        '        IntPtr p;'
        '        if (!CredRead(target, type, 0, out p)) throw new Win32Exception(Marshal.GetLastWin32Error());'
        '        try {'
        '            var c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));'
        '            if (c.CredentialBlob == IntPtr.Zero || c.CredentialBlobSize <= 0) return "";'
        '            var blob = new byte[c.CredentialBlobSize];'
        '            Marshal.Copy(c.CredentialBlob, blob, 0, blob.Length);'
        '            return Decode(blob);'
        '        } finally { CredFree(p); }'
        '    }'
        '    static string Decode(byte[] b) {'
        '        // Tabby(keytar) 存 UTF-8;Windows 自己存的通用凭据多为 UTF-16'
        '        if (b.Length >= 2 && b.Length % 2 == 0) {'
        '            int zeros = 0;'
        '            for (int i = 1; i < b.Length; i += 2) if (b[i] == 0) zeros++;'
        '            if (zeros * 2 >= b.Length / 2) return Encoding.Unicode.GetString(b);'
        '        }'
        '        return Encoding.UTF8.GetString(b);'
        '    }'
        '}'
    ) -join "`n"
    Add-Type -TypeDefinition $src
}

function Show-WinCred {
    param(
        [string]$Filter = 'ssh@',  # Tabby 的目标名形如 ssh@主机:端口/用户名
        [switch]$Show              # 直接显示密码,默认只复制到剪贴板
    )
    $typeNames = @{ 1 = '通用'; 2 = '域密码'; 3 = '域证书'; 4 = '域可见' }
    while ($true) {
        $all = [WinCredTool]::List()
        $list = @($all | Where-Object { -not $Filter -or $_.Target -like "*$Filter*" -or $_.UserName -like "*$Filter*" } |
            Sort-Object Target)
        Write-Host ''
        Write-Host ("筛选:'{0}'  匹配 {1} / 共 {2} 条" -f $Filter, $list.Count, @($all).Count) -ForegroundColor Cyan
        for ($i = 0; $i -lt $list.Count; $i++) {
            $c = $list[$i]
            $t = $typeNames[$c.Type]; if (-not $t) { $t = "类型$($c.Type)" }
            Write-Host ("[{0,2}] {1,-24} {2}  ({3}, {4:yyyy-MM-dd})" -f ($i + 1), $c.UserName, $c.Target, $t, $c.LastWritten)
        }
        $in = (Read-Host '输入序号取密码;/关键字 重新筛选(单独 / 显示全部);直接回车退出').Trim()
        if (-not $in) { return }
        if ($in.StartsWith('/')) { $Filter = $in.Substring(1).Trim(); continue }
        $n = 0
        if (-not [int]::TryParse($in, [ref]$n) -or $n -lt 1 -or $n -gt $list.Count) {
            Write-Host "无效序号:$in" -ForegroundColor Yellow
            continue
        }
        $c = $list[$n - 1]
        try {
            $secret = [WinCredTool]::Read($c.Target, $c.Type)
        } catch {
            Write-Host "读取失败:$($_.Exception.Message)" -ForegroundColor Red
            Write-Host '域密码类凭据系统不允许读出明文;另外必须用保存它的那个 Windows 用户运行。'
            continue
        }
        if ($Show) {
            Write-Host "$($c.UserName) @ $($c.Target)"
            Write-Host $secret -ForegroundColor Green
        } else {
            Set-Clipboard -Value $secret
            Write-Host "已复制 $($c.UserName) 的密码($($secret.Length) 位)到剪贴板。" -ForegroundColor Green
            [void](Read-Host '粘贴完后按回车清空剪贴板')
            cmd /c 'echo off | clip'
            Write-Host '剪贴板已清空。'
        }
        $secret = $null
    }
}

Show-WinCred

运行效果大致是:

text
筛选:'ssh@'  匹配 3 / 共 12 条
[ 1] admin                    ssh@192.168.1.5:22/admin  (通用, 2026-01-01)
[ 2] deploy                   ssh@192.168.1.10:22/deploy  (通用, 2026-01-02)
...
输入序号取密码;/关键字 重新筛选(单独 / 显示全部);直接回车退出: 2
已复制 deploy 的密码(N 位)到剪贴板。
粘贴完后按回车清空剪贴板

两种运行方式

推荐:保存成文件运行。 把脚本保存为 tabby-cred.ps1。如果用的是系统自带的 Windows PowerShell 5.1,记得保存为 UTF-8 with BOM,否则 5.1 会按 GBK 读取,中文提示乱码甚至解析失败。

powershell
powershell -ExecutionPolicy Bypass -File .\tabby-cred.ps1

临时:直接粘贴到 PowerShell 窗口。 粘贴后会立即进入选择界面;退出后在同一个窗口里还可以再调用 Show-WinCred、Show-WinCred -Filter 192.168 或 Show-WinCred -Show。

粘贴到 PowerShell 时内容错位

这是这次踩到的第二个坑:往 PowerShell 窗口粘贴多行脚本时,如果光标所在的行不对,粘贴的内容会错位,最后报一堆莫名其妙的语法错误。几个原因:

  1. 光标不在一个干净的提示符上。 控制台的粘贴本质上是把字符逐个「敲」进去,如果当前行已经有半截命令,或者光标在行中间,粘贴内容会插到那个位置,和原有内容拼在一起。粘贴前先按 Esc 清空当前行(或者 Ctrl+C 放弃当前输入),确认光标停在一个新的 PS> 提示符后面再粘贴。
  2. Tab 缩进会触发自动补全。 脚本里如果用 Tab 缩进,粘贴时每个 Tab 都会被当成补全键,把文件名之类的东西补进代码里。所以脚本全部用空格缩进。
  3. here-string 的结束符必须顶格。 原脚本用 @' ... '@ 包住 C# 代码,结束的 '@ 必须在行首。一旦粘贴时整体被加了缩进或和上一行拼到一起,here-string 就不会结束,后面的内容全都被吞进字符串里。新脚本改成用字符串数组 -join 拼出 C# 源码,没有这个限制。

最稳妥的办法还是保存成 .ps1 文件运行,根本不经过控制台的逐字输入。

安全提醒

  • 这个脚本能读出当前 Windows 用户保存的所有通用凭据明文,只在自己的电脑上用,不要拿到别人的机器上跑。
  • 「域密码」类型(例如远程桌面 TERMSRV/...)系统不允许读出明文,脚本会提示读取失败,这是正常的。
  • 找回密码后,顺手把它存进密码管理器,别再只依赖 Tabby 记住它。

Conversation

评论区

0 条讨论

还没有评论,来做第一个发言的人吧。