起因
之前在 Tabby 里连服务器时勾了「保存密码」,后来一直靠它自动登录,密码本身早就忘了。某天要在别的机器上登录同一台服务器,才发现 Tabby 的界面里没有任何地方能看到已保存的密码。
折腾了半天才搞清楚:Tabby 在 Windows 上通过 keytar 把密码交给系统的 Windows 凭据管理器保存,自己的配置文件里并没有明文。
密码存在哪
打开「控制面板 → 凭据管理器 → Windows 凭据」,在「普通凭据」里能看到一串形如下面的条目:
ssh@192.168.1.10:22/deploy
格式是 ssh@主机:端口/用户名。命令行里也能列出来:
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 也不会错位(原因见下一节)。
# 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
运行效果大致是:
筛选:'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 -ExecutionPolicy Bypass -File .\tabby-cred.ps1
临时:直接粘贴到 PowerShell 窗口。 粘贴后会立即进入选择界面;退出后在同一个窗口里还可以再调用 Show-WinCred、Show-WinCred -Filter 192.168 或 Show-WinCred -Show。
粘贴到 PowerShell 时内容错位
这是这次踩到的第二个坑:往 PowerShell 窗口粘贴多行脚本时,如果光标所在的行不对,粘贴的内容会错位,最后报一堆莫名其妙的语法错误。几个原因:
- 光标不在一个干净的提示符上。 控制台的粘贴本质上是把字符逐个「敲」进去,如果当前行已经有半截命令,或者光标在行中间,粘贴内容会插到那个位置,和原有内容拼在一起。粘贴前先按
Esc清空当前行(或者Ctrl+C放弃当前输入),确认光标停在一个新的PS>提示符后面再粘贴。 - Tab 缩进会触发自动补全。 脚本里如果用 Tab 缩进,粘贴时每个 Tab 都会被当成补全键,把文件名之类的东西补进代码里。所以脚本全部用空格缩进。
- here-string 的结束符必须顶格。 原脚本用
@' ... '@包住 C# 代码,结束的'@必须在行首。一旦粘贴时整体被加了缩进或和上一行拼到一起,here-string 就不会结束,后面的内容全都被吞进字符串里。新脚本改成用字符串数组-join拼出 C# 源码,没有这个限制。
最稳妥的办法还是保存成 .ps1 文件运行,根本不经过控制台的逐字输入。
安全提醒
- 这个脚本能读出当前 Windows 用户保存的所有通用凭据明文,只在自己的电脑上用,不要拿到别人的机器上跑。
- 「域密码」类型(例如远程桌面
TERMSRV/...)系统不允许读出明文,脚本会提示读取失败,这是正常的。 - 找回密码后,顺手把它存进密码管理器,别再只依赖 Tabby 记住它。
Conversation
评论区
还没有评论,来做第一个发言的人吧。