Feat: One-way TLS transport for the device port

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\<exehash>\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
This commit is contained in:
yuanyuanxianganddeepseek-v4-pro committed 2026-10-14 21:10:21 +02:00
1 parent 8d4c118ae6
commit ca0d7626d2
16 files changed
+1418 -5

No files matched your search

+6 -2
View File
@@ -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'",
+4
View File
@@ -0,0 +1,4 @@
# 私钥与中间产物不入库;.crt 为公钥证书,可提交
*.key
*.csr
*.srl
+19
View File
@@ -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-----
+15
View File
@@ -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)))
+22 -1
View File
@@ -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)
+113
View File
@@ -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
}
+55
View File
@@ -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)
}
})
}
}