简介
双因素认证(2FA)已经成为如今最常见的安全机制,而基于时间的一次性密码(TOTP: Time-based One-Time Password)则是其中最广泛使用的技术之一。
我们平时使用的 Google Authenticator、Microsoft Authenticator 都是基于同样的算法。TOTP 是一种根据预共享的密钥与当前时间计算一次性密码的算法。它已被互联网工程任务组接纳为RFC 6238标准,成为主动开放认证(OATH)的基石,并被用于众多多重要素验证系统当中。TOTP是散列消息认证码(HMAC)当中的一个例子。它结合一个私钥与当前时间戳,使用一个密码散列函数来生成一次性密码。由于网络延迟与时钟不同步可能导致密码接收者不得不尝试多次遇到正确的时间来进行身份验证,时间戳通常以30秒为间隔,从而避免反复尝试。
otpauth URL
所有 TOTP 应用(Google Authenticator /Microsoft Authenticator)都使用统一的 otpauth URL 格式。
otpauth URI 格式说明
otpauth:// 是用于 TOTP/HOTP 身份验证器的通用 URI 格式,通常与二维码一起使用,方便在各类身份验证 App 中导入账号。
基本结构
otpauth://TYPE/LABEL?PARAMETERS
-
TYPE
-
totp:基于时间的验证码(常见,典型为 30 秒刷新)
-
hotp:基于计数器的验证码(较少用于扫码导入场景)
-
-
LABEL(名称部分)
格式:
issuer:account-name示例:
GitHub:user@gmail.com -
PARAMETERS
参数写在 ? 后面,以 & 连接。参数名区分大小写(大多数实现小写)。
必填参数
- secret
Base32 编码的密钥(必须),例如 JBSWY3DPEHPK3PXP
推荐 / 可选参数
-
issuer:发行商(强烈推荐)
-
algorithm:哈希算法,常见值 SHA1(默认)、SHA256、SHA512
-
digits:验证码长度,常见 6(默认)或 8
-
period:TOTP 的时间窗口(秒),默认 30
-
counter:HOTP 专用计数器(仅 hotp 类型需要)
- secret
完整示例(TOTP)
otpauth://totp/GitHub:user@gmail.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&digits=6&period=30
该 URI 格式最初由 Google Authenticator / 社区推广(称为 “Key URI Format”)
| 字段 | 是否必需 | 说明 |
|---|---|---|
type(totp/hotp) | 必需 | 指定为时间或计数器类型 |
label(Issuer:Account) | 必需(或至少 account) | 显示名称 |
secret | 必需 | Base32 密钥 |
issuer | 推荐 | 服务名称,推荐同时存在于 label 和参数 |
algorithm | 可选 | SHA1/SHA256/SHA512(默认 SHA1) |
digits | 可选 | 验证码长度(默认 6) |
period | 可选(TOTP) | 刷新周期(秒,默认 30) |
counter | 可选(HOTP) | 初始计数器值 |
PowerShell 实现 TOTP 的思路
为了实现一个命令行 TOTP 工具,需要解决几个问题:
- 读取 otpauth URL
- 解析 issuer、account、secret、period、digits
- Base32 → Hex → ByteArray
- 使用 HMAC-SHA1 计算 hash
- RFC 规定的动态截断(Dynamic Truncation)
- 输出指定位数字的验证码
- UI 循环刷新
PowerShell 具体实现
authenticator.ps1
/* by 01022.hk - online tools website : 01022.hk/zh/dnsedu.html */
# ============================
# 配置:从authenticator.txt加载otpauth URL
# ============================
function Get-OtpAuthLinks {
param (
[string]$fileName = "authenticator.txt" # 默认文件名
)
$links = @()
$authenticatorPath = Join-Path (Get-Location) $fileName
if (Test-Path $authenticatorPath) {
$fileLinks = Get-Content $authenticatorPath | Where-Object { $_ -notmatch '^\s*(#|//|;)' -and $_.Trim() -ne '' }
$links += $fileLinks
}
# 示例链接
if ($links.Count -eq 0) {
$links += "otpauth://totp/Example:user?secret=DMETBKDJAAY3D2K3&issuer=这是一个示例"
}
return $links
}
# ============================
# 函数:解析 otpauth URL
# ============================
function Parse-OtpAuthUrl {
param([string]$url)
# 先解码 URL,防止 URL 编码的问题
$urlDecoded = [System.Uri]::UnescapeDataString($url).Trim()
# 移除 "otpauth://totp/" 部分
$clean = $urlDecoded -replace "^otpauth://totp/", ""
# 按 "?" 分割 URL,分为账户部分和查询参数部分
$parts = $clean -split "\?"
$namePart = $parts[0]
$queryPart = $parts[1]
# 解析账户部分
$account = $namePart
$issuerFromName = ""
if ($namePart -match "(.+?):(.+)") {
$issuerFromName = $matches[1]
$account = $matches[2]
}
# 解析查询参数部分
$params = @{}
foreach ($q in $queryPart -split "&") {
$kv = $q -split "="
$params[$kv[0]] = $kv[1]
}
# 获取 period 和 digits,若无则使用默认值
$period = if ($params["period"]) { [int]$params["period"] } else { 30 }
$digits = if ($params["digits"]) { [int]$params["digits"] } else { 6 }
$issuer = if ($params["issuer"]) { $params["issuer"] } else { $issuerFromName }
# 返回包含解析信息的对象
return [PSCustomObject]@{
Issuer = $issuer
Account = $account
Secret = $params["secret"]
Period = $period
Digits = $digits
}
}
function ConvertFrom-Base32 {
param([string]$Base32String)
$Base32String = $Base32String.ToUpper() -replace '[=]', '' -replace '[^A-Z2-7]', ''
$base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
$bytes = [System.Collections.Generic.List[byte]]::new()
$buffer = 0
$bitsLeft = 0
foreach ($char in $Base32String.ToCharArray()) {
$value = $base32Chars.IndexOf($char)
if ($value -eq -1) { continue }
$buffer = ($buffer -shl 5) -bor $value
$bitsLeft += 5
if ($bitsLeft -ge 8) {
$bytes.Add([byte](($buffer -shr ($bitsLeft - 8)) -band 0xFF))
$bitsLeft -= 8
}
}
return $bytes.ToArray()
}
function Get-Otp($SECRET, $LENGTH, $WINDOW){
$enc = [System.Text.Encoding]::UTF8
$hmac = New-Object -TypeName System.Security.Cryptography.HMACSHA1
$hmac.key = Convert-HexToByteArray(Convert-Base32ToHex(($SECRET.ToUpper())))
$timeBytes = Get-TimeByteArray $WINDOW
$randHash = $hmac.ComputeHash($timeBytes)
$offset = $randhash[($randHash.Length-1)] -band 0xf
$fullOTP = ($randhash[$offset] -band 0x7f) * [math]::pow(2, 24)
$fullOTP += ($randHash[$offset + 1] -band 0xff) * [math]::pow(2, 16)
$fullOTP += ($randHash[$offset + 2] -band 0xff) * [math]::pow(2, 8)
$fullOTP += ($randHash[$offset + 3] -band 0xff)
$modNumber = [math]::pow(10, $LENGTH)
$otp = $fullOTP % $modNumber
$otp = $otp.ToString("0" * $LENGTH)
return $otp
}
function Get-TimeByteArray($WINDOW) {
$span = [int]((Get-Date).ToUniversalTime() - [datetime]'1970-01-01').TotalSeconds
$unixTime = [Convert]::ToInt64([Math]::Floor($span/$WINDOW))
$byteArray = [BitConverter]::GetBytes($unixTime)
[array]::Reverse($byteArray)
return $byteArray
}
function Convert-HexToByteArray($hexString) {
$byteArray = $hexString -replace '^0x', '' -split "(?<=\G\w{2})(?=\w{2})" | %{ [Convert]::ToByte( $_, 16 ) }
return $byteArray
}
function Convert-Base32ToHex($base32) {
$base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
$bits = "";
$hex = "";
for ($i = 0; $i -lt $base32.Length; $i++) {
$val = $base32chars.IndexOf($base32.Chars($i));
$binary = [Convert]::ToString($val, 2)
$staticLen = 5
$padder = '0'
$bits += Add-LeftPad $binary.ToString() $staticLen $padder
}
for ($i = 0; $i+4 -le $bits.Length; $i+=4) {
$chunk = $bits.Substring($i, 4)
$intChunk = [Convert]::ToInt32($chunk, 2)
$hexChunk = Convert-IntToHex($intChunk)
$hex = $hex + $hexChunk
}
return $hex;
}
function Convert-IntToHex([int]$num) {
return ('{0:x}' -f $num)
}
function Add-LeftPad($str, $len, $pad) {
if(($len + 1) -ge $str.Length) {
while (($len - 1) -ge $str.Length) {
$str = ($pad + $str)
}
}
return $str;
}
# ============================
# 主循环
# ============================
$accounts = Get-OtpAuthLinks | ForEach-Object { Parse-OtpAuthUrl $_ }
$lastCycle = -1
while ($true) {
$now = [int]((Get-Date).ToUniversalTime() - [datetime]'1970-01-01').TotalSeconds
$period = $accounts[0].Period
$cycle = [Convert]::ToInt64([Math]::Floor($now/$period))
$remain = $period - ($now % $period)
if ($cycle -ne $lastCycle) {
$lastCycle = $cycle
Clear-Host
Write-Host "========== 身份验证器 =========="
foreach ($acc in $accounts) {
$code = Get-Otp -SECRET $acc.Secret -LENGTH $acc.Digits -WINDOW $acc.Period
Write-Host ("网站: " + $acc.Issuer)
Write-Host ("账号: " + $acc.Account)
Write-Host "验证码: " -NoNewline
Write-Host $code -ForegroundColor Green
Write-Host "----------------------------------------"
}
}
[Console]::SetCursorPosition(0, [Console]::CursorTop)
Write-Host ("剩余时间: $remain 秒") -NoNewLine
Start-Sleep -Milliseconds 950
}
authenticator.txt
# 身份验证器otpauth URL配置文件
#
# 每行一个otpauth URL
# 示例:otpauth://totp/GitHub:user1?secret=DMETBKDJAAY3D2K3&issuer=GitHub
使用方法
-
在脚本目录创建一个文件:
authenticator.txt- 每行写一个 otpauth 链接,例如:
otpauth://totp/GitHub:user@gmail.com?secret=ABC123&issuer=GitHub
- 每行写一个 otpauth 链接,例如:
-
将脚本保存为:
authenticator.ps1,编码UTF8 BOM -
运行程序:
authenticator.ps1
也可在Github进行下载
运行效果

2132

被折叠的 条评论
为什么被折叠?



