终端密码怎么在 WebSocket 上安全地走一趟?

前面两篇分别聊了 WebSocket 节点管理的工程问题和 HMAC 签名防篡改。但有个场景一直没细说——用户在浏览器里点”连接终端”,填入 SSH 密码,这条密码怎么安全地送到远端节点?

最直觉的做法是浏览器把密码明文扔给 Panel,Panel 再明文通过 WebSocket 转给节点。TLS 能防窃听没错,但中间的 Panel 服务端日志、节点日志、甚至 WebSocket 消息调试窗口里,密码都在那裸奔。换个思路,要是有人拿到了 Panel 数据库的访问权,存下来的日志里全是明文密码——想想都瘆人。

所以在 ServerManager 里,终端密码走的是一条完全不同的路:加密票据

密码不走明文,走票据

整个流程可以这么理解——密码是一张舞会的入场券,只有持票人能在指定时间段内入场,过了时间作废,而且这张票本身不包含任何有意义的原文信息。

实际做了这些:

  1. Panel 用 Fernet 对称加密,把密码和时间戳打包成票据
  2. 票据通过 WebSocket 送到节点
  3. 节点用同样的密钥解密票据,取出密码
  4. 校验时间戳——票据超过 45 秒就过期
  5. 密码用完即丢,不落盘、不进日志

这样就算有人中间截获了 WebSocket 消息,他能看到的也就是一串 Fernet token——没有密钥解不开,而且 45 秒后这事就翻篇了。

Fernet 密钥从哪来?

Fernet 是 cryptography 库里的对称加密方案,它的密钥是 32 字节的 base64url 编码字符串。但 ServerManager 里没有额外的密钥分发机制——密钥从 node_token 派生:

1
2
3
4
5
6
7
8
from cryptography.fernet import Fernet, InvalidToken
import base64
import hashlib

def _derive_fernet_key(node_token: str) -> bytes:
"""用 node_token 的 SHA-256 派生 Fernet 密钥"""
digest = hashlib.sha256(node_token.encode('utf-8')).digest()
return base64.urlsafe_b64encode(digest)

为什么选 node_token?因为 Panel 和 Node 都已经知道它——Node 注册时 Panel 就发下来了,HMAC 签名用的也是同一个 token。不需要额外的密钥协商,不需要密钥交换协议,现有的信任链就够了。

但要注意一点:node_token 本身千万不要以任何形式出现在 WebSocket 消息、日志、或 API 响应里。它只参与 HMAC 签名计算和 Fernet 密钥派生,结果是不可逆的摘要或密文。这是整条信任链的根——泄露了它,签名和加密就都形同虚设了。

Panel 端:签发票据

Panel 收到浏览器的终端连接请求后,先生成票据,再把票据塞进 WebSocket 消息发给 Node:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from cryptography.fernet import Fernet
import hashlib
import base64
import json
import time

def issue_terminal_ticket(node_token: str, password: str, ttl: int = 45) -> str:
key = base64.urlsafe_b64encode(hashlib.sha256(node_token.encode()).digest())
fernet = Fernet(key)
payload = json.dumps({
"password": password,
"expires_at": time.time() + ttl,
})
return fernet.encrypt(payload.encode()).decode()

expires_at 写进票据内部——Fernet 自身有时间戳(默认 TTL 60 秒),但那是 Fernet 层面的过期校验。我们再加上业务层的 expires_at,是为了做到更精确的控制,也为了在必要时给出更友好的错误信息。

票据生成后在 WebSocket 消息里长这样:

1
2
3
4
5
6
7
8
9
10
11
{
"action": "terminal:create_session",
"data": {
"host": "10.0.1.5",
"port": 22,
"username": "root",
"password_encrypted": "gAAAAABl...",
"index": 3
},
"_sign": { "timestamp": "...", "nonce": "...", "signature": "..." }
}

password_encrypted 就是 Fernet 票据。明文密码从来没出现在这条消息里。

顺带说一句,terminal:create_session 是个 SIGNED 级别的动作——没有 _sign 字段,节点直接断连。就算有人想伪造终端连接请求,没有 node_token 算不出合法签名。

Node 端:解密并校验

节点收到消息后的处理在 action_dispatcher.py 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def decrypt_terminal_password(self, payload: dict | None) -> str:
payload = payload if isinstance(payload, dict) else {}
encrypted = payload.get("password_encrypted")
if not encrypted:
return ""
try:
import base64
import hashlib

token = self.client.node_token().encode("utf-8")
key = base64.urlsafe_b64encode(hashlib.sha256(token).digest())
raw = Fernet(key).decrypt(str(encrypted).encode("utf-8"), ttl=45)
data = self.client.decode_json(raw.decode("utf-8"))
if data.get("expires_at", 0) and float(data["expires_at"]) < self.client.now():
raise ValueError("终端密码票据已过期")
return str(data.get("password") or "")
except (InvalidToken, ValueError, TypeError) as e:
logger.warning(f"终端密码票据解密失败: {e}")
return ""

几层防线叠在一起:

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
2
3
4
5
6
SENSITIVE_LOG_KEYS = {
'password', 'passwd', 'pwd', 'password_encrypted',
'token', 'node_token', 'client_token',
'access', 'refresh', 'authorization',
'signature', '_sign', 'secret',
}

passwordpassword_encrypted 都在脱敏列表里。就算有人翻遍了所有日志,他也找不到密码的任何痕迹——不管是明文还是密文。

回头看整条链路

把视角拉远一点,终端密码的安全传输其实是之前那套 HMAC 签名体系的自然延伸:

  • 签名保证的是”这条消息没被篡改”——密码票据里没有任何东西能被替掉
  • 加密保证的是”看不懂”——截获了也解不开
  • 时间窗保证的是”过期作废”——45 秒后就算解开了也没用
  • 日志脱敏保证的是”不留痕”——事后审计找不到密码

四层叠加,就算其中某一层出了问题(比如 TLS 配置失误被中间人截获),其他层仍然能兜住。安全从来不是单一技术能搞定的事,得像洋葱一样一层一层的。

欢迎关注我的其它发布渠道