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:
1 parent
8d4c118ae6
commit
ca0d7626d2
16 files changed
+1418
-5
No files matched your search
Vendored
+6
-2
@@ -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'",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# 私钥与中间产物不入库;.crt 为公钥证书,可提交
|
||||
*.key
|
||||
*.csr
|
||||
*.srl
|
||||
@@ -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-----
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user