马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
PBIDEA:用 uo_2fa 做 TOTP 动态口令全解(RFC 6238 对拍、种子生成与十个坑,PB10 基准 · PB12.5 实测)
【阅读说明】
- 适用版本:PB10 基准写作与编译验证,PB12.5 实机运行验证;两版行为逐项一致(见文末「PB12.5 差异」)。
- 支持数据库:本文不依赖数据库,全部功能离线可跑。
- 操作系统与环境要求:Windows 7 及以上;需安装 PBIDEA 运行库(PbIdea.dll 与 Pbidea_cs.dll,随程序分发);本文代码依赖 uo_2fa,其内部用到 uo_crypto、uo_hmac、uo_blob、uo_bits、uo_datetime,均随运行库提供。
- 难度系数:★★☆☆☆(API 只有两个函数,难点全在参数语义与坑上)。
- 前置知识:无密码学基础要求,但建议先了解「两步验证」是什么;用过的朋友知道,登录时除了密码还要输一个 30 秒一换的 6 位数字,那就是本文要生成的东西。 【版本声明】
- 本文以 PBIDEA 当前最新版本为准(版本源由维护方统一核验),并对照随版发布的源码整理。
- 核验日期:2026-10-21;当次核验的 PBIDEA 核心运行库:18,132,480 字节 / 2572 个导出,md5 54e4b9244973…。
- 版本识别口径:逐项解析 PE 导出表(文件体积、目录名都不能作为判据)。
- 适用平台:Windows XP 及以上(XP / 7 / 10 / 11 均可);PowerBuilder 8 及以上(PB 8/9/10/10.5/11.x/12.x 及以上通用)。本文的实机验证环境为 Windows 10 + PB 10 与 PB 12.5。
- PBIDEA 版本会持续更新,本文只对上述核验时点的最新版本负责;后续若行为有变,以新版为准。
一、这是什么
TOTP(Time-based One-Time Password,基于时间的一次性密码)是 RFC 6238 定义的两步验证标准:服务端和客户端共享一个密钥,各自用「当前时间除以周期」当计数器,对计数器做 HMAC-SHA1,再从结果里截出 6 位数字。只要两边时钟差不多、密钥一致,同一时刻算出来的数字就相同,而这个数字每 30 秒换一次,捡到也没用。
PBIDEA 把这套东西封进了 uo_2fa:一个函数生成密钥,一个函数算令牌。我这次的验证方式比较较真——用 Python 独立写了一份 RFC 6238(标准库实现,不借任何第三方库),和 PB 的输出逐位对拍,从 HMAC 中间值到最终 6 位令牌全部对上,这才敢说它就是标准 TOTP。
二、API 全貌
uo_2fa 一共只有三个入口,全部实测过:
| 成员 | 签名 | 返回 | 说明 | | getSecret | 无参 | string | 随机生成密钥,base32 文本,每次调用都不同 | | getSecret | 参数 seed(string) | string | 由种子确定性生成密钥,同种子必同密钥 | | of_get_token | secret(string), seconds(int), ref timeout(int) | long | 算当前 6 位令牌,timeout 带回本窗口剩余秒数 |
注意 getSecret 是对象级外部函数声明(type prototypes 里挂 pbidea.dll),必须先 create 出实例再调用;of_get_token 则是写在对象里的普通函数,走的是同一实例。
三、最小可运行示例
下面这段在窗口按钮或任意函数里都能跑,密钥、周期都是输入值,前段统一声明并注释:
- // 最小示例:生成密钥并取当前 6 位令牌
- string ls_secret // 密钥(base32 文本)
- string ls_newkey // 无参 getSecret 返回的新随机密钥
- integer li_secs // 周期(秒),常用 30
- long ll_token // 令牌(0 到 999999 的整数)
- integer li_left // 本窗口剩余秒数
- string ls_show // 展示用文本
- ls_secret = 'JBSWY3DPEHPK3PXP' // 示例密钥,真实场景用 getSecret 生成后保存
- li_secs = 30 // 标准周期 30 秒
- uo_2fa l2
- l2 = create uo_2fa
- ll_token = l2.of_get_token(ls_secret, li_secs, li_left)
- ls_newkey = l2.getSecret() // 演示无参版:每次调用都是新随机密钥
- destroy l2
- // 令牌是裸数字,不足 6 位要自己补零,否则界面显示 5 位会误导用户
- ls_show = string(ll_token)
- if len(ls_show) < 6 then
- ls_show = fill('0', 6 - len(ls_show)) + ls_show
- end if
- ls_show = '令牌 ' + ls_show + ',剩余 ' + string(li_left) + ' 秒;新随机密钥 ' + ls_newkey
- MessageBox('TOTP 演示', ls_show)
复制代码
实测输出形如「令牌 330710,剩余 22 秒」。剩余秒数是现成的,做倒计时界面不用自己算。
四、密钥怎么来:无参随机与种子确定性
getSecret 两个重载的行为完全不同,这是我实测里最值得写清楚的一条。
无参版每次调用都返回新密钥:PB10 和 PB12.5 两个进程各跑一次,一个返回 8 字符的 HAYDSQY=,另一个返回 IJAUKMQ=,同一进程里连调两次也不一样。它内部是随机取 4 字节再转 base32,所以密钥只有 8 个 base32 字符长,尾部带一个等号补位。
种子版则是确定性的:同一个种子调多少遍都一样。实测种子 pbai2026 返回 OBRGC2JSGAZDM===,两版一致、多次一致;换个种子 pbai2026_x 返回 OBRGC2JSGAZDMX3Y。我拿 Python 的 base32 编码对了一下,发现规律简单到出人意料——返回值就是 UTF-8 编码后的种子做 base32。中文种子也验证了:种子「测试」返回 422YX2FPSU======,恰好是「测试」二字 UTF-8 字节的 base32。
这条规律有两种用法:一是不想存随机密钥时,用「用户名加固定盐」当种子,服务端按同样规则算出密钥即可,数据库里连密钥列都省了;二是要小心——种子规则是可预测的,密钥强度等于种子强度,别用生日、工号这类能猜的东西当种子。
还有一个容易忽略的边界:空串种子会回落到随机生成。getSecret('') 与 getSecret() 行为相同,都返回新的随机密钥。如果种子是拼出来的,拼接失败变空串,你以为拿到了固定密钥,实际上每次都不同,服务端客户端自然对不上。
五、令牌算法内核:counter 与截断
of_get_token 的算法内核实测确认就是 RFC 6238:
- counter = 当前 Unix 秒数 整除 seconds。周期 30 时就是标准定义;周期传 1 时 counter 就是秒数本身,每秒一换。
- 对 counter 按 8 字节大端序做 HMAC-SHA1,密钥是 base32 解码后的 secret。
- 取 HMAC 结果最后一个字节的低 4 位做偏移,从偏移处取 4 字节,保留 31 位,对 1000000 取模。
第 2、3 步我不用它的封装也复现了一遍,直接用 PBIDEA 自家的 uo_hmac 走一遍,输出的十六进制与 Python 的 hmac 库逐字符相同(PB 侧 B5244D82AC6E2BF7371CE8C8BF25AE824BBB339C,Python 侧一字不差)。复现代码如下,注释里标了每步的用途:
- // 手工复现 HMAC-SHA1 内核:验证 uo_2fa 底层就是标准算法
- string ls_secret // base32 密钥
- string ls_hex // HMAC 结果的十六进制文本
- blob lb_key // base32 解码后的密钥字节
- blob lb_msg // 8 字节大端 counter
- long ll_now, ll_c // 当前秒数、计数器
- integer li_n // Final 返回的哈希长度
- ls_secret = 'JBSWY3DPEHPK3PXP'
- uo_crypto lc
- lc = create uo_crypto
- uo_datetime ldt
- ldt = create uo_datetime
- ll_now = ldt.timestamp(false) // Unix 秒数
- destroy ldt
- ll_c = longlong(ll_now / 30) // 30 秒一个窗口
- lb_key = lc.base32decode(ls_secret)
- destroy lc
- uo_blob lb
- lb = create uo_blob
- lb.SetAlign(1) // 大端对齐,RFC 要求
- lb.set(ll_c) // counter 转成 8 字节
- lb_msg = lb.toblob()
- destroy lb
- uo_hmac lh
- lh = create uo_hmac
- lh.open(lb_key, 'sha1') // 指定 SHA1,TOTP 标准算法
- lh.update(lb_msg)
- li_n = lh.Final(ls_hex) // 结果直接给十六进制文本
- lh.close()
- destroy lh
- MessageBox('手工 HMAC', 'counter=' + string(ll_c) + ',长度=' + string(li_n) + ',HMAC=' + ls_hex)
复制代码
这段的价值在于:当你怀疑 uo_2fa 算得不对时,可以用它把中间值掏出来看;也说明 uo_hmac 本身可以拿来算任何 HMAC-SHA1,不只为 2FA 服务。
六、timeout 参数的真实语义
ref timeout 带回的是「本窗口还剩几秒」,实测公式是 seconds 减去当前秒数对 seconds 取余。我拿 1、10、30、60、300 五个周期各跑了一遍,读数全部吻合这个公式,两版一致。知道这个公式有个实用价值:同一密钥换不同周期算出来的令牌完全不同,因为 counter 不同——实测同密钥下周期 1、10、30、60、300 各出一个不同令牌。所以客户端和服务端的 seconds 必须严格一致,这不是可以两边各配各的参数。
另外两个边界一并实测了:seconds 传负数不报错,令牌照常返回,timeout 带回负数(-30 时剩余 -47 秒,读数是实打实跑出来的);但 seconds 传 0 会直接运行期崩溃,报 Divide by zero,PB10 和 PB12.5 都一样,连 uo_2fa 源码里那一行取余都指得清清楚楚。所以调用前必须校验:
- // seconds 必须先校验再传入,0 会运行期 Divide by zero(守卫逻辑双轨实测通过)
- integer li_secs // 周期(秒),必须大于 0
- li_secs = 30
- if li_secs < 1 then
- MessageBox('参数错误', 'seconds 必须是大于 0 的整数')
- return
- end if
复制代码
七、非法输入的实际行为:不报错,但要命
我构造了三种非法密钥逐一实测,共同点是都不报错,都返回一个看似正常的 6 位令牌:
- 空串:按「空密钥」计算 HMAC。Python 侧用空 key 的 HMAC 对拍,令牌逐位命中。HMAC 标准本来就允许空密钥,所以这不是崩溃,而是「认真算了个错答案」。
- 含非法字符(0123,0 和 1 不在 base32 字母表里):解码截断到第一个非法字符为止,等于空密钥,对拍同样命中。
- 小写字母(abcdefgh):base32 解码自动转大写,等效于 ABCDEFGH,对拍命中。
三条合起来是一句话:uo_2fa 对密钥文本不做任何合法性校验,喂什么算什么。而 TOTP 校验失败在业务上表现为「用户输错验证码」,你会去查用户、查时钟,唯独不会想到是密钥文本里混进了一个小写字母或者空格。所以密钥必须从 getSecret 拿,或者入库前自己校验字符集(只允许 A 到 Z 和 2 到 7,等号补位可留可不留——实测带补位的密钥直接可用)。
八、对拍记录
把本次验证的数字证据列出来,读者可以照着重跑。同窗口内连续两次调用返回同值,这是能对拍的前提:
| 验证项 | PB 实测输出 | Python 独立计算 | 结论 | | HMAC-SHA1 中间值 | B5244D82AC6E2BF7371CE8C8BF25AE824BBB339C | 相同 | 一致 | | 6 位令牌(30 秒窗) | 330710 | 相同 | 一致 | | 带=补位的密钥 | 296415 | 相同 | 一致 | | 空串密钥 | 298249 | 相同(空密钥) | 一致 | | 小写密钥 | 29021 | 相同(大写等效) | 一致 | | 中文种子密钥 | 422YX2FPSU====== | 相同(UTF-8 后 base32) | 一致 |
九、坑清单
- seconds 传 0 运行期崩溃:Divide by zero,两版一致。传参前必须校验大于 0。
- seconds 传负数不报错:令牌正常、timeout 为负。业务上没意义,建议一并挡掉。
- 空串密钥按空密钥计算:不报错,令牌照样出。密钥来源必须可控。
- 非法字符静默截断:0123 等于空密钥。base32 字母表只有 A 到 Z 和 2 到 7。
- 小写自动转大写:通常无害,但如果你把大小写当作区分两把密钥的手段就会翻车。
- 无参 getSecret 每次都不同:生成后必须立即落库保存,不能每次登录现取。
- 空种子回落随机:拼接种子要先判空,否则「固定密钥」悄悄变成「随机密钥」。
- 令牌可能不足 6 位:实测出过 29021 这样的 5 位读数,展示前补零。
- 两端 seconds 必须一致:周期不同 counter 不同,令牌永远对不上。
- 令牌只在窗口内有效:RFC 惯例是校验端同时比对上一窗口以容忍时钟小偏差;这套比对流程属于服务端逻辑,本文未实测,仅作方向性提示。
十、PB12.5 差异
全部验证项两版逐项一致,无版本差异:密钥生成、令牌读数、timeout 公式、崩溃行为、非法输入行为全部相同。可以放心按 PB10 语法用。
十一、实机验证情况
- 双轨四绿:PB10 与 PB12.5 各自导入 0 错、全量编译 0 错、12 个用例探针全部通过(唯一按预期失败的用例就是 seconds=0 的崩溃定性,两版报错位置一致;守卫代码段双轨实测输出「OK secs=30 ;; REJECT ;; REJECT」)、静态检查 0 错 0 提醒、编码检查全部通过。
- 交叉对拍:Python 独立实现 RFC 6238(标准库 hmac、base64、struct),HMAC 中间值与最终令牌逐位一致。
- 未实测:与手机验证器App 的扫码互通(无手机环境)、seconds=0 崩溃是否可被 TRY-CATCH 捕获(未做隔离验证,不建议依赖)。
|