diff --git a/client/IOCPClient.cpp b/client/IOCPClient.cpp index 6f6ed70..e7c8bcc 100644 --- a/client/IOCPClient.cpp +++ b/client/IOCPClient.cpp @@ -665,7 +665,8 @@ VOID IOCPClient::OnServerReceiving(CBuffer* m_CompressedBuffer, char* szBuffer, for (int i = 0; i < 16 && i < (int)bufLen; ++i) { char tmp[12]; sprintf(tmp, "%02X ", (unsigned char)src[i]); buf += tmp; } - Mprintf("[ERROR] Unknown header! this=%p bufLen=%lu, first 16 bytes: %s\n", this, bufLen, buf.c_str()); + Mprintf("[ERROR] Unknown header! this=%p bufLen=%lu, first 16 bytes: %s (TlsEnabled=%d tlsReady=%d)\n", + this, bufLen, buf.c_str(), (int)TlsEnabled(), (int)m_tls.IsReady()); m_CompressedBuffer->ClearBuffer(); break; } diff --git a/client/TlsChannel.cpp b/client/TlsChannel.cpp index b978f1c..66c5c8a 100644 --- a/client/TlsChannel.cpp +++ b/client/TlsChannel.cpp @@ -368,6 +368,11 @@ bool TlsChannel::Handshake(SOCKET sock) return false; } + // 握手成功后 hs 里可能残留「未消费的尾部」= 服务端 Finished 后紧跟的首条应用数据 + // (TLS 记录)。若不下发,首包会被丢弃,导致随机丢包/协议错位。交给 Recv 解。 + if (!hs.empty()) + m_raw.insert(m_raw.end(), hs.begin(), hs.end()); + if (!VerifyServerCert()) { Mprintf("[TLS] server certificate not trusted against yama-ca.pem (fail-closed)\n"); CloseHandles(); @@ -404,14 +409,10 @@ int TlsChannel::Recv(SOCKET sock, char* out, int outLen) char raw[16384]; for (;;) { - int r = recv(sock, raw, sizeof(raw), 0); - if (r == SOCKET_ERROR) - return SOCKET_ERROR; - if (r == 0) - return 0; // 对端关闭(此时无 pending) - m_raw.insert(m_raw.end(), (BYTE*)raw, (BYTE*)raw + r); - - for (;;) { + // 优先解密 m_raw 里已缓冲的字节(握手时随 Finished 收下的首条应用数据,或上轮 + // 一次 recv 到多条记录后剩下的记录)。select() 只看底层 socket,看不到这些字节, + // 若先 recv 就会把它们卡住(随机延迟/死锁/协议错位)。 + while (!m_raw.empty()) { SecBuffer bufs[4] = { { (ULONG)m_raw.size(), SECBUFFER_DATA, m_raw.data() }, { 0, SECBUFFER_EMPTY, NULL }, @@ -422,11 +423,17 @@ int TlsChannel::Recv(SOCKET sock, char* out, int outLen) SECURITY_STATUS sc = DecryptMessage(&m_ctx, &desc, 0, NULL); if (sc == SEC_E_INCOMPLETE_MESSAGE) - break; // 记录不完整,再 recv + break; // 记录不完整,去 recv 补字节 if (sc == SEC_I_CONTEXT_EXPIRED) return 0; // close_notify - if (sc == SEC_I_RENEGOTIATE || FAILED(sc)) + if (sc == SEC_I_RENEGOTIATE || FAILED(sc)) { + // 客户端解密失败(服务端发来的密文损坏/错位)。这里只返回 SOCKET_ERROR, + // 而 WSAGetLastError() 会残留上一次 Winsock 调用的旧值(如 6001), + // 掩盖真实原因,故把真实的 SECURITY_STATUS 记下来。 + Mprintf("[TLS] client DecryptMessage failed 0x%08x (raw=%u pending=%u)\n", + (unsigned)sc, (unsigned)m_raw.size(), (unsigned)m_pending.size()); return SOCKET_ERROR; + } // 流模式 DecryptMessage 会把输入 SECBUFFER_DATA 重组为 4 段: // bufs[0]=STREAM_HEADER(记录头+nonce)、bufs[1]=DATA(明文)、 @@ -445,9 +452,16 @@ int TlsChannel::Recv(SOCKET sock, char* out, int outLen) m_pending.erase(m_pending.begin(), m_pending.begin() + n); return n; } - if (m_raw.empty()) - break; // 无明文(如 alert/CCS 记录),继续 recv + // 无明文的记录(如 alert/CCS):继续循环解下一条 } + + int r = recv(sock, raw, sizeof(raw), 0); + if (r == SOCKET_ERROR) + return SOCKET_ERROR; + if (r == 0) + return 0; // 对端关闭(此时无 pending,且 m_raw 已解完/为空) + m_raw.insert(m_raw.end(), (BYTE*)raw, (BYTE*)raw + r); + // 循环回顶部,解密刚收到的字节 } } diff --git a/client/TlsChannel.h b/client/TlsChannel.h index 0c6fafc..d228bdd 100644 --- a/client/TlsChannel.h +++ b/client/TlsChannel.h @@ -60,8 +60,14 @@ public: // 前缀字节数(>0)或 SOCKET_ERROR(一个字节都没发出去时)。 int Send(SOCKET sock, const char* buf, int len); - // 是否有已解密但尚未交付给应用层的明文(供接收线程在 select 前优先消费)。 - bool HasPending() const { return !m_pending.empty(); } + // 是否有待交付的数据:已解密明文(m_pending)或已缓冲但尚未解密的 TLS 记录(m_raw)。 + // 供接收线程在 select 前优先消费——select 只看底层 socket,看不到 Schannel 缓冲的 + // m_raw(握手时随 Finished 收下的首条应用数据、或一次 recv 到多条记录后的剩余), + // 若这里不把 m_raw 也算作「有数据」,这些字节会被 select 卡住。 + bool HasPending() const { return !m_pending.empty() || !m_raw.empty(); } + + // 握手是否已完成(Send/Recv 可用)。诊断用:区分「本连接已走 TLS」与「裸连接」。 + bool IsReady() const { return m_ready; } private: void CloseHandles(); diff --git a/docs/TransportTLS_Design.md b/docs/TransportTLS_Design.md index 37cb670..8c1655a 100644 --- a/docs/TransportTLS_Design.md +++ b/docs/TransportTLS_Design.md @@ -302,7 +302,7 @@ Set-ItemProperty -Path 'HKCU:\Software\\settings' -Name 'tls_cert' -Val - **阶段 1(Go 服务端,本期优先)**:`Config` + `tls.Config` + `acceptLoop` 检测回放;明文路径零改动。可独立上线、独立验证——用 `openssl s_client` 或 Go 测试客户端即可联调 TLS 链路,无需真实客户端配合。 - **阶段 2(Windows 客户端)**:证书检测 + TLS 栈 + fail-closed。 -- **阶段 3(MFC 服务端 `server/2015Remote`,延后)**:与 Go 服务端同款的 TLS 检测 + 解包接入。 +- **阶段 3(MFC 服务端 `server/2015Remote`,延后)**:与 Go 服务端同款的 TLS 检测 + 解包接入。设计稿见 `docs/TransportTLS_MFCServer_Design.md`。 - **回滚**:服务端清空 `YAMA_TLS_CERT/KEY` 即回到纯明文;客户端删除注册表 `tls_cert` 值(或 `yama-ca.pem` 文件)即回明文。 ## 12. 待定问题 diff --git a/docs/TransportTLS_MFCServer_Design.md b/docs/TransportTLS_MFCServer_Design.md new file mode 100644 index 0000000..28ae395 --- /dev/null +++ b/docs/TransportTLS_MFCServer_Design.md @@ -0,0 +1,280 @@ +# 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): + +```ini +[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 项) + +```cpp +// 每连接传输层状态(单向 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 tlsState{ TLS_Unclassified }; // ServerTlsState +std::vector 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 分叉: + +```cpp +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): + +```bash +# 若私钥是 PKCS#1(-----BEGIN RSA/EC PRIVATE KEY-----),先转 PKCS#8: +openssl pkcs8 -topk8 -nocrypt -in server.key -out server.key +``` + +MFC 服务端配置(直接指向与 Go 端相同的 PEM 文件): + +```ini +[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 点。 diff --git a/server/2015Remote/2015Remote_vs2015.vcxproj b/server/2015Remote/2015Remote_vs2015.vcxproj index aa60399..12777e9 100644 --- a/server/2015Remote/2015Remote_vs2015.vcxproj +++ b/server/2015Remote/2015Remote_vs2015.vcxproj @@ -350,6 +350,7 @@ + @@ -457,6 +458,7 @@ + diff --git a/server/2015Remote/2015Remote_vs2015.vcxproj.filters b/server/2015Remote/2015Remote_vs2015.vcxproj.filters index 3f88d7e..073178e 100644 --- a/server/2015Remote/2015Remote_vs2015.vcxproj.filters +++ b/server/2015Remote/2015Remote_vs2015.vcxproj.filters @@ -21,6 +21,7 @@ + @@ -155,6 +156,7 @@ + diff --git a/server/2015Remote/Buffer.cpp b/server/2015Remote/Buffer.cpp index 8c53d5e..b9cd264 100644 --- a/server/2015Remote/Buffer.cpp +++ b/server/2015Remote/Buffer.cpp @@ -337,3 +337,20 @@ VOID CBuffer::CommitWrite(ULONG writtenSize) m_Ptr += writtenSize; LeaveCriticalSection(&m_cs); } + +// 截断缓冲区到指定有效长度(仅回退写指针,不移动/释放底层内存)。有效长度指「当前读取 +// 位置之后的字节数」,与 GetBufferLength 一致;请求值超出现有有效长度时收敛到现有长度。 +VOID CBuffer::TruncateBuffer(ULONG effectiveLen) +{ + EnterCriticalSection(&m_cs); + if (m_Base == NULL) { + LeaveCriticalSection(&m_cs); + return; + } + ULONG totalDataLen = (ULONG)(m_Ptr - m_Base); + ULONG cur = (totalDataLen > m_ulReadOffset) ? (totalDataLen - m_ulReadOffset) : 0; + if (effectiveLen > cur) + effectiveLen = cur; + m_Ptr = m_Base + m_ulReadOffset + effectiveLen; + LeaveCriticalSection(&m_cs); +} diff --git a/server/2015Remote/Buffer.h b/server/2015Remote/Buffer.h index 88540e0..2d24648 100644 --- a/server/2015Remote/Buffer.h +++ b/server/2015Remote/Buffer.h @@ -130,6 +130,11 @@ public: // 确认写入完成,更新内部指针 VOID CommitWrite(ULONG writtenSize); + // 截断缓冲区到指定有效长度(仅回退写指针,不移动/释放底层内存)。用于「尾部明文替换为 + // 密文」:TLS 下 OutCompressedBuffer 追加明文后需换回密文,用此接口避免 ClearBuffer 的 + // DeAllocateBuffer 释放正在被异步 WSASend 引用的底层内存。 + VOID TruncateBuffer(ULONG effectiveLen); + protected: PBYTE m_Base; PBYTE m_Ptr; diff --git a/server/2015Remote/IOCPKCPServer.cpp b/server/2015Remote/IOCPKCPServer.cpp index e863657..181a33d 100644 --- a/server/2015Remote/IOCPKCPServer.cpp +++ b/server/2015Remote/IOCPKCPServer.cpp @@ -124,7 +124,7 @@ void IOCPKCPServer::WorkerThread() } if (n > 0&& m_notify) { memcpy(ctx->szBuffer, recvbuf, n); - BOOL ret = ParseReceivedData(ctx, n, m_notify); + BOOL ret = ParseReceivedData(ctx, (PBYTE)ctx->szBuffer, n, m_notify); } } while (n>0); } diff --git a/server/2015Remote/IOCPServer.cpp b/server/2015Remote/IOCPServer.cpp index b11c52a..9e21c15 100644 --- a/server/2015Remote/IOCPServer.cpp +++ b/server/2015Remote/IOCPServer.cpp @@ -3,6 +3,7 @@ #include "2015Remote.h" #include "common/IPWhitelist.h" #include "common/IPBlacklist.h" +#include "ServerTlsSession.h" #include #include @@ -483,6 +484,11 @@ UINT IOCPServer::StartServer(pfnNotifyProc NotifyProc, pfnOfflineProc OffProc, U m_nPort = uPort; m_NotifyProc = NotifyProc; m_OfflineProc = OffProc; + + // 单向 TLS:启动时加载服务端身份(证书+私钥)。加载失败时 IsLoadError()==true, + // 明文客户端不受影响,TLS ClientHello 会被识别并 fail-closed 关闭(见设计文档 §5.2)。 + ServerTlsIdentity::Load(THIS_CFG); + // manual-reset:本进程内可能有多个等待者(ListenThread / RttPollThreadProc)。 // 自动重置会让 SetEvent 只唤醒一个等待者,另一个要等自身 timeout(≤1s)。 // 改 manual-reset 后所有等待者一次性醒来;本工程从无 ResetEvent 调用,无副作用。 @@ -793,16 +799,16 @@ BOOL IOCPServer::OnClientInitializing(PCONTEXT_OBJECT ContextObject, DWORD dwTr } // May be this function should be a member of `CONTEXT_OBJECT`. -BOOL ParseReceivedData(CONTEXT_OBJECT * ContextObject, DWORD dwTrans, pfnNotifyProc m_NotifyProc, ZSTD_DCtx* m_Dctx, z_stream* z) +BOOL ParseReceivedData(CONTEXT_OBJECT * ContextObject, PBYTE data, DWORD dataLen, pfnNotifyProc m_NotifyProc, ZSTD_DCtx* m_Dctx, z_stream* z) { AUTO_TICK(50, ContextObject->GetPeerName()); BOOL ret = 1; try { - if (dwTrans == 0) { //对方关闭了套接字 + if (dataLen == 0) { //对方关闭了套接字 return FALSE; } //将接收到的数据拷贝到我们自己的内存中wsabuff 8192 - ContextObject->InCompressedBuffer.WriteBuffer((PBYTE)ContextObject->szBuffer,dwTrans); + ContextObject->InCompressedBuffer.WriteBuffer(data, dataLen); //查看数据包的完整性 while (true) { PR pr = ContextObject->Parse(ContextObject->InCompressedBuffer); @@ -889,18 +895,178 @@ BOOL ParseReceivedData(CONTEXT_OBJECT * ContextObject, DWORD dwTrans, pfnNotifyP return ret; } -BOOL IOCPServer::OnClientReceiving(PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream* z) +// 握手线程参数:把嗅探阶段已收到的 ClientHello 字节与连接上下文一起移交。 +struct TlsHandshakeParam { + IOCPServer* server; + CONTEXT_OBJECT* ctx; + std::vector initial; // 嗅探阶段已攒下的首批字节(≥6,可能只是 ClientHello 分片) +}; + +// 方案 A:专用阻塞握手线程。占线期间独占 socket(阻塞 recv/send,无未决 overlapped I/O), +// 完成后把连接交还给 IOCP(PostRecv)。线程持有 ctx 的一枚 IoRefCount,结束时释放。 +DWORD WINAPI IOCPServer::TlsHandshakeThreadProc(LPVOID lParam) { - if (FALSE == ParseReceivedData(ContextObject, dwTrans, m_NotifyProc, ctx, z)) { - // 先减少引用计数,再调用 RemoveStaleContext (#215) - // RemoveStaleContext 完成后对象会被移到空闲池,不能再访问 - ContextObject->IoRefCount.fetch_sub(1); - RemoveStaleContext(ContextObject); - return -2; // 特殊返回值:告诉 HandleIO 不要再减少引用计数 + TlsHandshakeParam* p = (TlsHandshakeParam*)lParam; + IOCPServer* This = p->server; + CONTEXT_OBJECT* ctx = p->ctx; + std::vector initial = std::move(p->initial); + delete p; + + // 若上下文已被移除(服务端关停竞态),直接释放引用退出。 + if (ctx->IsRemoved.load(std::memory_order_acquire)) { + ctx->IoRefCount.fetch_sub(1); + return 0; } - PostRecv(ContextObject); //投递新的接收数据的请求 + // 握手期间做同步 recv/send。注意:监听套接字经 WSAEventSelect 置为非阻塞后,accept 出的 + // 子套接字继承非阻塞态,FIONBIO=0 不一定能切回阻塞;ServerTlsSession::Handshake 内部已用 + // select 兜底 WSAEWOULDBLOCK,故这里仍设 FIONBIO=0 只是「尽量阻塞」的优化。 + u_long nb = 0; + if (ioctlsocket(ctx->sClientSocket, FIONBIO, &nb) == SOCKET_ERROR) + Mprintf("[TLS] handshake: ioctlsocket(FIONBIO=0) failed err=%d\n", WSAGetLastError()); + int tmo = 15000; // 15s 握手超时 + setsockopt(ctx->sClientSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tmo, sizeof(tmo)); + setsockopt(ctx->sClientSocket, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tmo, sizeof(tmo)); + bool ok = (ctx->tls != NULL) && + ctx->tls->Handshake(ctx->sClientSocket, initial.data(), (int)initial.size()); + + if (ok) { + ctx->tlsState.store(TLS_Ready, std::memory_order_release); + } + + // 先释放握手线程持有的引用,再触发任何可能 RemoveStaleContext 的动作(#215 顺序)。 + ctx->IoRefCount.fetch_sub(1); + + if (ok) { + This->PostRecv(ctx); // 交还 IOCP 接收 + } else { + Mprintf("[TLS] handshake failed for %s (fail-closed)\n", ctx->GetPeerName().c_str()); + This->RemoveStaleContext(ctx); + } + return 0; +} + +// 判定为 TLS 后:创建会话、预支一枚 IoRefCount 给握手线程、启动握手线程。 +BOOL IOCPServer::StartTlsHandshake(CONTEXT_OBJECT* ContextObject) +{ + if (!ServerTlsIdentity::IsReady()) { + // 服务端身份不可用(未启用 TLS、或证书/私钥加载失败):fail-closed 关闭该连接。 + Mprintf("[TLS] client %s sent ClientHello but server identity not ready " + "(TLS disabled or cert/key load failed); closing (fail-closed)\n", + ContextObject->GetPeerName().c_str()); + return FALSE; + } + + ContextObject->tls = new ServerTlsSession(); + TlsHandshakeParam* p = new TlsHandshakeParam; + p->server = this; + p->ctx = ContextObject; + p->initial = std::move(ContextObject->tlsPreClassify); + ContextObject->tlsPreClassify.clear(); + + // 预支引用:握手线程结束时释放。此引用保证握手期间对象不被回收。 + ContextObject->IoRefCount.fetch_add(1); + + HANDLE h = CreateThread(NULL, 0, TlsHandshakeThreadProc, p, 0, NULL); + if (h == NULL) { + // 启动失败:回退预支引用,清理会话,按失败关闭。 + ContextObject->IoRefCount.fetch_sub(1); + delete ContextObject->tls; + ContextObject->tls = NULL; + return FALSE; + } + CloseHandle(h); + return TRUE; +} + +BOOL IOCPServer::OnClientReceiving(PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream* z) +{ + int st = ContextObject->tlsState.load(std::memory_order_acquire); + + // ---- 未分类:按 ClientHello 头嗅探明文 / TLS(见设计文档 §5.2)---- + if (st == TLS_Unclassified) { + if (dwTrans == 0) { // 首个包即对端关闭 + ContextObject->IoRefCount.fetch_sub(1); + RemoveStaleContext(ContextObject); + return -2; + } + ContextObject->tlsPreClassify.insert(ContextObject->tlsPreClassify.end(), + (BYTE*)ContextObject->szBuffer, + (BYTE*)ContextObject->szBuffer + dwTrans); + if (ContextObject->tlsPreClassify.size() < 6) { + PostRecv(ContextObject); // 头还没攒够,继续收 + return TRUE; + } + const BYTE* h = ContextObject->tlsPreClassify.data(); + bool isTls = (h[0] == 0x16 && h[1] == 0x03 && h[5] == 0x01); + if (isTls) { + ContextObject->tlsState.store(TLS_Handshaking, std::memory_order_release); + if (!StartTlsHandshake(ContextObject)) { + Mprintf("[TLS] StartTlsHandshake failed for %s (fail-closed)\n", + ContextObject->GetPeerName().c_str()); + ContextObject->IoRefCount.fetch_sub(1); + RemoveStaleContext(ContextObject); + return -2; + } + return TRUE; // 不 PostRecv;握手线程完成后交还接收 + } + + // 明文:把嗅探阶段攒下的字节回放给现有解析路径,然后清空缓冲。 + ContextObject->tlsState.store(TLS_Plain, std::memory_order_release); + std::vector replay = std::move(ContextObject->tlsPreClassify); + ContextObject->tlsPreClassify.clear(); + if (FALSE == ParseReceivedData(ContextObject, replay.data(), (DWORD)replay.size(), m_NotifyProc, ctx, z)) { + ContextObject->IoRefCount.fetch_sub(1); + RemoveStaleContext(ContextObject); + return -2; + } + PostRecv(ContextObject); + return TRUE; + } + + // ---- 已分类 ---- + if (st == TLS_Ready) { + if (dwTrans == 0) { // 明文 FIN:TLS 连接对端关闭 + ContextObject->IoRefCount.fetch_sub(1); + RemoveStaleContext(ContextObject); + return -2; + } + std::vector plain; + if (ContextObject->tls->Decrypt((const BYTE*)ContextObject->szBuffer, dwTrans, plain) < 0) { + ContextObject->IoRefCount.fetch_sub(1); + RemoveStaleContext(ContextObject); + return -2; + } + if (plain.empty()) { + PostRecv(ContextObject); // 本轮无完整明文(TLS 记录分片/握手告警),继续收 + return TRUE; + } + if (FALSE == ParseReceivedData(ContextObject, plain.data(), (DWORD)plain.size(), m_NotifyProc, ctx, z)) { + ContextObject->IoRefCount.fetch_sub(1); + RemoveStaleContext(ContextObject); + return -2; + } + PostRecv(ContextObject); + return TRUE; + } + + // TLS_Handshaking:握手线程独占 socket,正常不应出现 IORead;若出现属异常,fail-closed。 + if (st == TLS_Handshaking) { + Mprintf("[TLS] unexpected IORead during handshake for %s (closing)\n", + ContextObject->GetPeerName().c_str()); + ContextObject->IoRefCount.fetch_sub(1); + RemoveStaleContext(ContextObject); + return -2; + } + + // TLS_Plain:明文直通(原路径)。 + if (FALSE == ParseReceivedData(ContextObject, (PBYTE)ContextObject->szBuffer, dwTrans, m_NotifyProc, ctx, z)) { + ContextObject->IoRefCount.fetch_sub(1); + RemoveStaleContext(ContextObject); + return -2; + } + PostRecv(ContextObject); return TRUE; } @@ -967,15 +1133,49 @@ BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOr BOOL IOCPServer::OnClientPreSending(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength) { + // TLS 下 OutCompressedBuffer 可能还残留上一轮尚未发完的密文(IOCP 线程异步 WSASend 中), + // 新明文由 WriteContextData 追加在尾部。若把整个缓冲区重新加密,会把旧密文二次加密, + // 客户端解密后得到的是「旧 TLS 记录」→ Unknown header 17 03 03。故只加密本次新追加的尾部。 + bool tlsReady = (ContextObject->tlsState.load(std::memory_order_acquire) == TLS_Ready); + ULONG oldLen = tlsReady ? ContextObject->OutCompressedBuffer.GetBufferLength() : 0; + if (WriteContextData(ContextObject, szBuffer, ulOriginalLength, ContextObject->Zcctx)) { - OVERLAPPEDPLUS* OverlappedPlus = new OVERLAPPEDPLUS(IOWrite); - BOOL bOk = PostQueuedCompletionStatus(m_hCompletionPort, 0, (ULONG_PTR)ContextObject, &OverlappedPlus->m_ol); - if ( (!bOk && GetLastError() != ERROR_IO_PENDING) ) { //如果投递失败 - int a = GetLastError(); - Mprintf("!!! OnClientPreSending 投递消息失败\n"); - RemoveStaleContext(ContextObject); - SAFE_DELETE(OverlappedPlus); - return FALSE; + // 单向 TLS:只加密新追加的明文尾部,产出密文后与前缀旧密文拼接,使缓冲区始终保持 + // 一串合法 TLS 记录;之后 IOWrite / OnClientPostSending 完全不变(见设计文档 §5.5)。 + if (tlsReady) { + ULONG totalLen = ContextObject->OutCompressedBuffer.GetBufferLength(); + ULONG plainLen = totalLen - oldLen; + if (plainLen > 0) { + std::vector wire; + if (ContextObject->tls->Encrypt((const BYTE*)ContextObject->OutCompressedBuffer.GetBuffer(oldLen), + (int)plainLen, wire) != (int)plainLen) { + Mprintf("[TLS] Encrypt failed for %s (fail-closed)\n", + ContextObject->GetPeerName().c_str()); + return FALSE; // 加密失败:丢弃本次发送 + } + // 截断尾部明文,再追加密文。用 TruncateBuffer 而非 ClearBuffer:ClearBuffer 会 + // DeAllocateBuffer(可能 VirtualFree 正在被异步 WSASend 引用的底层内存)。 + ContextObject->OutCompressedBuffer.TruncateBuffer(oldLen); + ContextObject->OutCompressedBuffer.WriteBuffer(wire.data(), (DWORD)wire.size()); + } + } + + // 仅当没有发送正在进行时才投递 IOWrite 唤醒发送。若已有 WSASend 在途,其完成回调 + // (OnClientPostSending)会看到排空后仍残留的新数据并继续发送,无需再投递;否则会对 + // 同一缓冲并发多个 WSASend,读到同一段可变内存,产出重复/错位密文(客户端 DecryptMessage + // 失败,表现为 recv -1、GetLastError=6001)。 + if (!ContextObject->bSendPending) { + ContextObject->bSendPending = true; + OVERLAPPEDPLUS* OverlappedPlus = new OVERLAPPEDPLUS(IOWrite); + BOOL bOk = PostQueuedCompletionStatus(m_hCompletionPort, 0, (ULONG_PTR)ContextObject, &OverlappedPlus->m_ol); + if ( (!bOk && GetLastError() != ERROR_IO_PENDING) ) { //如果投递失败 + int a = GetLastError(); + Mprintf("!!! OnClientPreSending 投递消息失败\n"); + ContextObject->bSendPending = false; + RemoveStaleContext(ContextObject); + SAFE_DELETE(OverlappedPlus); + return FALSE; + } } return TRUE; } @@ -990,6 +1190,7 @@ BOOL IOCPServer::OnClientPostSending(CONTEXT_OBJECT* ContextObject,ULONG ulCompl ContextObject->OutCompressedBuffer.RemoveCompletedBuffer(ulCompletedLength); //将完成的数据从数据结构中去除 if (ContextObject->OutCompressedBuffer.GetBufferLength() == 0) { + ContextObject->bSendPending = false; // 缓冲排空,允许下一次 OnClientPreSending 投递 ContextObject->OutCompressedBuffer.ClearBuffer(); return true; //走到这里说明我们的数据真正完全发送 } else { @@ -1001,6 +1202,7 @@ BOOL IOCPServer::OnClientPostSending(CONTEXT_OBJECT* ContextObject,ULONG ulCompl NULL, ulFlags,&OverlappedPlus->m_ol, NULL); if ( iOk == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING ) { // 先减少引用计数,再调用 RemoveStaleContext (#215) + ContextObject->bSendPending = false; ContextObject->IoRefCount.fetch_sub(1); if (RemoveStaleContext(ContextObject)) Mprintf("!!! OnClientPostSending 投递消息失败: %d\n", WSAGetLastError()); diff --git a/server/2015Remote/IOCPServer.h b/server/2015Remote/IOCPServer.h index 5752c1e..8b0467f 100644 --- a/server/2015Remote/IOCPServer.h +++ b/server/2015Remote/IOCPServer.h @@ -91,6 +91,7 @@ private: static DWORD WINAPI ListenThreadProc(LPVOID lParam); static DWORD WINAPI WorkThreadProc(LPVOID lParam); static DWORD WINAPI RttPollThreadProc(LPVOID lParam); + static DWORD WINAPI TlsHandshakeThreadProc(LPVOID lParam); BOOL InitializeIOCP(VOID); VOID OnAccept(); @@ -101,6 +102,7 @@ private: BOOL HandleIO(IOType PacketFlags, PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream *z); BOOL OnClientInitializing(PCONTEXT_OBJECT ContextObject, DWORD dwTrans); BOOL OnClientReceiving(PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream* z); + BOOL StartTlsHandshake(CONTEXT_OBJECT* ContextObject); BOOL OnClientPreSending(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength); BOOL OnClientPostSending(CONTEXT_OBJECT* ContextObject, ULONG ulCompressedLength); int AddWorkThread(int n) @@ -257,6 +259,8 @@ public: typedef CDialogBase DialogBase; -BOOL ParseReceivedData(CONTEXT_OBJECT* ContextObject, DWORD dwTrans, pfnNotifyProc m_NotifyProc, ZSTD_DCtx *ctx=NULL, z_stream* z=NULL); +// 解析一段已解密/直通的接收字节。data/dataLen 为数据源(TLS 连接传解密后的明文, +// 明文连接传 szBuffer 原始字节);dataLen==0 视为对端关闭。 +BOOL ParseReceivedData(CONTEXT_OBJECT* ContextObject, PBYTE data, DWORD dataLen, pfnNotifyProc m_NotifyProc, ZSTD_DCtx *ctx=NULL, z_stream* z=NULL); BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength, ZSTD_CCtx *ctx=NULL, z_stream* z = NULL); diff --git a/server/2015Remote/IOCPUDPServer.cpp b/server/2015Remote/IOCPUDPServer.cpp index f4557bc..ac373c3 100644 --- a/server/2015Remote/IOCPUDPServer.cpp +++ b/server/2015Remote/IOCPUDPServer.cpp @@ -107,7 +107,7 @@ void IOCPUDPServer::WorkerThread() IO_CONTEXT* ioCtx = CONTAINING_RECORD(pOverlapped, IO_CONTEXT, ol); CONTEXT_UDP* ctx = ioCtx->pContext; - BOOL ret = ParseReceivedData(ctx, bytes, m_notify); + BOOL ret = ParseReceivedData(ctx, (PBYTE)ctx->szBuffer, bytes, m_notify); if (999 != ret) ctx->Destroy(); diff --git a/server/2015Remote/Server.h b/server/2015Remote/Server.h index f4da931..efbb01c 100644 --- a/server/2015Remote/Server.h +++ b/server/2015Remote/Server.h @@ -329,6 +329,17 @@ public: #define PREALLOC_DECOMPRESS_SIZE (4 * 1024) #include "context.h" + +// 每连接传输层状态(单向 TLS,见 docs/TransportTLS_MFCServer_Design.md §5.2)。 +// 明文与 TLS 客户端在同一端口共存:首包按 ClientHello 头嗅探分派。 +enum ServerTlsState { + TLS_Unclassified = 0, // 尚未收到足够字节判定(缓冲 tlsPreClassify) + TLS_Plain = 1, // 明文客户端,直通现有解析路径 + TLS_Handshaking = 2, // TLS 客户端,握手线程阻塞 AcceptSecurityContext 中 + TLS_Ready = 3, // TLS 握手完成,收包解密 / 发包加密 +}; +class ServerTlsSession; // 定义见 ServerTlsSession.h(避免把 Schannel 头拖进本文件) + typedef class CONTEXT_OBJECT : public context { public: @@ -341,6 +352,7 @@ public: FreeDecompressBuffer(); FreeCompressBuffer(); FreeSendCompressBuffer(); + FreeTls(); if (Zcctx) { ZSTD_freeCCtx(Zcctx); Zcctx = nullptr; @@ -377,6 +389,11 @@ public: ikcpcb* kcp = nullptr; // 新增,指向KCP会话 std::string GroupName; // 分组名称 CLock SendLock; // fix #214 + // 发送是否处于「进行中」状态。OnClientPreSending 每次 Send2Client 都会 Post 一个 + // IOWrite,若不加节制,OnClientPostSending(0) 会对同一 OutCompressedBuffer 并发发起 + // 多个 WSASend,读到同一段可变内存(背靠背小包最易触发:resize→COMMAND_NEXT→resize)。 + // 置位后抑制多余的 IOWrite,待缓冲排空(最后一个 WSASend 完成)再清零。仅受 SendLock 保护。 + bool bSendPending = false; time_t OnlineTime = 0; // 上线时间 time_t LastHeartbeatTime = 0; // 最后心跳时间 @@ -392,6 +409,11 @@ public: // 试用版反代理逐连接检测器。仅由 IOCPServer 的 RTT 轮询线程访问,免锁。 TcpRttBreachDetector m_RttDetector; + // ---- 单向 TLS 状态(跨线程:握手线程写入 TLS_Ready,工作线程读取;用 atomic 保序)---- + std::atomic tlsState{ TLS_Unclassified }; // ServerTlsState + ServerTlsSession* tls = nullptr; // 每连接 TLS 会话(握手线程独占→就绪后共用) + std::vector tlsPreClassify; // 分类前缓冲的首批字节(嗅探 ClientHello 头) + // 子连接身份校验:客户端发 TOKEN_CONN_AUTH 通过验证后置位。 // 主连接(走 TOKEN_LOGIN 流程)不参与此机制。当前阶段宽容(未通过也接受), // 仅作为标记供后续命令处理 / 未来收紧策略使用。 @@ -495,6 +517,9 @@ public: } } + // 释放每连接 TLS 会话。定义在 ServerTlsSession.cpp(需 ServerTlsSession 完整类型才能 delete)。 + void FreeTls(); + std::string GetProtocol() const override { return Parser.m_Masker && Parser.m_Masker->GetMaskType() == MaskTypeNone ? "TCP" : "HTTP"; @@ -539,6 +564,12 @@ public: m_RttUs.store(0, std::memory_order_release); m_LastRttSampleMs.store(0, std::memory_order_release); m_RttDetector.Reset(); + // 复用对象池时重置 TLS 状态:释放旧会话、回到未分类、清空嗅探缓冲。 + FreeTls(); + tlsState.store(TLS_Unclassified, std::memory_order_release); + tlsPreClassify.clear(); + // 复用对象池时清空发送进行中标志,避免上次连接残留的 bSendPending 抑制新连接的发送。 + bSendPending = false; } void SetAuthenticated(bool v) { m_bAuthenticated.store(v, std::memory_order_release); } bool IsAuthenticated() const { return m_bAuthenticated.load(std::memory_order_acquire); } diff --git a/server/2015Remote/ServerTlsSession.cpp b/server/2015Remote/ServerTlsSession.cpp new file mode 100644 index 0000000..b09e230 --- /dev/null +++ b/server/2015Remote/ServerTlsSession.cpp @@ -0,0 +1,604 @@ +// ServerTlsSession.cpp — 服务端单向 TLS(Schannel 入站)实现 +// +// 与客户端 TlsChannel.cpp 对称,但走 AcceptSecurityContext 入站握手、DecryptMessage/ +// EncryptMessage 流式加解密。证书/私钥加载为 PEM + CNG(NCryptImportKey PKCS#8), +// 以便与 Go 服务端(server/go 的 crypto/tls,YAMA_TLS_CERT/KEY)共用同一对密钥。 +#include "StdAfx.h" +#include "ServerTlsSession.h" +#include "Server.h" // CONTEXT_OBJECT::FreeTls() +#include "common/iniFile.h" // config(GetInt/GetStr) + +#include +#include + +#include +#include +#include + +#pragma comment(lib, "secur32.lib") +#pragma comment(lib, "crypt32.lib") +#pragma comment(lib, "ncrypt.lib") + +namespace +{ +// 进程级身份(进程启动加载一次;未来 UI/指令热切换时改为引用计数 + 惰性释放)。 +std::mutex g_identityMtx; +bool g_enabled = false; +bool g_ready = false; +bool g_loadError = false; +CredHandle g_cred = {}; +bool g_credValid = false; +PCCERT_CONTEXT g_cert = NULL; // 与入站凭据同生命周期,FreeIdentity 时释放 +NCRYPT_KEY_HANDLE g_persistedKey = 0; // 已持久化到 CNG KSP 的私钥句柄,FreeIdentity 时删除 + +// 持久化私钥在 CNG KSP 中的名字。用固定名 + NCRYPT_OVERWRITE_KEY_FLAG,重启时覆盖旧键, +// 避免残留;同名键的密钥材料与 server.key 相同,覆盖无副作用。 +const wchar_t* g_keyName = L"YamaServerTlsKey"; + +// 一次性读文件为 std::string(二进制安全)。 +bool ReadFileText(const std::string& path, std::string& out) +{ + std::ifstream f(path, std::ios::binary); + if (!f.is_open()) + return false; + std::ostringstream ss; + ss << f.rdbuf(); + if (f.bad()) + return false; + out = ss.str(); + return true; +} + +// PEM(-----BEGIN ...----- 包裹的 base64)解码为 DER。失败返回 false。 +// CRYPT_STRING_ANY 会自动识别带头的 base64(PEM),对证书与 PKCS#8 私钥均适用。 +bool PemToDer(const std::string& pem, std::vector& der) +{ + if (pem.empty()) + return false; + DWORD need = 0; + if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY, + NULL, &need, NULL, NULL)) + return false; + der.resize(need); + return CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY, + der.data(), &need, NULL, NULL) == TRUE; +} + +// 从 PEM 文本里定位私钥块的类型,用于给出可操作的错误提示。 +enum KeyFmt { KF_PKCS8, KF_PKCS1, KF_UNKNOWN }; +KeyFmt DetectKeyFormat(const std::string& keyPem) +{ + if (keyPem.find("-----BEGIN PRIVATE KEY-----") != std::string::npos) + return KF_PKCS8; + if (keyPem.find("-----BEGIN RSA PRIVATE KEY-----") != std::string::npos || + keyPem.find("-----BEGIN EC PRIVATE KEY-----") != std::string::npos) + return KF_PKCS1; + return KF_UNKNOWN; +} +} + +// ===================================================================================== +// ServerTlsIdentity —— 进程级服务端身份(可重入加载,禁止 call_once,供未来热切换) +// ===================================================================================== + +void ServerTlsIdentity::FreeIdentity() +{ + // 先释放凭据,再释放证书上下文,最后删除持久化私钥(凭据/证书可能仍按名引用该键)。 + if (g_credValid) + { + FreeCredentialsHandle(&g_cred); + g_credValid = false; + } + g_cred = {}; + if (g_cert) + { + CertFreeCertificateContext(g_cert); + g_cert = NULL; + } + if (g_persistedKey) + { + NCryptDeleteKey(g_persistedKey, 0); // 删除并释放句柄 + g_persistedKey = 0; + } +} + +bool ServerTlsIdentity::Load(config& cfg) +{ + std::lock_guard lk(g_identityMtx); + + bool enabled = cfg.GetInt("tls", "Enabled", 0) != 0; + std::string certPath = cfg.GetStr("tls", "CertPath", ""); + std::string keyPath = cfg.GetStr("tls", "KeyPath", ""); + + g_enabled = enabled; + + // 先释放旧身份(本轮只在启动时调用一次,不存在「旧身份仍被并发握手使用」的问题; + // 未来热切换需先引用计数、再惰性释放旧凭据)。 + FreeIdentity(); + g_ready = false; + g_loadError = false; + + if (!enabled) + { + Mprintf("[TLS] server TLS disabled ([tls] Enabled=0) — plaintext only\n"); + return true; + } + + if (certPath.empty() || keyPath.empty()) + { + g_loadError = true; + Mprintf("[TLS] FATAL: [tls] Enabled=1 but CertPath/KeyPath empty; " + "refusing TLS ClientHello (fail-closed)\n"); + return false; + } + + if (!LoadIdentity(certPath, keyPath)) + { + g_loadError = true; + return false; + } + + g_ready = true; + Mprintf("[TLS] server TLS enabled: cert=%s key=%s\n", certPath.c_str(), keyPath.c_str()); + return true; +} + +bool ServerTlsIdentity::LoadIdentity(const std::string& certPath, const std::string& keyPath) +{ + std::string certPem, keyPem; + if (!ReadFileText(certPath, certPem)) + { + Mprintf("[TLS] FATAL: cannot read cert file %s\n", certPath.c_str()); + return false; + } + if (!ReadFileText(keyPath, keyPem)) + { + Mprintf("[TLS] FATAL: cannot read key file %s\n", keyPath.c_str()); + return false; + } + + // 1) 证书:PEM → DER → PCCERT_CONTEXT。 + std::vector certDer; + if (!PemToDer(certPem, certDer)) + { + Mprintf("[TLS] FATAL: cert file %s is not a valid PEM\n", certPath.c_str()); + return false; + } + PCCERT_CONTEXT cert = CertCreateCertificateContext(X509_ASN_ENCODING, + certDer.data(), (DWORD)certDer.size()); + if (cert == NULL) + { + Mprintf("[TLS] FATAL: CertCreateCertificateContext failed (%u)\n", GetLastError()); + return false; + } + + // 2) 私钥:PEM → DER → 持久化到 CNG KSP(仅支持 PKCS#8;PKCS#1 给出可操作的转换提示)。 + // + // 关键:Schannel 入站凭据(AcquireCredentialsHandle)需要「能按名重开」的持久化私钥。 + // NCryptImportKey 产出的是匿名临时键,绑定 CERT_NCRYPT_KEY_HANDLE_PROP_ID 后 Schannel 仍 + // 拿不到(AcquireCredentialsHandle 返回 SEC_E_NO_CREDENTIALS 0x8009030e)。因此先把 + // PKCS#8 导入为临时键、导出为裸 RSA 私钥 BLOB(NCryptSetProperty 只认裸 BLOB,不认 + // PKCS#8),再写入持久化键并 finalize,最后用 CERT_KEY_PROV_INFO_PROP_ID 按名引用。 + std::vector keyDer; + NCRYPT_KEY_HANDLE persistedKey = 0; + if (DetectKeyFormat(keyPem) == KF_PKCS8) + { + if (!PemToDer(keyPem, keyDer)) + { + Mprintf("[TLS] FATAL: key file %s is not a valid PEM\n", keyPath.c_str()); + CertFreeCertificateContext(cert); + return false; + } + // 显式打开 Microsoft Software Key Storage Provider。hProvider 传 NULL 会让 + // NCryptImportKey 走「默认提供者」解析,实测返回 NTE_INVALID_HANDLE(0x80090026), + // 故必须显式取得 KSP 句柄(临时键独立于 hProv,导入后即可释放)。 + NCRYPT_PROV_HANDLE hProv = 0; + SECURITY_STATUS st = NCryptOpenStorageProvider(&hProv, MS_KEY_STORAGE_PROVIDER, 0); + if (st != ERROR_SUCCESS) + { + Mprintf("[TLS] FATAL: NCryptOpenStorageProvider failed 0x%08x\n", (unsigned)st); + CertFreeCertificateContext(cert); + return false; + } + + // 2a) PKCS#8 → 临时键。 + NCRYPT_KEY_HANDLE tmp = 0; + st = NCryptImportKey(hProv, NULL, NCRYPT_PKCS8_PRIVATE_KEY_BLOB, NULL, + &tmp, keyDer.data(), (DWORD)keyDer.size(), 0); + if (st == ERROR_SUCCESS) + { + // 2b) 允许明文导出,导出为裸 RSA 私钥 BLOB。 + DWORD policy = NCRYPT_ALLOW_PLAINTEXT_EXPORT_FLAG | NCRYPT_ALLOW_EXPORT_FLAG; + NCryptSetProperty(tmp, NCRYPT_EXPORT_POLICY_PROPERTY, (PBYTE)&policy, sizeof(policy), 0); + + std::vector raw; + DWORD rawLen = 0; + st = NCryptExportKey(tmp, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, NULL, NULL, 0, &rawLen, 0); + if (st == ERROR_SUCCESS) + { + raw.resize(rawLen); + st = NCryptExportKey(tmp, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, NULL, + raw.data(), rawLen, &rawLen, 0); + } + + // 2c) 写入持久化键并 finalize(落到 KSP,可被 Schannel 按名重开)。 + if (st == ERROR_SUCCESS) + st = NCryptCreatePersistedKey(hProv, &persistedKey, BCRYPT_RSA_ALGORITHM, + g_keyName, AT_KEYEXCHANGE, NCRYPT_OVERWRITE_KEY_FLAG); + if (st == ERROR_SUCCESS) + st = NCryptSetProperty(persistedKey, BCRYPT_RSAFULLPRIVATE_BLOB, + raw.data(), (DWORD)raw.size(), 0); + if (st == ERROR_SUCCESS) + st = NCryptFinalizeKey(persistedKey, 0); + + NCryptFreeObject(tmp); + } + NCryptFreeObject(hProv); + + if (st != ERROR_SUCCESS) + { + Mprintf("[TLS] FATAL: failed to persist PKCS#8 key for %s (0x%08x)\n", + keyPath.c_str(), (unsigned)st); + if (persistedKey) + NCryptFreeObject(persistedKey); + CertFreeCertificateContext(cert); + return false; + } + } + else + { + // PKCS#1(RSA/EC PRIVATE KEY)或未知格式:本次不支持,给转换命令。 + Mprintf("[TLS] FATAL: key file %s must be PKCS#8 (-----BEGIN PRIVATE KEY-----). " + "Convert with: openssl pkcs8 -topk8 -nocrypt -in %s -out %s\n", + keyPath.c_str(), keyPath.c_str(), keyPath.c_str()); + CertFreeCertificateContext(cert); + return false; + } + + // 3) 绑定私钥到证书上下文:按名引用持久化键。Schannel 经 CryptAcquireCertificatePrivateKey + // 在 MS_KEY_STORAGE_PROVIDER 里用 pwszContainerName 重开该键。 + CRYPT_KEY_PROV_INFO provInfo = {}; + provInfo.pwszContainerName = (LPWSTR)g_keyName; + provInfo.pwszProvName = (LPWSTR)MS_KEY_STORAGE_PROVIDER; + provInfo.dwProvType = 0; // 0 = CNG(无 CAPI provider type) + provInfo.dwKeySpec = AT_KEYEXCHANGE; + if (!CertSetCertificateContextProperty(cert, CERT_KEY_PROV_INFO_PROP_ID, 0, &provInfo)) + { + Mprintf("[TLS] FATAL: CertSetCertificateContextProperty(CERT_KEY_PROV_INFO_PROP_ID) " + "failed (%u)\n", GetLastError()); + NCryptDeleteKey(persistedKey, 0); // 删除并释放句柄 + CertFreeCertificateContext(cert); + return false; + } + // 绑定后持久化键句柄交进程级 g_persistedKey,随 FreeIdentity 删除(覆盖旧键,避免残留)。 + g_persistedKey = persistedKey; + + // 4) 入站凭据:显式指定服务端证书,不请求客户端证书(单向 TLS)。 + SCHANNEL_CRED sc = {}; + sc.dwVersion = SCHANNEL_CRED_VERSION; + sc.cCreds = 1; + sc.paCred = &cert; + sc.grbitEnabledProtocols = SP_PROT_TLS1_2_SERVER; + // NO_DEFAULT_CREDS 避免回退到系统默认凭据,确保只用我们提供的证书/私钥。 + //(单向 TLS 不请求客户端证书,故无需 SCH_CRED_MANUAL_CRED_VALIDATION。) + sc.dwFlags = SCH_CRED_NO_DEFAULT_CREDS; + + TimeStamp tsExpiry = {}; + SECURITY_STATUS st = AcquireCredentialsHandleA( + NULL, (SEC_CHAR*)"Microsoft Unified Security Protocol Provider", SECPKG_CRED_INBOUND, + NULL, &sc, NULL, NULL, &g_cred, &tsExpiry); + + if (st != SEC_E_OK) + { + Mprintf("[TLS] FATAL: AcquireCredentialsHandle(SECPKG_CRED_INBOUND) failed 0x%08x\n", + (unsigned)st); + CertFreeCertificateContext(cert); // 无凭据引用,安全释放 + g_cred = {}; + if (g_persistedKey) // 回滚:删除已持久化的私钥,避免残留 + { + NCryptDeleteKey(g_persistedKey, 0); // 删除并释放句柄 + g_persistedKey = 0; + } + return false; + } + + // 成功:把证书上下文生命周期交给进程级 g_cert,随 FreeIdentity 释放, + // 保证在 FreeCredentialsHandle 之前私钥一直可用。 + g_cert = cert; + g_credValid = true; + return true; +} + +bool ServerTlsIdentity::IsEnabled() +{ + std::lock_guard lk(g_identityMtx); + return g_enabled; +} + +bool ServerTlsIdentity::IsReady() +{ + std::lock_guard lk(g_identityMtx); + return g_ready && g_credValid; +} + +bool ServerTlsIdentity::IsLoadError() +{ + std::lock_guard lk(g_identityMtx); + return g_loadError; +} + +CredHandle ServerTlsIdentity::Cred() +{ + std::lock_guard lk(g_identityMtx); + return g_cred; +} + +// ===================================================================================== +// ServerTlsSession —— 单连接会话(握手 + 加解密) +// ===================================================================================== + +void ServerTlsSession::CloseHandles() +{ + if (m_ctxValid) + { + DeleteSecurityContext(&m_ctx); + m_ctxValid = false; + } + m_ctx = {}; + m_ready = false; + m_raw.clear(); +} + +// 握手线程在 IOCP 的子套接字上做同步 I/O。该套接字可能是非阻塞的(监听套接字经 +// WSAEventSelect 置为非阻塞后,accept 出的子套接字继承非阻塞态;线程里 FIONBIO=0 也 +// 不一定即时生效)。故 recv/send 遇 WSAEWOULDBLOCK 时用 select 等可读/可写再重试, +// 而非当致命错误,避免「客户端回包还没到就误判握手失败」。 +static bool WaitSocketReady(SOCKET sock, bool forRead, int timeoutMs) +{ + fd_set fds; + FD_ZERO(&fds); + FD_SET(sock, &fds); + timeval tv; + tv.tv_sec = timeoutMs / 1000; + tv.tv_usec = (timeoutMs % 1000) * 1000; + return select(0, forRead ? &fds : NULL, forRead ? NULL : &fds, NULL, &tv) > 0; +} + +bool ServerTlsSession::SendAll(SOCKET sock, const void* data, int len) +{ + const BYTE* p = (const BYTE*)data; + int sent = 0; + while (sent < len) + { + int n = send(sock, (const char*)p + sent, len - sent, 0); + if (n == SOCKET_ERROR && WSAGetLastError() == WSAEWOULDBLOCK) + { + if (!WaitSocketReady(sock, false, 15000)) + return false; + continue; // 非阻塞套接字发送缓冲满:等可写后重试 + } + if (n <= 0) + return false; + sent += n; + } + return true; +} + +bool ServerTlsSession::Handshake(SOCKET sock, const BYTE* initial, int initialLen) +{ + std::lock_guard lk(m_mtx); + if (m_ctxValid) + CloseHandles(); // 防御:会话应只握手一次 + + CredHandle cred = ServerTlsIdentity::Cred(); + + // 单向 TLS:服务端不请求客户端证书,故无需 ASC_REQ_MANUAL_CRED_VALIDATION + //(该标志只在 mTLS 校验客户端证书时有意义;SDK 的 ASC 命名空间里亦无此常量)。 + DWORD req = ASC_REQ_SEQUENCE_DETECT | ASC_REQ_REPLAY_DETECT | ASC_REQ_CONFIDENTIALITY | + ASC_REQ_ALLOCATE_MEMORY | ASC_REQ_STREAM; + + std::vector hs(initial, initial + initialLen); + CtxtHandle ctx = {}; + bool ctxValid = false; + TimeStamp tsExpiry = {}; + ULONG attrs = 0; + SECURITY_STATUS sc = SEC_I_CONTINUE_NEEDED; + + for (;;) + { + SecBuffer inBufs[2] = { + { (ULONG)hs.size(), SECBUFFER_TOKEN, hs.empty() ? NULL : hs.data() }, + { 0, SECBUFFER_EMPTY, NULL }, + }; + SecBufferDesc inDesc = { SECBUFFER_VERSION, 2, inBufs }; + SecBuffer outBuf = { 0, SECBUFFER_TOKEN, NULL }; + SecBufferDesc outDesc = { SECBUFFER_VERSION, 1, &outBuf }; + + sc = AcceptSecurityContext(&cred, ctxValid ? &ctx : NULL, &inDesc, req, + SECURITY_NATIVE_DREP, ctxValid ? NULL : &ctx, + &outDesc, &attrs, &tsExpiry); + ctxValid = true; + + // 发出服务端握手记录(ServerHello/Certificate/... 或 Finished)。 + if (outBuf.cbBuffer > 0 && outBuf.pvBuffer) + { + bool ok = SendAll(sock, outBuf.pvBuffer, (int)outBuf.cbBuffer); + FreeContextBuffer(outBuf.pvBuffer); + if (!ok) + { + sc = SEC_E_INTERNAL_ERROR; + break; + } + } + + // 计算已消费字节:SECBUFFER_EXTRA 里是「未消费的尾部」(下一条握手消息 / 首条应用数据)。 + // 必须在 SEC_E_OK / 失败判定之前处理:否则握手完成时 hs 里仍混着已消费的握手记录, + // 紧跟 Finished 的首条应用数据(客户端登录包)会随 hs 一起被丢弃,导致随机丢包。 + if (inBufs[1].BufferType == SECBUFFER_EXTRA) + { + int extra = (int)inBufs[1].cbBuffer; + hs.erase(hs.begin(), hs.begin() + (hs.size() - extra)); + } + else if (sc != SEC_E_INCOMPLETE_MESSAGE && sc != SEC_I_INCOMPLETE_CREDENTIALS) + { + hs.clear(); // 已全部消费,继续等下一轮握手消息 + } + // 否则:SEC_E_INCOMPLETE_MESSAGE —— 保留 hs(不完整消息),续收补齐。 + + if (sc == SEC_E_OK) + break; + if (FAILED(sc) && sc != SEC_E_INCOMPLETE_MESSAGE && sc != SEC_I_INCOMPLETE_CREDENTIALS) + { + Mprintf("[TLS] AcceptSecurityContext failed 0x%08x (in=%u consumed? extra=%u)\n", + (unsigned)sc, inBufs[0].cbBuffer, inBufs[1].BufferType == SECBUFFER_EXTRA ? inBufs[1].cbBuffer : 0); + break; // 真正的失败 + } + + char raw[8192]; + int r; + for (;;) + { + r = recv(sock, raw, sizeof(raw), 0); + if (r == SOCKET_ERROR && WSAGetLastError() == WSAEWOULDBLOCK) + { + if (!WaitSocketReady(sock, true, 15000)) + { + r = SOCKET_ERROR; + break; // 等可读超时 + } + continue; // 非阻塞套接字暂无数据:等可读后重试 + } + break; + } + if (r <= 0) + { + Mprintf("[TLS] handshake recv failed/EOF (r=%d err=%d)\n", r, WSAGetLastError()); + sc = SEC_E_INTERNAL_ERROR; + break; + } + hs.insert(hs.end(), (BYTE*)raw, (BYTE*)raw + r); + } + + if (sc != SEC_E_OK) + { + Mprintf("[TLS] handshake failed: final status 0x%08x (fail-closed)\n", (unsigned)sc); + if (ctxValid) + DeleteSecurityContext(&ctx); + return false; + } + + // 握手成功后 hs 里残留的「未消费尾部」是紧跟 Finished 的首条应用数据(TLS 记录)。 + // 交给 Decrypt 处理,否则首包会丢(表现为随机登录超时/重连/协议错位)。 + if (!hs.empty()) + m_raw.insert(m_raw.end(), hs.begin(), hs.end()); + + // 查询流式记录尺寸(加密封装用)。 + SecPkgContext_StreamSizes sizes = {}; + if (QueryContextAttributes(&ctx, SECPKG_ATTR_STREAM_SIZES, &sizes) != SEC_E_OK) + { + DeleteSecurityContext(&ctx); + return false; + } + + m_ctx = ctx; + m_ctxValid = true; + m_sizes = sizes; + m_ready = true; + return true; +} + +int ServerTlsSession::Decrypt(const BYTE* cipher, int len, std::vector& plain) +{ + std::lock_guard lk(m_mtx); + if (!m_ready) + return -1; + if (len <= 0) + return 0; + + m_raw.insert(m_raw.end(), cipher, cipher + len); + + for (;;) + { + SecBuffer bufs[4] = { + { (ULONG)m_raw.size(), SECBUFFER_DATA, m_raw.empty() ? NULL : m_raw.data() }, + { 0, SECBUFFER_EMPTY, NULL }, + { 0, SECBUFFER_EMPTY, NULL }, + { 0, SECBUFFER_EMPTY, NULL }, + }; + SecBufferDesc desc = { SECBUFFER_VERSION, 4, bufs }; + + SECURITY_STATUS sc = DecryptMessage(&m_ctx, &desc, 0, NULL); + if (sc == SEC_E_INCOMPLETE_MESSAGE) + break; // 记录不完整,等下一轮 WSARecv + if (sc == SEC_I_CONTEXT_EXPIRED) { + Mprintf("[TLS] DecryptMessage: peer close_notify (SEC_I_CONTEXT_EXPIRED)\n"); + return -1; // 对端 close_notify / 会话结束 + } + if (sc == SEC_I_RENEGOTIATE || FAILED(sc)) { + Mprintf("[TLS] DecryptMessage failed 0x%08x (raw=%u)\n", (unsigned)sc, (unsigned)m_raw.size()); + return -1; + } + + int decLen = (int)bufs[1].cbBuffer; + const BYTE* p = (const BYTE*)bufs[1].pvBuffer; + int extra = (bufs[3].BufferType == SECBUFFER_EXTRA) ? (int)bufs[3].cbBuffer : 0; + int consumed = (int)m_raw.size() - extra; + + if (decLen > 0 && p) + plain.insert(plain.end(), p, p + decLen); + + m_raw.erase(m_raw.begin(), m_raw.begin() + consumed); + if (m_raw.empty()) + break; + } + return 0; +} + +int ServerTlsSession::Encrypt(const BYTE* plain, int len, std::vector& wire) +{ + std::lock_guard lk(m_mtx); + if (!m_ready) + return -1; + if (len <= 0) + return 0; + + ULONG maxMsg = m_sizes.cbMaximumMessage > 0 ? m_sizes.cbMaximumMessage : 16384; + int total = 0; + while (total < len) + { + int chunk = (len - total > (int)maxMsg) ? (int)maxMsg : (len - total); + + std::vector rec(m_sizes.cbHeader + chunk + m_sizes.cbTrailer); + BYTE* hdr = rec.data(); + BYTE* body = hdr + m_sizes.cbHeader; + BYTE* trailer = body + chunk; + memcpy(body, plain + total, chunk); + + SecBuffer bufs[4] = { + { m_sizes.cbHeader, SECBUFFER_STREAM_HEADER, hdr }, + { (ULONG)chunk, SECBUFFER_DATA, body }, + { m_sizes.cbTrailer, SECBUFFER_STREAM_TRAILER, trailer }, + { 0, SECBUFFER_EMPTY, NULL }, + }; + SecBufferDesc desc = { SECBUFFER_VERSION, 4, bufs }; + + SECURITY_STATUS sc = EncryptMessage(&m_ctx, 0, &desc, 0); + if (FAILED(sc)) + return -1; // 握手已建立,此处失败视为致命(调用方关闭连接) + + int wireLen = (int)(bufs[0].cbBuffer + bufs[1].cbBuffer + bufs[2].cbBuffer); + wire.insert(wire.end(), rec.data(), rec.data() + wireLen); + total += chunk; + } + return total; +} + +// ===================================================================================== +// CONTEXT_OBJECT::FreeTls —— 释放每连接 TLS 会话(定义在此以便 delete 完整类型) +// ===================================================================================== + +void CONTEXT_OBJECT::FreeTls() +{ + delete tls; + tls = NULL; +} diff --git a/server/2015Remote/ServerTlsSession.h b/server/2015Remote/ServerTlsSession.h new file mode 100644 index 0000000..46cabdd --- /dev/null +++ b/server/2015Remote/ServerTlsSession.h @@ -0,0 +1,92 @@ +// ServerTlsSession.h — MFC 服务端单向 TLS(Schannel 入站)会话封装 +// +// 与客户端 TlsChannel 对称:客户端走 InitializeSecurityContext(出站),本类走 +// AcceptSecurityContext(入站)。单向 TLS:不请求/校验客户端证书,客户端身份仍由 +// 隧道内登录(V1 PwdHmac / V2 ECDSA)承担。 +// +// 分层: +// ServerTlsIdentity —— 进程级「服务端身份」(证书 + 私钥 → 入站凭据)。可重入加载 +// (Load 禁止用 call_once,供未来 UI/指令热切换),本轮启动时加载一次。 +// ServerTlsSession —— 每条连接一个,持有握手/加解密所需的 Schannel 上下文与缓冲, +// 随 CONTEXT_OBJECT 池化复用而重建。 +// +// 线程模型:Handshake 在专用握手线程上阻塞执行(方案 A);握手完成后,Decrypt 由 +// IOCP 工作线程调用(每连接同一时刻只有一个未决 WSARecv,天然串行)、Encrypt 在 +// SendLock 内调用。Schannel 上下文非线程安全,故 Encrypt/Decrypt 内部以 m_mtx 互斥。 +// +// 选型:Schannel(Windows 原生 SSPI),secur32.lib / crypt32.lib / ncrypt.lib 系统库。 +// 证书/私钥为 PEM(与 Go 服务端 YAMA_TLS_CERT/KEY 同构,可共用同一对密钥);私钥 +// 须为 PKCS#8(-----BEGIN PRIVATE KEY-----),见 ServerTlsSession.cpp 的加载实现。 +#pragma once + +#include + +#ifndef SECURITY_WIN32 +#define SECURITY_WIN32 1 +#endif +#include +#include + +#include +#include +#include + +class config; // common/iniFile.h(仅需引用,避免把 ini 头拖进 Server.h 的 include 图) + +// ---- 进程级服务端身份(证书 + 私钥),可重入加载(禁止 call_once,供未来热切换)---- +class ServerTlsIdentity +{ +public: + // 从 cfg(THIS_CFG)读 [tls] 节:Enabled / CertPath / KeyPath,并加载身份。 + // 返回 true 表示「配置已生效」(开关关、或开关开且身份加载成功);开关开但加载 + // 失败时返回 false 且 IsLoadError()==true,服务端须对 TLS ClientHello fail-closed。 + static bool Load(config& cfg); + + static bool IsEnabled(); // [tls] Enabled != 0 + static bool IsReady(); // 开关开 && 身份加载成功(有可用入站凭据) + static bool IsLoadError(); // 开关开 && 身份加载失败(fail-closed) + + // 入站凭据(AcquireCredentialsHandle 产出)。返回拷贝;句柄值本身可安全拷贝。 + static CredHandle Cred(); + +private: + static void FreeIdentity(); + static bool LoadIdentity(const std::string& certPath, const std::string& keyPath); +}; + +// ---- 单连接服务端 TLS 会话(握手 + 加解密)---- +class ServerTlsSession +{ +public: + ServerTlsSession() {} + ~ServerTlsSession() { CloseHandles(); } + + ServerTlsSession(const ServerTlsSession&) = delete; + ServerTlsSession& operator=(const ServerTlsSession&) = delete; + + // 阻塞式服务端握手(方案 A:专用握手线程上调用)。initial/initialLen 是嗅探阶段已 + // 收到的 ClientHello 字节(可能只是分片;内部按 SEC_E_INCOMPLETE_MESSAGE 续收补齐)。 + // 成功返回 true,失败 false(调用方关闭连接,fail-closed)。 + bool Handshake(SOCKET sock, const BYTE* initial, int initialLen); + + // 解密:把本次 WSARecv 收到的密文 cipher[0..len] 喂入,把能解出的明文追加到 plain。 + // 返回 0 = 成功(可能无明文);-1 = 错误 / 对端 close_notify(调用方关闭连接)。 + int Decrypt(const BYTE* cipher, int len, std::vector& plain); + + // 加密:把明文 plain[0..len] 加密成完整 TLS 记录,追加到 wire。 + // 返回加密的明文字节数(== len);-1 = 错误。 + int Encrypt(const BYTE* plain, int len, std::vector& wire); + + bool Ready() const { return m_ready; } + +private: + void CloseHandles(); + bool SendAll(SOCKET sock, const void* data, int len); + + std::mutex m_mtx; // 序列化对 m_ctx 的 Encrypt/Decrypt(Schannel 非线程安全) + CtxtHandle m_ctx = {}; + bool m_ctxValid = false; + bool m_ready = false; + SecPkgContext_StreamSizes m_sizes = {}; + std::vector m_raw; // 尚未解密 / 未消费完的 TLS 记录原始字节 +};