利用Windows Powershell原生功能生成TOTP令牌

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时)生效。