From ca0d7626d2fc5c559db78dd9f04137abb72095df Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Wed, 14 Oct 2026 19:05:05 +0200 Subject: [PATCH] Feat: One-way TLS transport for the device port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional one-way TLS on the device port (6543), enabled per client via a pinned trust anchor, so new clients encrypt their transport while legacy plaintext clients keep working unchanged. Server (server/go): sniff each accepted connection and wrap TLS peers (ClientHello 0x16 0x03 + handshake type 0x01) in crypto/tls with MinVersion TLS 1.2; everything else stays on the plaintext path. TLS is enabled only when both YAMA_TLS_CERT and YAMA_TLS_KEY are set, and a cert/key load failure fails startup loudly rather than silently serving plaintext. Client (client/, Windows): Schannel one-way TLS. When the trust anchor is present — registry value Software\\settings\tls_cert (REG_SZ holding the PEM), with fallback to settings.ini [settings] tls_cert or yama-ca.pem beside the exe — every outbound connection (main + sub-connections) completes a TLS 1.2 handshake before the app protocol and is strictly pinned to that anchor via a chain engine with hExclusiveRoot; any handshake/cert failure is fail-closed (no plaintext downgrade). Send uses EncryptMessage; Recv uses DecryptMessage and reads plaintext from the stream-mode DATA segment (bufs[1]) rather than the record header. Co-Authored-By: deepseek-v4-pro --- client/ClientDll_vs2015.vcxproj | 2 + client/ClientDll_vs2015.vcxproj.filters | 23 ++ client/IOCPClient.cpp | 35 +- client/IOCPClient.h | 25 +- client/TlsChannel.cpp | 494 ++++++++++++++++++++++++ client/TlsChannel.h | 88 +++++ client/ghost_vs2015.vcxproj | 2 + client/ghost_vs2015.vcxproj.filters | 204 ++++++++++ docs/TransportTLS_Design.md | 313 +++++++++++++++ server/go/.vscode/launch.json | 8 +- server/go/certs/.gitignore | 4 + server/go/certs/server.crt | 19 + server/go/cmd/main.go | 15 + server/go/server/server.go | 23 +- server/go/server/tls.go | 113 ++++++ server/go/server/tls_test.go | 55 +++ 16 files changed, 1418 insertions(+), 5 deletions(-) create mode 100644 client/TlsChannel.cpp create mode 100644 client/TlsChannel.h create mode 100644 client/ghost_vs2015.vcxproj.filters create mode 100644 docs/TransportTLS_Design.md create mode 100644 server/go/certs/.gitignore create mode 100644 server/go/certs/server.crt create mode 100644 server/go/server/tls.go create mode 100644 server/go/server/tls_test.go diff --git a/client/ClientDll_vs2015.vcxproj b/client/ClientDll_vs2015.vcxproj index 34e7c79..09145ea 100644 --- a/client/ClientDll_vs2015.vcxproj +++ b/client/ClientDll_vs2015.vcxproj @@ -225,6 +225,7 @@ + @@ -264,6 +265,7 @@ + diff --git a/client/ClientDll_vs2015.vcxproj.filters b/client/ClientDll_vs2015.vcxproj.filters index 373048a..ff7eabf 100644 --- a/client/ClientDll_vs2015.vcxproj.filters +++ b/client/ClientDll_vs2015.vcxproj.filters @@ -46,6 +46,16 @@ + + + + + + + + + + @@ -99,6 +109,19 @@ + + + + + + + + + + + + + diff --git a/client/IOCPClient.cpp b/client/IOCPClient.cpp index 1402e70..6f6ed70 100644 --- a/client/IOCPClient.cpp +++ b/client/IOCPClient.cpp @@ -227,7 +227,11 @@ IOCPClient::IOCPClient(const State&bExit, bool exit_while_disconnect, int mask, m_DataProcess = NULL; m_ReconnectFunc = NULL; m_masker = mask ? new HttpMask(DEFAULT_HOST) : new PkgMask(); +#ifdef _DEBUG + auto enc = GetHeaderEncoder(HeaderEncType(time(nullptr) % (HeaderEncV6 + 1))); +#else auto enc = GetHeaderEncoder(HeaderEncType(time(nullptr) % HeaderEncNum)); +#endif m_EncoderType = encoder; m_Encoder = encoder ? new HellEncoder(enc, new XOREncoder16()) : new ProtocolEncoder(); #ifdef _WIN32 @@ -463,6 +467,25 @@ BOOL IOCPClient::ConnectServer(const char* szServerIP, unsigned short uPort) SetKeepAliveOptions(m_sClientSocket); #endif } +#ifdef _WIN32 + // 单向 TLS:证书存在(TlsEnabled())时,在应用协议首包前完成握手;握手失败即断开, + // 不回退明文(fail-closed,防降级)。证书存在但加载失败(TlsLoadError())同样拒绝 + // 连接。握手须在 m_bConnected 置位前,确保工作线程不会在握手尚未完成时就开始 recv。 + if (TlsLoadError()) { + Mprintf("[TLS] 证书加载失败,拒绝连接(fail-closed)\n"); + closesocket(m_sClientSocket); + m_sClientSocket = INVALID_SOCKET; + return FALSE; + } + if (TlsEnabled()) { + if (!m_tls.Handshake(m_sClientSocket)) { + Mprintf("[TLS] 握手失败,断开连接(fail-closed)\n"); + closesocket(m_sClientSocket); + m_sClientSocket = INVALID_SOCKET; + return FALSE; + } + } +#endif m_bConnected = TRUE; Mprintf("连接服务端成功: %s:%d.\n", m_sCurIP.c_str(), (int)port); @@ -526,6 +549,16 @@ DWORD WINAPI IOCPClient::WorkThreadProc(LPVOID lParam) Sleep(50); continue; } +#ifdef _WIN32 + // TLS 会话里可能有已解密、尚未交付的明文(select 只看底层 socket,看不到 + // Schannel 内部缓冲),优先消费,避免被 select 卡住。 + if (This->HasPendingData()) { + if (!This->ProcessRecvData(&m_CompressedBuffer, szBuffer, MAX_RECV_BUFFER - 1, 0)) { + break; + } + continue; + } +#endif FD_ZERO(&fd); FD_SET(This->m_sClientSocket, &fd); // Linux select() 会修改 timeval,必须每次重置 @@ -632,7 +665,7 @@ 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! bufLen=%lu, first 16 bytes: %s\n", bufLen, buf.c_str()); + Mprintf("[ERROR] Unknown header! this=%p bufLen=%lu, first 16 bytes: %s\n", this, bufLen, buf.c_str()); m_CompressedBuffer->ClearBuffer(); break; } diff --git a/client/IOCPClient.h b/client/IOCPClient.h index 8415ae0..8aa6a61 100644 --- a/client/IOCPClient.h +++ b/client/IOCPClient.h @@ -8,6 +8,7 @@ #include "stdafx.h" #include #include +#include "TlsChannel.h" #pragma comment(lib,"ws2_32.lib") #endif @@ -289,14 +290,33 @@ public: protected: virtual int ReceiveData(char* buffer, int bufSize, int flags) { +#ifdef _WIN32 + if (TlsEnabled()) + return m_tls.Recv(m_sClientSocket, buffer, bufSize - 1); +#endif // TCP版本调用 recv - return recv(m_sClientSocket, buffer, bufSize - 1, 0); + int n = recv(m_sClientSocket, buffer, bufSize - 1, 0); + return n; } virtual bool ProcessRecvData(CBuffer* m_CompressedBuffer, char* szBuffer, int len, int flag); virtual int SendTo(const char* buf, int len, int flags) { +#ifdef _WIN32 + if (TlsEnabled()) + return m_tls.Send(m_sClientSocket, buf, len); +#endif return ::send(m_sClientSocket, buf, len, flags); } + // TLS 会话里可能有已解密但尚未交付的明文(select 只能看到底层 socket 可读性, + // 看不到 Schannel 内部缓冲),接收线程在 select 前须优先消费,避免其被卡住。 + bool HasPendingData() const + { +#ifdef _WIN32 + return m_tls.HasPending(); +#else + return false; +#endif + } BOOL OnServerSending(const char* szBuffer, ULONG ulOriginalLength, PkgMask* mask); static DWORD WINAPI WorkThreadProc(LPVOID lParam); VOID OnServerReceiving(CBuffer *m_CompressedBuffer, char* szBuffer, ULONG ulReceivedLength); @@ -305,6 +325,9 @@ protected: protected: sockaddr_in m_ServerAddr; SOCKET m_sClientSocket; +#ifdef _WIN32 + TlsChannel m_tls; // TLS 会话(证书存在时才启用,见 TlsChannel.h) +#endif BOOL m_bWorkThread; HANDLE m_hWorkThread; BOOL m_bIsRunning; diff --git a/client/TlsChannel.cpp b/client/TlsChannel.cpp new file mode 100644 index 0000000..b978f1c --- /dev/null +++ b/client/TlsChannel.cpp @@ -0,0 +1,494 @@ +// TlsChannel.cpp — 见 TlsChannel.h 头注释。仅 _WIN32 编译。 +#ifdef _WIN32 + +#include "stdafx.h" +#include "TlsChannel.h" + +#include "common/iniFile.h" // config(settings.ini 文件回退) + GetRegistryName/GetCurrentUserRegistryKey(注册表正文) + +#include // 证书/链引擎 API +#include // RegOpenKeyExA / RegQueryValueExA +#include // _access +#include // std::ifstream +#include // std::istreambuf_iterator +#include +#include // std::call_once + +#pragma comment(lib, "secur32.lib") +#pragma comment(lib, "crypt32.lib") + +namespace { + +std::once_flag g_tlsOnce; +bool g_tlsEnabled = false; // TLS 会话可用:send/recv 走加密 +bool g_tlsError = false; // 证书存在但加载失败:拒绝连接(fail-closed) +HCERTSTORE g_tlsStore = NULL; +HCERTCHAINENGINE g_tlsEngine = NULL; // 只信任 g_tlsStore 的链引擎(严格钉扎) + +// 解析信任锚证书路径:优先 settings.ini 的 [settings] tls_cert,其次可执行文件同目录 +// yama-ca.pem。 +std::string ResolveCertPath() +{ + std::string certPath; + { + config cfg; + certPath = cfg.GetStr("settings", "tls_cert", ""); + } + if (certPath.empty()) { + char path[MAX_PATH]; + if (!GetModuleFileNameA(NULL, path, sizeof(path))) + return std::string(); // 拿不到 exe 路径,视为未配置 + char* p = path + strlen(path); + while (p > path && *p != '\\') --p; + strcpy(p + 1, "yama-ca.pem"); + certPath = path; + } + return certPath; +} + +// 读取注册表 REG_SZ(动态扩容)。iniFile::GetStr 用 512 字节固定缓冲,会把 PEM 正文 +// (约 1.3~1.8KB)截断成空,故此处自行两段式 RegQueryValueEx 读取。 +// 返回 true 表示值存在且为 REG_SZ,out 为去掉末尾 '\0' 后的内容;否则 false。 +bool ReadRegistrySz(HKEY root, const std::string& subKey, const std::string& valueName, std::string& out) +{ + HKEY hKey = NULL; + if (RegOpenKeyExA(root, subKey.c_str(), 0, KEY_READ, &hKey) != ERROR_SUCCESS) + return false; + + DWORD type = 0, size = 0; + LONG r = RegQueryValueExA(hKey, valueName.c_str(), NULL, &type, NULL, &size); + if (r != ERROR_SUCCESS || type != REG_SZ) { + RegCloseKey(hKey); + return false; + } + std::vector buf(size ? size : 1, 0); + r = RegQueryValueExA(hKey, valueName.c_str(), NULL, &type, + reinterpret_cast(buf.data()), &size); + RegCloseKey(hKey); + if (r != ERROR_SUCCESS) + return false; + if (size > 0 && buf[size - 1] == '\0') + --size; // REG_SZ 的 size 含末尾 '\0' + out.assign(buf.data(), size); + return true; +} + +// 取信任锚 PEM 正文。优先注册表 Software\\settings\tls_cert(REG_SZ 直接存 +// PEM 正文,便于后续服务端下发/撤销),其次 settings.ini [settings] tls_cert 路径、 +// exe 同目录 yama-ca.pem 文件(向后兼容旧的文件投递方式)。 +// 返回 true 表示已配置(pem 可能为空:值/文件存在但内容为空,由 LoadTrustAnchor 判为 +// fail-closed);false 表示完全未配置(走明文)。source 描述来源,仅用于日志。 +bool ReadTrustAnchor(std::string& pem, std::string& source) +{ + // 1) 注册表正文(首选)。值存在(即便为空)即视为「已配置」,交由 LoadTrustAnchor + // 对空/损坏内容判 fail-closed,与「空文件 → fail-closed」语义保持一致,避免空值 + // 被静默当成未配置而回退明文。 + std::string regSubKey = GetRegistryName() + "\\settings"; + std::string regVal; + if (ReadRegistrySz(GetCurrentUserRegistryKey(), regSubKey, "tls_cert", regVal)) { + pem = regVal; + source = "registry " + regSubKey + "\\tls_cert"; + return true; + } + + // 2) 文件回退:settings.ini [settings] tls_cert 路径 → exe 同目录 yama-ca.pem + std::string certPath = ResolveCertPath(); + if (certPath.empty() || _access(certPath.c_str(), 0) != 0) + return false; // 无证书 → 明文 + + std::ifstream f(certPath.c_str(), std::ios::binary); + if (!f) { + Mprintf("[TLS] cannot open %s\n", certPath.c_str()); + return false; + } + pem.assign((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + source = certPath; + return true; +} + +// 解码 PEM/DER 信任锚正文为 DER,并加入内存信任锚存储。 +bool LoadTrustAnchor(const std::string& pem, const std::string& source, HCERTSTORE* outStore) +{ + if (pem.empty()) { + Mprintf("[TLS] empty certificate %s\n", source.c_str()); + return false; + } + + // PEM(-----BEGIN CERTIFICATE----- base64)或 DER → DER。CRYPT_STRING_ANY 两者都认, + // 失败再退回 CRYPT_STRING_BASE64HEADER(纯 PEM)。 + DWORD derLen = 0; + if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY, + NULL, &derLen, NULL, NULL) || derLen == 0) { + if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_BASE64HEADER, + NULL, &derLen, NULL, NULL) || derLen == 0) { + Mprintf("[TLS] failed to decode certificate %s\n", source.c_str()); + return false; + } + } + std::vector der(derLen); + if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY, + der.data(), &derLen, NULL, NULL)) { + if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_BASE64HEADER, + der.data(), &derLen, NULL, NULL)) { + Mprintf("[TLS] failed to decode certificate %s\n", source.c_str()); + return false; + } + } + + PCCERT_CONTEXT cert = CertCreateCertificateContext(X509_ASN_ENCODING, der.data(), derLen); + if (!cert) { + Mprintf("[TLS] invalid certificate %s\n", source.c_str()); + return false; + } + + HCERTSTORE store = CertOpenStore(CERT_STORE_PROV_MEMORY, X509_ASN_ENCODING, + 0, CERT_STORE_CREATE_NEW_FLAG, NULL); + if (!store) { + CertFreeCertificateContext(cert); + Mprintf("[TLS] CertOpenStore failed\n"); + return false; + } + if (!CertAddCertificateContextToStore(store, cert, CERT_STORE_ADD_REPLACE_EXISTING, NULL)) { + CertCloseStore(store, 0); + CertFreeCertificateContext(cert); + Mprintf("[TLS] CertAddCertificateContextToStore failed\n"); + return false; + } + CertFreeCertificateContext(cert); + + *outStore = store; + return true; +} + +void InitTls() +{ + std::string pem, source; + if (!ReadTrustAnchor(pem, source)) { + // 无证书 → 明文(默认,与老客户端一致) + g_tlsEnabled = false; + g_tlsError = false; + Mprintf("[TLS] disabled: no trust anchor configured, plaintext mode\n"); + return; + } + + // 证书已配置:管理员已「颁发」,必须走 TLS;加载失败即拒绝连接(fail-closed), + // 不能静默回退明文。 + HCERTSTORE store = NULL; + if (!LoadTrustAnchor(pem, source, &store)) { + g_tlsEnabled = false; + g_tlsError = true; + Mprintf("[TLS] FATAL: trust anchor (%s) present but failed to load; refusing connections (fail-closed)\n", + source.c_str()); + return; + } + + // 严格钉扎:链引擎只信任该 store 里的根证书,排除系统根库。否则攻击者拿一张任意 + // 公共 CA 签发的证书就能通过 CertGetCertificateChain(默认引擎信任系统根库)。 + CERT_CHAIN_ENGINE_CONFIG ec = {}; + ec.cbSize = sizeof(ec); + ec.hExclusiveRoot = store; + HCERTCHAINENGINE engine = NULL; + if (!CertCreateCertificateChainEngine(&ec, &engine)) { + CertCloseStore(store, 0); + g_tlsEnabled = false; + g_tlsError = true; + Mprintf("[TLS] FATAL: CertCreateCertificateChainEngine failed; refusing connections (fail-closed)\n"); + return; + } + + g_tlsStore = store; + g_tlsEngine = engine; + g_tlsEnabled = true; + g_tlsError = false; + Mprintf("[TLS] enabled: trust anchor loaded (%s), all connections use TLS\n", source.c_str()); +} + +} // namespace + +bool TlsEnabled() +{ + std::call_once(g_tlsOnce, InitTls); + return g_tlsEnabled; +} + +bool TlsLoadError() +{ + std::call_once(g_tlsOnce, InitTls); + return g_tlsError; +} + +// ---- TlsChannel ---- + +void TlsChannel::CloseHandles() +{ + std::lock_guard lk(m_ctxMtx); + if (m_ctxValid) { DeleteSecurityContext(&m_ctx); m_ctx = {}; m_ctxValid = false; } + if (m_credValid) { FreeCredentialsHandle(&m_cred); m_cred = {}; m_credValid = false; } + m_ready = false; + m_raw.clear(); + m_pending.clear(); +} + +bool TlsChannel::SendAll(SOCKET sock, const void* data, int len) +{ + const char* p = (const char*)data; + int sent = 0; + while (sent < len) { + int n = ::send(sock, p + sent, len - sent, 0); + if (n == SOCKET_ERROR || n == 0) + return false; + sent += n; + } + return true; +} + +bool TlsChannel::VerifyServerCert() +{ + PCCERT_CONTEXT remote = NULL; + SECURITY_STATUS sc = QueryContextAttributesA(&m_ctx, SECPKG_ATTR_REMOTE_CERT_CONTEXT, &remote); + if (sc != SEC_E_OK || !remote) + return false; + + if (!g_tlsStore || !g_tlsEngine) { + CertFreeCertificateContext(remote); + return false; + } + + CERT_CHAIN_PARA para = {}; + para.cbSize = sizeof(para); + PCCERT_CHAIN_CONTEXT chain = NULL; + // g_tlsEngine 的 hExclusiveRoot 已把信任锚限制为 g_tlsStore;hAdditionalStore 再传一次 + // 该 store 供链引擎查找 CA/中间证书。只接受「无任何错误位」的链(含时间有效性)。 + // 不设置任何 CERT_CHAIN_REVOCATION_CHECK_* 位 = 不做吊销检查(私有 CA 无 CRL/OCSP, + // 且这些位会触发联网,反而失败);只保留 CERT_CHAIN_DISABLE_AIA 禁止联网取中间证书。 + BOOL ok = CertGetCertificateChain(g_tlsEngine, remote, NULL, g_tlsStore, ¶, + CERT_CHAIN_DISABLE_AIA, + NULL, &chain); + bool trusted = false; + if (ok && chain) { + trusted = (chain->TrustStatus.dwErrorStatus == CERT_TRUST_NO_ERROR); + } + if (chain) CertFreeCertificateChain(chain); + CertFreeCertificateContext(remote); + return trusted; +} + +bool TlsChannel::Handshake(SOCKET sock) +{ + std::lock_guard lk(m_ctxMtx); + CloseHandles(); // 重连时清理上一次会话(recursive 锁,此处可重入) + + SCHANNEL_CRED cred = {}; + cred.dwVersion = SCHANNEL_CRED_VERSION; + cred.dwFlags = SCH_CRED_MANUAL_CRED_VALIDATION | SCH_CRED_NO_DEFAULT_CREDS; + cred.grbitEnabledProtocols = SP_PROT_TLS1_2_CLIENT; // TLS 1.2(与 Go 端 MinVersion 对齐) + + TimeStamp tsExpiry = {}; + SECURITY_STATUS sc = AcquireCredentialsHandleA( + NULL, (SEC_CHAR*)"Microsoft Unified Security Protocol Provider", + SECPKG_CRED_OUTBOUND, NULL, &cred, NULL, NULL, &m_cred, &tsExpiry); + if (sc != SEC_E_OK) { + Mprintf("[TLS] AcquireCredentialsHandle failed: 0x%08X\n", (unsigned)sc); + return false; + } + m_credValid = true; + + DWORD flags = ISC_REQ_SEQUENCE_DETECT | ISC_REQ_REPLAY_DETECT | ISC_REQ_CONFIDENTIALITY + | ISC_REQ_ALLOCATE_MEMORY | ISC_REQ_STREAM | ISC_REQ_MANUAL_CRED_VALIDATION; + + SecBuffer outBuf = { 0, SECBUFFER_TOKEN, NULL }; + SecBufferDesc outDesc = { SECBUFFER_VERSION, 1, &outBuf }; + ULONG attrs = 0; + + // 首次调用产生 ClientHello + sc = InitializeSecurityContextA(&m_cred, NULL, NULL, flags, 0, SECURITY_NATIVE_DREP, + NULL, 0, &m_ctx, &outDesc, &attrs, &tsExpiry); + m_ctxValid = true; // 上下文已分配,之后必须 DeleteSecurityContext + if (sc != SEC_I_CONTINUE_NEEDED && sc != SEC_E_OK) { + Mprintf("[TLS] InitializeSecurityContext(initial) failed: 0x%08X\n", (unsigned)sc); + CloseHandles(); + return false; + } + if (outBuf.cbBuffer > 0 && outBuf.pvBuffer) { + if (!SendAll(sock, outBuf.pvBuffer, (int)outBuf.cbBuffer)) { + FreeContextBuffer(outBuf.pvBuffer); + CloseHandles(); + return false; + } + FreeContextBuffer(outBuf.pvBuffer); + } + + std::vector hs; + char raw[8192]; + while (sc == SEC_I_CONTINUE_NEEDED || sc == SEC_E_INCOMPLETE_MESSAGE || + sc == SEC_I_INCOMPLETE_CREDENTIALS) + { + int r = recv(sock, raw, sizeof(raw), 0); + if (r == SOCKET_ERROR || r == 0) { + Mprintf("[TLS] handshake recv failed/EOF\n"); + CloseHandles(); + return false; + } + hs.insert(hs.end(), (BYTE*)raw, (BYTE*)raw + r); + + SecBuffer inBufs[2] = { + { (ULONG)hs.size(), SECBUFFER_TOKEN, hs.data() }, + { 0, SECBUFFER_EMPTY, NULL }, + }; + SecBufferDesc inDesc = { SECBUFFER_VERSION, 2, inBufs }; + + outBuf = { 0, SECBUFFER_TOKEN, NULL }; + outDesc = { SECBUFFER_VERSION, 1, &outBuf }; + + sc = InitializeSecurityContextA(&m_cred, &m_ctx, NULL, flags, 0, SECURITY_NATIVE_DREP, + &inDesc, 0, NULL, &outDesc, &attrs, &tsExpiry); + + // 裁剪已消费的输入,仅保留 SECBUFFER_EXTRA 指向的未消费尾部 + if (inBufs[1].BufferType == SECBUFFER_EXTRA) { + int extra = inBufs[1].cbBuffer; + int consumed = (int)hs.size() - extra; + hs.erase(hs.begin(), hs.begin() + consumed); + } else { + hs.clear(); + } + + if (outBuf.cbBuffer > 0 && outBuf.pvBuffer) { + if (!SendAll(sock, outBuf.pvBuffer, (int)outBuf.cbBuffer)) { + FreeContextBuffer(outBuf.pvBuffer); + CloseHandles(); + return false; + } + FreeContextBuffer(outBuf.pvBuffer); + } + } + + if (sc != SEC_E_OK) { + Mprintf("[TLS] handshake failed: 0x%08X\n", (unsigned)sc); + CloseHandles(); + return false; + } + + if (!VerifyServerCert()) { + Mprintf("[TLS] server certificate not trusted against yama-ca.pem (fail-closed)\n"); + CloseHandles(); + return false; + } + + sc = QueryContextAttributesA(&m_ctx, SECPKG_ATTR_STREAM_SIZES, &m_sizes); + if (sc != SEC_E_OK) { + Mprintf("[TLS] QueryContextAttributes(StreamSizes) failed: 0x%08X\n", (unsigned)sc); + CloseHandles(); + return false; + } + + m_ready = true; + Mprintf("[TLS] handshake OK (TLS 1.2, server cert trusted)\n"); + return true; +} + +int TlsChannel::Recv(SOCKET sock, char* out, int outLen) +{ + std::lock_guard lk(m_ctxMtx); + if (!m_ready || outLen <= 0) { + Mprintf("[TLS] Recv called but m_ready=%d (outLen=%d), returning SOCKET_ERROR\n", + (int)m_ready, outLen); + return SOCKET_ERROR; + } + + if (!m_pending.empty()) { + int n = (int)m_pending.size() < outLen ? (int)m_pending.size() : outLen; + memcpy(out, m_pending.data(), n); + m_pending.erase(m_pending.begin(), m_pending.begin() + n); + return n; + } + + 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 (;;) { + SecBuffer bufs[4] = { + { (ULONG)m_raw.size(), SECBUFFER_DATA, 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; // 记录不完整,再 recv + if (sc == SEC_I_CONTEXT_EXPIRED) + return 0; // close_notify + if (sc == SEC_I_RENEGOTIATE || FAILED(sc)) + return SOCKET_ERROR; + + // 流模式 DecryptMessage 会把输入 SECBUFFER_DATA 重组为 4 段: + // bufs[0]=STREAM_HEADER(记录头+nonce)、bufs[1]=DATA(明文)、 + // bufs[2]=STREAM_TRAILER(标签)、bufs[3]=EXTRA(下一条记录)。 + // 明文在 bufs[1].pvBuffer,不在 m_raw 首字节。 + int decLen = bufs[1].cbBuffer; + const BYTE* plain = (const BYTE*)bufs[1].pvBuffer; + int extra = (bufs[3].BufferType == SECBUFFER_EXTRA) ? bufs[3].cbBuffer : 0; + int consumed = (int)m_raw.size() - extra; + m_pending.insert(m_pending.end(), plain, plain + decLen); + m_raw.erase(m_raw.begin(), m_raw.begin() + consumed); + + if (!m_pending.empty()) { + int n = (int)m_pending.size() < outLen ? (int)m_pending.size() : outLen; + memcpy(out, m_pending.data(), n); + m_pending.erase(m_pending.begin(), m_pending.begin() + n); + return n; + } + if (m_raw.empty()) + break; // 无明文(如 alert/CCS 记录),继续 recv + } + } +} + +int TlsChannel::Send(SOCKET sock, const char* data, int len) +{ + std::lock_guard lk(m_ctxMtx); + if (!m_ready) + return SOCKET_ERROR; + 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, data + 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 total > 0 ? total : SOCKET_ERROR; + } + + int wireLen = bufs[0].cbBuffer + bufs[1].cbBuffer + bufs[2].cbBuffer; + if (!SendAll(sock, rec.data(), wireLen)) { + return total > 0 ? total : SOCKET_ERROR; + } + total += chunk; + } + return total; +} + +#endif // _WIN32 diff --git a/client/TlsChannel.h b/client/TlsChannel.h new file mode 100644 index 0000000..0c6fafc --- /dev/null +++ b/client/TlsChannel.h @@ -0,0 +1,88 @@ +// TlsChannel.h — Windows 客户端单向 TLS(Schannel)会话封装 +// +// 用途:为 YAMA 客户端(被控端)提供「按需开启」的传输层加密。当信任锚证书存在时 +// (注册表 Software\\settings\tls_cert,或 yama-ca.pem 文件,见 TlsChannel.cpp +// 的 InitTls),所有出站 TCP 连接在应用协议首包前先完成 TLS 握手;握手失败一律断开 +// (fail-closed,不回退明文)。 +// +// 选型:Schannel(Windows 原生 SSPI),零第三方依赖;secur32.lib / crypt32.lib 为 +// 系统库。单向 TLS:客户端只校验服务端证书链到固定的信任锚,不提供客户端证书, +// 客户端身份仍由隧道内登录(V1 PwdHmac / V2 ECDSA)承担。 +// +// 线程模型:Handshake 在连接建立线程上、m_bConnected 置位前调用;Send/Recv 分别在 +// 发送线程 / 接收线程上调用。上下文生命周期(Handshake/CloseHandles 释放 m_ctx)与 +// Send/Recv 对 m_ctx 的使用(EncryptMessage/DecryptMessage)由 m_ctxMtx 互斥,避免 +// 重连线程释放上下文时与使用线程产生 use-after-free。本对象作为 IOCPClient 的值成员 +// 存在,析构时释放上下文。 +// +// 注意:本文件仅在 _WIN32 下有效,非 Windows 构建(Linux/macOS/Android)不含任何内容, +// 客户端维持明文(TLS 接入按设计文档延后)。 +#pragma once + +#ifdef _WIN32 + +#include + +#ifndef SECURITY_WIN32 +#define SECURITY_WIN32 1 +#endif +#include +#include + +#include +#include + +// 进程级 TLS 开关:首次调用时加载信任锚(注册表或文件),返回是否启用。线程安全、幂等。 +bool TlsEnabled(); + +// 信任锚已配置但加载失败(损坏/格式错误)时为 true:表示管理员已「颁发」证书, +// 客户端必须拒绝连接(fail-closed),不得静默回退明文。线程安全、幂等。 +bool TlsLoadError(); + +class TlsChannel +{ +public: + TlsChannel() {} + ~TlsChannel() { CloseHandles(); } + + // 持 Schannel 句柄,禁止拷贝/赋值(避免句柄被二次释放)。 + TlsChannel(const TlsChannel&) = delete; + TlsChannel& operator=(const TlsChannel&) = delete; + + // 在已连通的 socket 上完成 TLS 客户端握手并校验服务端证书链。失败返回 false, + // 调用方必须关闭 socket(fail-closed)。可重复调用(重连时复用同一对象)。 + bool Handshake(SOCKET sock); + + // 解密读取:返回 >0 的明文字节数;0 表示对端正常关闭;SOCKET_ERROR(-1) 出错。 + int Recv(SOCKET sock, char* buf, int len); + + // 加密发送 len 字节:成功返回已完整发送的明文字节数(== len);失败返回已发送的 + // 前缀字节数(>0)或 SOCKET_ERROR(一个字节都没发出去时)。 + int Send(SOCKET sock, const char* buf, int len); + + // 是否有已解密但尚未交付给应用层的明文(供接收线程在 select 前优先消费)。 + bool HasPending() const { return !m_pending.empty(); } + +private: + void CloseHandles(); + bool SendAll(SOCKET sock, const void* data, int len); + bool VerifyServerCert(); + + CredHandle m_cred = {}; + CtxtHandle m_ctx = {}; + bool m_credValid = false; // m_cred 已分配,需 FreeCredentialsHandle + bool m_ctxValid = false; // m_ctx 已分配,需 DeleteSecurityContext + bool m_ready = false; // 握手完成,Send/Recv 可用 + + SecPkgContext_StreamSizes m_sizes = {}; + + // 保护上下文生命周期:重连线程的 Handshake/CloseHandles(释放 m_ctx)与工作线程的 + // Recv(DecryptMessage)、发送线程的 Send(EncryptMessage)互斥,避免 use-after-free。 + // recursive 是因为 Handshake 内部会再调 CloseHandles。 + std::recursive_mutex m_ctxMtx; + + std::vector m_raw; // 尚未解密 / 未消费完的 TLS 记录原始字节 + std::vector m_pending; // 已解密、未交付给应用层的明文 +}; + +#endif // _WIN32 diff --git a/client/ghost_vs2015.vcxproj b/client/ghost_vs2015.vcxproj index 46f8598..70713e1 100644 --- a/client/ghost_vs2015.vcxproj +++ b/client/ghost_vs2015.vcxproj @@ -233,6 +233,7 @@ + @@ -299,6 +300,7 @@ + diff --git a/client/ghost_vs2015.vcxproj.filters b/client/ghost_vs2015.vcxproj.filters new file mode 100644 index 0000000..3759f1b --- /dev/null +++ b/client/ghost_vs2015.vcxproj.filters @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + decrypt + + + + + + + + + + + + + + + + + + {4b839e4d-0a78-4ee2-8965-bfb259b28273} + + + \ No newline at end of file diff --git a/docs/TransportTLS_Design.md b/docs/TransportTLS_Design.md new file mode 100644 index 0000000..37cb670 --- /dev/null +++ b/docs/TransportTLS_Design.md @@ -0,0 +1,313 @@ +# 传输层单向 TLS 加密设计(按需加密) + +> 版本:v0.5 · 状态:**Go 服务端 + Windows 客户端已实现并验证;信任锚改存注册表;MFC 主控端(阶段 3)未施工** · 日期:2026-10-14 +> 目标:让 YAMA 设备端口(TCP 6543)在继续兼容老客户端(明文/XOR)的同时,允许新客户端在拿到证书后按需开启**单向 TLS** 加密传输。 +> 本期范围:**Go 服务端(`server/go`)+ Windows 客户端**;MFC 服务端(`server/2015Remote`)延后。 +> +> 需求原文(三点,逐一对应下文): +> 1. 新客户端**默认不加密**,行为与老客户端完全一致; +> 2. 某些高要求场景,管理员「颁发一个证书给该客户端 + 重启」,该客户端即改走加密; +> 3. 服务端同一端口(6543)同时兼容明文与 TLS 两种客户端,老客户端零改动。 + +## 1. 目标与范围 + +### 目标 + +- 同一 `6543` 端口上,服务端自动区分「明文客户端」与「TLS 客户端」,按连接路由。 +- 加密是**按部署、按客户端开关**的:证书(注册表值或文件)在 → TLS;证书不在 → 明文(与老客户端一致)。 +- 老客户端(无证书概念)行为不变、零改动。 +- 满足 Measure 1 的「传输层真加密 + 抗重放」目标(对 TLS 客户端而言)。 + +### 非目标(Non-goals,本次不做) + +- 不做 **mTLS / 客户端证书**:单向 TLS 不验证客户端证书,客户端身份仍由隧道内的现有登录(`PwdHmac` V1 / `ECDSA` V2)承担。 +- 不做传输层 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 行): + +1. 可选剥离 HTTP-mask(`POST /...\r\n\r\n` 外壳,`common/mask.h` 的 `HttpMask`); +2. `parseHeader()`(88–140 行)调用 `CheckHead()` 识别协议; +3. `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) | `<>` | `<`=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 + 两轮伪随机交换,密钥是明文带内 1~2 字节)+ `header.go` 的 V0~V6 头混淆——**等于明文**。这正是 Measure 1 要解决的。 + +### 2.2 客户端登录(已核实,隧道内照常) + +- V1:`client/KernelManager.cpp:1857` 从配置读 `settings/PwdHmac`(预计算摘要),服务端 `auth.go:240` `VerifyMessage(YAMA_PWD, passcode, hmacSig)` 校验。`YAMA_PWD` 只存在服务端,客户端只有摘要,不持有 `YAMA_PWD`。 +- V2:`auth.go:188` ECDSA 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\\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(仅明文,向后兼容): + +```go +TLSCertFile string // 服务端证书(PEM) +TLSKeyFile string // 服务端私钥(PEM) +``` + +`cmd/main.go` 读取环境变量(沿用现有 `YAMA_*` 命名): + +```go +cfg.TLSCertFile = os.Getenv("YAMA_TLS_CERT") +cfg.TLSKeyFile = os.Getenv("YAMA_TLS_KEY") +``` + +仅当两者都非空才构建 `*tls.Config`;否则 `s.tlsCfg == nil`,`acceptLoop` 走纯明文。 + +### 5.2 `tls.Config` + +```go +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 走明文,由读循环处理: + +```go +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\\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`): + +```bash +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout yama-server.key -out yama-server.crt \ + -days 3650 -subj "/CN=yama-server" +``` + +或自建 CA + 签服务端证书(便于轮换,客户端信任锚为 `ca.crt`): + +```bash +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 服务端 + +```bash +YAMA_TLS_CERT=/etc/yama/server.crt \ +YAMA_TLS_KEY=/etc/yama/server.key \ +./server_linux_amd64 ... +``` + +### 9.3 客户端「颁发 + 重启」 + +首选:把信任锚 PEM 正文写入注册表值 `Software\\settings\tls_cert`(REG_SZ),重启客户端: + +```powershell +$pem = Get-Content -Raw 'C:\path\yama-ca.pem' +New-Item -Path 'HKCU:\Software\\settings' -Force | Out-Null +Set-ItemProperty -Path 'HKCU:\Software\\settings' -Name 'tls_cert' -Value $pem -Type String +``` + +- `` = 客户端 exe 全路径小写后的 FNV 哈希(`common/iniFile.h` 的 `GetRegistryName()`)。 +- 客户端以服务(SYSTEM)运行时,注册表落在会话用户的 `HKEY_USERS\`(`GetCurrentUserRegistryKey()`)。 +- REG_SZ 直接存 PEM 正文(含换行);客户端动态读取,不受 `iniFile::GetStr` 512 字节固定缓冲限制。 + +兼容旧方式:把信任锚复制到客户端目录并命名为 `yama-ca.pem`(或经 `settings.ini` `[settings] tls_cert` 指向),重启客户端。 + +移除注册表值(或删除文件)并重启即退回明文。**远程下发/撤销信任锚属后续工作,本期仅支持本地写注册表。** + +## 10. 测试计划 + +1. **检测单元**:`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`)。 +2. **TLS 未配置**:不设 `YAMA_TLS_*` → 纯明文,行为与现状一致。 +3. **明文回归**:老客户端 + 新客户端(无证书)均走明文,登录/屏幕/终端/文件全链路不回归。 +4. **TLS 加密**:新客户端(有证书)→ 握手成功,登录/屏幕/终端/文件子连接全部在 TLS 内跑通;抓包确认 payload 不可读。 +5. **混合并发**:明文与 TLS 客户端同时在线,互不干扰。 +6. **fail-closed**:客户端有证书但服务端证书不匹配 / 中间人 → 客户端报错断开,**不**回退明文。 +7. **握手超时 / 限流**:恶意慢握手被 `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 检测 + 解包接入。 +- **回滚**:服务端清空 `YAMA_TLS_CERT/KEY` 即回到纯明文;客户端删除注册表 `tls_cert` 值(或 `yama-ca.pem` 文件)即回明文。 + +## 12. 待定问题 + +1. ~~客户端 TLS 栈最终选型~~ → **已定 Schannel**(见 §6.2,2026-10-13 落地)。 +2. ~~信任锚文件固定名(`yama-ca.pem`)是否需可配置~~ → **已改为注册表 `tls_cert` 值优先、文件回退**(见 §6.1/§9.3,2026-10-14)。 +3. 是否同步把「TLS 内去掉 XOR」列为后续精简项(当前保留 XOR,换取零协议改动)。 +4. Linux / macOS / Android 客户端的 TLS 接入排期。 diff --git a/server/go/.vscode/launch.json b/server/go/.vscode/launch.json index 5af3e26..bb490fe 100644 --- a/server/go/.vscode/launch.json +++ b/server/go/.vscode/launch.json @@ -13,7 +13,9 @@ "--http-port=8080" ], "env": { - "YAMA_WEB_ADMIN_PASS": "3.14159" + "YAMA_WEB_ADMIN_PASS": "3.14159", + "YAMA_TLS_CERT": "${workspaceFolder}/certs/server.crt", + "YAMA_TLS_KEY": "${workspaceFolder}/certs/server.key" }, "console": "integratedTerminal", "preLaunchTask": "sync-web-assets" @@ -30,7 +32,9 @@ "--http-port=8080" ], "env": { - "YAMA_WEB_ADMIN_PASS": "3.14159" + "YAMA_WEB_ADMIN_PASS": "3.14159", + "YAMA_TLS_CERT": "${workspaceFolder}/certs/server.crt", + "YAMA_TLS_KEY": "${workspaceFolder}/certs/server.key" }, "console": "integratedTerminal", "buildFlags": "-gcflags='all=-N -l'", diff --git a/server/go/certs/.gitignore b/server/go/certs/.gitignore new file mode 100644 index 0000000..b8d37b9 --- /dev/null +++ b/server/go/certs/.gitignore @@ -0,0 +1,4 @@ +# 私钥与中间产物不入库;.crt 为公钥证书,可提交 +*.key +*.csr +*.srl diff --git a/server/go/certs/server.crt b/server/go/certs/server.crt new file mode 100644 index 0000000..e0a66e8 --- /dev/null +++ b/server/go/certs/server.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDLDCCAhSgAwIBAgIUPkDGqH/CbV5Qgy3mFPOJ4VwdZq0wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAxNDEzMDMwMVoXDTM2MTAx +MTEzMDMwMVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAh+nNFRRn9pK94OfgUv7hqVPI774HyUoMEzgiAx/XB3Mf +UdEkJExvtMnHGT4FSlQ80oi1cIUuh/9svYCyPVwGuY1Bd5Jt8me9pN9CY7kJmFl+ +MlBv8mz34e5gNwrybd2orn/2RATmVPCgQ82nXRmY8vFqs6KA/Eqh5Sd8S+b2rUs7 +wzlg80lDQtH/7Bf7egsQ+cYS3Xeex6H82Q9AwVpvljjrst7HI+kxKEifHRsE/amW +Bx88evqx8keE0AQ30gyXsbIRgID+LF8jcFf3WzMx+cqYStn9JJG+SlakaIlKw2I6 +FCES/UTSYoFsk06EaOy+od2+wo6jpfkJCGdEItlJlwIDAQABo3YwdDAgBgNVHREE +GTAXgglsb2NhbGhvc3SHBH8AAAGHBAAAAAAwDAYDVR0TAQH/BAIwADAOBgNVHQ8B +Af8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFBoxa/G+knDe +DBs5vl+Nrdxy0uJpMA0GCSqGSIb3DQEBCwUAA4IBAQCBE/Xj13AzPhxZacbdAvpn +xNxfF+3wJs9j0qo8w2LcB+x10pe8/ay2Gs0ThZc63o/LqDxlS6LrRvkjRlypM+Ls +gahajW0hJs+sXfa+EhcJx6c1IR/uolSpRYuel/Ui6Xvhsr11BTpsgrHZdcYlKZqf +75mX0byKRoPVkG/dIVPWpnoFkm8BLJdlZ0OpgGL9iyBVqgH0rw5hejnDZVVH2K4g +0ZeE9QUbNl0oS01bpBJfCJfZT70+38yU61TTegpZEzCTBTMKccNU3hKnX7n9C7ZM +JMb0lEZ/IuCaOxmpz5zmHPa4bibkAPSbpozOKP3S48zQPDEa58CvSUlVIrmo7+d+ +-----END CERTIFICATE----- diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index 9f541a1..af6a717 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -920,6 +920,19 @@ func main() { } }() + // Optional one-way TLS on the device port. Both vars must be set to enable; + // a cert without its key (or vice versa) is treated as disabled with a + // warning. The cert is loaded (and errors surfaced) inside Server.Start(). + tlsCert := os.Getenv("YAMA_TLS_CERT") + tlsKey := os.Getenv("YAMA_TLS_KEY") + if tlsCert != "" && tlsKey != "" { + log.Info("TLS enabled for device port (one-way): cert=%s", tlsCert) + } else if tlsCert != "" || tlsKey != "" { + log.Warn("TLS disabled: both YAMA_TLS_CERT and YAMA_TLS_KEY must be set") + } else { + log.Info("TLS disabled (YAMA_TLS_CERT/YAMA_TLS_KEY not set) — device port is plaintext") + } + // Create servers for each port var servers []*server.Server for _, port := range ports { @@ -928,6 +941,8 @@ func main() { config.MaxConnections = 9999 config.PerIPMaxConnections = envInt("YAMA_PERIP_MAX_CONNS", config.PerIPMaxConnections) config.PerIPConnRateLimit = envInt("YAMA_PERIP_CONN_RATE", config.PerIPConnRateLimit) + config.TLSCertFile = tlsCert + config.TLSKeyFile = tlsKey srv := server.New(config) srv.SetLogger(log.WithPrefix(fmt.Sprintf("Server:%d", port))) diff --git a/server/go/server/server.go b/server/go/server/server.go index e71f6a6..6b51696 100644 --- a/server/go/server/server.go +++ b/server/go/server/server.go @@ -2,6 +2,7 @@ package server import ( "context" + "crypto/tls" "io" "net" "sync" @@ -40,6 +41,13 @@ type Config struct { PerIPMaxConnections int // max concurrent connections per source IP (0 = unlimited) PerIPConnRateLimit int // max new connections per PerIPConnRateWindow per IP (0 = unlimited) PerIPConnRateWindow time.Duration // window for PerIPConnRateLimit + + // TLSCertFile / TLSKeyFile enable optional one-way TLS on the device port. + // When both are set the server loads the cert and auto-detects TLS clients + // by their ClientHello; plaintext clients keep working unchanged. Both empty + // (the default) disables TLS and behaves exactly as before. + TLSCertFile string // server certificate (PEM) + TLSKeyFile string // server private key (PEM) } // DefaultConfig returns default configuration @@ -75,6 +83,10 @@ type Server struct { parser *protocol.Parser ipLimit *ipLimiter + // tlsCfg is non-nil when one-way TLS is enabled (cert + key configured). + // wrapTLS consults it to sniff and wrap incoming connections. + tlsCfg *tls.Config + running atomic.Bool wg sync.WaitGroup ctx context.Context @@ -234,6 +246,12 @@ func (s *Server) SetLogger(l *logger.Logger) { // Start starts the server func (s *Server) Start() error { + // Load optional one-way TLS cert up front so a bad path/key fails startup + // loudly rather than silently serving plaintext. + if err := s.loadTLSConfig(); err != nil { + return err + } + addr := net.JoinHostPort("0.0.0.0", itoa(s.config.Port)) listener, err := net.Listen("tcp", addr) if err != nil { @@ -360,7 +378,10 @@ func (s *Server) acceptLoop() { } // handleConnection handles a single connection -func (s *Server) handleConnection(conn net.Conn) { +func (s *Server) handleConnection(rawConn net.Conn) { + // Sniff and (when TLS is enabled) wrap TLS peers; plaintext passes through. + conn := s.wrapTLS(rawConn) + // Create context ctx := connection.NewContext(conn, nil) diff --git a/server/go/server/tls.go b/server/go/server/tls.go new file mode 100644 index 0000000..4a9ee13 --- /dev/null +++ b/server/go/server/tls.go @@ -0,0 +1,113 @@ +package server + +import ( + "bufio" + "crypto/tls" + "fmt" + "net" + "time" +) + +// loadTLSConfig builds the server's one-way TLS configuration from the +// configured cert/key paths, leaving s.tlsCfg nil when TLS is disabled. A cert +// path set without its key (or vice versa) is a misconfiguration: TLS is left +// disabled and a warning is logged rather than guessing. A load failure is +// returned to the caller so startup fails loudly instead of silently serving +// plaintext. +func (s *Server) loadTLSConfig() error { + if s.config.TLSCertFile == "" && s.config.TLSKeyFile == "" { + return nil + } + if s.config.TLSCertFile == "" || s.config.TLSKeyFile == "" { + s.logger.Warn("TLS disabled: both YAMA_TLS_CERT and YAMA_TLS_KEY must be set (cert=%q key=%q)", + s.config.TLSCertFile, s.config.TLSKeyFile) + return nil + } + + cert, err := tls.LoadX509KeyPair(s.config.TLSCertFile, s.config.TLSKeyFile) + if err != nil { + return fmt.Errorf("load TLS cert/key: %w", err) + } + + s.tlsCfg = &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + // One-way TLS: no ClientAuth / ClientCAs. Client identity is still + // established by the in-tunnel login (V1 PwdHmac / V2 ECDSA). + } + s.logger.Info("TLS enabled (one-way): cert=%s", s.config.TLSCertFile) + return nil +} + +// isTLSClientHello reports whether b begins with a TLS handshake record whose +// first message is a ClientHello: +// +// 0x16 — handshake record type +// 0x03 — TLS major version (all TLS 1.x / 1.3 records) +// 0x01 (b[5])— handshake message type ClientHello +// +// The first two bytes alone are insufficient: the C++ client obfuscates its +// "HELL" header (common/header.h GetHead) with a per-connection key, so a +// plaintext client can in principle emit 0x16 0x03 as its first two bytes. For +// that obfuscated header byte 5 is always 0x00, and for the plain "Shine"/ +// "POST" variants it is an ASCII byte — never 0x01. Requiring b[5]==0x01 makes +// a false positive effectively impossible. +func isTLSClientHello(b []byte) bool { + return len(b) >= 6 && b[0] == 0x16 && b[1] == 0x03 && b[5] == 0x01 +} + +// bufferedConn replays bytes already peeked from a net.Conn, so the caller can +// sniff a prefix and then hand the connection (peeked bytes included) to the +// TLS or plaintext read loop. Read delegates to the buffered reader; everything +// else delegates to the embedded net.Conn. +type bufferedConn struct { + net.Conn + r *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { + return c.r.Read(p) +} + +// wrapTLS sniffs the first bytes of a freshly accepted connection and, when TLS +// is enabled and the peer is speaking TLS, returns a tls.Conn wrapper. The TLS +// handshake is lazy: it runs on the first Read, which handleConnection already +// bounds with its ReadTimeout. Plaintext peers are returned unchanged (wrapped +// in a bufferedConn so the sniffed prefix is replayed). +func (s *Server) wrapTLS(conn net.Conn) net.Conn { + // NewContext only disables Nagle on a *net.TCPConn; once we wrap the conn + // (bufferedConn / tls.Conn) that assertion no longer fires, so disable it + // here on the raw conn to preserve screen-handshake responsiveness. + if tcp, ok := conn.(*net.TCPConn); ok { + _ = tcp.SetNoDelay(true) + } + + if s.tlsCfg == nil { + return conn + } + + br := bufio.NewReader(conn) + if s.config.ReadTimeout > 0 { + _ = conn.SetReadDeadline(time.Now().Add(s.config.ReadTimeout)) + } + hdr, err := br.Peek(6) + _ = conn.SetReadDeadline(time.Time{}) + + // Peek failed (timeout / EOF): the peer never delivered enough bytes to + // identify itself. Fall through with the raw conn so the read loop surfaces + // EOF/timeout exactly as it does for the plaintext path today. Any <6 + // partial bytes already buffered are discarded, which only affects peers + // that were already broken (real clients send ≥13 bytes immediately). + if err != nil { + s.logger.Warn("wrapTLS peek failed for %s: %v", conn.RemoteAddr(), err) + return conn + } + + bc := &bufferedConn{Conn: conn, r: br} + if isTLSClientHello(hdr) { + s.logger.Info("wrapTLS: TLS peer %s first6=%x", conn.RemoteAddr(), hdr) + return tls.Server(bc, s.tlsCfg) + } + s.logger.Info("wrapTLS: plaintext peer %s first6=%x", conn.RemoteAddr(), hdr) + return bc +} diff --git a/server/go/server/tls_test.go b/server/go/server/tls_test.go new file mode 100644 index 0000000..8a4ae2a --- /dev/null +++ b/server/go/server/tls_test.go @@ -0,0 +1,55 @@ +package server + +import "testing" + +func TestIsTLSClientHello(t *testing.T) { + tests := []struct { + name string + b []byte + want bool + }{ + { + name: "TLS 1.2 ClientHello", + b: []byte{0x16, 0x03, 0x03, 0x00, 0x01, 0x01, 0x00}, + want: true, + }, + { + name: "TLS 1.3 ClientHello (legacy record version)", + b: []byte{0x16, 0x03, 0x01, 0x00, 0x01, 0x01, 0x00}, + want: true, + }, + { + name: "plaintext Shine flag", + b: []byte{'S', 'h', 'i', 'n', 'e', 0x00, 0x00}, + want: false, + }, + { + name: "obfuscated HELL header that happens to start 0x16 0x03 but byte5=0x00", + b: []byte{0x16, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, + want: false, + }, + { + name: "HTTP-mask POST envelope", + b: []byte{'P', 'O', 'S', 'T', ' ', '/', 'x'}, + want: false, + }, + { + name: "0x16 0x03 but handshake type ServerHello (0x02), not ClientHello", + b: []byte{0x16, 0x03, 0x03, 0x00, 0x01, 0x02, 0x00}, + want: false, + }, + { + name: "too short", + b: []byte{0x16, 0x03, 0x01}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isTLSClientHello(tt.b); got != tt.want { + t.Errorf("isTLSClientHello(% x) = %v, want %v", tt.b, got, tt.want) + } + }) + } +}