前面两篇分别聊了 WebSocket 节点管理的工程问题和 HMAC 签名防篡改。但有个场景一直没细说——用户在浏览器里点”连接终端”,填入 SSH 密码,这条密码怎么安全地送到远端节点?
最直觉的做法是浏览器把密码明文扔给 Panel,Panel 再明文通过 WebSocket 转给节点。TLS 能防窃听没错,但中间的 Panel 服务端日志、节点日志、甚至 WebSocket 消息调试窗口里,密码都在那裸奔。换个思路,要是有人拿到了 Panel 数据库的访问权,存下来的日志里全是明文密码——想想都瘆人。
所以在 ServerManager 里,终端密码走的是一条完全不同的路:加密票据。
密码不走明文,走票据
整个流程可以这么理解——密码是一张舞会的入场券,只有持票人能在指定时间段内入场,过了时间作废,而且这张票本身不包含任何有意义的原文信息。
实际做了这些:
- Panel 用 Fernet 对称加密,把密码和时间戳打包成票据
- 票据通过 WebSocket 送到节点
- 节点用同样的密钥解密票据,取出密码
- 校验时间戳——票据超过 45 秒就过期
- 密码用完即丢,不落盘、不进日志
这样就算有人中间截获了 WebSocket 消息,他能看到的也就是一串 Fernet token——没有密钥解不开,而且 45 秒后这事就翻篇了。
Fernet 密钥从哪来?
Fernet 是 cryptography 库里的对称加密方案,它的密钥是 32 字节的 base64url 编码字符串。但 ServerManager 里没有额外的密钥分发机制——密钥从 node_token 派生:
1 | |
为什么选 node_token?因为 Panel 和 Node 都已经知道它——Node 注册时 Panel 就发下来了,HMAC 签名用的也是同一个 token。不需要额外的密钥协商,不需要密钥交换协议,现有的信任链就够了。
但要注意一点:node_token 本身千万不要以任何形式出现在 WebSocket 消息、日志、或 API 响应里。它只参与 HMAC 签名计算和 Fernet 密钥派生,结果是不可逆的摘要或密文。这是整条信任链的根——泄露了它,签名和加密就都形同虚设了。
Panel 端:签发票据
Panel 收到浏览器的终端连接请求后,先生成票据,再把票据塞进 WebSocket 消息发给 Node:
1 | |
expires_at 写进票据内部——Fernet 自身有时间戳(默认 TTL 60 秒),但那是 Fernet 层面的过期校验。我们再加上业务层的 expires_at,是为了做到更精确的控制,也为了在必要时给出更友好的错误信息。
票据生成后在 WebSocket 消息里长这样:
1 | |
password_encrypted 就是 Fernet 票据。明文密码从来没出现在这条消息里。
顺带说一句,terminal:create_session 是个 SIGNED 级别的动作——没有 _sign 字段,节点直接断连。就算有人想伪造终端连接请求,没有 node_token 算不出合法签名。
Node 端:解密并校验
节点收到消息后的处理在 action_dispatcher.py 里:
1 | |
几层防线叠在一起:
Fernet 的 ttl=45。Fernet 在解密时自动检查 token 自身的时间戳是否在 45 秒内生成。这个校验是加密层面的——攻击者没法篡改 Fernet 内部的时间戳,因为 Fernet token 带有 HMAC。
业务层的 expires_at。即使 Fernet 校验通过(说明票据是最近生成的),还得看业务时间戳有没有过期。双重时间校验,谁也不敢偷懒。
解密失败返回空字符串,而不是报错断连。这跟签名验证的策略不一样——签名验证失败直接 close(3001),因为那意味着连接可信度出了大问题。但密码解密失败,可能只是 Panel 版本太老没加密、或者票据格式不对,没必要把整个连接都干掉。终端连接创建会失败,但节点继续运行。
为什么不用 RSA 非对称加密?
有人可能会问:既然 Panel 要加密、Node 要解密,为什么不用 RSA——Panel 拿 Node 的公钥加密密码,Node 用私钥解密?这样连节点都不用存对称密钥了。
实际上在这个场景下,RSA 反而更麻烦:
密钥分发问题。RSA 需要每个节点生成密钥对、把公钥注册到 Panel。对 ServerManager 这种”一个 Panel 管一堆 Node”的架构来说,多了一个密钥生命周期要管理。
性能没必要。密码就几十个字节,RSA 的性能优势在这里毫无意义。对称加密的加解密速度远快于 RSA,虽然几十字节数据差异可以忽略。
信任模型相同。node_token 已经是 Panel 和 Node 共享的密钥,再用 RSA 生成一对密钥只是增加了一个等效的信任根,安全边际并没有提升——node_token 泄露,RSA 私钥当然也可以从节点拿走。
Fernet 自身的优势。Fernet token 自带时间戳和 HMAC,这意味着即使攻击者截获了 token,他既不能解密(没有密钥),也不能延长时间窗口(篡改时间戳会导致 HMAC 校验失败)。RSA 加密后的密文没有这些校验机制。
所以在这个”两端共享密钥”的场景下,Fernet 是更自然的选择。
日志安全:密码绝不上墙
decrypt_terminal_password 的日志只记录”解密失败”这个事实,不记录密文原文、不记录解密结果、不记录 node_token。而在 WebSocket 消息的日志脱敏里,password_encrypted 也会被替换成 ***:
1 | |
password 和 password_encrypted 都在脱敏列表里。就算有人翻遍了所有日志,他也找不到密码的任何痕迹——不管是明文还是密文。
回头看整条链路
把视角拉远一点,终端密码的安全传输其实是之前那套 HMAC 签名体系的自然延伸:
- 签名保证的是”这条消息没被篡改”——密码票据里没有任何东西能被替掉
- 加密保证的是”看不懂”——截获了也解不开
- 时间窗保证的是”过期作废”——45 秒后就算解开了也没用
- 日志脱敏保证的是”不留痕”——事后审计找不到密码
四层叠加,就算其中某一层出了问题(比如 TLS 配置失误被中间人截获),其他层仍然能兜住。安全从来不是单一技术能搞定的事,得像洋葱一样一层一层的。