Add optional one-way TLS to the MFC server (server/2015Remote) so the device port can serve both TLS clients and legacy plaintext clients on the same port, mirroring the Go server. Server: on a connection's first bytes, sniff for a TLS ClientHello (0x16 0x03 + handshake type 0x01). When [tls] Enabled=1 and the identity loads, hand the socket to a short-lived blocking handshake thread (Option A) running Schannel AcceptSecurityContext, then decrypt on recv and encrypt on send; plaintext connections replay the sniffed bytes straight into the existing parse path, so the plaintext code path is otherwise unchanged. The identity is loaded once from [tls] CertPath/KeyPath as PEM + CNG (PKCS#8) — the same key pair the Go server uses — and a missing [tls] section, an empty value, or a load failure leaves plaintext serving intact and fail-closes any TLS ClientHello. Also fix two transport-reliability bugs that surfaced end-to-end: - send path: encrypt only the freshly appended plaintext tail and post a single IOWrite wake-up guarded by a bSendPending flag, so back-to-back small sends (terminal resize -> COMMAND_NEXT -> resize) no longer run concurrent WSASends over the same mutable buffer (client saw DecryptMessage failures, with WSAGetLastError() = 6001 / ERROR_DECRYPTION_FAILED); - client Recv: keep the handshake's unconsumed tail bytes in m_raw and drain them before the next recv, and log the real SECURITY_STATUS on DecryptMessage failure instead of the stale Winsock error. Co-Authored-By: deepseek-v4-pro
19 KiB
传输层单向 TLS 加密设计(按需加密)
版本:v0.5 · 状态:Go 服务端 + Windows 客户端已实现并验证;信任锚改存注册表;MFC 主控端(阶段 3)未施工 · 日期:2026-10-14 目标:让 YAMA 设备端口(TCP 6543)在继续兼容老客户端(明文/XOR)的同时,允许新客户端在拿到证书后按需开启单向 TLS 加密传输。 本期范围:Go 服务端(
server/go)+ Windows 客户端;MFC 服务端(server/2015Remote)延后。需求原文(三点,逐一对应下文):
- 新客户端默认不加密,行为与老客户端完全一致;
- 某些高要求场景,管理员「颁发一个证书给该客户端 + 重启」,该客户端即改走加密;
- 服务端同一端口(6543)同时兼容明文与 TLS 两种客户端,老客户端零改动。
1. 目标与范围
目标
- 同一
6543端口上,服务端自动区分「明文客户端」与「TLS 客户端」,按连接路由。 - 加密是按部署、按客户端开关的:证书(注册表值或文件)在 → TLS;证书不在 → 明文(与老客户端一致)。
- 老客户端(无证书概念)行为不变、零改动。
- 满足 Measure 1 的「传输层真加密 + 抗重放」目标(对 TLS 客户端而言)。
非目标(Non-goals,本次不做)
- 不做 mTLS / 客户端证书:单向 TLS 不验证客户端证书,客户端身份仍由隧道内的现有登录(
PwdHmacV1 /ECDSAV2)承担。 - 不做传输层 PSK(
YAMA_TRANSPORT_KEY)方案——本设计改用证书,更贴合「颁发证书」的运维模型。 - 不改应用层协议(flag 帧格式 / XOR / zstd),不做「TLS 内去掉 XOR」的二次精简(后续可选)。
- 不做加密开关的运行时热切换(证书只在客户端启动时读取一次,改配置需重启——这正是需求)。
- 不做 Linux / macOS / Android 客户端的 TLS 接入(本文以 Windows MFC 客户端为主,其余平台同样式跟进)。
- 不做 MFC 服务端(
server/2015Remote)的 TLS 接入——本期只做 Go 服务端(server/go),MFC 主控端延后到阶段 3。
2. 现状与复用
2.1 当前传输(已核实)
服务端 TCP 传输链路:server/go/server/server.go acceptLoop()(329–360 行)Accept 后 go handleConnection(conn);handleConnection()(363–425 行)读入 ctx.InBuffer,交给 protocol.Parser。
server/go/protocol/parser.go Parse()(51–85 行):
- 可选剥离 HTTP-mask(
POST /...\r\n\r\n外壳,common/mask.h的HttpMask); parseHeader()(88–140 行)调用CheckHead()识别协议;parsePacket()(143–225 行)解头 + XOR 解码 + zstd 解压。
server/go/protocol/header.go CheckHead()(149–165 行)对前 12 字节依次尝试 8 种头混淆 × 4 种魔数,命中即得 FlagType:
| FlagType | 魔数 | 首字节 |
|---|---|---|
| FlagHell(4) | HELL |
H=0x48 |
| FlagShine(1) | Shine |
S=0x53 |
| FlagFuck(2) | <<FUCK>> |
<=0x3C |
| FlagHello(3) | Hello? |
H=0x48 |
| HTTP-mask | POST |
P=0x50 |
注意:上表「首字节」是解密后的魔数首字节。实际线上,客户端(
common/header.h的GetHead)会用time() % 12随机选一个混淆方法对前 4 字节魔数加密(仅在superAdmin命中的HellEncoder路径;默认ProtocolEncoder直接发明文Shine)。因此原始首字节可能是任意值,包括0x16。这正是 TLS 检测不能只看前两字节、必须再加一字节判据的原因(见 §3.1)。
当前「加密」为 codec.go 的 xorDecoder16(XOR + 两轮伪随机交换,密钥是明文带内 12 字节)+ V6 头混淆——等于明文。这正是 Measure 1 要解决的。header.go 的 V0
2.2 客户端登录(已核实,隧道内照常)
- V1:
client/KernelManager.cpp:1857从配置读settings/PwdHmac(预计算摘要),服务端auth.go:240VerifyMessage(YAMA_PWD, passcode, hmacSig)校验。YAMA_PWD只存在服务端,客户端只有摘要,不持有YAMA_PWD。 - V2:
auth.go:188ECDSA P-256 验签(v2:BASE64),客户端持私钥、服务端持公钥。
2.3 关键结论(本设计的基础)
TLS 作为「透明传输包装」,应用层协议一行不改。 即:
明文客户端: [TCP] = flag 帧 + XOR payload + zstd
TLS 客户端: [TCP][TLS] = { flag 帧 + XOR payload + zstd }
服务端 parser.go/codec.go/header.go/handler 全部不动;TLS 客户端解密后的字节流就是同一套协议。唯一新增的是连接建立时的 TLS 检测与解包。XOR 套在 TLS 里冗余但无害,保留它换来「应用协议零改动、老新客户端共用同一套代码」。
3. 总体方案
3.1 同端口共存 + 首字节检测
服务端在 handleConnection 开头(Accept 之后)偷看连接开头 6 字节:
- 命中
0x16 0x03 … 0x01(TLS 握手记录 + TLS 版本号 + 握手类型 ClientHello)→ 交给tls.Server解包; - 其它 → 走现有明文路径。
为何要看第 6 字节:客户端会随机混淆魔数头(见 §2.1),所以明文连接的前两字节可能恰好是 0x16 0x03。但混淆只作用于前 4 字节魔数,字节 5 对混淆后的 HELL 头恒为 0x00,对明文 Shine/POST 变体是 ASCII 字符——永远不会是 0x01;而任何 TLS 握手记录里,字节 5 就是握手消息类型,ClientHello 恒为 0x01。于是 0x16 0x03 加 b[5]==0x01 三重判据让误判概率趋近于零。
accept
├─ 现有 MaxConnections / per-IP 限流(保持,先于 TLS 握手,廉价拒绝 + 抗握手 DoS)
├─ peek 3 字节
│ ├─ 0x16 0x03 → tls.Server(conn, tlsCfg)(惰性握手,首次 Read 触发)
│ └─ 其它 → 明文
└─ handleConnection(conn) // 不区分明文/TLS,tls.Conn 实现 net.Conn
3.2 客户端行为
客户端启动时检查「证书文件是否存在」:
- 不在 → 明文(与老客户端完全一致,默认即此);
- 在 → 所有出站 TCP 连接(主连接 + screen/terminal/file 子连接)统一走 TLS。
3.3 架构图
┌──────────── 被控端(客户端,无证书) ────────────┐
│ TCP ── flag+XOR+zstd ─────────────────────────► │ 明文
└───────────────────────────────────────────────────┘
┌──────────── 被控端(客户端,有证书) ────────────┐
│ TCP ── TLS ── { flag+XOR+zstd } ──────────────► │ 加密
└───────────────────────────────────────────────────┘
│
▼
┌──────────────────────┐
│ 服务端 6543 │
│ peek 首字节路由 │
│ ├─ 0x16 0x03 → TLS │
│ └─ 其它 → 明文 │
│ ── 同一套 parser/handler │
└──────────────────────┘
4. 证书模型(单向 TLS)
- 服务端持有一张服务端证书 + 私钥(可自签名,或由自建 CA 签发)。
- 客户端持有信任锚证书(CA 证书,或自签名服务端证书本身)用于在握手时校验服务端。
- 「颁发证书给某客户端」= 把信任锚证书投递到该客户端(注册表值,见 §6.1/§9.3)。证书是共享的信任锚(不是每个客户端一张唯一证书),「颁发」的本质是决定给哪个客户端开加密。
- 单向 TLS 不认证客户端身份;客户端身份仍由隧道内的登录(V1
PwdHmac/ V2 ECDSA)承担。 - 信任锚首选存注册表
Software\<exehash>\settings\tls_cert(REG_SZ 直接存 PEM 正文);兼容旧方式:settings.ini[settings] tls_cert路径,或 exe 同目录yama-ca.pem文件。
5. 服务端改动
5.1 配置(server/go/server/server.go Config)
新增两个字段,均为证书/私钥文件路径;空 = 禁用 TLS(仅明文,向后兼容):
TLSCertFile string // 服务端证书(PEM)
TLSKeyFile string // 服务端私钥(PEM)
cmd/main.go 读取环境变量(沿用现有 YAMA_* 命名):
cfg.TLSCertFile = os.Getenv("YAMA_TLS_CERT")
cfg.TLSKeyFile = os.Getenv("YAMA_TLS_KEY")
仅当两者都非空才构建 *tls.Config;否则 s.tlsCfg == nil,acceptLoop 走纯明文。
5.2 tls.Config
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
// 单向 TLS:不设 ClientAuth / ClientCAs
}
5.3 handleConnection 检测 + 回放
标准 peek 回放:bufio.Reader 预读 6 字节,封装成 bufferedConn(实现 net.Conn,Read 委托给 Reader),判断后把已读字节一并交给目标。嗅探本身设了 ReadTimeout 截止,超时/EOF 则退回原始 conn 走明文,由读循环处理:
func (s *Server) wrapTLS(conn net.Conn) net.Conn {
br := bufio.NewReader(conn)
_ = conn.SetReadDeadline(time.Now().Add(s.config.ReadTimeout)) // 嗅探受超时约束
hdr, err := br.Peek(6)
_ = conn.SetReadDeadline(time.Time{})
if err != nil {
return conn // 超时/EOF:走明文,交由读循环处理
}
bc := &bufferedConn{Conn: conn, r: br}
if isTLSClientHello(hdr) { // 0x16 0x03 且 b[5]==0x01
return tls.Server(bc, s.tlsCfg) // 握手惰性,首次 Read 触发
}
return bc
}
5.4 握手超时
handleConnection 首次 conn.Read 会触发 TLS 握手,而它已按 ReadTimeout 设了 SetReadDeadline,因此握手天然受 ReadTimeout 约束,无需额外机制。per-IP 限流(现有)继续在握手前生效,压制握手 DoS。
5.5 明确不动的部分
parser.go/codec.go/header.go:零改动。handleConnection/processData/ handler 栈:零改动(tls.Conn满足net.Conn)。
6. 客户端改动
6.1 证书检测
启动时(连接前)按优先级找信任锚:① 注册表 Software\<exehash>\settings\tls_cert(REG_SZ 存 PEM 正文)→ ② settings.ini [settings] tls_cert 路径 → ③ exe 同目录 yama-ca.pem。找到即走 TLS,否则明文。实现:client/TlsChannel.cpp 的 ReadTrustAnchor() + TlsEnabled()(std::call_once 惰性加载、线程安全、幂等)。
6.2 TLS 栈选型(已定:Schannel)
- 已选 Schannel(Windows 原生 SSPI):零第三方依赖/零额外 DLL 部署,
secur32.lib/crypt32.lib均为系统库(secur32.lib已在CFFmpegH264Encoder.cpp:38链接)。 - 信任锚(PEM/DER 正文,来自注册表或文件)经
CryptStringToBinaryA解码 →CertCreateCertificateContext→ 内存HCERTSTORE;握手后CertGetCertificateChain把服务端证书链到该信任锚,dwErrorStatus == 0才放行(含时间有效性)。严格钉扎:链引擎用hExclusiveRoot只信任该 store,排除系统根库,否则公共 CA 签发的证书也能通过。fail-closed。 - 握手凭据:
SCH_CRED_MANUAL_CRED_VALIDATION | SCH_CRED_NO_DEFAULT_CREDS,grbitEnabledProtocols = SP_PROT_TLS1_2_CLIENT(与 Go 端MinVersion: TLS 1.2对齐)。 - 单向 TLS:不提供客户端证书;客户端身份仍由隧道内登录承担。
6.3 连接层封装
在 socket 建立后、应用协议首包前插入 TLS 握手(IOCPClient::ConnectServer,m_bConnected 置位前);ReceiveData/SendTo 改走 TlsChannel 会话。所有出站连接(主连接 + 子连接)统一遵循 TLS 开关,因此子连接同样被加密——服务端按连接检测,天然支持。实现:client/TlsChannel.{h,cpp},接入点 client/IOCPClient.{h,cpp}。
6.4 关键:无静默降级(fail-closed)
证书文件存在但握手失败(证书不匹配 / 中间人)时,客户端必须报错并放弃连接,禁止回退到明文。 否则攻击者可制造降级。这必须写进实现与测试。
6.5 实现要点:Schannel 流模式 DecryptMessage 的缓冲重组
Schannel 流模式(ISC_REQ_STREAM)下,DecryptMessage 会把输入的那段 SECBUFFER_DATA
重组为 4 段输出,而不是就地解密到原偏移:
| 索引 | 类型 | 内容 |
|---|---|---|
bufs[0] |
SECBUFFER_STREAM_HEADER |
TLS 记录头(5) + 显式 nonce(8) |
bufs[1] |
SECBUFFER_DATA |
明文 |
bufs[2] |
SECBUFFER_STREAM_TRAILER |
认证标签 |
bufs[3] |
SECBUFFER_EXTRA |
下一条记录的剩余密文(如有) |
因此客户端 Recv 必须从 bufs[1].pvBuffer/bufs[1].cbBuffer 取明文、EXTRA 从 bufs[3]
取。若误用 bufs[0](记录头+nonce)或从原缓冲首字节取数,会把 TLS 记录头 17 03 03 …
当作明文交给协议解析器(表现为 Unknown header)。Send 方向相反:EncryptMessage 用
STREAM_HEADER/DATA/TRAILER 三段布局,本就正确。这一处是联调阶段实际踩到的、唯一的方向性
不对称 bug,已修复。
7. 兼容矩阵
| 客户端 | 服务端行为 | 结果 |
|---|---|---|
| 老客户端(无证书概念) | 明文路径 | 与现状完全一致 |
| 新客户端 · 无证书 | 明文路径 | 与老客户端行为一致(默认) |
| 新客户端 · 有证书 | TLS 路径 | 全量加密(主 + 子连接) |
服务端 TLS 未配置(YAMA_TLS_CERT/KEY 为空)时,一切与现状一致,TLS 客户端握手自然失败(fail-closed)。
8. 安全考量
- TLS 提供:机密性、完整性、服务端身份(客户端校验)、抗重放(TLS record 序号 + nonce)。即对 TLS 客户端而言,Measure 1(真加密 + 防重放)达成。
- TLS 不提供(单向):客户端证书身份——仍由隧道内登录承担;对「未颁证客户端」不强制加密(这是本设计刻意保留的兼容性,也是薄弱点:部署安全性取决于最弱客户端)。
- 明文前导:TLS ClientHello 前几字节明文(协议固有),仅暴露「这是 TLS 客户端」,不含业务数据;客户端不设 SNI(连接目标是 IP,无域名)。
- 子连接:客户端统一走 TLS,screen/terminal/file 子连接一并加密;对应 Measure 2 的「源 IP 绑定」降级为纵深防御,不再是唯一防线。
- 证书轮换:重签服务端证书后,需向已颁证客户端重新投递新信任锚(或在证书到期前提前过渡)。自签名建议
-days 3650,到期前统一更换。 - 协议强度:
MinVersion固定 TLS 1.2+,套件用 Go 默认安全集(tls.Config默认已排除弱套件)。
9. 部署与运维
9.1 生成证书
自签名(最简,客户端信任锚即 yama-server.crt):
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout yama-server.key -out yama-server.crt \
-days 3650 -subj "/CN=yama-server"
或自建 CA + 签服务端证书(便于轮换,客户端信任锚为 ca.crt):
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 3650 -subj "/CN=yama-ca"
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=yama-server"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 3650
9.2 服务端
YAMA_TLS_CERT=/etc/yama/server.crt \
YAMA_TLS_KEY=/etc/yama/server.key \
./server_linux_amd64 ...
9.3 客户端「颁发 + 重启」
首选:把信任锚 PEM 正文写入注册表值 Software\<exehash>\settings\tls_cert(REG_SZ),重启客户端:
$pem = Get-Content -Raw 'C:\path\yama-ca.pem'
New-Item -Path 'HKCU:\Software\<exehash>\settings' -Force | Out-Null
Set-ItemProperty -Path 'HKCU:\Software\<exehash>\settings' -Name 'tls_cert' -Value $pem -Type String
<exehash>= 客户端 exe 全路径小写后的 FNV 哈希(common/iniFile.h的GetRegistryName())。- 客户端以服务(SYSTEM)运行时,注册表落在会话用户的
HKEY_USERS\<SID>(GetCurrentUserRegistryKey())。 - REG_SZ 直接存 PEM 正文(含换行);客户端动态读取,不受
iniFile::GetStr512 字节固定缓冲限制。
兼容旧方式:把信任锚复制到客户端目录并命名为 yama-ca.pem(或经 settings.ini [settings] tls_cert 指向),重启客户端。
移除注册表值(或删除文件)并重启即退回明文。远程下发/撤销信任锚属后续工作,本期仅支持本地写注册表。
10. 测试计划
- 检测单元:
isTLSClientHello对0x16 0x03 … 0x01(TLS 1.2/1.3 ClientHello)/POST/Shine/ 混淆HELL(首两字节恰好0x16 0x03但b[5]==0x00)/ 短于 6 字节 等判定的单测(已实现server/go/server/tls_test.go)。 - TLS 未配置:不设
YAMA_TLS_*→ 纯明文,行为与现状一致。 - 明文回归:老客户端 + 新客户端(无证书)均走明文,登录/屏幕/终端/文件全链路不回归。
- TLS 加密:新客户端(有证书)→ 握手成功,登录/屏幕/终端/文件子连接全部在 TLS 内跑通;抓包确认 payload 不可读。
- 混合并发:明文与 TLS 客户端同时在线,互不干扰。
- fail-closed:客户端有证书但服务端证书不匹配 / 中间人 → 客户端报错断开,不回退明文。
- 握手超时 / 限流:恶意慢握手被
ReadTimeout+ per-IP 限流压制。
11. 分期与回滚
- 阶段 1(Go 服务端,本期优先):
Config+tls.Config+acceptLoop检测回放;明文路径零改动。可独立上线、独立验证——用openssl s_client或 Go 测试客户端即可联调 TLS 链路,无需真实客户端配合。 - 阶段 2(Windows 客户端):证书检测 + TLS 栈 + fail-closed。
- 阶段 3(MFC 服务端
server/2015Remote,延后):与 Go 服务端同款的 TLS 检测 + 解包接入。设计稿见docs/TransportTLS_MFCServer_Design.md。 - 回滚:服务端清空
YAMA_TLS_CERT/KEY即回到纯明文;客户端删除注册表tls_cert值(或yama-ca.pem文件)即回明文。
12. 待定问题
客户端 TLS 栈最终选型→ 已定 Schannel(见 §6.2,2026-10-13 落地)。信任锚文件固定名(→ 已改为注册表yama-ca.pem)是否需可配置tls_cert值优先、文件回退(见 §6.1/§9.3,2026-10-14)。- 是否同步把「TLS 内去掉 XOR」列为后续精简项(当前保留 XOR,换取零协议改动)。
- Linux / macOS / Android 客户端的 TLS 接入排期。