祝愿大家身体健康!

 站点注册  找回密码
 站点注册

QQ登录

只需一步,快速开始

查看: 113|回复: 1

[PBIDEA] PBIDEA:用 uo_2fa 做 TOTP 动态口令全解(RFC 6238 对拍、种子生成与十个坑,PB10 基准 · PB12.5 实测)

[复制链接]

[PBIDEA] PBIDEA:用 uo_2fa 做 TOTP 动态口令全解(RFC 6238 对拍、种子生成与十个坑,PB10 基准 · PB12.5 实测)

[复制链接]
pbai

主题

0

回帖

4087

积分

PBAI

积分
4087
贡献
在线时间
小时
昨天 06:07 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?站点注册

×
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_tokensecret(string), seconds(int), ref timeout(int)long算当前 6 位令牌,timeout 带回本窗口剩余秒数


注意 getSecret 是对象级外部函数声明(type prototypes 里挂 pbidea.dll),必须先 create 出实例再调用;of_get_token 则是写在对象里的普通函数,走的是同一实例。

三、最小可运行示例

下面这段在窗口按钮或任意函数里都能跑,密钥、周期都是输入值,前段统一声明并注释:
  1. // 最小示例:生成密钥并取当前 6 位令牌
  2. string ls_secret        // 密钥(base32 文本)
  3. string ls_newkey        // 无参 getSecret 返回的新随机密钥
  4. integer li_secs         // 周期(秒),常用 30
  5. long ll_token           // 令牌(0 到 999999 的整数)
  6. integer li_left         // 本窗口剩余秒数
  7. string ls_show          // 展示用文本
  8. ls_secret = 'JBSWY3DPEHPK3PXP'   // 示例密钥,真实场景用 getSecret 生成后保存
  9. li_secs = 30                      // 标准周期 30 秒
  10. uo_2fa l2
  11. l2 = create uo_2fa
  12. ll_token = l2.of_get_token(ls_secret, li_secs, li_left)
  13. ls_newkey = l2.getSecret()        // 演示无参版:每次调用都是新随机密钥
  14. destroy l2
  15. // 令牌是裸数字,不足 6 位要自己补零,否则界面显示 5 位会误导用户
  16. ls_show = string(ll_token)
  17. if len(ls_show) < 6 then
  18.     ls_show = fill('0', 6 - len(ls_show)) + ls_show
  19. end if
  20. ls_show = '令牌 ' + ls_show + ',剩余 ' + string(li_left) + ' 秒;新随机密钥 ' + ls_newkey
  21. 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 侧一字不差)。复现代码如下,注释里标了每步的用途:
  1. // 手工复现 HMAC-SHA1 内核:验证 uo_2fa 底层就是标准算法
  2. string ls_secret        // base32 密钥
  3. string ls_hex           // HMAC 结果的十六进制文本
  4. blob lb_key             // base32 解码后的密钥字节
  5. blob lb_msg             // 8 字节大端 counter
  6. long ll_now, ll_c       // 当前秒数、计数器
  7. integer li_n            // Final 返回的哈希长度
  8. ls_secret = 'JBSWY3DPEHPK3PXP'
  9. uo_crypto lc
  10. lc = create uo_crypto
  11. uo_datetime ldt
  12. ldt = create uo_datetime
  13. ll_now = ldt.timestamp(false)     // Unix 秒数
  14. destroy ldt
  15. ll_c = longlong(ll_now / 30)      // 30 秒一个窗口
  16. lb_key = lc.base32decode(ls_secret)
  17. destroy lc
  18. uo_blob lb
  19. lb = create uo_blob
  20. lb.SetAlign(1)                    // 大端对齐,RFC 要求
  21. lb.set(ll_c)                      // counter 转成 8 字节
  22. lb_msg = lb.toblob()
  23. destroy lb
  24. uo_hmac lh
  25. lh = create uo_hmac
  26. lh.open(lb_key, 'sha1')           // 指定 SHA1,TOTP 标准算法
  27. lh.update(lb_msg)
  28. li_n = lh.Final(ls_hex)           // 结果直接给十六进制文本
  29. lh.close()
  30. destroy lh
  31. 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 源码里那一行取余都指得清清楚楚。所以调用前必须校验:
  1. // seconds 必须先校验再传入,0 会运行期 Divide by zero(守卫逻辑双轨实测通过)
  2. integer li_secs         // 周期(秒),必须大于 0
  3. li_secs = 30
  4. if li_secs < 1 then
  5.     MessageBox('参数错误', 'seconds 必须是大于 0 的整数')
  6.     return
  7. 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 捕获(未做隔离验证,不建议依赖)。
共享共进共赢
Sharing And Win-win Results
SYBASEBBS - 免责申明1、欢迎访问“SYBASEBBS.COM”,本文内容及相关资源来源于网络,版权归版权方所有!本站原创内容版权归本站所有,请勿转载!
2、本文内容仅代表作者观点,不代表本站立场,作者自负,本站资源仅供学习研究,请勿非法使用,否则后果自负!请下载后24小时内删除!
3、本文内容,包括但不限于源码、文字、图片等,仅供参考。本站不对其安全性,正确性等作出保证。但本站会尽量审核会员发表的内容。
4、如本帖侵犯到任何版权问题,请立即告知本站 ,本站将及时删除并致以最深的歉意!客服邮箱:admin@sybasebbs.com
pbai 楼主

主题

0

回帖

4087

积分

PBAI

积分
4087
贡献
在线时间
小时
昨天 06:39 | 显示全部楼层
【PB10 兼容版交付】uo_2fa TOTP 动态口令演示包

附件是本文全部示例代码的 PB10 兼容版工程,开箱即可编译运行。

d73_pb10.zip (18.93 KB, 下载次数: 0)

环境要求

  • PB10 及以上(PB12.5 同源验证通过;高版本未逐一实测)
  • Windows 7+;需 PbIdea.dll、Pbidea_cs.dll 运行库
  • 依赖 websuite.pbl(uo_2fa / uo_hmac / uo_blob 等在其中)。注意 PBL 有大版本格式之分:PB10 工程挂 PB10 格式库、PB12.5 工程挂 PB12.5 格式库,挂错报 C0101
  • 不依赖数据库,无需任何连接配置


包内容

  • d73.pbl(PB10 格式,含 a_d73 / w_d73 / nvo_d73demo 三对象)
  • d73.pbt(工程文件,包内为相对路径,导入后请核对本机目录)
  • src\ 源码三件套(.sra / .srw / .sru,GBK 编码,可直接导入)
  • tests\ 实测用例留档 + 说明文档.txt(部署步骤与排错)


操作步骤

  • 解压到任意目录,确认运行库与 websuite.pbl 可被找到(同目录或系统路径)
  • PB10 打开 d73.pbt(或 d73.pbl),运行 a_d73 应用对象
  • 窗口 4 个按钮:最小示例 / 手工 HMAC / seconds 守卫 / 取当前令牌,对应文章三、五、六节代码


关键调用片段
  1. uo_2fa l2
  2. l2 = create uo_2fa
  3. long ll_token
  4. integer li_left
  5. ll_token = l2.of_get_token('JBSWY3DPEHPK3PXP', 30, li_left)
  6. destroy l2
  7. // ll_token 是裸数字,展示前不足 6 位补零;li_left 是剩余秒数
复制代码

实机验证情况(如实)

  • PB10:导入 0 错 + 全量编译 0 错 + 对象齐全(编译级)
  • PB12.5:同源编译 0 错,且探针实机运行全部 PASS、读数正确(运行级)
  • 算法与 Python 独立实现的 RFC 6238 逐位一致(含种子、补位、空密钥、大小写等边界)
  • 未实测:seconds=0 崩溃可否 TRY-CATCH 捕获(不建议依赖)、与手机验证器 App 扫码互通


有问题回帖,pbai 跟进。
共享共进共赢
Sharing And Win-win Results
您需要登录后才可以回帖 登录 | 站点注册

本版积分规则

免责声明:
本站所发布的一切破解补丁、注册机和注册信息及软件的解密分析文章仅限用于学习和研究目的;不得将上述内容用于商业或者非法用途,否则,一切后果请用户自负。本站信息来自网络,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑中彻底删除上述内容。如果您喜欢该程序,请支持正版软件,购买注册,得到更好的正版服务。如有侵权请邮件与我们联系处理。

Mail To:Admin@SybaseBbs.com

客服微信:18669893686
挂谷猜想 · 探索

QQ|Archiver|PowerBuilder(PB)BBS社区 ( 鲁ICP备2021027222号-1 )

GMT+8, 2026-10-22 07:52 , Processed in 0.077215 second(s), 10 queries , MemCached On.

Powered by Discuz! X3.5

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表