Files
SimpleRemoter/docs/TransportTLS_MFCServer_Design.md
yuanyuanxianganddeepseek-v4-pro 96b9eea70a Feat: One-way TLS transport for the MFC server
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
2026-10-15 13:48:27 +02:00

24 KiB
Raw Permalink Blame History

MFC 服务端(server/2015Remote)单向 TLS 加密设计

版本:v0.3 · 状态:已实施并验证(屏幕 + 终端联调通过) · 日期:2026-10-15 上游依赖:docs/TransportTLS_Design.md(Go 服务端 + Windows 客户端,已实现并验证)。 本期范围:MFC 服务端 server/2015Remote 的传输层加密(加载证书+私钥、握手、加解密、新旧客户端并存)。 显式排除(本次不做,但须预埋接缝):① MFC 的 MCP 式配置 UI;② 「向在线主机下发是否启用加密传输」的指令;③ 客户端运行时热切换。

已拍板的三个决策(2026-10-14,见 §12):

  1. 握手采用 方案 A(专用短命阻塞线程);
  2. 身份文件采用 PEM + CNG(CertPath + KeyPath),与 Go 服务端共用同一对密钥;私钥须为 PKCS#8;
  3. 配置 新建 [tls] 节(Enabled / CertPath / KeyPath)。

需求原文(已确认):

  1. MFC 服务端有一个类似 MCP 的配置,可选启用传输层加密;启用时需指定一对密钥(证书 + 私钥);
  2. 启用后,可向在线主机发送「是否启用加密传输」的指令 —— UI 与新指令属后期内容;
  3. 新旧客户端都须支持:同一端口同时兼容明文(老客户端)与 TLS(新客户端)。

1. 目标与范围

目标

  • MFC 服务端在同一个 TCP 端口(6543)上,按连接自动区分「明文客户端」与「TLS 客户端」,路由到各自路径。
  • TLS 客户端解密后的字节流 == 同一套应用协议(flag 帧 + XOR + zstd),协议解析/解压/解码零改动。
  • 老客户端(无 TLS 概念)行为不变、零改动。
  • 服务端「启用 TLS」是配置驱动的:未配证书 → 纯明文;配了证书 → 新旧并存。

非目标(Non-goals,本次不做)

  • 不做 mTLS / 客户端证书:单向 TLS,客户端身份仍由隧道内登录(V1 PwdHmac / V2 ECDSA)承担。
  • 不做「TLS 内去掉 XOR」的二次精简(与上游设计一致,保留 XOR 换取零协议改动)。
  • 不做 UI:不新增配置对话框、不加「启用加密」复选框、不加证书/私钥路径选择控件。
  • 不做 「向在线主机下发启用/撤销加密」指令:不下发信任锚、不发加密开关指令、不改客户端运行时状态。
  • 不做 KCP/UDP 传输(IOCPKCPServer)的 TLS 接入(本期只做 TCP 设备端口;KCP 另行评估)。

2. 现状与复用(IOCP 数据流 + 四个接缝)

MFC 服务端 TCP 接收链路:OnAccept(IOCPServer.cpp:1144)accept 后 AllocateContext(:1289)分配 CONTEXT_OBJECT,绑定 IOCP,PostRecv(:1268)投递首个 WSARecv(:1278,收进 wsaInBuf→szBuffer[PACKET_LENGTH],:1215)。收满后 HandleIO(IORead)(:746)→ OnClientReceiving(:892)→ ParseReceivedData(:796)。

原始 socket 字节在整套流程里各只有一个入口和一个出口,这是 TLS 能做成「过滤器」而非「改写」的根本原因:

接缝 位置 作用
① 收包咽喉点 IOCPServer.cpp:805 InCompressedBuffer.WriteBuffer(szBuffer, dwTrans) 原始字节进入协议解析的唯一位置(szBuffer 全文件仅此一处消费)
② 发包咽喉点 IOCPServer.cpp:1000 WSASend(...wsaOutBuffer...) OutCompressedBuffer 上行的唯一位置
③ 嗅探点 OnAccept→PostRecv;OnClientInitializing(:790)现为空桩 return TRUE 首次收到数据处,可挂 ClientHello 判定
④ 状态字段 Server.h:332 CONTEXT_OBJECT(szBuffer/wsaInBuf/InCompressedBuffer/InDeCompressedBuffer/OutCompressedBuffer/Parser 等,:359-398) 新增 TLS 会话指针 + 标志位

关键结论(本设计的基础):TLS 作为「透明传输包装」,加解密插在 ①② 两处,③ 做新旧分流,④ 加一个 per-connection 状态。ParseReceivedData 内的 Parse/解压/Decode、WriteContextData 内的 Encode/压缩/掩码,全部不动。这与上游 Go 服务端「tls.Conn 满足 net.Conn、parser 零改动」是同一策略(见上游 §2.3)。

3. 总体方案

3.1 按连接嗅探(与 Go 服务端同款判定)

首次收到数据后,只看 szBuffer 前 6 字节:

  • 命中 0x16 0x03 … 0x01(TLS 握手记录 + TLS 版本号 + ClientHello)→ TLS 路径;
  • 其它 → 明文路径(老客户端透明,一行不改)。

判据与 Go 端 isTLSClientHello(0x16 0x03 且 b[5]==0x01)完全一致,理由也一致:客户端会随机混淆魔数头(见上游 §2.1),明文前两字节可能恰为 0x16 0x03,但字节 5 对混淆头恒非 0x01,故三重判据误判率趋近于零。

3.2 数据路径 = 条件过滤器

              OnAccept → PostRecv
                    │
        首次 IORead 完成(嗅探 szBuffer[0..5])
              ┌─────┴─────┐
        明文(其它)      TLS(0x16 0x03 .. 0x01)
              │              │
      ParseReceivedData   TLS 握手(AcceptSecurityContext)
      (现状,零改动)          │ 成功
              │              ▼
              │     握手后:IORead → DecryptMessage → 明文 → InCompressedBuffer.WriteBuffer
              │     发送:OutCompressedBuffer → EncryptMessage → WSASend
              └────────── 同一套 Parse/Encode/解压/压缩 ──────────┘

明文连接与握手后的 TLS 连接,下游共用同一套 Parse/Encode/解压/压缩。唯一差别是 ①② 两处各多一个 if (tls) 加解密 else 直通 分支。

4. 配置设计(含未来 UI/指令的预埋)

4.1 配置面(本次定键名,UI 后期加)

MFC 服务端配置走 THIS_CFG(config/iniFile,GetInt/GetStr(section,key,default),见 2015Remote.cpp:481-543 用法)。新增 [tls] 节,三键(已拍板 PEM + CNG,见 §5.6、§12):

[tls]
Enabled   = 0                 ; 是否启用传输层加密(0/1)
CertPath  = ""                ; 服务端证书路径(PEM,-----BEGIN CERTIFICATE-----)
KeyPath   = ""                ; 服务端私钥路径(PEM PKCS#8,-----BEGIN PRIVATE KEY-----)

三键都是短字符串路径,不落 PEM 正文,故不受 iniFile::GetStr 512 字节固定缓冲限制(该限制只在「存 PEM 正文」时才成问题,客户端已为此单独绕行,见上游 §6.1)。

4.2 未来不返工的四条硬约束(本次预埋)

  1. 配置单一访问器:TLS 开关与身份路径只经一个中心对象 ServerTlsIdentity 暴露:Load(config&) / IsEnabled() / IsReady() / IsLoadError() / Cred()。未来 UI 改配置、指令查询「我当前是否加密态」都走这同一个入口,禁止代码里散落 GetInt("Enabled")。
  2. 身份加载做成可重入,绝不用 std::call_once:ServerTlsIdentity::Load(config&) 每次调用重新加载证书+私钥并重建凭据句柄。本次启动时调一次;未来「轮换证书 / 开关切换」可再调。这是对客户端 TlsEnabled() 用 std::call_once 一次读死、不可热切换(见上游 §6.1 与 docs/tls-cert-push-revoke-deferred 相关记忆)的主动规避。
  3. 「我是否 TLS 就绪」是全局只读态,与每条连接的 TLS 态分开:ServerTlsIdentity::IsEnabled()(全局配置态)/ IsReady()(身份加载成功)与 per-connection 的「该连接是否走 TLS/握手是否完成」是两个概念。未来指令/UI 只碰前者。
  4. 按连接嗅探本身就是为混合上线准备的:服务端开 TLS 后,没切换的旧主机照样明文连、已切换的走 TLS。未来「下发开启指令」只改客户端行为,服务端传输层不用动。

5. 服务端改动详述

5.1 CONTEXT_OBJECT 字段(Server.h,加 4 项)

// 每连接传输层状态(单向 TLS,见 §5.2);跨线程(握手线程写 TLS_Ready,工作线程读)用 atomic 保序
enum ServerTlsState {
    TLS_Unclassified = 0,   // 尚未收到足够字节判定(缓冲 tlsPreClassify)
    TLS_Plain = 1,          // 明文客户端,直通现有解析路径
    TLS_Handshaking = 2,    // TLS 客户端,握手线程阻塞 AcceptSecurityContext 中
    TLS_Ready = 3,          // TLS 握手完成,收包解密 / 发包加密
};
ServerTlsSession*  tls = nullptr;                 // 懒分配;握手完成后承载加解密状态
std::atomic<int>   tlsState{ TLS_Unclassified };  // ServerTlsState
std::vector<BYTE>  tlsPreClassify;                // 分类前缓冲的首批字节(嗅探 ClientHello 头)
bool               bSendPending = false;          // 发送在途抑制(见 §5.5);受 SendLock 保护

void FreeTls();   // delete tls; tls = NULL;(定义在 ServerTlsSession.cpp,避免本头拖入 Schannel)

生命周期见 §5.7:InitMember 时复位 tlsState/tlsPreClassify/tls,析构 / 回收时 FreeTls()。

5.2 嗅探与预分类缓冲

首次 IORead 完成时(OnClientReceiving 开头,ParseReceivedData 之前)判定:

  • dwTrans >= 6:看 szBuffer[0..5],一次定死明文/TLS;
  • dwTrans < 6(TCP 把 ClientHello 头分片,极罕见但须正确):把这 dwTrans 字节追加进 tlsPreClassify,PostRecv 继续收,直到攒够 6 字节再判定。判定后清空 tlsPreClassify。

明文连接判定后,把 tlsPreClassify(如有)+ 本次 szBuffer 一并按现状喂给 ParseReceivedData,保证分片字节不丢。

5.3 握手(已拍板方案 A)

服务端握手需 AcceptSecurityContext(Schannel 入站),与客户端 InitializeSecurityContext(出站)不通用,需新写。数据阶段的 DecryptMessage/EncryptMessage 是同步缓冲变换,与异步收发包天然兼容,两个方案都复用。

方案 A:专用握手线程(阻塞式,已实施)

  • 首次 IORead 判定为 TLS 后,此刻 WSARecv 已因完成回调结束、socket 上无 pending overlapped 操作,可安全转交一个短命线程做阻塞握手。
  • 线程内用与客户端 TlsChannel::Handshake 同型的阻塞 Schannel 循环跑 AcceptSecurityContext(TlsChannel.cpp 已证明该模式可靠),把已收到的 ClientHello 字节(tlsPreClassify,可能是分片)作为初始输入,后续 handshake 航程走阻塞 recv/send;握手前把 socket 切回阻塞态并设 15s 收发超时。
  • 成功 → tlsState.store(TLS_Ready),PostRecv 投递首个 IORead,回到 IOCP 正常流程;失败 → 关连接(fail-closed,§8)。
  • 优点:复用客户端已验证的阻塞 Schannel 代码形态、状态最少;缺点:每个 TLS 连接多一个短命线程(仅在握手期间存活)。

方案 B:异步状态机(无额外线程,更贴合 IOCP)

  • 握手由 IORead/IOWrite 完成回调推进:每次回调喂一段握手字节给 AcceptSecurityContext,按其返回(SEC_I_CONTINUE_NEEDED → 继续收/发;SEC_E_OK → 完成)驱动下一动作。
  • 优点:无额外线程、与现有 IOCP 事件循环同构;缺点:状态管理明显更重,需把「握手待收/待发/完成」编码进 per-connection 状态。

已拍板(2026-10-14):采用 方案 A(简单、复用客户端阻塞 Schannel 经验、风险低)。方案 B 留作后续压测发现短命线程开销不可接受时再演进;二者切换只影响 §5.3,不影响 ①②④ 的过滤器设计。

5.4 收包解密(接缝 ①,ParseReceivedData 之上)

OnClientReceiving 里,明文与 TLS 分叉:

int st = ContextObject->tlsState.load(std::memory_order_acquire);
if (st == TLS_Ready) {
    // 1) 把 szBuffer[0..dwTrans] 喂给 DecryptMessage,得到明文 plain、长度 n
    // 2) 处理 SECBUFFER_EXTRA:多余密文留存到 tls 对象内部缓冲(m_raw),下次续解(见上游 §6.5)
    // 3) plain 为空(记录分片未齐)→ PostRecv 继续收;否则 ContextObject->InCompressedBuffer.WriteBuffer(plain.data(), n);
} else {
    ContextObject->InCompressedBuffer.WriteBuffer(szBuffer, dwTrans);  // 现状,零改动
}

即 ① 从「一行直写」变成「条件分支」,明文侧原文不动。解密缓冲重组(STREAM_HEADER/DATA/TRAILER/EXTRA 四段布局)已在上游 §6.5 踩过坑、给出正确写法,服务端直接沿用(ServerTlsSession::Decrypt 内维护 m_raw 续解缓冲)。

5.5 发包加密(接缝 ②,OnClientPreSending 之上)

OutCompressedBuffer 是「可能残留上一轮未发完密文」的可变缓冲:异步 WSASend 在途时,新明文由 WriteContextData 追加在尾部。因此 OnClientPreSending(IOCPServer.cpp)只加密本次新追加的尾部明文,而非整个缓冲:

  1. 追加前记 oldLen = OutCompressedBuffer.GetBufferLength();
  2. WriteContextData 追加新明文后,plainLen = totalLen - oldLen,只对 GetBuffer(oldLen) 起的 plainLen 字节调用 tls->Encrypt 得密文 wire;
  3. TruncateBuffer(oldLen) 回退尾部明文(不用 ClearBuffer:后者会 DeAllocateBuffer,可能 VirtualFree 正在被在途 WSASend 引用的底层内存),再 WriteBuffer(wire) 追加新密文。

这样 OutCompressedBuffer 始终保持一串合法 TLS 记录。若把整个缓冲重新加密,会把旧密文二次加密,客户端解密后得到「旧 TLS 记录」→ Unknown header 17 03 03。

发送唤醒的并发抑制(bSendPending):OnClientPreSending 每次 Send2Client 若都 PostQueuedCompletionStatus(IOWrite, 0),则每个 OnClientPostSending(ctx, 0) 都会对整个缓冲再发起一次 WSASend;背靠背小包(终端 resize → COMMAND_NEXT → resize)会触发多个并发 WSASend 读到同一段可变内存,产出重复/错位密文(客户端 DecryptMessage 失败,WSAGetLastError() 残留 6001 = ERROR_DECRYPTION_FAILED)。故新增 bool bSendPending(受 SendLock 保护):仅在 !bSendPending 时投递 IOWrite 并置位;OnClientPostSending 在缓冲排空(最后一个 WSASend 完成)或发送失败时清零;InitMember 复用对象池时复位。在途 WSASend 的完成回调会看到排空后仍残留的新数据并继续发送,无需再投递。后续 OnClientPostSending 的部分发送循环照旧——它发送的已是密文字节,逐段 WSASend 天然成立。

5.6 证书/私钥加载(Schannel 入站凭据,已拍板 PEM+CNG)

AcquireCredentialsHandle 取 SCHANNEL_CRED(入站)时需一个已绑定私钥的证书。已拍板用 PEM + CNG(与 Go 端 YAMA_TLS_CERT/KEY 同构,服务端与 Go 共用同一对密钥):

  • 证书:CryptStringToBinaryA(CRYPT_STRING_ANY) 把 PEM 解为 DER → CertCreateCertificateContext(X509_ASN_ENCODING)。
  • 私钥(仅 PKCS#8):Schannel 入站凭据要求私钥是**「能按名重开」的持久化键**——NCryptImportKey 产出的是匿名临时键,绑定 CERT_NCRYPT_KEY_HANDLE_PROP_ID 后 Schannel 仍拿不到(AcquireCredentialsHandle 返回 SEC_E_NO_CREDENTIALS 0x8009030e)。因此:
    1. 导入临时键并导出裸 BLOB:NCryptOpenStorageProvider(MS_KEY_STORAGE_PROVIDER) + NCryptImportKey(NCRYPT_PKCS8_PRIVATE_KEY_BLOB) 得临时键(hProvider 不能传 NULL——默认提供者解析实测返回 NTE_INVALID_HANDLE 0x80090026,须显式打开 KSP);再 NCryptSetProperty(NCRYPT_EXPORT_POLICY_PROPERTY=ALLOW_PLAINTEXT_EXPORT|ALLOW_EXPORT) 后 NCryptExportKey(BCRYPT_RSAFULLPRIVATE_BLOB) 得裸 RSA 私钥 BLOB。
    2. 写入持久化键:NCryptCreatePersistedKey(BCRYPT_RSA_ALGORITHM, "YamaServerTlsKey", AT_KEYEXCHANGE, NCRYPT_OVERWRITE_KEY_FLAG) → NCryptSetProperty(BCRYPT_RSAFULLPRIVATE_BLOB) → NCryptFinalizeKey。NCryptSetProperty 只认裸 BLOB、不认 PKCS#8(直接设 NCRYPT_PKCS8_PRIVATE_KEY_BLOB 实测 NTE_NOT_SUPPORTED 0x80090029)。
    3. 按名绑定:CertSetCertificateContextProperty(CERT_KEY_PROV_INFO_PROP_ID, CRYPT_KEY_PROV_INFO{ pwszContainerName="YamaServerTlsKey", pwszProvName=MS_KEY_STORAGE_PROVIDER, dwProvType=0, dwKeySpec=AT_KEYEXCHANGE })。Schannel 经 CryptAcquireCertificatePrivateKey 按名在 KSP 里重开该键。
  • 入站凭据:AcquireCredentialsHandleA("Microsoft Unified Security Protocol Provider", SECPKG_CRED_INBOUND, &SCHANNEL_CRED{ cCreds=1, paCred=&cert, grbitEnabledProtocols=SP_PROT_TLS1_2_SERVER, dwFlags=NO_DEFAULT_CREDS })。

关键点:私钥必须是 PKCS#8(-----BEGIN PRIVATE KEY-----);若拿到的是 PKCS#1(-----BEGIN RSA/EC PRIVATE KEY-----),用 openssl pkcs8 -topk8 -nocrypt -in server.key -out server.key 转换。证书上下文生命周期交给进程级 g_cert,持久化键句柄交给 g_persistedKey;FreeIdentity 里按序 FreeCredentialsHandle → CertFreeCertificateContext → NCryptDeleteKey(NCryptDeleteKey 会同时释放句柄,勿再 NCryptFreeObject)。

5.7 生命周期(context 池化复用)

CONTEXT_OBJECT 经 AllocateContext(:1311)从 m_ContextFreePoolList 复用、InitMember(:1314)复位。故 TLS 状态必须在 InitMember 里清空 tlsPreClassify、FreeTls()、复位 tlsState(store(TLS_Unclassified))与 bSendPending=false(避免上次连接残留的在途标志抑制新连接发送),并在 ~CONTEXT_OBJECT(Server.h:346)FreeTls()。否则「前一个 TLS 连接的状态漏给下一个明文连接」会直接导致解析错乱。这是唯一需要跨过 InitMember/析构两处做的生命周期接线。

5.8 明确不动的部分

  • ParseReceivedData 内的 Parse/解压/Decode、WriteContextData 内的 Encode/压缩/掩码:零改动。
  • HeaderParser / context(context.h)协议层:零改动。
  • OnAccept / PostRecv / AllocateContext / RemoveStaleContext 主结构:零改动(仅新增 TLS 分支与字段复位)。
  • KCP / UDP 传输(IOCPKCPServer):零改动(不在本期范围)。

6. 新老客户端兼容矩阵

客户端 服务端 TLS 状态 服务端行为 结果
老客户端(无 TLS 概念) 未配置 明文路径 与现状完全一致
老客户端 已配置 嗅探判为明文 → 明文路径 与现状完全一致(透明)
新客户端 · 无证书 未配置 / 已配置 明文路径 与老客户端一致(默认)
新客户端 · 有证书 已配置 嗅探判为 TLS → 握手 → 加密 全量加密
新客户端 · 有证书 未配置 嗅探判为 TLS → 无证书可握手 → 关连接(fail-closed) 拒绝(见 §8)

7. 与未来「下发/撤销指令 + UI」的接缝

本次不实现 UI 与指令,但通过 §4.2 四条约束预留干净接缝。二者未来的分工与依赖:

  • UI:在 MFC 配置对话框(类 MCP)里加「启用传输加密 + 指定一对密钥」,写入 [tls] 三键 → 调 ServerTlsIdentity::Load(config&) 重载 → IsEnabled() 即时生效。只碰 §4.2 第 1/2 点,不碰传输层。
  • 「向在线主机下发启用/撤销加密」指令:服务端侧是一条新的命令通道(遍历在线连接广播),客户端侧是运行时热切换——收到指令后热加载/删除信任锚、断开重连走新通道。

必须先说破的跨端依赖:这条指令真正的大头在客户端——客户端目前读 tls_cert 是进程启动读一次、std::call_once、不可热切换(见上游 §6.1 及 docs/tls-cert-push-revoke-deferred 记忆)。因此「下发/撤销」落地时,客户端要先把 TlsEnabled() 的一次读死改成「指令驱动的热重载」。这与本次服务端传输层正交——只要 §4.2 第 2/3 点把服务端状态做成可重载、可中心化查询,未来就能干净接上,无需回头改本次代码。

8. 安全考量

  • TLS 提供:机密性、完整性、服务端身份(客户端校验)、抗重放(TLS record 序号 + nonce)。对 TLS 客户端而言,Measure 1(真加密 + 防重放)达成,与 Go 端一致。
  • TLS 不提供(单向):客户端身份——仍由隧道内登录承担。
  • 服务端 fail-closed:服务端收到 TLS ClientHello 但自身未配证书/身份加载失败 → 直接关连接,禁止把 TLS 字节当协议去解析(否则新客户端握手失败重试 + 服务端狂报解析错)。与客户端 fail-closed 语义对称(见上游 §6.4)。
  • 握手 DoS:TLS 握手有 CPU 开销,须受 per-IP 限流(现有)保护;方案 A 的短命握手线程还须加握手超时(SSPI 握手期间设 socket 收发超时,超时关连接)。
  • 部署顺序(关键):MFC 服务端证书必须与客户端 CA 一起(或先于)上线。否则「新客户端(已钉 CA)连旧 MFC 服务端(无证书)」→ 客户端 fail-closed 直接拒绝,表现为「升级客户端后连不上老服务端」。这是设计使然,部署/文档须写明。
  • 私钥安全:服务器私钥只在握手时由 Schannel 内部签名使用(ECDHE)或解密预主密钥(RSA),不参与业务数据加解密;私钥文件应最小权限存放、不落日志。真正的长期秘密(CA 私钥)平时离线,仅在签发/轮换服务器证书时使用。

9. 部署与运维

生成服务端身份(与 Go 端 §9.1 的 server.crt + server.key 完全同一对密钥;私钥须 PKCS#8):

# 若私钥是 PKCS#1(-----BEGIN RSA/EC PRIVATE KEY-----),先转 PKCS#8:
openssl pkcs8 -topk8 -nocrypt -in server.key -out server.key

MFC 服务端配置(直接指向与 Go 端相同的 PEM 文件):

[tls]
Enabled  = 1
CertPath = C:\YAMA\server.crt
KeyPath  = C:\YAMA\server.key

重启 MFC 服务端即生效(本次仅启动时读一次,热切换待未来 UI)。删除 [tls] 节或置 Enabled=0 即回纯明文。

10. 测试计划

  1. 嗅探单元:0x16 0x03 .. 0x01(TLS 1.2/1.3 ClientHello)/ 明文首包 / 混淆头(首两字节 0x16 0x03 但 b[5]!=0x01)/ 首包 <6 字节分片 的判定。
  2. TLS 未配置:Enabled=0 或无证书 → 纯明文,行为与现状一致(老 + 新无证书客户端回归)。
  3. 明文回归:老客户端全链路(登录/屏幕/终端/文件)不回归。
  4. TLS 加密:新客户端(有证书)连 MFC 服务端 → 握手成功,登录/屏幕/终端/文件子连接全部在 TLS 内跑通;抓包确认 payload 不可读。
  5. 混合并发:明文与 TLS 客户端同时在线,互不干扰(对应未来过渡期)。
  6. fail-closed 双向:① 服务端未配证书但收到 ClientHello → 关连接不误解析;② 客户端有证书但服务端证书不匹配 → 客户端断开不回退明文。
  7. 握手超时 / 限流:恶意慢握手被超时 + per-IP 限流压制。
  8. 连接复用:一条 TLS 连接断开后,context 回池再被明文连接复用,TLS 状态已复位、无泄漏(§5.7)。

11. 分期与回滚

  • 本期(MFC 服务端传输加密,已实施):ServerTlsIdentity(PEM+CNG 加载)+ CONTEXT_OBJECT 字段(tlsState 枚举)+ 嗅探/预分类 + 握手(方案 A)+ ①收包解密 + ②发包加密 + 生命周期复位。可独立验证——用 openssl s_client 或复用 Go 端测试客户端联调,无需真实客户端配合。
  • 后续 A(UI):MCP 式配置对话框 + 重载 ServerTlsIdentity。
  • 后续 B(下发/撤销指令):服务端指令通道 + 客户端热切换(客户端 TlsEnabled() 改指令驱动,见 §7)。
  • 回滚:服务端 [tls] Enabled=0 即回纯明文,无需改客户端(客户端无证书时本就明文)。

12. 已拍板决策与仍待定问题

已拍板(2026-10-14):

  1. 握手方案:采用 方案 A(专用阻塞线程),见 §5.3。
  2. 身份文件格式:采用 PEM + CNG(CertPath + KeyPath,私钥 PKCS#8),与 Go 服务端共用同一对密钥,见 §5.6。
  3. 配置节名:新建 [tls] 节,键 Enabled / CertPath / KeyPath,见 §4.1。

仍待定:

  1. KCP/UDP 传输是否也要 TLS:本期不含,另行评估。
  2. MFC 的 MCP 式配置 UI:显式排除,见 §7;落地时只碰 §4.2 第 1/2 点。