ProtectHome 与 TOTP 种子:VPN 登录为何失败
正确的验证码也帮不了无法打开种子的 PAM 模块。请在 drop-in 与可写的状态路径之间做选择,然后检查单元。
认证器显示了一个全新的六位验证码,VPN 却拒绝它,而同一个验证码在你自己的 shell 里测试时却验证无误。这个差距就是全部故事。无法打开注册密钥的 PAM 模块会返回一个与错误验证码看起来完全相同的失败,而隐藏主目录的服务环境正是常见原因。在放宽任何权限之前,先确定你属于三种情况中的哪一种。
这是三种失败中的哪一种?
第一种是模块根本无法读取密钥。第二种是能读取但无法更新密钥。第三种是验证码本身就错了,通常是因为时钟漂移或重放的值。
要区分它们,请查看一次失败尝试的服务日志和系统认证日志,然后确认插件加载的是哪个 PAM 服务,以及该服务使用哪一行模块。通用的认证失败消息无法区分它们,成功的手动测试也不能,因为你的 shell 并不是服务的环境。
当只有服务失败时,单元的文件系统视图通常是罪魁祸首。
systemctl show -p ProtectHome -p ProtectSystem -p ReadWritePaths [email protected]
ls -l /var/lib/vpn-otp/*/.google_authenticator 2>/dev/null | head -n 20方案 A:把密钥移出主目录
当服务隐藏 /home 而你又想保留这种保护时,把密钥存放在它能看见的地方。模块接受 secret= 路径和负责执行文件操作的 user= 账户,这样集中存储就能服务许多身份。
这是最整洁的方案,也是最需要谨慎的方案,因为共享存储会把一个目录变成每个账户的安全边界。
auth required pam_google_authenticator.so secret=/var/lib/vpn-otp/${USER}/.google_authenticator user=vpn-otp信任它之前检查三件事:账户存在、模块的工作进程能切换到它、文件归它所有且权限受限制。不要将 ~ 或带主目录变量的路径与 user= 一起使用,也不要为了让路径生效而关闭模块的所有权检查。保持密码阶段和其余栈不变。
方案 B:保留文件并打开一条路径
如果密钥必须留在用户的主目录中,单元需要一种只包含那一个目录、而不暴露主机上所有主目录的视图。drop-in 可以在沙箱化主目录下添加一个范围很窄的绑定挂载。
这样会保留按用户区分的所有权模型,代价是每次沙箱变化时都要审查一次单元改动。
有两个限制容易被忽略。在沙箱隐藏的目录下添加只读路径并不会暴露它,因为父目录根本不存在。而如果模块必须更新状态,挂载就必须可写,这就引出了下一个方案。把 drop-in 写成单独的文件,而不是编辑 /usr/lib 下的打包单元,这样软件包升级不会悄悄撤销它。
方案 C:保留沙箱并修复状态写入
读取密钥只是认证的一半。模块可能会重写时间偏移信息、防重放信息或计数器,而通过读取检查的文件仍可能在写入时失败。
这种失败会在下一次尝试时表现为验证码错误,而不是这一次,因此常被误认为时钟漂移。
给它一个自己的可写目录,并把密钥路径指向那里,而不是让整个主目录都可写。确认目录的所有者、文件系统的可用空间,以及单元是否限制了其他位置的写入。然后测试连续两次登录,因为第二次才能证明写入路径,第一次只证明读取。
以服务身份验证,而不是以你自己
每一项重要的检查都必须在单元的沙箱内、以单元的身份运行。
以 root 身份成功的命令只能告诉你文件存在,别的什么也说明不了。通过真实服务登录才能告诉你模块能否读取密钥、使用它并写回它需要的内容。
最后,保持要求不变。添加空密码选项、接受只读失败或移除沙箱,都会通过改变策略让症状消失。被要求出示验证码的用户,在密钥不可达时仍应被拒绝。