Featured image of post BNBU 校园 VPN 一键连接:OpenConnect、AnyConnect 与宁盾令牌 TOTP 教程

BNBU 校园 VPN 一键连接:OpenConnect、AnyConnect 与宁盾令牌 TOTP 教程

从受不了 Cisco AnyConnect 的后台服务和重复认证,到用 OpenConnect-GUI、Batch mode 与本地转换的宁盾 TOTP,实现 BNBU VPN 一次点击连接。

我做这个项目,没有什么宏大的理由,只是每次连学校 VPN 都觉得麻烦。

以前打开 Cisco AnyConnect,要输入账号、密码,再拿出手机,打开宁盾令牌看六位验证码。有时宁盾令牌自己还要先输入安全锁密码。一套流程走完并不久,但每次都重复,次数多了就很烦。

原版 Cisco AnyConnect 还会安装后台服务,并随 Windows 启动。这个设计对企业设备可能很正常,对我却是一直挂着一个平时用不到的服务。我实在忍受不了,才开始找替代客户端。

最后的效果很简单:打开 OpenConnect-GUI,选中 BNBU VPN,点击 Connect。账号、静态密码和动态码都由客户端处理,不需要再拿手机。

最终的一次点击连接界面

现在连接 BNBU VPN 只剩一次点击。

这篇先讲我为什么一路折腾到这里,再给出一套可以照着做的完整流程。教程只适用于你本人有权使用的账号和学校发给你的个人令牌。二维码、seed、Base32 secret 和完整 otpauth:// URI 都是登录凭据,不要发给别人,也不要上传到在线转换网站。

四篇文章给我的启发

我不是从零猜出 OpenConnect 和宁盾令牌的用法。真正让我把思路串起来的是四篇博客:

  1. HaoTian:UIC AnyConnect 的校园 VPN 研究,写于 2024 年。它从 Clash DNS、路由表、局域网和 ZeroTier 冲突一路研究到 OpenConnect-GUI,让我第一次意识到 Cisco AnyConnect 客户端本身也可以替换。文中的 v.uic.edu.cn 属于当时的 UIC 环境,当前 BNBU 配置应使用实际可用的 v.bnbu.edu.cn
  2. Kirin:将 Dkey Token(宁盾令牌)转换为标准 TOTP,写于 2022 年。它展示了宁盾 App 激活时下发的 seedtimeSteppasswordLength,并说明 Hex seed 可以转换成 Base32。
  3. Steven:宁盾令牌(Dkey Token)转换为标准 TOTP,写于 2023 年。它同时记录了网页响应和二维码两条路线,还解释了 Base64 seed、Hex seed 与 Base32 secret 其实是同一串二进制密钥的不同表示。
  4. 唐长老:宁盾令牌转标准 2FA TOTP,写于 2020 年。它把二维码里的 token、Hex 到 Base32 的转换和 otpauth:// URI 结构连成了一条完整链路。

第一篇解决的是“为什么换客户端”和网络协同,后面三篇解决的是“怎样让宁盾验证码自动生成”。它们的学校、年份和客户端环境不完全相同,不能逐项照抄;放在一起之后,OpenConnect 替代、TOTP 参数和 BNBU 实测才组成一条完整路线。

整个项目是怎样走弯路的

我先确认 OpenConnect 能不能代替 Cisco AnyConnect。OpenConnect-GUI 可以连接 BNBU,登录窗口也能正确显示 Student 认证组,但当时仍要输入密码和宁盾验证码。

接着我试过命令行脚本。脚本能提交账号和静态密码,也能进入:

Please enter a dynamic token

可它提交动态码后总是 Login failed,同一时间用 GUI 却能成功。这说明密码不是问题,失败发生在宁盾挑战的交互方式。继续堆脚本只会增加登录失败次数,所以我停掉了命令行路线。

后来查看 OpenConnect-GUI 的行为,才确认 Batch mode 可以保存静态密码。这样已经能少输一项,但动态码仍要拿手机看。

最后卡住的是宁盾令牌。最初拿到的公共激活二维码只有服务器信息,没有个人 token。我一度准备抓宁盾 App 的激活流量。后来从 BNBU 门户重新获取个人手机令牌,才发现新二维码属于另一种结构,里面直接有个人 token,不需要抓包。

ChatGPT 完成了 OpenConnect-GUI 配置、认证流程排查和 OpenConnect 9.21 升级,但在个人令牌转换这里停了下来。我只把宁盾令牌这一段交给 DeepSeek harness 继续处理。它调用本机离线二维码库读取我自己的二维码,检查字段结构,再完成 Hex 到 Base32 的转换。最终验收仍然是官方宁盾 App 对码和真实 VPN 连接,不是模型说“应该可以”。

准备工作与风险

开始前先确认这些条件:

  • 你能正常登录 portal.bnbu.edu.cn
  • 你自己的 BNBU VPN 账号当前可用。
  • 手机里的宁盾令牌当前能生成正确动态码。
  • Windows 自动设置时间和时区已经开启。
  • 你接受把静态密码与第二因素放在同一台电脑上的风险。

重新获取手机令牌可能让旧令牌立即失效。先留出足够时间,一次做完重新绑定、转换、对码和 VPN 测试。不要在快要用 VPN 时临时操作。

第一步:安装并验证 OpenConnect-GUI

安装 OpenConnect-GUI 后,先不要碰 OTP。只配置服务器并完成一次普通连接,确认替代客户端本身可用。

基础项目如下:

项目填写内容
NameBNBU VPN
Gatewayhttps://v.bnbu.edu.cn
Username你的 BNBU 账号
Groupnamestudent
VPN protocolCisco AnyConnect or OpenConnect
CA Certificate留空,使用系统证书
Server Certificate留空
User Certificate / User Key留空
Disable UDP默认不勾
Use Proxy不勾

第一次测试时,把 OTP Token 设为 None 或 Disabled。连接窗口出现认证组时选择 Student,输入账号、密码和手机上的宁盾六位码。只有这一步成功,后面才有继续自动化的意义。

第二步:确认底层 OpenConnect 至少是 9.20

OpenConnect-GUI 1.6.2 当时附带的底层核心是 9.12。这个版本不能完整解析 otpauth:// URI,也不能从 URI 正确读取非默认的 60 秒周期。

OpenConnect 更新记录显示,otpauth:// URI 和非默认 TOTP 周期支持在 9.20 加入。我实际使用的是 9.21,GnuTLS 版本为 3.8.11。

在 PowerShell 中检查:

& 'C:\Program Files\OpenConnect-GUI\openconnect.exe' --version

应该看到类似:

OpenConnect version v9.21
Using GnuTLS 3.8.11
Features present: ... HOTP software token, TOTP software token ...

如果还是 9.12,不要只复制一个来路不明的 DLL。OpenConnect、GnuTLS 及依赖库要使用同一套 Windows 构建。优先使用 OpenConnect 官方发布页或可信发行包,并保留能够恢复的旧安装。升级后先启动 GUI,再重新做一次手动验证码连接。

第三步:从 BNBU 门户进入令牌解绑

登录 BNBU Portal,进入应用中心,在通用服务中找到“令牌解绑”。

BNBU Portal 中的令牌解绑入口

图中的“令牌解绑”就是进入宁盾自助页面的入口。

进入后选择“手机令牌”。页面会显示当前令牌状态,并提供“重新获取手机令牌”和“显示激活码内容”。

宁盾手机令牌自助页面

点击重新获取后,以新页面生成的个人二维码为准。图中二维码区域已经留空。

公共激活二维码与这里重新生成的个人二维码可能不同:

类型解码后的特征应该怎样处理
公共激活码activationMethod: 2,没有 token不能直接转换,需要走官方 App 激活流程
个人手机令牌activationMethod: 1,包含 token可以在本人电脑上离线检查和转换

BNBU 这次实测走的是第二种。个人二维码中的 token 带有 Hex seed、算法编号、刷新周期和验证码长度。

第四步:完全离线地读取和转换

不要把个人二维码传给在线二维码网站。最方便的入口是宁盾页面的“显示激活码内容”,复制完整激活链接后,在本人电脑上处理。

下面脚本只使用 Python 标准库。它会读取粘贴的激活链接,解析 # 后的 Base64 JSON,检查是否包含个人 token,再把 Hex seed 转为 Base32 并生成 otpauth:// URI。

新建 convert_dkey.py

import base64
import json
from urllib.parse import quote, urlsplit

raw = input("粘贴个人手机令牌的完整激活链接:").strip()
fragment = urlsplit(raw).fragment

if not fragment:
    raise SystemExit("链接中没有 #Base64 片段")

fragment += "=" * (-len(fragment) % 4)
payload = base64.urlsafe_b64decode(fragment).decode("utf-8")
data = json.loads(payload)
token = data.get("token")

if not token:
    raise SystemExit(
        "这个二维码没有个人 token,不能按本教程直接转换。"
        f" activationMethod={data.get('activationMethod')}"
    )

seed_hex = token["seed"]
seed_bytes = bytes.fromhex(seed_hex)
secret = base64.b32encode(seed_bytes).decode("ascii").rstrip("=")
digits = int(token["passwordLength"])
period = int(token["timeStep"])

label = quote("BNBU:DKEY")
issuer = quote("BNBU")
uri = (
    f"otpauth://totp/{label}?secret={secret}"
    f"&issuer={issuer}&algorithm=SHA1"
    f"&digits={digits}&period={period}"
)

print(f"activationMethod: {data.get('activationMethod')}")
print(f"seed 字节数: {len(seed_bytes)}")
print(f"digits: {digits}")
print(f"period: {period}")
print("\n下面一行是登录凭据,不要发给任何人:")
print(uri)

在脚本所在目录运行:

python .\convert_dkey.py

我这次实际读到的结构是:

{
  "activationMethod": 1,
  "token": {
    "crypto": 1,
    "seed": "<40 位 Hex,已删除>",
    "timeStep": 60,
    "passwordLength": 6
  }
}

seed 的 40 位 Hex 对应 20 字节,也就是 160 bit。crypto: 1 在这次部署中对应 HMAC-SHA1,验证码每 60 秒更新一次,共 6 位。教程里的数字来自这次 BNBU 实测,但脚本仍以你自己的 JSON 字段为准。

第五步:先对码,不要直接登录

先把脚本生成的 URI 导入支持自定义周期的验证器,例如 Bitwarden。不要急着填进 OpenConnect。

同时打开宁盾官方 App 和新的验证器,观察同一个 60 秒周期内的六位码。至少连续跨过 2 到 3 个周期,每次都完全一致,才能确认:

  • Hex 解码正确。
  • Base32 转换正确。
  • 算法是 SHA-1。
  • digits=6
  • period=60
  • 电脑和手机时间同步。

只对上一次不够。周期或时间刚好错位时,偶尔也可能造成误判。对码失败时,先检查自动时间与时区,再核对脚本读出的 timeSteppasswordLengthcrypto,不要连续尝试 VPN 登录。

第六步:把 TOTP 与密码交给 OpenConnect-GUI

对码通过后,编辑 OpenConnect-GUI 配置:

脱敏后的 OpenConnect-GUI 完整配置

Username 与 OTP URI 已脱敏,其余项目按实测配置保留。

需要改动的项目:

项目设置
OTP TokenTOTP (RFC6238)
OTP 右侧输入框粘贴完整 otpauth:// URI
Batch mode勾选
Minimize on Connect按需勾选

保存后连接。第一次可能仍要完成一次密码输入,让 GUI 保存静态密码。成功后断开,再重新打开 GUI 测试第二次连接。

最终验收标准不是“配置保存成功”,而是:

  1. 点击 Connect 后不再要求输入静态密码。
  2. 不再弹出宁盾六位码输入框。
  3. 日志进入 Connected,学校内网资源可以正常访问。
  4. 断开后再次连接仍然成功。

如果二维码里没有 token

如果脚本提示 activationMethod=2 或没有 token,你拿到的是公共激活码。这种二维码只有宁盾服务器信息,不能凭空推导个人 seed。

宁盾转换的三篇参考文章还记录了两条备用路线:一种是在令牌绑定网页中检查本人 refreshMobileToken 响应,另一种是在本人 App 激活时检查返回数据。响应里的 seed 可能是 Base64,也可能是 Hex,而且某些 data 字段还是双重编码的 JSON。

这两条路线会接触账号、激活流量和个人二次认证密钥,环境差异也很大。BNBU 当前可以从门户重新获取包含 token 的个人二维码,因此本文不把抓包作为默认步骤。没有 token 时,优先回门户确认是否点了“重新获取手机令牌”,或者联系 ITSC 获取正式支持。

收尾:把暴露面清干净

完成后检查并删除这些内容:

  • 个人二维码截图。
  • 保存完整二维码文本的文件。
  • 解码后的 JSON。
  • 包含 seed、Base32 或完整 URI 的终端历史、聊天记录和剪贴板。
  • 为排查临时生成的日志与抓包文件。

如果真实 seed 或 Base32 曾经发进聊天、上传网盘或交给在线转换网站,按已经泄露处理。回 Portal 重新获取手机令牌,让旧令牌失效,再用新的二维码重新做一次。不要只删除聊天记录后继续使用旧密钥。

还要接受一个现实:把静态密码和 TOTP seed 都放进同一台电脑后,便利性提高了,双因素原本的设备隔离却减弱了。Windows 登录、磁盘加密和电脑的物理安全会变得更重要。

回头看这次折腾

我最开始只是受不了每次连接都要输入账号密码,再拿手机、解锁宁盾令牌、抄六位码,也不喜欢 Cisco AnyConnect 的服务一直随系统启动。

中间试过 GUI、命令行脚本、密码自动填写、宁盾 Push、二维码识别,甚至差点走到手机抓包。真正解决问题的几个点都很具体:Batch mode 能保存密码;个人重新获取的二维码与公共二维码不同;Hex、Base32 和 otpauth:// 只是同一密钥的不同表示;OpenConnect 9.20 之后才支持完整 URI 与非默认 TOTP 周期。

现在我打开 OpenConnect-GUI,点击一次 Connect 就结束了。折腾过程比每天多输几次验证码花的时间多得多,但我终于不用再和那个重复流程较劲。

参考资料

学校认证策略、门户页面和客户端版本以后都可能变化。重新操作时,以本人二维码里的字段、OpenConnect 实际版本和一次真实连接为准。