From 7651ee86020926f5fe9a77c3566799bb0d1a9ce2 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Thu, 15 Oct 2026 18:50:19 +0200 Subject: [PATCH] Feat: ConnAuth HMAC verification gated by YAMA_CONN_AUTH_KEY Verify the HMAC-SHA256 signature and timestamp of device sub-connections (screen/terminal/file) when the YAMA_CONN_AUTH_KEY environment variable is set. With the key set, a sub-connection whose signature or clock skew fails is answered with a non-OK ConnAuth status and dropped; without the key the legacy always-OK behavior is preserved. The key is never compiled in: it is read from the environment at startup, so no secret enters the repository. Source-IP binding in hub.BindScreenConn remains in place as defense-in-depth. Co-Authored-By: deepseek-v4-pro --- server/go/cmd/main.go | 119 +++++++++++++++++++++---- server/go/cmd/verify_conn_auth_test.go | 103 +++++++++++++++++++++ server/go/protocol/commands.go | 19 +++- 3 files changed, 222 insertions(+), 19 deletions(-) create mode 100644 server/go/cmd/verify_conn_auth_test.go diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index 0a55ccd..4a60192 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -2,7 +2,10 @@ package main import ( "context" + "crypto/hmac" + "crypto/sha256" "encoding/binary" + "encoding/hex" "flag" "fmt" "net/http" @@ -26,12 +29,13 @@ import ( // MyHandler implements the server.Handler interface type MyHandler struct { - log *logger.Logger - auth *auth.Authenticator - srv *server.Server - hub *hub.Hub - signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop) - loginLimit *wsauth.RateLimiter // per-IP TOKEN_AUTH throttle + log *logger.Logger + auth *auth.Authenticator + srv *server.Server + hub *hub.Hub + signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop) + loginLimit *wsauth.RateLimiter // per-IP TOKEN_AUTH throttle + connAuthKey []byte // nil = ConnAuth HMAC verification disabled (legacy always-OK) } // OnConnect is called when a client connects @@ -154,12 +158,25 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) { // on our 256-byte ConnAuthAck. Without an OK reply the client closes the // connection, so a missing ack here means nothing else can proceed. // -// The handshake includes an HMAC signature field. The reference server -// treats verification failures as soft (logs and still allows commands), -// and the signing primitive lives in a vendored component out of scope -// for this server, so we always reply OK and let TOKEN_BITMAPINFO carry -// the device ID via offset 41 when the screen sub-conn proceeds. +// When the operator supplied YAMA_CONN_AUTH_KEY, the packet's HMAC-SHA256 +// signature and timestamp are verified first (see verifyConnAuth); a bad +// packet is answered with a non-OK status and the sub-conn is dropped. +// Without a key we keep the legacy behavior: always reply OK and let +// TOKEN_BITMAPINFO carry the device ID via offset 41 when the screen +// sub-conn proceeds. func (h *MyHandler) handleConnAuth(ctx *connection.Context, data []byte) { + // When the operator supplied a ConnAuth HMAC key (YAMA_CONN_AUTH_KEY), + // verify the packet's signature and timestamp before trusting its + // clientID. This closes the sub-connection hijack hole that the + // source-IP check in hub.BindScreenConn otherwise alone guards against. + // A nil key preserves the legacy always-OK behavior. + if len(h.connAuthKey) > 0 { + if status := h.verifyConnAuth(data); status != protocol.ConnAuthStatusOK { + h.rejectConnAuth(ctx, status) + return + } + } + // Pin the parent device's ClientID onto the sub-conn. Without this, // later 1-byte tokens (TOKEN_TERMINAL_START / TOKEN_SHELL_START) have // no way to identify which device they belong to — they carry no @@ -188,6 +205,61 @@ func (h *MyHandler) handleConnAuth(ctx *connection.Context, data []byte) { } } +// verifyConnAuth checks the ConnAuthPacket's HMAC-SHA256 signature and its +// timestamp against the configured shared key. Returns a ConnAuthStatus code +// (OK on success). +func (h *MyHandler) verifyConnAuth(data []byte) byte { + // Need the full [token][clientID][timestamp][nonce][signature] prefix. + if len(data) < protocol.ConnAuthOffSignature+protocol.ConnAuthSignatureSize { + return protocol.ConnAuthStatusBadSize + } + + // Anti-replay: the client signs its own Unix timestamp, so an old (but + // valid) packet cannot be replayed once it leaves the tolerance window. + ts := binary.LittleEndian.Uint64( + data[protocol.ConnAuthOffTimestamp : protocol.ConnAuthOffTimestamp+8]) + now := uint64(time.Now().Unix()) + var skew uint64 + if now >= ts { + skew = now - ts + } else { + skew = ts - now + } + if skew > protocol.ConnAuthTimestampToleranceSec { + return protocol.ConnAuthStatusClockSkew + } + + // HMAC-SHA256 over clientID||timestamp||nonce (offsets 1..33), matching + // the client's signMessage("", sigInput, 32). The client stores the hex + // encoding of the 32-byte digest in the 64-byte signature field. + mac := hmac.New(sha256.New, h.connAuthKey) + mac.Write(data[protocol.ConnAuthOffClientID:protocol.ConnAuthOffSignature]) + want := make([]byte, protocol.ConnAuthSignatureSize) + hex.Encode(want, mac.Sum(nil)) + if !hmac.Equal(want, data[protocol.ConnAuthOffSignature:protocol.ConnAuthOffSignature+protocol.ConnAuthSignatureSize]) { + return protocol.ConnAuthStatusBadSignature + } + return protocol.ConnAuthStatusOK +} + +// rejectConnAuth replies with a non-OK ConnAuth status and closes the +// sub-connection. The client (IOCPClient::TryHandleAuthResponse) treats any +// non-zero status as failure and disconnects, so the close here is +// belt-and-suspenders for peers that don't. +func (h *MyHandler) rejectConnAuth(ctx *connection.Context, status byte) { + ack := make([]byte, protocol.ConnAuthAckSize) + ack[0] = protocol.TokenConnAuth + ack[protocol.ConnAuthAckOffStatus] = status + binary.LittleEndian.PutUint64( + ack[protocol.ConnAuthAckOffServerTime:protocol.ConnAuthAckOffServerTime+8], + uint64(time.Now().Unix())) + if err := h.srv.Send(ctx, ack); err != nil { + h.log.Error("ConnAuth reject send failed for conn=%d: %v", ctx.ID, err) + } + h.log.Warn("ConnAuth rejected conn=%d ip=%s status=%d", ctx.ID, ctx.GetPeerIP(), status) + _ = ctx.Close() +} + // deviceIDOfSubConn resolves the parent device of a sub-conn from the // ClientID pinned by handleConnAuth. Returns "" for the rare case of a // legacy client that skipped ConnAuth (the Go server's only target is @@ -933,6 +1005,18 @@ func main() { log.Info("TLS disabled (YAMA_TLS_CERT/YAMA_TLS_KEY not set) — device port is plaintext") } + // Optional ConnAuth HMAC verification for sub-connections (screen/terminal/ + // file). The shared key must match what the client signs with — either the + // client's built-in HMAC key (see client license.cpp / license_unix.cpp) or + // a custom key once the client is taught to read one. Unset means legacy + // behavior: accept any sub-connection and rely on the source-IP check in + // hub.BindScreenConn alone. + var connAuthKey []byte + if v := strings.TrimSpace(os.Getenv("YAMA_CONN_AUTH_KEY")); v != "" { + connAuthKey = []byte(v) + log.Info("ConnAuth HMAC verification enabled for device sub-connections (YAMA_CONN_AUTH_KEY)") + } + // Create servers for each port var servers []*server.Server for _, port := range ports { @@ -949,12 +1033,13 @@ func main() { // Create handler for this server handler := &MyHandler{ - log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)), - auth: authenticator, - srv: srv, - hub: deviceHub, - signer: signer, - loginLimit: authLoginLimit, + log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)), + auth: authenticator, + srv: srv, + hub: deviceHub, + signer: signer, + loginLimit: authLoginLimit, + connAuthKey: connAuthKey, } srv.SetHandler(handler) diff --git a/server/go/cmd/verify_conn_auth_test.go b/server/go/cmd/verify_conn_auth_test.go new file mode 100644 index 0000000..30a2c95 --- /dev/null +++ b/server/go/cmd/verify_conn_auth_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "testing" + "time" + + "github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol" +) + +// connAuthTestKey is an arbitrary non-secret key used only to exercise the +// verifyConnAuth code path in tests. The production key is never compiled in: +// it is read from the YAMA_CONN_AUTH_KEY environment variable at startup. +const connAuthTestKey = "test-conn-auth-key" + +// signConnAuth reproduces the client's ConnAuth signature: hex(HMAC-SHA256(key, +// clientID||timestamp||nonce)) as 64 lowercase hex chars. Mirrors +// client/IOCPClient.cpp PerformConnAuth + license.cpp signMessage. +func signConnAuth(key string, clientID, ts uint64, nonce [16]byte) string { + mac := hmac.New(sha256.New, []byte(key)) + var msg [32]byte + binary.LittleEndian.PutUint64(msg[0:8], clientID) + binary.LittleEndian.PutUint64(msg[8:16], ts) + copy(msg[16:32], nonce[:]) + mac.Write(msg[:]) + return hex.EncodeToString(mac.Sum(nil)) +} + +func testNonce() [16]byte { + var n [16]byte + for i := range n { + n[i] = byte(i) + } + return n +} + +// TestConnAuthHmacAlgorithmVector pins the signing algorithm and byte layout +// (HMAC-SHA256 over clientID||timestamp||nonce, lowercase hex) against a vector +// computed independently with Python's hmac/hashlib (not Go's crypto). The key +// value is irrelevant here — what matters is that the server reproduces the +// exact signing the client performs. The real key is supplied at runtime via +// YAMA_CONN_AUTH_KEY, never compiled in. +func TestConnAuthHmacAlgorithmVector(t *testing.T) { + const want = "e59bf1259c77e065afa604cb5b1794b0c8c03fbb7e3473f5c3c34c2b94a90406" + if got := signConnAuth(connAuthTestKey, 0x1122334455667788, 1700000000, testNonce()); got != want { + t.Fatalf("HMAC mismatch:\n got %s\n want %s", got, want) + } +} + +func TestVerifyConnAuth(t *testing.T) { + h := &MyHandler{connAuthKey: []byte(connAuthTestKey)} + + build := func(ts uint64, sig []byte) []byte { + pkt := make([]byte, protocol.ConnAuthPacketSize) + pkt[0] = protocol.TokenConnAuth + binary.LittleEndian.PutUint64(pkt[protocol.ConnAuthOffClientID:], 0x1122334455667788) + binary.LittleEndian.PutUint64(pkt[protocol.ConnAuthOffTimestamp:], ts) + for i := range 16 { + pkt[protocol.ConnAuthOffNonce+i] = byte(i) + } + copy(pkt[protocol.ConnAuthOffSignature:], sig) + return pkt + } + + nonce := testNonce() + + t.Run("ok", func(t *testing.T) { + ts := uint64(time.Now().Unix()) + sig := signConnAuth(connAuthTestKey, 0x1122334455667788, ts, nonce) + if got := h.verifyConnAuth(build(ts, []byte(sig))); got != protocol.ConnAuthStatusOK { + t.Fatalf("expected OK, got status %d", got) + } + }) + + t.Run("bad_signature", func(t *testing.T) { + ts := uint64(time.Now().Unix()) + sig := signConnAuth(connAuthTestKey, 0x1122334455667788, ts, nonce) + pkt := build(ts, []byte(sig)) + pkt[protocol.ConnAuthOffSignature] ^= 0xff + if got := h.verifyConnAuth(pkt); got != protocol.ConnAuthStatusBadSignature { + t.Fatalf("expected BadSignature, got %d", got) + } + }) + + t.Run("clock_skew", func(t *testing.T) { + // Just outside the 300 s tolerance, with a signature valid for that + // timestamp, so the skew check fires before the signature check. + ts := uint64(time.Now().Unix()) - protocol.ConnAuthTimestampToleranceSec - 1 + sig := signConnAuth(connAuthTestKey, 0x1122334455667788, ts, nonce) + if got := h.verifyConnAuth(build(ts, []byte(sig))); got != protocol.ConnAuthStatusClockSkew { + t.Fatalf("expected ClockSkew, got %d", got) + } + }) + + t.Run("bad_size", func(t *testing.T) { + if got := h.verifyConnAuth([]byte{protocol.TokenConnAuth}); got != protocol.ConnAuthStatusBadSize { + t.Fatalf("expected BadSize, got %d", got) + } + }) +} diff --git a/server/go/protocol/commands.go b/server/go/protocol/commands.go index 874a680..f5cd62a 100644 --- a/server/go/protocol/commands.go +++ b/server/go/protocol/commands.go @@ -140,11 +140,26 @@ const ( ConnAuthOffClientID = 1 // uint64 LE — pin to the sub-conn so later // // 1-byte tokens (TOKEN_TERMINAL_START etc.) can // // resolve the parent device. + ConnAuthOffTimestamp = 9 // uint64 LE - Unix seconds, anti-replay window + ConnAuthOffNonce = 17 // [16]byte - part of the signed message + ConnAuthOffSignature = 33 // [64]byte - hex of HMAC-SHA256(key, clientID||timestamp||nonce) + ConnAuthSignatureSize = 64 + // ConnAuthAck field offsets within the outbound 256-byte buffer. ConnAuthAckOffStatus = 1 // uint8 ConnAuthAckOffServerTime = 2 // uint64 LE - // Status codes. - ConnAuthStatusOK byte = 0 + // ConnAuthTimestampToleranceSec bounds |server - client| clock skew in + // seconds (matches CONN_AUTH_TIMESTAMP_TOLERANCE_SEC in common/commands.h). + // A timestamp outside this window is treated as replayed or forged. + ConnAuthTimestampToleranceSec = 300 + + // Status codes (match common/commands.h::ConnAuthStatus). The client treats + // any non-zero status as failure and drops the sub-connection. + ConnAuthStatusOK byte = 0 + ConnAuthStatusBadSize byte = 1 // packet shorter than the signature field + ConnAuthStatusClockSkew byte = 2 // timestamp outside tolerance + ConnAuthStatusBadSignature byte = 3 // HMAC mismatch + ConnAuthStatusInternalError byte = 4 ) // CMD_MASTERSETTING is the server's reply to a fresh client login. The