TOTP(Time-base One-Time Password)是常用二次认证手段,其原理为利用密钥对当前时间戳进行HMAC计算,生成一次性令牌,从而可以在不传输密钥原文的情况下实现身份验证。
通常,TOTP密钥通过二维码的形式提供,用户通过认证器应用(如Google Authenticator)扫描二维码来导入密钥。
尽管Windows有自己的凭证管理系统,而且系统本身也包含HMAC的相关计算函数,但系统并没有原生提供或预装TOTP认证器,使得用户不得不使用第三方应用乃至另一台设备来计算TOTP令牌,十分的不方便。
有没有什么办法可以直接利用Windows的功能快速获取TOTP令牌呢?答案是肯定的,我们可以通过注册表和PowerShell脚本来实现相应功能。
实现思路
在实现之前,我们先来分析一下TOTP的工作流程:
首先,网站通过二维码提供TOTP密钥,其内容是一个URL,格式一般为otpauth://totp/[网站]:[用户名]?secret=[密钥]&issuer=[发行方]&digits=6&period=30。
其中[密钥]是一个Base32编码的字符串。例如:
要从密钥计算TOTP令牌,我们首先要将Base32字符串解码为字节,然后将其作为HMAC的密钥,根据刷新周期对当前时间戳进行计算,最后根据指定的位数截取结果。
为了安全地使用TOTP,我们需要将密钥用合适的方式存储在合适的位置。Windows的注册表就是一个不错的选择。
Windows注册表支持存储二进制数据,因此我们可以在存储时直接存储密钥字节以省去每次解码Base32的麻烦。为了保护密钥,我们可以使用Windows原生提供的Data Protection API(DPAPI)来加密存储在注册表中的密钥字节,即使注册表内容泄漏,没有当前用户的登录凭据也无法解密。
实现方法
为了存储TOTP密钥,我们需要解析otpauthURL,PowerShell自带的System.Uri对象和[System.Web.HttpUtility]::ParseQueryString函数可以帮我们自动解析相关内容。
PowerShell没有自带的Base32解码函数,考虑到Base32的解码并不复杂,我们可以自行实现secret字符串的解码,得到原始密钥字节。
之后,我们利用[System.Security.Cryptography.ProtectedData]::Protect函数,使用当前用户的默认凭据加密原始密钥字节,和元数据(刷新周期和截取位数)一起存入注册表。
需要2FA认证时,我们从注册表读取相应数据,利用[System.Security.Cryptography.ProtectedData]::Unprotect函数解密原始密钥字节,并使用密钥创建System.Security.Cryptography.HMACSHA1对象,根据刷新周期对当前时间戳进行HMAC计算,最终截取对应的位数作为TOTP令牌。
完整的代码实现
将以下内容添加到Powershell的启动配置文档(默认位于[我的文档]\WindowsPowerShell\profile.ps1),此后启动powershell
powershell
Add-Type -AssemblyName System.Security, System.Web
function Save-Totp {
param(
[Parameter(Mandatory = $true)]
[string]$KeyName
)
$ErrorActionPreference = "Stop"
$cliptext = Get-Clipboard -Raw
if (-not $cliptext.StartsWith("otpauth://")) { throw "Invalid OTP URL" }
$base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
$uri = [System.Uri]::new($cliptext)
$query = [System.Web.HttpUtility]::ParseQueryString($uri.Query)
$bits = $query["secret"].ToUpper().TrimEnd("=").ToCharArray() | ForEach-Object {
[Convert]::ToString($base32chars.IndexOf($_), 2).PadLeft(5, '0')
}
$secretBytes = [regex]::Matches([string]::Concat($bits), '.{8}') | ForEach-Object {
[Convert]::ToByte($_.Value, 2)
}
$encryptedBytes = [System.Security.Cryptography.ProtectedData]::Protect(
$secretBytes, $null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
$regPath = "HKCU:\Software\MyOTP\$KeyName"
if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null }
$digits = if ($null -ne $query["digits"]) { [int]$query["digits"] } else { 6 }
$period = if ($null -ne $query["period"]) { [int]$query["period"] } else { 30 }
Set-ItemProperty -Path $regPath -Name "Secret" -Value $encryptedBytes -Type Binary
Set-ItemProperty -Path $regPath -Name "Digits" -Value $digits
Set-ItemProperty -Path $regPath -Name "Period" -Value $period
}
function Get-Totp {
param(
[Parameter(Mandatory = $true)]
[string]$KeyName
)
$ErrorActionPreference = "Stop"
$data = Get-ItemProperty -Path "HKCU:\Software\MyOTP\$KeyName"
$keyBytes = [System.Security.Cryptography.ProtectedData]::Unprotect(
$data.Secret, $null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
$digits = [int]$data.Digits
$period = [int]$data.Period
try {
$hmac = [System.Security.Cryptography.HMACSHA1]::new($keyBytes)
$unixTime = [Math]::Floor([decimal]([DateTimeOffset]::Now.ToUnixTimeSeconds()) / $period)
$timeBytes = [BitConverter]::GetBytes([long]$unixTime)
if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($timeBytes) }
$hash = $hmac.ComputeHash($timeBytes)
$offset = $hash[-1] -band 0x0f
$fullOTP = (($hash[$offset] -band 0x7f) -shl 24) +
(($hash[$offset+1] -band 0xff) -shl 16) +
(($hash[$offset+2] -band 0xff) -shl 8) +
($hash[$offset+3] -band 0xff)
return ($fullOTP % [Math]::Pow(10, $digits)).ToString().PadLeft($digits, '0')
}
finally {
$hmac.Dispose()
}
}
导入TOTP时,先利用其他工具解析TOTP URL二维码,将获得的结果复制到剪贴板,然后打开PowerShell,通过Save-Totp [自定义名称]向注册表内写入加密后的密钥字节(会自动读取剪贴板)。
需要2FA认证时,通过Get-Totp [自定义名称]获取TOTP令牌,或者Get-Totp [自定义名称] | Set-Clipboard将令牌直接写入剪贴板。
要查看、编辑或删除保存的TOTP条目,可以从regedit.exe导航到HKEY_CURRENT_USER\Software\MyOTP,编辑会即时(下一次调用Get-Totp时)生效。