From 69f6c75dd78938d3f3a524fa17b8e72ee9b103b4 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Thu, 26 Feb 2026 18:03:59 +0300 Subject: [PATCH] Add vless encryption --- common/vision/hook.go | 25 +++ common/xray/cpuid/cpuid.go | 18 ++ go.mod | 6 +- go.sum | 4 +- option/vless.go | 10 +- protocol/vless/encryption/client.go | 214 ++++++++++++++++++ protocol/vless/encryption/common.go | 297 ++++++++++++++++++++++++ protocol/vless/encryption/server.go | 336 ++++++++++++++++++++++++++++ protocol/vless/encryption/xor.go | 101 +++++++++ protocol/vless/inbound.go | 142 +++++++++++- protocol/vless/outbound.go | 257 ++++++++++++++++++++- 11 files changed, 1391 insertions(+), 19 deletions(-) create mode 100644 common/vision/hook.go create mode 100644 common/xray/cpuid/cpuid.go create mode 100644 protocol/vless/encryption/client.go create mode 100644 protocol/vless/encryption/common.go create mode 100644 protocol/vless/encryption/server.go create mode 100644 protocol/vless/encryption/xor.go diff --git a/common/vision/hook.go b/common/vision/hook.go new file mode 100644 index 00000000..8a63cb7f --- /dev/null +++ b/common/vision/hook.go @@ -0,0 +1,25 @@ +package vision + +import ( + "context" + "net" +) + +type Hook func(net.Conn) + +type hookKey struct{} + +func WithHook(ctx context.Context, hook Hook) context.Context { + if hook == nil { + return ctx + } + return context.WithValue(ctx, hookKey{}, hook) +} + +func HookFromContext(ctx context.Context) (Hook, bool) { + if ctx == nil { + return nil, false + } + hook, ok := ctx.Value(hookKey{}).(Hook) + return hook, ok +} diff --git a/common/xray/cpuid/cpuid.go b/common/xray/cpuid/cpuid.go new file mode 100644 index 00000000..60938fe3 --- /dev/null +++ b/common/xray/cpuid/cpuid.go @@ -0,0 +1,18 @@ +package cpuid + +import ( + "runtime" + + "golang.org/x/sys/cpu" +) + +var ( + // Keep in sync with crypto/tls/cipher_suites.go. + hasGCMAsmAMD64 = cpu.X86.HasAES && cpu.X86.HasPCLMULQDQ + hasGCMAsmARM64 = cpu.ARM64.HasAES && cpu.ARM64.HasPMULL + hasGCMAsmS390X = cpu.S390X.HasAES && cpu.S390X.HasAESCBC && cpu.S390X.HasGHASH + hasGCMAsmPPC64 = runtime.GOARCH == "ppc64" || runtime.GOARCH == "ppc64le" + + // HasAESGCM indicates whether the CPU has AES-GCM hardware acceleration. + HasAESGCM = hasGCMAsmAMD64 || hasGCMAsmARM64 || hasGCMAsmS390X || hasGCMAsmPPC64 +) diff --git a/go.mod b/go.mod index b59e5e02..8c44917f 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 github.com/gofrs/uuid/v5 v5.3.2 github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f + github.com/klauspost/cpuid/v2 v2.2.10 github.com/libdns/alidns v1.0.5-libdns.v1.beta1 github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 github.com/logrusorgru/aurora v2.0.3+incompatible @@ -57,6 +58,7 @@ require ( google.golang.org/grpc v1.73.0 google.golang.org/protobuf v1.36.6 howett.net/plist v1.0.1 + lukechampine.com/blake3 v1.4.1 ) require ( @@ -107,7 +109,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect github.com/klauspost/compress v1.17.11 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect github.com/libdns/libdns v1.1.0 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect @@ -145,7 +146,6 @@ require ( golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - lukechampine.com/blake3 v1.4.1 // indirect ) replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.2.0 @@ -155,3 +155,5 @@ replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.80.3-s replace github.com/sagernet/sing-dns => github.com/shtorm-7/sing-dns v0.4.6-extended-1.0.0 replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 + +replace github.com/sagernet/sing-vmess => github.com/starifly/sing-vmess v0.2.7-mod.9 diff --git a/go.sum b/go.sum index d5ff1912..43fc6d85 100644 --- a/go.sum +++ b/go.sum @@ -185,8 +185,6 @@ github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75 github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-tun v0.7.11 h1:qB7jy8JKqXg73fYBsDkBSy4ulRSbLrFut0e+y+QPhqU= github.com/sagernet/sing-tun v0.7.11/go.mod h1:pUEjh9YHQ2gJT6Lk0TYDklh3WJy7lz+848vleGM3JPM= -github.com/sagernet/sing-vmess v0.2.7 h1:2ee+9kO0xW5P4mfe6TYVWf9VtY8k1JhNysBqsiYj0sk= -github.com/sagernet/sing-vmess v0.2.7/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= @@ -201,6 +199,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/starifly/sing-vmess v0.2.7-mod.9 h1:xobAmejSbBQ0A3f/EtJ9cJd3m6gK7dDPccPdeGz7tXY= +github.com/starifly/sing-vmess v0.2.7-mod.9/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= diff --git a/option/vless.go b/option/vless.go index 5acf2aee..989bcc65 100644 --- a/option/vless.go +++ b/option/vless.go @@ -2,7 +2,8 @@ package option type VLESSInboundOptions struct { ListenOptions - Users []VLESSUser `json:"users,omitempty"` + Users []VLESSUser `json:"users,omitempty"` + Decryption string `json:"decryption,omitempty"` InboundTLSOptionsContainer Multiplex *InboundMultiplexOptions `json:"multiplex,omitempty"` Transport *V2RayTransportOptions `json:"transport,omitempty"` @@ -17,9 +18,10 @@ type VLESSUser struct { type VLESSOutboundOptions struct { DialerOptions ServerOptions - UUID string `json:"uuid"` - Flow string `json:"flow,omitempty"` - Network NetworkList `json:"network,omitempty"` + UUID string `json:"uuid"` + Flow string `json:"flow,omitempty"` + Encryption string `json:"encryption,omitempty"` + Network NetworkList `json:"network,omitempty"` OutboundTLSOptionsContainer Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"` Transport *V2RayTransportOptions `json:"transport,omitempty"` diff --git a/protocol/vless/encryption/client.go b/protocol/vless/encryption/client.go new file mode 100644 index 00000000..947b279e --- /dev/null +++ b/protocol/vless/encryption/client.go @@ -0,0 +1,214 @@ +package encryption + +import ( + "crypto/cipher" + "crypto/ecdh" + "crypto/mlkem" + "crypto/rand" + "io" + "net" + "sync" + "time" + + "github.com/sagernet/sing-box/common/xray/cpuid" + E "github.com/sagernet/sing/common/exceptions" + "lukechampine.com/blake3" +) + +type ClientInstance struct { + NfsPKeys []any + NfsPKeysBytes [][]byte + Hash32s [][32]byte + RelaysLength int + XorMode uint32 + Seconds uint32 + PaddingLens [][3]int + PaddingGaps [][3]int + + RWLock sync.RWMutex + Expire time.Time + PfsKey []byte + Ticket []byte +} + +func (i *ClientInstance) Init(nfsPKeysBytes [][]byte, xorMode, seconds uint32, padding string) (err error) { + if i.NfsPKeys != nil { + return E.New("already initialized") + } + l := len(nfsPKeysBytes) + if l == 0 { + return E.New("empty nfsPKeysBytes") + } + i.NfsPKeys = make([]any, l) + i.NfsPKeysBytes = nfsPKeysBytes + i.Hash32s = make([][32]byte, l) + for j, k := range nfsPKeysBytes { + if len(k) == 32 { + if i.NfsPKeys[j], err = ecdh.X25519().NewPublicKey(k); err != nil { + return + } + i.RelaysLength += 32 + 32 + } else { + if i.NfsPKeys[j], err = mlkem.NewEncapsulationKey768(k); err != nil { + return + } + i.RelaysLength += 1088 + 32 + } + i.Hash32s[j] = blake3.Sum256(k) + } + i.RelaysLength -= 32 + i.XorMode = xorMode + i.Seconds = seconds + return ParsePadding(padding, &i.PaddingLens, &i.PaddingGaps) +} + +func (i *ClientInstance) IsFullRandomXorMode() bool { + return i.XorMode == 2 +} + +func (i *ClientInstance) Handshake(conn net.Conn) (*CommonConn, error) { + if i.NfsPKeys == nil { + return nil, E.New("uninitialized") + } + c := NewCommonConn(conn, cpuid.HasAESGCM) + + ivAndRealysLength := 16 + i.RelaysLength + pfsKeyExchangeLength := 18 + 1184 + 32 + 16 + paddingLength, paddingLens, paddingGaps := CreatePadding(i.PaddingLens, i.PaddingGaps) + clientHello := make([]byte, ivAndRealysLength+pfsKeyExchangeLength+paddingLength) + + iv := clientHello[:16] + rand.Read(iv) + relays := clientHello[16:ivAndRealysLength] + var nfsKey []byte + var lastCTR cipher.Stream + for j, k := range i.NfsPKeys { + var index = 32 + if k, ok := k.(*ecdh.PublicKey); ok { + privateKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + copy(relays, privateKey.PublicKey().Bytes()) + var err error + nfsKey, err = privateKey.ECDH(k) + if err != nil { + return nil, err + } + } + if k, ok := k.(*mlkem.EncapsulationKey768); ok { + var ciphertext []byte + nfsKey, ciphertext = k.Encapsulate() + copy(relays, ciphertext) + index = 1088 + } + if i.XorMode > 0 { // this xor can (others can't) be recovered by client's config, revealing an X25519 public key / ML-KEM-768 ciphertext, that's why "native" values + NewCTR(i.NfsPKeysBytes[j], iv).XORKeyStream(relays, relays[:index]) // make X25519 public key / ML-KEM-768 ciphertext distinguishable from random bytes + } + if lastCTR != nil { + lastCTR.XORKeyStream(relays, relays[:32]) // make this relay irreplaceable + } + if j == len(i.NfsPKeys)-1 { + break + } + lastCTR = NewCTR(nfsKey, iv) + lastCTR.XORKeyStream(relays[index:], i.Hash32s[j+1][:]) + relays = relays[index+32:] + } + nfsAEAD := NewAEAD(iv, nfsKey, c.UseAES) + + if i.Seconds > 0 { + i.RWLock.RLock() + if time.Now().Before(i.Expire) { + c.Client = i + c.UnitedKey = append(i.PfsKey, nfsKey...) // different unitedKey for each connection + nfsAEAD.Seal(clientHello[:ivAndRealysLength], nil, EncodeLength(32), nil) + nfsAEAD.Seal(clientHello[:ivAndRealysLength+18], nil, i.Ticket, nil) + i.RWLock.RUnlock() + c.PreWrite = clientHello[:ivAndRealysLength+18+32] + c.AEAD = NewAEAD(clientHello[ivAndRealysLength+18:ivAndRealysLength+18+32], c.UnitedKey, c.UseAES) + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, iv), nil, len(c.PreWrite), 16) + } + return c, nil + } + i.RWLock.RUnlock() + } + + pfsKeyExchange := clientHello[ivAndRealysLength : ivAndRealysLength+pfsKeyExchangeLength] + nfsAEAD.Seal(pfsKeyExchange[:0], nil, EncodeLength(pfsKeyExchangeLength-18), nil) + mlkem768DKey, _ := mlkem.GenerateKey768() + x25519SKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + pfsPublicKey := append(mlkem768DKey.EncapsulationKey().Bytes(), x25519SKey.PublicKey().Bytes()...) + nfsAEAD.Seal(pfsKeyExchange[:18], nil, pfsPublicKey, nil) + + padding := clientHello[ivAndRealysLength+pfsKeyExchangeLength:] + nfsAEAD.Seal(padding[:0], nil, EncodeLength(paddingLength-18), nil) + nfsAEAD.Seal(padding[:18], nil, padding[18:paddingLength-16], nil) + + paddingLens[0] = ivAndRealysLength + pfsKeyExchangeLength + paddingLens[0] + for i, l := range paddingLens { // sends padding in a fragmented way, to create variable traffic pattern, before inner VLESS flow takes control + if l > 0 { + if _, err := conn.Write(clientHello[:l]); err != nil { + return nil, err + } + clientHello = clientHello[l:] + } + if len(paddingGaps) > i { + time.Sleep(paddingGaps[i]) + } + } + + encryptedPfsPublicKey := make([]byte, 1088+32+16) + if _, err := io.ReadFull(conn, encryptedPfsPublicKey); err != nil { + return nil, err + } + nfsAEAD.Open(encryptedPfsPublicKey[:0], MaxNonce, encryptedPfsPublicKey, nil) + mlkem768Key, err := mlkem768DKey.Decapsulate(encryptedPfsPublicKey[:1088]) + if err != nil { + return nil, err + } + peerX25519PKey, err := ecdh.X25519().NewPublicKey(encryptedPfsPublicKey[1088 : 1088+32]) + if err != nil { + return nil, err + } + x25519Key, err := x25519SKey.ECDH(peerX25519PKey) + if err != nil { + return nil, err + } + pfsKey := make([]byte, 32+32) // no more capacity + copy(pfsKey, mlkem768Key) + copy(pfsKey[32:], x25519Key) + c.UnitedKey = append(pfsKey, nfsKey...) + c.AEAD = NewAEAD(pfsPublicKey, c.UnitedKey, c.UseAES) + c.PeerAEAD = NewAEAD(encryptedPfsPublicKey[:1088+32], c.UnitedKey, c.UseAES) + + encryptedTicket := make([]byte, 32) + if _, err := io.ReadFull(conn, encryptedTicket); err != nil { + return nil, err + } + if _, err := c.PeerAEAD.Open(encryptedTicket[:0], nil, encryptedTicket, nil); err != nil { + return nil, err + } + seconds := DecodeLength(encryptedTicket) + + if i.Seconds > 0 && seconds > 0 { + i.RWLock.Lock() + i.Expire = time.Now().Add(time.Duration(seconds) * time.Second) + i.PfsKey = pfsKey + i.Ticket = encryptedTicket[:16] + i.RWLock.Unlock() + } + + encryptedLength := make([]byte, 18) + if _, err := io.ReadFull(conn, encryptedLength); err != nil { + return nil, err + } + if _, err := c.PeerAEAD.Open(encryptedLength[:0], nil, encryptedLength, nil); err != nil { + return nil, err + } + length := DecodeLength(encryptedLength[:2]) + c.PeerPadding = make([]byte, length) // important: allows server sends padding slowly, eliminating 1-RTT's traffic pattern + + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, iv), NewCTR(c.UnitedKey, encryptedTicket[:16]), 0, length) + } + return c, nil +} diff --git a/protocol/vless/encryption/common.go b/protocol/vless/encryption/common.go new file mode 100644 index 00000000..84e3818d --- /dev/null +++ b/protocol/vless/encryption/common.go @@ -0,0 +1,297 @@ +package encryption + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/common/xray/crypto" + E "github.com/sagernet/sing/common/exceptions" + "golang.org/x/crypto/chacha20poly1305" + "lukechampine.com/blake3" +) + +var OutBytesPool = sync.Pool{ + New: func() any { + return make([]byte, 5+8192+16) + }, +} + +type EncryptionConn interface { + net.Conn + IsEncryptionLayer() bool +} + +type CommonConn struct { + net.Conn + UseAES bool + Client *ClientInstance + UnitedKey []byte + PreWrite []byte + AEAD *AEAD + PeerAEAD *AEAD + PeerPadding []byte + rawInput bytes.Buffer + input bytes.Reader +} + +func NewCommonConn(conn net.Conn, useAES bool) *CommonConn { + return &CommonConn{ + Conn: conn, + UseAES: useAES, + } +} + +func (c *CommonConn) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + outBytes := OutBytesPool.Get().([]byte) + defer OutBytesPool.Put(outBytes) + for n := 0; n < len(b); { + b := b[n:] + if len(b) > 8192 { + b = b[:8192] // for avoiding another copy() in peer's Read() + } + n += len(b) + headerAndData := outBytes[:5+len(b)+16] + EncodeHeader(headerAndData, len(b)+16) + max := false + if bytes.Equal(c.AEAD.Nonce[:], MaxNonce) { + max = true + } + c.AEAD.Seal(headerAndData[:5], nil, b, headerAndData[:5]) + if max { + c.AEAD = NewAEAD(headerAndData, c.UnitedKey, c.UseAES) + } + if c.PreWrite != nil { + headerAndData = append(c.PreWrite, headerAndData...) + c.PreWrite = nil + } + if _, err := c.Conn.Write(headerAndData); err != nil { + return 0, err + } + } + return len(b), nil +} + +func (c *CommonConn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + if c.PeerAEAD == nil { // client's 0-RTT + serverRandom := make([]byte, 16) + if _, err := io.ReadFull(c.Conn, serverRandom); err != nil { + return 0, err + } + c.PeerAEAD = NewAEAD(serverRandom, c.UnitedKey, c.UseAES) + if xorConn, ok := c.Conn.(*XorConn); ok { + xorConn.PeerCTR = NewCTR(c.UnitedKey, serverRandom) + } + } + if c.PeerPadding != nil { // client's 1-RTT + if _, err := io.ReadFull(c.Conn, c.PeerPadding); err != nil { + return 0, err + } + if _, err := c.PeerAEAD.Open(c.PeerPadding[:0], nil, c.PeerPadding, nil); err != nil { + return 0, err + } + c.PeerPadding = nil + } + if c.input.Len() > 0 { + return c.input.Read(b) + } + peerHeader := [5]byte{} + if _, err := io.ReadFull(c.Conn, peerHeader[:]); err != nil { + return 0, err + } + l, err := DecodeHeader(peerHeader[:]) // l: 17~17000 + if err != nil { + if c.Client != nil && errors.Is(err, ErrInvalidHeader) { // client's 0-RTT + c.Client.RWLock.Lock() + if bytes.HasPrefix(c.UnitedKey, c.Client.PfsKey) { + c.Client.Expire = time.Now() // expired + } + c.Client.RWLock.Unlock() + return 0, E.New("new handshake needed") + } + return 0, err + } + c.Client = nil + if c.rawInput.Cap() < l { + c.rawInput.Grow(l) // no need to use sync.Pool, because we are always reading + } + peerData := c.rawInput.Bytes()[:l] + if _, err := io.ReadFull(c.Conn, peerData); err != nil { + return 0, err + } + dst := peerData[:l-16] + if len(dst) <= len(b) { + dst = b[:len(dst)] // avoids another copy() + } + var newAEAD *AEAD + if bytes.Equal(c.PeerAEAD.Nonce[:], MaxNonce) { + newAEAD = NewAEAD(append(peerHeader[:], peerData...), c.UnitedKey, c.UseAES) + } + _, err = c.PeerAEAD.Open(dst[:0], nil, peerData, peerHeader[:]) + if newAEAD != nil { + c.PeerAEAD = newAEAD + } + if err != nil { + return 0, err + } + if len(dst) > len(b) { + c.input.Reset(dst[copy(b, dst):]) + dst = b // for len(dst) + } + return len(dst), nil +} + +// Upstream returns the underlying connection, allowing Vision to unwrap and access the TLS connection +func (c *CommonConn) Upstream() any { + return c.Conn +} + +func (c *CommonConn) IsEncryptionLayer() bool { + return true +} + +type AEAD struct { + cipher.AEAD + Nonce [12]byte +} + +func NewAEAD(ctx, key []byte, useAES bool) *AEAD { + k := make([]byte, 32) + blake3.DeriveKey(k, string(ctx), key) + var aead cipher.AEAD + if useAES { + block, _ := aes.NewCipher(k) + aead, _ = cipher.NewGCM(block) + } else { + aead, _ = chacha20poly1305.New(k) + } + return &AEAD{AEAD: aead} +} + +func (a *AEAD) Seal(dst, nonce, plaintext, additionalData []byte) []byte { + if nonce == nil { + nonce = IncreaseNonce(a.Nonce[:]) + } + return a.AEAD.Seal(dst, nonce, plaintext, additionalData) +} + +func (a *AEAD) Open(dst, nonce, ciphertext, additionalData []byte) ([]byte, error) { + if nonce == nil { + nonce = IncreaseNonce(a.Nonce[:]) + } + return a.AEAD.Open(dst, nonce, ciphertext, additionalData) +} + +func IncreaseNonce(nonce []byte) []byte { + for i := range 12 { + nonce[11-i]++ + if nonce[11-i] != 0 { + break + } + } + return nonce +} + +var MaxNonce = bytes.Repeat([]byte{255}, 12) + +func EncodeLength(l int) []byte { + return []byte{byte(l >> 8), byte(l)} +} + +func DecodeLength(b []byte) int { + return int(b[0])<<8 | int(b[1]) +} + +func EncodeHeader(h []byte, l int) { + h[0] = 23 + h[1] = 3 + h[2] = 3 + h[3] = byte(l >> 8) + h[4] = byte(l) +} + +var ErrInvalidHeader = errors.New("invalid header") + +func DecodeHeader(h []byte) (l int, err error) { + l = int(h[3])<<8 | int(h[4]) + if h[0] != 23 || h[1] != 3 || h[2] != 3 { + l = 0 + } + if l < 17 || l > 17000 { // TODO: TLSv1.3 max length + err = fmt.Errorf("%w: %v", ErrInvalidHeader, h[:5]) // DO NOT CHANGE: relied by client's Read() + } + return +} + +func ParsePadding(padding string, paddingLens, paddingGaps *[][3]int) (err error) { + if padding == "" { + return + } + maxLen := 0 + for i, s := range strings.Split(padding, ".") { + x := strings.Split(s, "-") + if len(x) < 3 || x[0] == "" || x[1] == "" || x[2] == "" { + return E.New("invalid padding lenth/gap parameter: " + s) + } + y := [3]int{} + if y[0], err = strconv.Atoi(x[0]); err != nil { + return + } + if y[1], err = strconv.Atoi(x[1]); err != nil { + return + } + if y[2], err = strconv.Atoi(x[2]); err != nil { + return + } + if i == 0 && (y[0] < 100 || y[1] < 18+17 || y[2] < 18+17) { + return E.New("first padding length must not be smaller than 35") + } + if i%2 == 0 { + *paddingLens = append(*paddingLens, y) + maxLen += max(y[1], y[2]) + } else { + *paddingGaps = append(*paddingGaps, y) + } + } + if maxLen > 18+65535 { + return E.New("total padding length must not be larger than 65553") + } + return +} + +func CreatePadding(paddingLens, paddingGaps [][3]int) (length int, lens []int, gaps []time.Duration) { + if len(paddingLens) == 0 { + paddingLens = [][3]int{{100, 111, 1111}, {50, 0, 3333}} + paddingGaps = [][3]int{{75, 0, 111}} + } + for _, y := range paddingLens { + l := 0 + if y[0] >= int(crypto.RandBetween(0, 100)) { + l = int(crypto.RandBetween(int64(y[1]), int64(y[2]))) + } + lens = append(lens, l) + length += l + } + for _, y := range paddingGaps { + g := 0 + if y[0] >= int(crypto.RandBetween(0, 100)) { + g = int(crypto.RandBetween(int64(y[1]), int64(y[2]))) + } + gaps = append(gaps, time.Duration(g)*time.Millisecond) + } + return +} diff --git a/protocol/vless/encryption/server.go b/protocol/vless/encryption/server.go new file mode 100644 index 00000000..6e25f166 --- /dev/null +++ b/protocol/vless/encryption/server.go @@ -0,0 +1,336 @@ +package encryption + +import ( + "bytes" + "crypto/cipher" + "crypto/ecdh" + "crypto/mlkem" + "crypto/rand" + "fmt" + "io" + "net" + "sync" + "time" + + "github.com/sagernet/sing-box/common/xray/crypto" + E "github.com/sagernet/sing/common/exceptions" + "lukechampine.com/blake3" +) + +type ServerSession struct { + PfsKey []byte + NfsKeys sync.Map +} + +type ServerInstance struct { + NfsSKeys []any + NfsPKeysBytes [][]byte + Hash32s [][32]byte + RelaysLength int + XorMode uint32 + SecondsFrom int64 + SecondsTo int64 + PaddingLens [][3]int + PaddingGaps [][3]int + + RWLock sync.RWMutex + Closed bool + Lasts map[int64][16]byte + Tickets [][16]byte + Sessions map[[16]byte]*ServerSession +} + +func (i *ServerInstance) Init(nfsSKeysBytes [][]byte, xorMode uint32, secondsFrom, secondsTo int64, padding string) (err error) { + if i.NfsSKeys != nil { + return E.New("already initialized") + } + l := len(nfsSKeysBytes) + if l == 0 { + return E.New("empty nfsSKeysBytes") + } + i.NfsSKeys = make([]any, l) + i.NfsPKeysBytes = make([][]byte, l) + i.Hash32s = make([][32]byte, l) + for j, k := range nfsSKeysBytes { + if len(k) == 32 { + if i.NfsSKeys[j], err = ecdh.X25519().NewPrivateKey(k); err != nil { + return + } + i.NfsPKeysBytes[j] = i.NfsSKeys[j].(*ecdh.PrivateKey).PublicKey().Bytes() + i.RelaysLength += 32 + 32 + } else { + if i.NfsSKeys[j], err = mlkem.NewDecapsulationKey768(k); err != nil { + return + } + i.NfsPKeysBytes[j] = i.NfsSKeys[j].(*mlkem.DecapsulationKey768).EncapsulationKey().Bytes() + i.RelaysLength += 1088 + 32 + } + i.Hash32s[j] = blake3.Sum256(i.NfsPKeysBytes[j]) + } + i.RelaysLength -= 32 + i.XorMode = xorMode + i.SecondsFrom = secondsFrom + i.SecondsTo = secondsTo + err = ParsePadding(padding, &i.PaddingLens, &i.PaddingGaps) + if err != nil { + return + } + if i.SecondsFrom > 0 || i.SecondsTo > 0 { + i.Lasts = make(map[int64][16]byte) + i.Tickets = make([][16]byte, 0, 1024) + i.Sessions = make(map[[16]byte]*ServerSession) + go func() { + for { + time.Sleep(time.Minute) + i.RWLock.Lock() + if i.Closed { + i.RWLock.Unlock() + return + } + minute := time.Now().Unix() / 60 + last := i.Lasts[minute] + delete(i.Lasts, minute) + delete(i.Lasts, minute-1) // for insurance + if last != [16]byte{} { + for j, ticket := range i.Tickets { + delete(i.Sessions, ticket) + if ticket == last { + i.Tickets = i.Tickets[j+1:] + break + } + } + } + i.RWLock.Unlock() + } + }() + } + return +} + +func (i *ServerInstance) Close() (err error) { + i.RWLock.Lock() + i.Closed = true + i.RWLock.Unlock() + return +} + +func (i *ServerInstance) IsXorMode() bool { + return i.XorMode > 0 +} + +func (i *ServerInstance) IsFullRandomXorMode() bool { + return i.XorMode == 2 +} + +func (i *ServerInstance) Handshake(conn net.Conn, fallback *[]byte) (*CommonConn, error) { + if i.NfsSKeys == nil { + return nil, E.New("uninitialized") + } + c := NewCommonConn(conn, true) + + ivAndRelays := make([]byte, 16+i.RelaysLength) + if _, err := io.ReadFull(conn, ivAndRelays); err != nil { + return nil, err + } + if fallback != nil { + *fallback = append(*fallback, ivAndRelays...) + } + iv := ivAndRelays[:16] + relays := ivAndRelays[16:] + var nfsKey []byte + var lastCTR cipher.Stream + for j, k := range i.NfsSKeys { + if lastCTR != nil { + lastCTR.XORKeyStream(relays, relays[:32]) // recover this relay + } + var index = 32 + if _, ok := k.(*mlkem.DecapsulationKey768); ok { + index = 1088 + } + if i.XorMode > 0 { + NewCTR(i.NfsPKeysBytes[j], iv).XORKeyStream(relays, relays[:index]) // we don't use buggy elligator2, because we have PSK :) + } + if k, ok := k.(*ecdh.PrivateKey); ok { + publicKey, err := ecdh.X25519().NewPublicKey(relays[:index]) + if err != nil { + return nil, err + } + if publicKey.Bytes()[31] > 127 { // we just don't want the observer can change even one bit without breaking the connection, though it has nothing to do with security + return nil, E.New("the highest bit of the last byte of the peer-sent X25519 public key is not 0") + } + nfsKey, err = k.ECDH(publicKey) + if err != nil { + return nil, err + } + } + if k, ok := k.(*mlkem.DecapsulationKey768); ok { + var err error + nfsKey, err = k.Decapsulate(relays[:index]) + if err != nil { + return nil, err + } + } + if j == len(i.NfsSKeys)-1 { + break + } + relays = relays[index:] + lastCTR = NewCTR(nfsKey, iv) + lastCTR.XORKeyStream(relays, relays[:32]) + if !bytes.Equal(relays[:32], i.Hash32s[j+1][:]) { + return nil, E.New("unexpected hash32: " + fmt.Sprintf("%v", relays[:32])) + } + relays = relays[32:] + } + nfsAEAD := NewAEAD(iv, nfsKey, c.UseAES) + + encryptedLength := make([]byte, 18) + if _, err := io.ReadFull(conn, encryptedLength); err != nil { + return nil, err + } + if fallback != nil { + *fallback = append(*fallback, encryptedLength...) + } + decryptedLength := make([]byte, 2) + if _, err := nfsAEAD.Open(decryptedLength[:0], nil, encryptedLength, nil); err != nil { + c.UseAES = !c.UseAES + nfsAEAD = NewAEAD(iv, nfsKey, c.UseAES) + if _, err := nfsAEAD.Open(decryptedLength[:0], nil, encryptedLength, nil); err != nil { + return nil, err + } + } + if fallback != nil { + *fallback = nil + } + length := DecodeLength(decryptedLength) + + if length == 32 { + if i.SecondsFrom == 0 && i.SecondsTo == 0 { + return nil, E.New("0-RTT is not allowed") + } + encryptedTicket := make([]byte, 32) + if _, err := io.ReadFull(conn, encryptedTicket); err != nil { + return nil, err + } + ticket, err := nfsAEAD.Open(nil, nil, encryptedTicket, nil) + if err != nil { + return nil, err + } + i.RWLock.RLock() + s := i.Sessions[[16]byte(ticket)] + i.RWLock.RUnlock() + if s == nil { + noises := make([]byte, crypto.RandBetween(1279, 2279)) // matches 1-RTT's server hello length for "random", though it is not important, just for example + var err error + for err == nil { + rand.Read(noises) + _, err = DecodeHeader(noises) + } + conn.Write(noises) // make client do new handshake + return nil, E.New("expired ticket") + } + if _, loaded := s.NfsKeys.LoadOrStore([32]byte(nfsKey), true); loaded { // prevents bad client also + return nil, E.New("replay detected") + } + c.UnitedKey = append(s.PfsKey, nfsKey...) // the same nfsKey links the upload & download (prevents server -> client's another request) + c.PreWrite = make([]byte, 16) + rand.Read(c.PreWrite) // always trust yourself, not the client (also prevents being parsed as TLS thus causing false interruption for "native" and "xorpub") + c.AEAD = NewAEAD(c.PreWrite, c.UnitedKey, c.UseAES) + c.PeerAEAD = NewAEAD(encryptedTicket, c.UnitedKey, c.UseAES) // unchangeable ctx (prevents server -> server), and different ctx length for upload / download (prevents client -> client) + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, c.PreWrite), NewCTR(c.UnitedKey, iv), 16, 0) // it doesn't matter if the attacker sends client's iv back to the client + } + return c, nil + } + + if length < 1184+32+16 { // client may send more public keys in the future's version + return nil, E.New("too short length") + } + encryptedPfsPublicKey := make([]byte, length) + if _, err := io.ReadFull(conn, encryptedPfsPublicKey); err != nil { + return nil, err + } + if _, err := nfsAEAD.Open(encryptedPfsPublicKey[:0], nil, encryptedPfsPublicKey, nil); err != nil { + return nil, err + } + mlkem768EKey, err := mlkem.NewEncapsulationKey768(encryptedPfsPublicKey[:1184]) + if err != nil { + return nil, err + } + mlkem768Key, encapsulatedPfsKey := mlkem768EKey.Encapsulate() + peerX25519PKey, err := ecdh.X25519().NewPublicKey(encryptedPfsPublicKey[1184 : 1184+32]) + if err != nil { + return nil, err + } + x25519SKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + x25519Key, err := x25519SKey.ECDH(peerX25519PKey) + if err != nil { + return nil, err + } + pfsKey := make([]byte, 32+32) // no more capacity + copy(pfsKey, mlkem768Key) + copy(pfsKey[32:], x25519Key) + pfsPublicKey := append(encapsulatedPfsKey, x25519SKey.PublicKey().Bytes()...) + c.UnitedKey = append(pfsKey, nfsKey...) + c.AEAD = NewAEAD(pfsPublicKey, c.UnitedKey, c.UseAES) + c.PeerAEAD = NewAEAD(encryptedPfsPublicKey[:1184+32], c.UnitedKey, c.UseAES) + + ticket := [16]byte{} + rand.Read(ticket[:]) + var seconds int64 + if i.SecondsTo == 0 { + seconds = i.SecondsFrom * crypto.RandBetween(50, 100) / 100 + } else { + seconds = crypto.RandBetween(i.SecondsFrom, i.SecondsTo) + } + copy(ticket[:], EncodeLength(int(seconds))) + if seconds > 0 { + i.RWLock.Lock() + i.Lasts[(time.Now().Unix()+max(i.SecondsFrom, i.SecondsTo))/60+2] = ticket + i.Tickets = append(i.Tickets, ticket) + i.Sessions[ticket] = &ServerSession{PfsKey: pfsKey} + i.RWLock.Unlock() + } + + pfsKeyExchangeLength := 1088 + 32 + 16 + encryptedTicketLength := 32 + paddingLength, paddingLens, paddingGaps := CreatePadding(i.PaddingLens, i.PaddingGaps) + serverHello := make([]byte, pfsKeyExchangeLength+encryptedTicketLength+paddingLength) + nfsAEAD.Seal(serverHello[:0], MaxNonce, pfsPublicKey, nil) + c.AEAD.Seal(serverHello[:pfsKeyExchangeLength], nil, ticket[:], nil) + padding := serverHello[pfsKeyExchangeLength+encryptedTicketLength:] + c.AEAD.Seal(padding[:0], nil, EncodeLength(paddingLength-18), nil) + c.AEAD.Seal(padding[:18], nil, padding[18:paddingLength-16], nil) + + paddingLens[0] = pfsKeyExchangeLength + encryptedTicketLength + paddingLens[0] + for i, l := range paddingLens { // sends padding in a fragmented way, to create variable traffic pattern, before inner VLESS flow takes control + if l > 0 { + if _, err := conn.Write(serverHello[:l]); err != nil { + return nil, err + } + serverHello = serverHello[l:] + } + if len(paddingGaps) > i { + time.Sleep(paddingGaps[i]) + } + } + + // important: allows client sends padding slowly, eliminating 1-RTT's traffic pattern + if _, err := io.ReadFull(conn, encryptedLength); err != nil { + return nil, err + } + if _, err := nfsAEAD.Open(encryptedLength[:0], nil, encryptedLength, nil); err != nil { + return nil, err + } + encryptedPadding := make([]byte, DecodeLength(encryptedLength[:2])) + if _, err := io.ReadFull(conn, encryptedPadding); err != nil { + return nil, err + } + if _, err := nfsAEAD.Open(encryptedPadding[:0], nil, encryptedPadding, nil); err != nil { + return nil, err + } + + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, ticket[:]), NewCTR(c.UnitedKey, iv), 0, 0) + } + return c, nil +} diff --git a/protocol/vless/encryption/xor.go b/protocol/vless/encryption/xor.go new file mode 100644 index 00000000..ac7bd58d --- /dev/null +++ b/protocol/vless/encryption/xor.go @@ -0,0 +1,101 @@ +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "net" + + "lukechampine.com/blake3" +) + +func NewCTR(key, iv []byte) cipher.Stream { + k := make([]byte, 32) + blake3.DeriveKey(k, "VLESS", key) // avoids using key directly + block, _ := aes.NewCipher(k) + return cipher.NewCTR(block, iv) +} + +type XorConn struct { + net.Conn + CTR cipher.Stream + PeerCTR cipher.Stream + OutSkip int + OutHeader []byte + InSkip int + InHeader []byte +} + +func NewXorConn(conn net.Conn, ctr, peerCTR cipher.Stream, outSkip, inSkip int) *XorConn { + return &XorConn{ + Conn: conn, + CTR: ctr, + PeerCTR: peerCTR, + OutSkip: outSkip, + OutHeader: make([]byte, 0, 5), // important + InSkip: inSkip, + InHeader: make([]byte, 0, 5), // important + } +} + +func (c *XorConn) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + for p := b; ; { + if len(p) <= c.OutSkip { + c.OutSkip -= len(p) + break + } + p = p[c.OutSkip:] + c.OutSkip = 0 + need := 5 - len(c.OutHeader) + if len(p) < need { + c.OutHeader = append(c.OutHeader, p...) + c.CTR.XORKeyStream(p, p) + break + } + c.OutSkip, _ = DecodeHeader(append(c.OutHeader, p[:need]...)) + c.OutHeader = c.OutHeader[:0] + c.CTR.XORKeyStream(p[:need], p[:need]) + p = p[need:] + } + if _, err := c.Conn.Write(b); err != nil { + return 0, err + } + return len(b), nil +} + +func (c *XorConn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + n, err := c.Conn.Read(b) + for p := b[:n]; ; { + if len(p) <= c.InSkip { + c.InSkip -= len(p) + break + } + p = p[c.InSkip:] + c.InSkip = 0 + need := 5 - len(c.InHeader) + if len(p) < need { + c.PeerCTR.XORKeyStream(p, p) + c.InHeader = append(c.InHeader, p...) + break + } + c.PeerCTR.XORKeyStream(p[:need], p[:need]) + c.InSkip, _ = DecodeHeader(append(c.InHeader, p[:need]...)) + c.InHeader = c.InHeader[:0] + p = p[need:] + } + return n, err +} + +// Upstream returns the underlying connection, allowing Vision to unwrap and access the TLS connection +func (c *XorConn) Upstream() any { + return c.Conn +} + +func (c *XorConn) IsEncryptionLayer() bool { + return true +} diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 3cc53db4..a63c2187 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -2,8 +2,11 @@ package vless import ( "context" + "encoding/base64" "net" "os" + "strconv" + "strings" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -14,6 +17,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/vless/encryption" "github.com/sagernet/sing-box/transport/v2ray" "github.com/sagernet/sing-vmess/packetaddr" "github.com/sagernet/sing-vmess/vless" @@ -35,14 +39,15 @@ var _ adapter.TCPInjectableInbound = (*Inbound)(nil) type Inbound struct { inbound.Adapter - ctx context.Context - router adapter.ConnectionRouterEx - logger logger.ContextLogger - listener *listener.Listener - users []option.VLESSUser - service *vless.Service[int] - tlsConfig tls.ServerConfig - transport adapter.V2RayServerTransport + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + users []option.VLESSUser + service *vless.Service[int] + tlsConfig tls.ServerConfig + transport adapter.V2RayServerTransport + decryption *encryption.ServerInstance } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) { @@ -79,6 +84,18 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return nil, E.Cause(err, "create server transport: ", options.Transport.Type) } } + // Parse decryption configuration + if options.Decryption != "" && options.Decryption != "none" { + decryptionConfig, err := parseServerDecryption(options.Decryption) + if err != nil { + return nil, E.Cause(err, "parse decryption") + } + inbound.decryption = &encryption.ServerInstance{} + if err := inbound.decryption.Init(decryptionConfig.keys, decryptionConfig.xorMode, decryptionConfig.secondsFrom, decryptionConfig.secondsTo, decryptionConfig.padding); err != nil { + return nil, E.Cause(err, "initialize decryption") + } + logger.Debug("decryption initialized with ", len(decryptionConfig.keys), " keys xorMode=", decryptionConfig.xorMode, " secondsFrom=", decryptionConfig.secondsFrom, " secondsTo=", decryptionConfig.secondsTo, " padding=", decryptionConfig.padding) + } inbound.listener = listener.New(listener.Options{ Context: ctx, Logger: logger, @@ -130,6 +147,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error { } func (h *Inbound) Close() error { + if h.decryption != nil { + h.decryption.Close() + } return common.Close( h.service, h.listener, @@ -139,6 +159,14 @@ func (h *Inbound) Close() error { } func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + canSplice := h.transport == nil + if canSplice && h.decryption != nil && h.decryption.IsFullRandomXorMode() { + canSplice = false + } + h.newConnectionExInternal(ctx, conn, metadata, onClose, canSplice) +} + +func (h *Inbound) newConnectionExInternal(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc, canSplice bool) { if h.tlsConfig != nil && h.transport == nil { tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) if err != nil { @@ -148,7 +176,17 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } conn = tlsConn } - err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) + // Apply decryption if configured + if h.decryption != nil { + encConn, err := h.decryption.Handshake(conn, nil) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source, ": encryption handshake")) + return + } + conn = encConn + } + err := h.service.NewConnectionWithOptions(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose, canSplice) if err != nil { N.CloseOnHandshakeFailure(conn, onClose, err) h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) @@ -197,6 +235,90 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } +type serverDecryptionConfig struct { + keys [][]byte + xorMode uint32 + secondsFrom int64 + secondsTo int64 + padding string +} + +func parseServerDecryption(raw string) (serverDecryptionConfig, error) { + var cfg serverDecryptionConfig + raw = strings.TrimSpace(raw) + if raw == "" { + return cfg, E.New("empty decryption string") + } + parts := strings.Split(raw, ".") + if len(parts) < 4 { + return cfg, E.New("invalid decryption string: missing components") + } + if parts[0] != "mlkem768x25519plus" { + return cfg, E.New("unsupported decryption prefix: ", parts[0]) + } + switch parts[1] { + case "native": + cfg.xorMode = 0 + case "xorpub": + cfg.xorMode = 1 + case "random": + cfg.xorMode = 2 + default: + return cfg, E.New("unknown decryption mode: ", parts[1]) + } + + secondsToken := strings.TrimSpace(parts[2]) + if secondsToken == "" { + return cfg, E.New("invalid decryption seconds segment") + } + trimmed := strings.TrimSuffix(secondsToken, "s") + if trimmed == "" { + return cfg, E.New("invalid decryption seconds segment") + } + values := strings.SplitN(trimmed, "-", 2) + secondsFrom, err := strconv.ParseInt(values[0], 10, 64) + if err != nil { + return cfg, E.Cause(err, "parse decryption seconds_from") + } + cfg.secondsFrom = secondsFrom + if len(values) == 2 && values[1] != "" { + secondsTo, err := strconv.ParseInt(values[1], 10, 64) + if err != nil { + return cfg, E.Cause(err, "parse decryption seconds_to") + } + cfg.secondsTo = secondsTo + } + + paddingPhase := true + var paddingParts []string + for _, segment := range parts[3:] { + segment = strings.TrimSpace(segment) + if segment == "" { + return cfg, E.New("invalid empty segment in decryption string") + } + if data, err := base64.RawURLEncoding.DecodeString(segment); err == nil { + if len(data) == 32 || len(data) == 64 { + cfg.keys = append(cfg.keys, data) + paddingPhase = false + continue + } + return cfg, E.New("invalid decryption key length: ", len(data)) + } + if paddingPhase { + paddingParts = append(paddingParts, segment) + continue + } + return cfg, E.New("invalid decryption key: ", segment) + } + if len(cfg.keys) == 0 { + return cfg, E.New("no valid decryption keys found in decryption string") + } + if len(paddingParts) > 0 { + cfg.padding = strings.Join(paddingParts, ".") + } + return cfg, nil +} + var _ adapter.V2RayServerTransportHandler = (*inboundTransportHandler)(nil) type inboundTransportHandler Inbound @@ -210,5 +332,5 @@ func (h *inboundTransportHandler) NewConnectionEx(ctx context.Context, conn net. //nolint:staticcheck metadata.InboundOptions = h.listener.ListenOptions().InboundOptions h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) - (*Inbound)(h).NewConnectionEx(ctx, conn, metadata, onClose) + (*Inbound)(h).newConnectionExInternal(ctx, conn, metadata, onClose, false) } diff --git a/protocol/vless/outbound.go b/protocol/vless/outbound.go index b95a36f7..2fc05cda 100644 --- a/protocol/vless/outbound.go +++ b/protocol/vless/outbound.go @@ -2,16 +2,23 @@ package vless import ( "context" + stdtls "crypto/tls" + "encoding/base64" "net" + "reflect" + "strings" + "sync" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/outbound" "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/common/mux" "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/common/vision" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/vless/encryption" "github.com/sagernet/sing-box/transport/v2ray" "github.com/sagernet/sing-vmess/packetaddr" "github.com/sagernet/sing-vmess/vless" @@ -38,6 +45,8 @@ type Outbound struct { transport adapter.V2RayClientTransport packetAddr bool xudp bool + encryption *encryption.ClientInstance + vision bool } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSOutboundOptions) (adapter.Outbound, error) { @@ -50,6 +59,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL logger: logger, dialer: outboundDialer, serverAddr: options.ServerOptions.Build(), + vision: strings.HasPrefix(options.Flow, "xtls-rprx-vision"), } if options.TLS != nil { outbound.tlsConfig, err = tls.NewClient(ctx, options.Server, common.PtrValueOrDefault(options.TLS)) @@ -76,11 +86,28 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL return nil, E.New("unknown packet encoding: ", options.PacketEncoding) } } + // Parse encryption configuration + if options.Encryption != "" && options.Encryption != "none" { + encryptionConfig, err := parseClientEncryption(options.Encryption) + if err != nil { + return nil, E.Cause(err, "parse encryption") + } + outbound.encryption = &encryption.ClientInstance{} + if err := outbound.encryption.Init(encryptionConfig.keys, encryptionConfig.xorMode, encryptionConfig.seconds, encryptionConfig.padding); err != nil { + return nil, E.Cause(err, "initialize encryption") + } + logger.Debug("encryption initialized: keys=", len(encryptionConfig.keys), " xorMode=", encryptionConfig.xorMode, " seconds=", encryptionConfig.seconds, " padding=", encryptionConfig.padding) + } + + muxOpts := common.PtrValueOrDefault(options.Multiplex) + if muxOpts.Enabled { + options.Flow = "" + } outbound.client, err = vless.NewClient(options.UUID, options.Flow, logger) if err != nil { return nil, err } - outbound.multiplexDialer, err = mux.NewClientWithOptions((*vlessDialer)(outbound), logger, common.PtrValueOrDefault(options.Multiplex)) + outbound.multiplexDialer, err = mux.NewClientWithOptions((*vlessDialer)(outbound), logger, muxOpts) if err != nil { return nil, err } @@ -137,21 +164,92 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati metadata.Outbound = h.Tag() metadata.Destination = destination var conn net.Conn + var baseConn net.Conn + var hookOnce sync.Once + if h.vision { + ctx = vision.WithHook(ctx, func(tlsConn net.Conn) { + if tlsConn == nil || !isVisionTLSConn(tlsConn) { + return + } + hookOnce.Do(func() { + baseConn = tlsConn + }) + }) + } var err error if h.transport != nil { conn, err = h.transport.DialContext(ctx) + if err == nil && h.vision { + if baseConn == nil { + // Only set baseConn if the transport delivered a TLS-capable connection + if isVisionTLSConn(conn) { + h.logger.Warn("Vision enabled but hook was not called by transport, using fallback") + baseConn = conn + } + } + } } else { conn, err = h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr) if err == nil && h.tlsConfig != nil { conn, err = tls.ClientHandshake(ctx, conn, h.tlsConfig) + if err == nil && h.vision && baseConn == nil { + baseConn = conn + } } } if err != nil { return nil, err } + + // Apply encryption if configured + if h.encryption != nil { + conn, err = h.encryption.Handshake(conn) + if err != nil { + return nil, E.Cause(err, "encryption handshake") + } + } + + // For Vision: wrap the connection to expose the TLS/encryption connection for vless client + var visionBaseConn net.Conn // The connection to pass to Vision (TLS or encryption layer) + var visionCanSplice bool + if h.vision { + isRAWTransport := h.transport == nil + + if baseConn != nil && !isVisionTLSConn(baseConn) { + baseConn = nil + } + if baseConn != nil { + // Has TLS/Reality: use baseConn (TLS connection) + visionBaseConn = baseConn + visionCanSplice = isRAWTransport + conn = newVisionConnWrapper(conn, baseConn) + } else if h.encryption != nil { + // Only has encryption (no TLS/Reality): use encryption layer itself + encConn := findEncryptionLayer(conn) + if encConn != nil { + visionBaseConn = encConn + if h.encryption.IsFullRandomXorMode() { + visionCanSplice = false + } else { + visionCanSplice = isRAWTransport + } + conn = newVisionConnWrapper(conn, encConn) + } else { + return nil, E.New("Vision: failed to find encryption layer") + } + } else { + return nil, E.New("Vision requires either TLS/Reality or Encryption") + } + } + switch N.NetworkName(network) { case N.NetworkTCP: h.logger.InfoContext(ctx, "outbound connection to ", destination) + if h.vision && visionBaseConn != nil { + // For Vision, we need to pass the base connection (TLS or encryption layer) + // to prepareConn so it can properly initialize VisionConn + return h.client.DialEarlyConnWithOptions(conn, visionBaseConn, destination, visionCanSplice) + } return h.client.DialEarlyConn(conn, destination) case N.NetworkUDP: h.logger.InfoContext(ctx, "outbound packet connection to ", destination) @@ -193,6 +291,14 @@ func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) common.Close(conn) return nil, err } + // Apply encryption if configured + if h.encryption != nil { + conn, err = h.encryption.Handshake(conn) + if err != nil { + common.Close(conn) + return nil, E.Cause(err, "encryption handshake") + } + } if h.xudp { return h.client.DialEarlyXUDPPacketConn(conn, destination) } else if h.packetAddr { @@ -208,3 +314,152 @@ func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) return h.client.DialEarlyPacketConn(conn, destination) } } + +type visionConnWrapper struct { + net.Conn + upstream net.Conn +} + +var ( + _ N.ReaderWithUpstream = (*visionConnWrapper)(nil) + _ N.WriterWithUpstream = (*visionConnWrapper)(nil) + _ common.WithUpstream = (*visionConnWrapper)(nil) +) + +func newVisionConnWrapper(conn net.Conn, upstream net.Conn) net.Conn { + if upstream == nil || conn == nil || conn == upstream { + return conn + } + return &visionConnWrapper{ + Conn: conn, + upstream: upstream, + } +} + +func (c *visionConnWrapper) Upstream() any { + return c.upstream +} + +func (c *visionConnWrapper) ReaderReplaceable() bool { + if replacer, ok := c.Conn.(N.ReaderWithUpstream); ok { + return replacer.ReaderReplaceable() + } + return true +} + +func (c *visionConnWrapper) WriterReplaceable() bool { + if replacer, ok := c.Conn.(N.WriterWithUpstream); ok { + return replacer.WriterReplaceable() + } + return true +} + +// isVisionTLSConn returns true when the provided connection exposes TLS semantics Vision expects. +func isVisionTLSConn(conn net.Conn) bool { + if conn == nil { + return false + } + if _, ok := conn.(interface{ ConnectionState() stdtls.ConnectionState }); ok { + return true + } + if _, ok := conn.(interface{ Handshake() error }); ok { + return true + } + connType := reflect.TypeOf(conn) + if connType == nil { + return false + } + if connType.Kind() == reflect.Ptr { + pkgPath := connType.Elem().PkgPath() + if pkgPath == "crypto/tls" || strings.Contains(pkgPath, "utls") || strings.Contains(pkgPath, "shadowtls") { + return true + } + } + return false +} + +func findEncryptionLayer(conn net.Conn) net.Conn { + for conn != nil { + if enc, ok := conn.(encryption.EncryptionConn); ok && enc.IsEncryptionLayer() { + return conn + } + if upstream, ok := conn.(common.WithUpstream); ok { + if next := upstream.Upstream(); next != nil { + if nextConn, ok := next.(net.Conn); ok { + conn = nextConn + continue + } + } + } + break + } + return nil +} + +type clientEncryptionConfig struct { + keys [][]byte + xorMode uint32 + seconds uint32 + padding string +} + +func parseClientEncryption(raw string) (clientEncryptionConfig, error) { + var cfg clientEncryptionConfig + raw = strings.TrimSpace(raw) + if raw == "" { + return cfg, E.New("empty encryption string") + } + parts := strings.Split(raw, ".") + if len(parts) < 4 { + return cfg, E.New("invalid encryption string: missing components") + } + if parts[0] != "mlkem768x25519plus" { + return cfg, E.New("unsupported encryption prefix: ", parts[0]) + } + switch parts[1] { + case "native": + cfg.xorMode = 0 + case "xorpub": + cfg.xorMode = 1 + case "random": + cfg.xorMode = 2 + default: + return cfg, E.New("unknown encryption mode: ", parts[1]) + } + switch parts[2] { + case "0rtt": + cfg.seconds = 1 + case "1rtt": + cfg.seconds = 0 + default: + return cfg, E.New("unsupported encryption RTT value: ", parts[2]) + } + paddingPhase := true + var paddingParts []string + for _, segment := range parts[3:] { + segment = strings.TrimSpace(segment) + if segment == "" { + return cfg, E.New("invalid empty segment in encryption string") + } + if data, err := base64.RawURLEncoding.DecodeString(segment); err == nil { + if len(data) == 32 || len(data) == 1184 { + cfg.keys = append(cfg.keys, data) + paddingPhase = false + continue + } + return cfg, E.New("invalid encryption key length: ", len(data)) + } + if paddingPhase { + paddingParts = append(paddingParts, segment) + continue + } + return cfg, E.New("invalid encryption key: ", segment) + } + if len(cfg.keys) == 0 { + return cfg, E.New("no valid encryption keys found in encryption string") + } + if len(paddingParts) > 0 { + cfg.padding = strings.Join(paddingParts, ".") + } + return cfg, nil +}