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
This commit is contained in:
1 parent
4ff965a2b4
commit
7651ee8602
3 files changed
+209
-6
No files matched your search
+90
-5
@@ -2,7 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -32,6 +35,7 @@ type MyHandler struct {
|
|||||||
hub *hub.Hub
|
hub *hub.Hub
|
||||||
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
|
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
|
||||||
loginLimit *wsauth.RateLimiter // per-IP TOKEN_AUTH throttle
|
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
|
// 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
|
// on our 256-byte ConnAuthAck. Without an OK reply the client closes the
|
||||||
// connection, so a missing ack here means nothing else can proceed.
|
// connection, so a missing ack here means nothing else can proceed.
|
||||||
//
|
//
|
||||||
// The handshake includes an HMAC signature field. The reference server
|
// When the operator supplied YAMA_CONN_AUTH_KEY, the packet's HMAC-SHA256
|
||||||
// treats verification failures as soft (logs and still allows commands),
|
// signature and timestamp are verified first (see verifyConnAuth); a bad
|
||||||
// and the signing primitive lives in a vendored component out of scope
|
// packet is answered with a non-OK status and the sub-conn is dropped.
|
||||||
// for this server, so we always reply OK and let TOKEN_BITMAPINFO carry
|
// Without a key we keep the legacy behavior: always reply OK and let
|
||||||
// the device ID via offset 41 when the screen sub-conn proceeds.
|
// TOKEN_BITMAPINFO carry the device ID via offset 41 when the screen
|
||||||
|
// sub-conn proceeds.
|
||||||
func (h *MyHandler) handleConnAuth(ctx *connection.Context, data []byte) {
|
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,
|
// Pin the parent device's ClientID onto the sub-conn. Without this,
|
||||||
// later 1-byte tokens (TOKEN_TERMINAL_START / TOKEN_SHELL_START) have
|
// later 1-byte tokens (TOKEN_TERMINAL_START / TOKEN_SHELL_START) have
|
||||||
// no way to identify which device they belong to — they carry no
|
// 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
|
// deviceIDOfSubConn resolves the parent device of a sub-conn from the
|
||||||
// ClientID pinned by handleConnAuth. Returns "" for the rare case of a
|
// ClientID pinned by handleConnAuth. Returns "" for the rare case of a
|
||||||
// legacy client that skipped ConnAuth (the Go server's only target is
|
// 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")
|
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
|
// Create servers for each port
|
||||||
var servers []*server.Server
|
var servers []*server.Server
|
||||||
for _, port := range ports {
|
for _, port := range ports {
|
||||||
@@ -955,6 +1039,7 @@ func main() {
|
|||||||
hub: deviceHub,
|
hub: deviceHub,
|
||||||
signer: signer,
|
signer: signer,
|
||||||
loginLimit: authLoginLimit,
|
loginLimit: authLoginLimit,
|
||||||
|
connAuthKey: connAuthKey,
|
||||||
}
|
}
|
||||||
srv.SetHandler(handler)
|
srv.SetHandler(handler)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -140,11 +140,26 @@ const (
|
|||||||
ConnAuthOffClientID = 1 // uint64 LE — pin to the sub-conn so later
|
ConnAuthOffClientID = 1 // uint64 LE — pin to the sub-conn so later
|
||||||
// // 1-byte tokens (TOKEN_TERMINAL_START etc.) can
|
// // 1-byte tokens (TOKEN_TERMINAL_START etc.) can
|
||||||
// // resolve the parent device.
|
// // 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.
|
// ConnAuthAck field offsets within the outbound 256-byte buffer.
|
||||||
ConnAuthAckOffStatus = 1 // uint8
|
ConnAuthAckOffStatus = 1 // uint8
|
||||||
ConnAuthAckOffServerTime = 2 // uint64 LE
|
ConnAuthAckOffServerTime = 2 // uint64 LE
|
||||||
// Status codes.
|
// 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
|
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
|
// CMD_MASTERSETTING is the server's reply to a fresh client login. The
|
||||||
|
|||||||
Reference in new issue
Block a user