From 3ff848dd24ab55f3d4208fca17b5eec8add9a2dc Mon Sep 17 00:00:00 2001 From: chulq Date: Sun, 25 Jan 2026 16:26:36 +0800 Subject: [PATCH] Update shadowsocks tests and SS2022 UDP behavior --- CLAUDE.md | 91 ++++ dialer/shadowsocks/shadowsocks.go | 6 + dialer/shadowsocks/shadowsocks_test.go | 292 ++++++++++ go.mod | 2 + go.sum | 4 + protocol/shadowsocks2022/IMPLEMENTATION.md | 398 ++++++++++++++ protocol/shadowsocks2022/REVIEW_FINDINGS.md | 113 ++++ protocol/shadowsocks2022/cipher.go | 185 +++++++ protocol/shadowsocks2022/cipher_test.go | 199 +++++++ protocol/shadowsocks2022/dialer.go | 154 ++++++ protocol/shadowsocks2022/header.go | 197 +++++++ protocol/shadowsocks2022/header_test.go | 110 ++++ protocol/shadowsocks2022/identity.go | 169 ++++++ protocol/shadowsocks2022/replay.go | 193 +++++++ protocol/shadowsocks2022/replay_test.go | 139 +++++ protocol/shadowsocks2022/tcp_conn.go | 434 +++++++++++++++ protocol/shadowsocks2022/udp_conn.go | 559 ++++++++++++++++++++ 17 files changed, 3245 insertions(+) create mode 100644 CLAUDE.md create mode 100644 dialer/shadowsocks/shadowsocks_test.go create mode 100644 protocol/shadowsocks2022/IMPLEMENTATION.md create mode 100644 protocol/shadowsocks2022/REVIEW_FINDINGS.md create mode 100644 protocol/shadowsocks2022/cipher.go create mode 100644 protocol/shadowsocks2022/cipher_test.go create mode 100644 protocol/shadowsocks2022/dialer.go create mode 100644 protocol/shadowsocks2022/header.go create mode 100644 protocol/shadowsocks2022/header_test.go create mode 100644 protocol/shadowsocks2022/identity.go create mode 100644 protocol/shadowsocks2022/replay.go create mode 100644 protocol/shadowsocks2022/replay_test.go create mode 100644 protocol/shadowsocks2022/tcp_conn.go create mode 100644 protocol/shadowsocks2022/udp_conn.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7bd82d0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 概述 + +Outbound 是一个 Go 库,提供统一的接口,从分享链接 URL 创建网络 dialer。属于 daeuniverse 组织,支持多种代理协议。 + +## 构建与测试命令 + +```bash +# 构建 +go build ./... + +# 运行所有测试 +go test ./... + +# 运行特定包的测试 +go test ./protocol/hysteria2/... +go test ./common/... + +# 运行单个测试 +go test -run TestFunctionName ./path/to/package + +# 详细输出 +go test -v ./... +``` + +## 架构 + +### 核心抽象 + +**netproxy.Dialer** (`netproxy/dialer.go`) - 所有 dialer 实现的基础接口: +```go +type Dialer interface { + DialContext(ctx context.Context, network, addr string) (Conn, error) +} +``` + +**dialer.FromLinkCreator** (`dialer/register.go`) - 从分享链接创建 dialer 的工厂函数类型。 + +### 分层 Dialer 模式 + +Dialer 可组合链式调用。典型的 Trojan over WebSocket over TLS 链: +``` +direct -> tls -> ws -> trojanc +``` + +每层包装前一个 dialer(`nextDialer`),添加自己的协议处理。 + +### 包结构 + +- **dialer/** - 各协议的分享链接解析器和高层 dialer 工厂(ss, trojan, vmess, vless, hysteria2, tuic, juicity 等) +- **protocol/** - 底层协议实现,处理实际代理协议(shadowsocks, trojanc, vmess, vless 等) +- **transport/** - 传输层实现(tls, ws, grpc, simpleobfs, httpupgrade, meek, mux, shadowsocksr obfs/proto) +- **netproxy/** - 核心 Dialer/Conn 接口和包装器 +- **ciphers/** - 加密实现(AEAD、流加密) +- **pool/** - 字节缓冲池,优化内存 + +### 添加新协议 + +1. 在 `protocol/yourproto/` 创建协议处理器,实现连接逻辑 +2. 在 `protocol/dialer.go` 通过 `protocol.Register("yourproto", creator)` 注册 +3. 在 `dialer/yourproto/` 创建分享链接解析器,包含 `ParseXxxURL()` 和 `ExportToURL()` +4. 在 `init()` 中通过 `dialer.FromLinkRegister("scheme", creator)` 注册 + +### 入口点 + +`dialer.NewNetproxyDialerFromLink()` 是主入口 - 解析分享链接 URL,识别协议 scheme,构建对应的 dialer 链。 + +### 支持的协议 + +- Shadowsocks (ss://)、ShadowsocksR (ssr://) +- VMess (vmess://)、VLESS (vless://) 含 REALITY 支持 +- Trojan (trojan://)、Trojan-Go (trojan-go://) +- TUIC (tuic://)、Juicity (juicity://) +- Hysteria2 (hysteria2://, hy2://) 含 UDP 端口跳跃 +- HTTP 代理、SOCKS5 代理 +- AnyTLS (anytls://) + +### 传输选项 + +协议可叠加多种传输层: +- TLS/uTLS(含指纹模拟) +- WebSocket +- gRPC +- HTTP/2 +- HTTPUpgrade +- Meek +- Simple-obfs +- REALITY diff --git a/dialer/shadowsocks/shadowsocks.go b/dialer/shadowsocks/shadowsocks.go index ec9dd57..af3f6ae 100644 --- a/dialer/shadowsocks/shadowsocks.go +++ b/dialer/shadowsocks/shadowsocks.go @@ -13,6 +13,7 @@ import ( "github.com/daeuniverse/outbound/netproxy" "github.com/daeuniverse/outbound/protocol" "github.com/daeuniverse/outbound/protocol/shadowsocks" + _ "github.com/daeuniverse/outbound/protocol/shadowsocks2022" // Register SS2022 protocol "github.com/daeuniverse/outbound/transport/mux" "github.com/daeuniverse/outbound/transport/simpleobfs" "github.com/daeuniverse/outbound/transport/tls" @@ -117,8 +118,13 @@ func (s *Shadowsocks) Dialer(option *dialer.ExtraOption, nextDialer netproxy.Dia } var nextDialerName string switch s.Cipher { + // Shadowsocks 2022 ciphers + case "2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305": + nextDialerName = "shadowsocks2022" + // Legacy AEAD ciphers case "aes-256-gcm", "aes-128-gcm", "chacha20-poly1305", "chacha20-ietf-poly1305": nextDialerName = "shadowsocks" + // Legacy stream ciphers case "aes-128-cfb", "aes-192-cfb", "aes-256-cfb", "aes-128-ctr", "aes-192-ctr", "aes-256-ctr", "aes-128-ofb", "aes-192-ofb", "aes-256-ofb", "des-cfb", "bf-cfb", "cast5-cfb", "rc4-md5", "rc4-md5-6", "chacha20", "chacha20-ietf", "salsa20", "camellia-128-cfb", "camellia-192-cfb", "camellia-256-cfb", "idea-cfb", "rc2-cfb", "seed-cfb", "rc4", "none", "plain": nextDialerName = "shadowsocks_stream" default: diff --git a/dialer/shadowsocks/shadowsocks_test.go b/dialer/shadowsocks/shadowsocks_test.go new file mode 100644 index 0000000..7373c11 --- /dev/null +++ b/dialer/shadowsocks/shadowsocks_test.go @@ -0,0 +1,292 @@ +package shadowsocks + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/daeuniverse/outbound/dialer" + "github.com/daeuniverse/outbound/netproxy" + "github.com/daeuniverse/outbound/protocol/direct" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var initOnce sync.Once + +const ( + ssLegacyLinkExample = "ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpwYXNzd29yZA==@example.com:8388/#Example_Legacy_SIP002" + ss2022LinkExample = "ss://MjAyMi1ibGFrZTMtY2hhY2hhMjAtcG9seTEzMDU6QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQT0=@example.com:8388/#Example_SS2022" +) + +func initDirect() { + initOnce.Do(func() { + direct.InitDirectDialers("") + }) +} + +// TestParseSSURL 测试 ss:// 链接解析 +func TestParseSSURL(t *testing.T) { + tests := []struct { + name string + link string + wantCipher string + wantServer string + wantPort int + wantName string + wantErr bool + }{ + { + name: "标准 SIP002 格式", + link: ssLegacyLinkExample, + wantCipher: "chacha20-ietf-poly1305", + wantServer: "example.com", + wantPort: 8388, + wantName: "Example_Legacy_SIP002", + wantErr: false, + }, + { + name: "简单格式", + link: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@example.com:8388#TestNode", + wantCipher: "aes-256-gcm", + wantServer: "example.com", + wantPort: 8388, + wantName: "TestNode", + wantErr: false, + }, + { + name: "无效链接", + link: "ss://invalid", + wantErr: true, + }, + { + name: "SS2022 ChaCha20 格式", + link: ss2022LinkExample, + wantCipher: "2022-blake3-chacha20-poly1305", + wantServer: "example.com", + wantPort: 8388, + wantName: "Example_SS2022", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ss, err := ParseSSURL(tt.link) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantCipher, ss.Cipher) + assert.Equal(t, tt.wantServer, ss.Server) + assert.Equal(t, tt.wantPort, ss.Port) + assert.Equal(t, tt.wantName, ss.Name) + t.Logf("解析结果: Cipher=%s, Server=%s:%d, Password=%s, Name=%s", + ss.Cipher, ss.Server, ss.Port, ss.Password, ss.Name) + }) + } +} + +// TestNewDialerFromLink 测试从链接创建 Dialer +func TestNewDialerFromLink(t *testing.T) { + initDirect() + link := ssLegacyLinkExample + + d, prop, err := NewShadowsocksFromLink(nil, direct.SymmetricDirect, link) + require.NoError(t, err, "创建 Dialer 失败") + + t.Logf("Dialer 创建成功:") + t.Logf(" Name: %s", prop.Name) + t.Logf(" Protocol: %s", prop.Protocol) + t.Logf(" Address: %s", prop.Address) + t.Logf(" Link: %s", prop.Link) + + assert.NotNil(t, d) + assert.Equal(t, "Example_Legacy_SIP002", prop.Name) + assert.Equal(t, "shadowsocks", prop.Protocol) +} + +// TestSSConnection 测试实际连接(需要代理服务器在线) +// 运行: go test -v -run TestSSConnection ./dialer/shadowsocks/ +func TestSSConnection(t *testing.T) { + initDirect() + if testing.Short() { + t.Skip("跳过连接测试(使用 -short 标志)") + } + + links := getTestLinks() + if allPlaceholderLinks(links) { + t.Skip("未设置 SS_TEST_LINKS,跳过真实连接测试") + } + + // 创建 HTTP 客户端(每个节点设置自己的 Transport) + client := &http.Client{ + Timeout: 30 * time.Second, + } + + // 测试连接 + testURLs := []string{ + "https://www.google.com", + "https://api.ipify.org?format=json", + } + + for _, link := range links { + d, prop, err := NewShadowsocksFromLink(nil, direct.SymmetricDirect, link) + require.NoError(t, err, "创建 Dialer 失败") + t.Logf("使用节点: %s (%s)", prop.Name, prop.Address) + + // 绑定当前节点的 dialer + client.Transport = &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + t.Logf("正在连接: %s %s", network, addr) + conn, err := d.DialContext(ctx, network, addr) + if err != nil { + return nil, err + } + return &netproxy.FakeNetConn{Conn: conn}, nil + }, + } + + for _, url := range testURLs { + t.Run(prop.Name+" "+url, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + require.NoError(t, err) + + resp, err := client.Do(req) + if err != nil { + t.Logf("连接失败: %v", err) + t.FailNow() + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + t.Logf("状态码: %d", resp.StatusCode) + t.Logf("响应: %s", truncate(body, 200)) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + } + } +} + +// TestSSConnectionTCP 测试 TCP 原始连接 +func TestSSConnectionTCP(t *testing.T) { + initDirect() + if testing.Short() { + t.Skip("跳过连接测试") + } + + links := getTestLinks() + if allPlaceholderLinks(links) { + t.Skip("未设置 SS_TEST_LINKS,跳过真实连接测试") + } + + for _, link := range links { + d, prop, err := NewShadowsocksFromLink(nil, direct.SymmetricDirect, link) + require.NoError(t, err) + + t.Run(prop.Name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // 连接到一个简单的 TCP 服务 + conn, err := d.DialContext(ctx, "tcp", "httpbin.org:80") + if err != nil { + t.Fatalf("TCP 连接失败: %v", err) + } + defer conn.Close() + + // 发送简单的 HTTP 请求 + _, err = conn.Write([]byte("GET /ip HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n")) + require.NoError(t, err) + + // 读取响应 + buf := new(bytes.Buffer) + _, err = io.Copy(buf, conn) + require.NoError(t, err) + + t.Logf("TCP 响应:\n%s", truncate(buf.Bytes(), 500)) + assert.Contains(t, buf.String(), "HTTP/1.1") + }) + } +} + +// TestDialerExport 测试链接导出 +func TestDialerExport(t *testing.T) { + link := "ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpwYXNzd29yZA==@example.com:8388/#TestExport" + + ss, err := ParseSSURL(link) + require.NoError(t, err) + + exported := ss.ExportToURL() + t.Logf("原链接: %s", link) + t.Logf("导出链接: %s", exported) + + // 验证导出的链接可以被重新解析 + ss2, err := ParseSSURL(exported) + require.NoError(t, err) + assert.Equal(t, ss.Cipher, ss2.Cipher) + assert.Equal(t, ss.Password, ss2.Password) + assert.Equal(t, ss.Server, ss2.Server) + assert.Equal(t, ss.Port, ss2.Port) +} + +// TestWithCustomDialer 使用 dialer.NewNetproxyDialerFromLink 的完整测试 +func TestWithCustomDialer(t *testing.T) { + initDirect() + link := ssLegacyLinkExample + + // 使用主入口函数 + d, prop, err := dialer.NewNetproxyDialerFromLink(direct.SymmetricDirect, nil, link) + require.NoError(t, err) + + t.Logf("通过 NewNetproxyDialerFromLink 创建:") + t.Logf(" Name: %s", prop.Name) + t.Logf(" Protocol: %s", prop.Protocol) + t.Logf(" Address: %s", prop.Address) + + assert.NotNil(t, d) +} + +func truncate(b []byte, maxLen int) string { + if len(b) > maxLen { + return string(b[:maxLen]) + "..." + } + return string(b) +} + +func getTestLinks() []string { + if v := strings.TrimSpace(os.Getenv("SS_TEST_LINKS")); v != "" { + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + if len(out) > 0 { + return out + } + } + return []string{ssLegacyLinkExample, ss2022LinkExample} +} + +func allPlaceholderLinks(links []string) bool { + for _, link := range links { + if !strings.Contains(link, "example.com") { + return false + } + } + return true +} diff --git a/go.mod b/go.mod index f91d3fc..01a30be 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/onsi/ginkgo/v2 v2.22.2 // indirect @@ -52,4 +53,5 @@ require ( golang.org/x/tools v0.29.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230807174057-1744710a1577 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index b15ba1f..6b507a6 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -123,3 +125,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= diff --git a/protocol/shadowsocks2022/IMPLEMENTATION.md b/protocol/shadowsocks2022/IMPLEMENTATION.md new file mode 100644 index 0000000..3c61b2f --- /dev/null +++ b/protocol/shadowsocks2022/IMPLEMENTATION.md @@ -0,0 +1,398 @@ +# Shadowsocks 2022 协议实现文档 + +## 概述 + +本文档记录了 Shadowsocks 2022 (SS2022) 协议在 outbound 库中的实现细节,包括协议规范、参考代码分析和实现要点。 + +## 参考实现 + +- **shadowsocks-go**: https://github.com/database64128/shadowsocks-go + - 主要参考目录: `ss2022/` + - 关键文件: `crypto.go`, `header.go`, `stream.go`, `udp.go`, `packet.go` + +- **sing-shadowsocks2**: https://github.com/SagerNet/sing-shadowsocks2 + - 主要参考目录: `shadowaead_2022/` + - 关键文件: `protocol.go`, `method.go`, `slidingwindow.go` + +--- + +## 协议规范 + +### 支持的加密方法 + +| 方法 | 密钥长度 | Salt 长度 | 备注 | +|------|----------|-----------|------| +| `2022-blake3-aes-128-gcm` | 16 字节 | 16 字节 | 支持 EIH | +| `2022-blake3-aes-256-gcm` | 32 字节 | 32 字节 | 支持 EIH | +| `2022-blake3-chacha20-poly1305` | 32 字节 | 32 字节 | 不支持 EIH | + +### 密钥派生 + +使用 BLAKE3 进行密钥派生: + +```go +// 会话子密钥派生 +// Context: "shadowsocks 2022 session subkey" +func DeriveSessionKey(psk, salt []byte, keyLen int) []byte { + keyMaterial := append(psk, salt...) + subkey := make([]byte, keyLen) + blake3.DeriveKey(subkey, "shadowsocks 2022 session subkey", keyMaterial) + return subkey +} + +// 身份子密钥派生 (用于 TCP EIH) +// Context: "shadowsocks 2022 identity subkey" +func DeriveIdentitySubkeyWithSalt(iPSK, salt []byte, keyLen int) []byte { + keyMaterial := append(iPSK, salt...) + subkey := make([]byte, keyLen) + blake3.DeriveKey(subkey, "shadowsocks 2022 identity subkey", keyMaterial) + return subkey +} +``` + +### PSK 哈希 + +```go +// 用于 EIH 身份验证 +func PSKHash(psk []byte) [16]byte { + hash := blake3.Sum512(psk) + var result [16]byte + copy(result[:], hash[:16]) + return result +} +``` + +--- + +## TCP 协议 + +### 请求格式 + +``` ++--------+------------------+---------------------+----------------------+ +| Salt | Identity Headers | Encrypted Fixed | Encrypted Variable | +| | (optional) | Header | Header | ++--------+------------------+---------------------+----------------------+ +| 16/32B | N * 16B | 11B + 16B tag | variable + 16B tag | ++--------+------------------+---------------------+----------------------+ +``` + +#### 固定头 (11 字节) + +``` ++------+---------------+--------+ +| Type | Timestamp | Length | ++------+---------------+--------+ +| 1B | 8B unix epoch | u16be | ++------+---------------+--------+ +``` + +- Type: 0 = 客户端请求, 1 = 服务端响应 +- Timestamp: Unix 时间戳,允许 ±30 秒误差 +- Length: 可变头长度 + +#### 可变头 + +``` ++------+----------+-------+----------------+----------+-----------------+ +| ATYP | Address | Port | Padding Length | Padding | Initial Payload | ++------+----------+-------+----------------+----------+-----------------+ +| 1B | variable | u16be | u16be | variable | variable | ++------+----------+-------+----------------+----------+-----------------+ +``` + +**注意**: Padding Length 在地址之后,Initial Payload 在最后。 + +### 响应格式 + +``` ++--------+---------------------+----------------------+ +| Salt | Encrypted Response | Encrypted Payload | +| | Header | Chunk | ++--------+---------------------+----------------------+ +| 16/32B | (11+SaltLen)B + tag | variable + 16B tag | ++--------+---------------------+----------------------+ +``` + +#### 响应头 + +``` ++------+---------------+----------------+--------+ +| Type | Timestamp | Request Salt | Length | ++------+---------------+----------------+--------+ +| 1B | 8B unix epoch | 16/32B | u16be | ++------+---------------+----------------+--------+ +``` + +- Request Salt: 客户端请求中的 salt,用于验证响应 +- Length: 第一个 payload chunk 的长度 + +### 数据传输 (Chunk 格式) + +``` ++------------------------+---------------------------+ +| Encrypted Length Chunk | Encrypted Payload Chunk | ++------------------------+---------------------------+ +| 2B length + 16B tag | variable length + 16B tag | ++------------------------+---------------------------+ +``` + +### TCP 身份头 (EIH) + +对于多用户模式,在 salt 之后添加身份头: + +```go +// 派生身份子密钥 +identitySubkey := DeriveIdentitySubkeyWithSalt(iPSK, salt, keyLen) + +// 计算 uPSK 哈希 +uPSKHash := PSKHash(uPSK) + +// AES-ECB 加密 +block, _ := aes.NewCipher(identitySubkey[:16]) +identityHeader := make([]byte, 16) +block.Encrypt(identityHeader, uPSKHash[:]) +``` + +--- + +## UDP 协议 + +### 包格式 (AES-GCM) + +``` ++-------------------+------------------+---------------------------+ +| Encrypted Separate| Identity Headers| Encrypted Message | +| Header | (optional) | | ++-------------------+------------------+---------------------------+ +| 16B | N * 16B | variable + 16B tag | ++-------------------+------------------+---------------------------+ +``` + +### 分离头 (Separate Header) + +``` ++------------+-----------+ +| Session ID | Packet ID | ++------------+-----------+ +| 8B | u64be | ++------------+-----------+ +``` + +- 使用 AES-ECB 加密整个 16 字节 +- **Nonce**: 分离头的后 12 字节 (偏移 4-16),在加密前提取 + +### 会话密钥派生 + +```go +// 使用 session ID (前 8 字节) 作为 salt +sessionKey := DeriveSessionKey(psk, sessionID[:8], keyLen) +``` + +### 客户端消息 + +``` ++------+---------------+----------------+----------+------+----------+-------+----------+ +| Type | Timestamp | Padding Length | Padding | ATYP | Address | Port | Payload | ++------+---------------+----------------+----------+------+----------+-------+----------+ +| 1B | 8B unix epoch | u16be | variable | 1B | variable | u16be | variable | ++------+---------------+----------------+----------+------+----------+-------+----------+ +``` + +### 服务端消息 + +``` ++------+---------------+-------------------+----------------+----------+------+----------+-------+----------+ +| Type | Timestamp | Client Session ID | Padding Length | Padding | ATYP | Address | Port | Payload | ++------+---------------+-------------------+----------------+----------+------+----------+-------+----------+ +| 1B | 8B unix epoch | 8B | u16be | variable | 1B | variable | u16be | variable | ++------+---------------+-------------------+----------------+----------+------+----------+-------+----------+ +``` + +### UDP 身份头 (EIH) + +```go +// XOR uPSK 哈希与分离头 +xored := make([]byte, 16) +subtle.XORBytes(xored, uPSKHash[:], separateHeader[:16]) + +// AES-ECB 加密 (使用 iPSK 直接) +block, _ := aes.NewCipher(iPSK[:16]) +identityHeader := make([]byte, 16) +block.Encrypt(identityHeader, xored) +``` + +**关键区别**: UDP 身份头使用 `iPSK` 直接作为密钥,而 TCP 身份头使用派生的子密钥。 + +--- + +## 重放防护 + +### TCP + +- 使用 salt 作为唯一标识 +- 可选使用 bloom filter 检测重复 salt + +### UDP + +使用滑动窗口过滤器: + +```go +type SlidingWindowFilter struct { + lastID uint64 + windowSize uint64 + bitmap []uint64 // 每个 uint64 可跟踪 64 个 packet ID +} + +// 检查 packet ID 是否有效 (非重放) +func (f *SlidingWindowFilter) Check(id uint64) bool { + // 1. 如果 ID 太旧 (在窗口之前),拒绝 + // 2. 如果 ID 更新,滑动窗口 + // 3. 如果 ID 在窗口内,检查是否已见过 +} +``` + +### 服务端会话跟踪 + +客户端需要跟踪服务端会话变化: + +```go +type serverSessionState struct { + currentSessionID uint64 + currentCipher cipher.AEAD + currentFilter *SlidingWindowFilter + + oldSessionID uint64 + oldCipher cipher.AEAD + oldFilter *SlidingWindowFilter + oldLastSeen time.Time +} +``` + +- 保留当前会话和上一个会话 +- 如果会话在 60 秒内变化超过一次,拒绝新会话 + +--- + +## URL 格式 + +### 单用户模式 + +``` +ss://BASE64(method:BASE64_PSK)@server:port#name +``` + +示例: +``` +ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=@example.com:8388#MyServer +``` + +### 多用户模式 (EIH) + +``` +ss://BASE64(method:BASE64_iPSK:BASE64_uPSK)@server:port#name +``` + +--- + +## 实现文件结构 + +``` +protocol/shadowsocks2022/ +├── cipher.go # BLAKE3 密钥派生和 cipher 配置 +├── header.go # 固定头/可变头编解码,时间戳验证,地址编解码 +├── identity.go # EIH 多用户身份头处理 +├── tcp_conn.go # TCP 连接实现 +├── udp_conn.go # UDP 连接实现 +├── dialer.go # 协议 dialer,注册 "shadowsocks2022" +├── replay.go # 滑动窗口重放防护 +└── *_test.go # 单元测试 +``` + +--- + +## 关键实现细节 + +### 1. Nonce 递增 + +使用小端序递增: + +```go +func BytesIncLittleEndian(b []byte) { + for i := 0; i < len(b); i++ { + b[i]++ + if b[i] != 0 { + break + } + } +} +``` + +### 2. AEAD 操作 + +```go +// 加密 +ciphertext := aead.Seal(dst[:0], nonce, plaintext, nil) +increment(nonce) + +// 解密 +plaintext, err := aead.Open(dst[:0], nonce, ciphertext, nil) +if err == nil { + increment(nonce) +} +``` + +### 3. 时间戳验证 + +```go +const MaxEpochDiff = 30 // 秒 + +func ValidateTimestamp(timestamp uint64) error { + now := uint64(time.Now().Unix()) + diff := int64(timestamp) - int64(now) + if diff < 0 { + diff = -diff + } + if diff > MaxEpochDiff { + return ErrBadTimestamp + } + return nil +} +``` + +--- + +## 与传统 Shadowsocks 的区别 + +| 特性 | 传统 SS | SS2022 | +|------|---------|--------| +| 密钥派生 | HKDF-SHA1 | BLAKE3 | +| 密码格式 | 明文密码 | Base64 编码的 PSK | +| TCP 头部 | Salt + 加密数据 | Salt + 固定头(时间戳) + 可变头 | +| 时间验证 | 无 | ±30秒 | +| 响应验证 | 无 | 响应包含请求 Salt | +| 多用户 | 不支持 | EIH (Encrypted Identity Header) | +| UDP 格式 | Salt + 加密数据 | 分离头 + 身份头(可选) + 加密消息 | + +--- + +## 测试 + +```bash +# 运行单元测试 +go test ./protocol/shadowsocks2022/... -v + +# 运行构建 +go build ./... + +# 运行 dialer 测试 +go test ./dialer/shadowsocks/... -v +``` + +--- + +## 参考资料 + +- [Shadowsocks 2022 Edition 规范](https://github.com/Shadowsocks-NET/shadowsocks-specs/blob/main/2022-1-shadowsocks-2022-edition.md) +- [shadowsocks-go 实现](https://github.com/database64128/shadowsocks-go) +- [sing-shadowsocks2 实现](https://github.com/SagerNet/sing-shadowsocks2) diff --git a/protocol/shadowsocks2022/REVIEW_FINDINGS.md b/protocol/shadowsocks2022/REVIEW_FINDINGS.md new file mode 100644 index 0000000..5612b23 --- /dev/null +++ b/protocol/shadowsocks2022/REVIEW_FINDINGS.md @@ -0,0 +1,113 @@ +# Shadowsocks 2022 实现检查问题清单 + +按严重度排序,基于对照: +- 实现代码:`/Users/chulq/Code/outbound/protocol/shadowsocks2022` +- 参考实现:`/Users/chulq/Code/shadowsocks-go/ss2022` 与 `/Users/chulq/Code/sing-shadowsocks2/shadowaead_2022` + +## 严重 + +1. **[已修复] TCP 首次写入会重复发送首段数据(协议层数据损坏)** + `Write` 里 `initWrite(b)` 已把 `b` 当作 initial payload 写进可变头,随后 `Write` 又把同一份 `b` 作为 chunk 再写一次。 + - 位置:`protocol/shadowsocks2022/tcp_conn.go:67-99` 与 `protocol/shadowsocks2022/tcp_conn.go:103-156` + - 修复:添加 `firstWriteDone` 标志,确保首次写入数据不会再作为 chunk 重发 + +2. **[已修复] TCP 可变头长度未限制,存在 u16 溢出与超大首包问题** + `varHeaderLen` 含 `len(initialData)` 后被 `uint16(varHeaderLen)` 写入固定头,若首次写入超过 65535 会溢出导致解析错位。 + - 位置:`protocol/shadowsocks2022/tcp_conn.go:141-157` + - 修复:添加 `MaxInitialPayloadLen` 常量限制首包大小,并在 `initWrite` 中检查溢出 + +## 高 + +1. **[已修复] UDP 多用户(EIH)时,client→server 的 separate header 加密 key 用错** + 当前 `headerBlockCipher` 使用 `psk`(uPSK)初始化并用于加解密。参考实现中,客户端发包时应使用 `iPSK` 加密 separate header(便于服务器先解 header 与 identity header);服务端回包才用 `uPSK`,客户端用 `uPSK` 解密。 + - 位置:`protocol/shadowsocks2022/udp_conn.go:88-92` + - 修复:分离 `clientHeaderBlockCipher`(使用 iPSK)和 `serverHeaderBlockCipher`(使用 uPSK) + +2. **[已修复] UDP 的 chacha20-poly1305 路径不符合 SS2022 规范实现** + 目前所有方法都使用 16B separate header + AES-ECB,但参考实现对 chacha20 使用 24B nonce header(XChaCha20-Poly1305)且不支持 EIH。当前实现会与标准实现不互通。 + - 位置:`protocol/shadowsocks2022/cipher.go` 与 `protocol/shadowsocks2022/udp_conn.go:179-200` + - 修复: + - `cipher.go`:添加 `UDPCipherMode` 枚举区分 AES/ChaCha 模式,为 ChaCha20 配置 `NewUDPCipher: chacha20poly1305.NewX` + - `udp_conn.go`:实现双模式 WriteTo/ReadFrom,ChaCha20 使用 24B nonce(SessionID+PacketID+Random)+ XChaCha20-Poly1305 + +3. **[已修复] AES-256 路径下 AES-ECB 使用了错误的密钥长度** + `CreateECBEncryptor/CreateECBDecryptor` 与 UDP/TCP EIH 相关逻辑固定使用 `key[:16]`,导致 AES-256 方法被当成 AES-128 使用,和标准实现不兼容。 + - 位置:`protocol/shadowsocks2022/identity.go:96-128` + - 修复:添加 `CreateECBEncryptorWithKeyLen` 和 `CreateECBDecryptorWithKeyLen` 函数,根据 keyLen 选择 AES-128 或 AES-256 + +4. **[已修复] UDP Packet ID 起始值应为 1** + 当前 `packetID` 从 0 开始(`Add(1)-1`),而参考实现明确"AEAD-2022 Packet ID starts from 1"。 + - 位置:`protocol/shadowsocks2022/udp_conn.go:138-139` + - 修复:改为 `packetID := c.packetID.Add(1)`,首包使用 ID 1 + +## 中 + +1. **[已修复] 未阻止 chacha20 方法启用 EIH** + 规范与参考实现中 chacha20 不支持 EIH,但目前 dialer/初始化未做校验。 + - 位置:`protocol/shadowsocks2022/dialer.go`(PSK 解析)与 `protocol/shadowsocks2022/identity.go`(EIH 使用路径) + - 修复:在 `NewDialer` 中检查 chacha20 + iPSK 组合并返回错误 +## 验证对照(shadowsocks-rust / sslocal) + +以下对照基于 `/Users/chulq/Code/shadowsocks-rust`: + +1. **TCP 首次写入不重复发送首段数据(支持“重复发送”问题成立)** + sslocal 首次写入将 `addr + padding + payload` 拼成单个 buffer 发送,随后进入 Connected 状态,不会再把同一 payload 作为 chunk 重发。 + - 位置:`crates/shadowsocks/src/relay/tcprelay/proxy_stream/client.rs:246-314` + +2. **TCP 首包 payload 被限制到 u16::MAX(支持“u16 溢出”问题成立)** + AEAD2022 writer 会将 buf 截断到 `MAX_PACKET_SIZE = 0xFFFF` 并写入 u16 length。 + - 位置:`crates/shadowsocks/src/relay/tcprelay/aead_2022.rs:81-82`、`crates/shadowsocks/src/relay/tcprelay/aead_2022.rs:637-675` + +3. **UDP 多用户 EIH:client→server separate header 使用 iPSK 加密(支持“key 用错”问题成立)** + `encrypt_client_payload_aead_2022` 在有 EIH 时选择 `identity_keys[0]` 作为 `ipsk`,并在 `encrypt_message` 里用该 `ipsk` 进行 AES-ECB 加密 separate header。 + - 位置:`crates/shadowsocks/src/relay/udprelay/aead_2022.rs:510-515`、`crates/shadowsocks/src/relay/udprelay/aead_2022.rs:175-236` + +4. **chacha20-poly1305 UDP 头部使用 24B nonce;EIH 仅支持 AES(支持“chacha/EIH”问题成立)** + - nonce_len 对 AES 为 0、对 chacha 为 `method.nonce_len()`;并在包头 prepend nonce。 + - 位置:`crates/shadowsocks/src/relay/udprelay/aead_2022.rs:390-396` + - `method_support_eih` 仅匹配 AES 方法。 + - 位置:`crates/shadowsocks/src/config.rs:487-491` + +5. **AES-256 使用 AES-256 进行 AES-ECB(支持“密钥长度错误”问题成立)** + rust 在 AES-256 路径下用 `Aes256` 处理 separate header 与 EIH,而非截断为 16 字节。 + - 位置:`crates/shadowsocks/src/relay/udprelay/aead_2022.rs:208-235` + +6. **UDP Packet ID 从 1 开始(支持“起始值错误”问题成立)** + rust 侧有明确注释并在发送前自增 packet_id。 + - 位置:`crates/shadowsocks-service/src/local/dns/upstream.rs:114` + - 位置:`crates/shadowsocks-service/src/local/net/udp/association.rs:533-587` + +## 再次对照结论(shadowsocks-go / shadowsocks-rust / sing-shadowsocks2) + +以下结论基于同时对照三仓: + +1. **可以确认成立(3 仓一致或 2 仓一致 + 1 仓不支持该功能)** + - TCP 首包只发送一次,不会把首段 payload 再作为 chunk 重发。 + - rust:`crates/shadowsocks/src/relay/tcprelay/proxy_stream/client.rs:246-314` + - sing:`shadowaead_2022/method.go`(首包发送路径) + - go:`ss2022/stream.go`(首包与后续 chunk 分离) + - TCP 首包 payload 有 u16 上限(0xFFFF),避免溢出。 + - rust:`crates/shadowsocks/src/relay/tcprelay/aead_2022.rs:81-82, 637-675` + - go:`ss2022/stream.go:18, 126-127` + - UDP 多用户 EIH 时,client→server separate header 使用 iPSK 加密。 + - rust:`crates/shadowsocks/src/relay/udprelay/aead_2022.rs:510-515, 175-236` + - sing:`shadowaead_2022/method.go:112, 340-390` + - go:`ss2022/crypto.go:90-111` 与 `ss2022/udp.go`(EIH 走 iPSK block) + - AES-256 路径下 AES-ECB 使用 32 字节密钥(不是截断为 16B)。 + - rust:`crates/shadowsocks/src/relay/udprelay/aead_2022.rs:208-235` + - sing:`shadowaead_2022/method.go:61-110` + - go:`ss2022/crypto.go:24-60` + +2. **不完全一致(不能用三仓一致性“确认”)** + - UDP Packet ID 起始值: + - rust:明确“从 1 开始”。 + - `crates/shadowsocks-service/src/local/dns/upstream.rs:114` + - sing:通过 `packetId--` 使首包从 1 开始。 + - `shadowaead_2022/method.go:366-386` + - go:实现里 `cpid++` 写入前值,首包从 0。 + - `ss2022/packet.go:144-145` + +3. **部分一致(仅在支持该算法的实现里成立)** + - chacha20-poly1305 UDP 头部 nonce 格式与 EIH 支持: + - rust 与 sing:chacha 使用额外 nonce 头,且不支持 EIH。 + - go:不支持 chacha2022。 diff --git a/protocol/shadowsocks2022/cipher.go b/protocol/shadowsocks2022/cipher.go new file mode 100644 index 0000000..39654f2 --- /dev/null +++ b/protocol/shadowsocks2022/cipher.go @@ -0,0 +1,185 @@ +package shadowsocks2022 + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/chacha20poly1305" + "lukechampine.com/blake3" +) + +const ( + // KeyLen for different ciphers + KeyLen128 = 16 + KeyLen256 = 32 + + // BLAKE3 context strings + SessionSubkeyContext = "shadowsocks 2022 session subkey" + IdentitySubkeyContext = "shadowsocks 2022 identity subkey" + + // Salt length (same as key length for SS2022) + SaltLen128 = 16 + SaltLen256 = 32 + + // AEAD tag length + TagLen = 16 + + // Nonce length for TCP (standard AEAD) + NonceLen = 12 + + // Nonce length for UDP XChaCha20-Poly1305 + XChaCha20NonceLen = 24 +) + +// UDPCipherMode indicates which UDP encryption mode to use +type UDPCipherMode int + +const ( + // UDPModeAES uses 16B separate header + AES-ECB + AES-GCM AEAD + UDPModeAES UDPCipherMode = iota + // UDPModeChaCha uses 24B nonce prefix + XChaCha20-Poly1305 AEAD + UDPModeChaCha +) + +// CipherConfig holds the configuration for a SS2022 cipher +type CipherConfig struct { + KeyLen int + SaltLen int + NonceLen int // For TCP + TagLen int + UDPMode UDPCipherMode + UDPNonceLen int // For UDP (12 for AES, 24 for XChaCha20) + NewCipher func(key []byte) (cipher.AEAD, error) // For TCP + NewUDPCipher func(key []byte) (cipher.AEAD, error) // For UDP (may differ for ChaCha20) +} + +// Cipher configs for SS2022 +var CipherConfigs = map[string]*CipherConfig{ + "2022-blake3-aes-128-gcm": { + KeyLen: KeyLen128, + SaltLen: SaltLen128, + NonceLen: NonceLen, + TagLen: TagLen, + UDPMode: UDPModeAES, + UDPNonceLen: NonceLen, + NewCipher: newAESGCM, + NewUDPCipher: newAESGCM, + }, + "2022-blake3-aes-256-gcm": { + KeyLen: KeyLen256, + SaltLen: SaltLen256, + NonceLen: NonceLen, + TagLen: TagLen, + UDPMode: UDPModeAES, + UDPNonceLen: NonceLen, + NewCipher: newAESGCM, + NewUDPCipher: newAESGCM, + }, + "2022-blake3-chacha20-poly1305": { + KeyLen: KeyLen256, + SaltLen: SaltLen256, + NonceLen: NonceLen, + TagLen: TagLen, + UDPMode: UDPModeChaCha, + UDPNonceLen: XChaCha20NonceLen, + NewCipher: chacha20poly1305.New, + NewUDPCipher: chacha20poly1305.NewX, // XChaCha20-Poly1305 for UDP + }, +} + +// newAESGCM creates a new AES-GCM cipher +func newAESGCM(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +// DeriveSessionKey derives a session subkey using BLAKE3 +func DeriveSessionKey(psk, salt []byte, keyLen int) []byte { + keyMaterial := make([]byte, len(psk)+len(salt)) + copy(keyMaterial, psk) + copy(keyMaterial[len(psk):], salt) + + subkey := make([]byte, keyLen) + blake3.DeriveKey(subkey, SessionSubkeyContext, keyMaterial) + return subkey +} + +// DeriveIdentitySubkey derives an identity subkey for EIH using BLAKE3 +func DeriveIdentitySubkey(psk []byte, keyLen int) []byte { + subkey := make([]byte, keyLen) + blake3.DeriveKey(subkey, IdentitySubkeyContext, psk) + return subkey +} + +// ParsePSK parses a base64-encoded PSK and validates its length +func ParsePSK(password string, expectedLen int) ([]byte, error) { + // Handle the format "method:psk" or "method:ipsk:upsk" + parts := strings.SplitN(password, ":", 3) + var pskStr string + switch len(parts) { + case 1: + pskStr = parts[0] + case 2: + // method:psk + pskStr = parts[1] + case 3: + // method:ipsk:upsk - return uPSK + pskStr = parts[2] + default: + return nil, fmt.Errorf("invalid password format") + } + + psk, err := base64.StdEncoding.DecodeString(pskStr) + if err != nil { + return nil, fmt.Errorf("failed to decode PSK: %w", err) + } + + if len(psk) != expectedLen { + return nil, fmt.Errorf("PSK length mismatch: expected %d, got %d", expectedLen, len(psk)) + } + + return psk, nil +} + +// ParseMultiUserPSK parses multi-user PSK format (ipsk:upsk) +func ParseMultiUserPSK(password string, expectedLen int) (iPSK, uPSK []byte, err error) { + parts := strings.SplitN(password, ":", 3) + if len(parts) < 3 { + // Single user mode + return nil, nil, nil + } + + // parts[0] is method, parts[1] is iPSK, parts[2] is uPSK + iPSK, err = base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return nil, nil, fmt.Errorf("failed to decode iPSK: %w", err) + } + if len(iPSK) != expectedLen { + return nil, nil, fmt.Errorf("iPSK length mismatch: expected %d, got %d", expectedLen, len(iPSK)) + } + + uPSK, err = base64.StdEncoding.DecodeString(parts[2]) + if err != nil { + return nil, nil, fmt.Errorf("failed to decode uPSK: %w", err) + } + if len(uPSK) != expectedLen { + return nil, nil, fmt.Errorf("uPSK length mismatch: expected %d, got %d", expectedLen, len(uPSK)) + } + + return iPSK, uPSK, nil +} + +// GetCipherConfig returns the cipher config for the given method +func GetCipherConfig(method string) (*CipherConfig, error) { + config, ok := CipherConfigs[method] + if !ok { + return nil, fmt.Errorf("unsupported cipher method: %s", method) + } + return config, nil +} diff --git a/protocol/shadowsocks2022/cipher_test.go b/protocol/shadowsocks2022/cipher_test.go new file mode 100644 index 0000000..d35cbae --- /dev/null +++ b/protocol/shadowsocks2022/cipher_test.go @@ -0,0 +1,199 @@ +package shadowsocks2022 + +import ( + "encoding/base64" + "testing" +) + +func TestDeriveSessionKey(t *testing.T) { + // Test key derivation produces consistent output + psk := make([]byte, 32) + for i := range psk { + psk[i] = byte(i) + } + + salt := make([]byte, 32) + for i := range salt { + salt[i] = byte(i + 32) + } + + key1 := DeriveSessionKey(psk, salt, 32) + key2 := DeriveSessionKey(psk, salt, 32) + + if len(key1) != 32 { + t.Errorf("expected key length 32, got %d", len(key1)) + } + + for i := range key1 { + if key1[i] != key2[i] { + t.Errorf("key derivation not deterministic at position %d", i) + } + } + + // Different salt should produce different key + salt[0] = 0xFF + key3 := DeriveSessionKey(psk, salt, 32) + same := true + for i := range key1 { + if key1[i] != key3[i] { + same = false + break + } + } + if same { + t.Error("different salt should produce different key") + } +} + +func TestDeriveIdentitySubkey(t *testing.T) { + psk := make([]byte, 32) + for i := range psk { + psk[i] = byte(i) + } + + key := DeriveIdentitySubkey(psk, 32) + if len(key) != 32 { + t.Errorf("expected key length 32, got %d", len(key)) + } +} + +func TestParsePSK(t *testing.T) { + // Create a valid 32-byte PSK + pskBytes := make([]byte, 32) + for i := range pskBytes { + pskBytes[i] = byte(i) + } + pskBase64 := base64.StdEncoding.EncodeToString(pskBytes) + + // Test single PSK + password := pskBase64 + psk, err := ParsePSK(password, 32) + if err != nil { + t.Fatalf("ParsePSK failed: %v", err) + } + if len(psk) != 32 { + t.Errorf("expected PSK length 32, got %d", len(psk)) + } + + // Test method:psk format + password = "2022-blake3-aes-256-gcm:" + pskBase64 + psk, err = ParsePSK(password, 32) + if err != nil { + t.Fatalf("ParsePSK with method failed: %v", err) + } + if len(psk) != 32 { + t.Errorf("expected PSK length 32, got %d", len(psk)) + } + + // Test wrong length + shortPSK := base64.StdEncoding.EncodeToString([]byte{1, 2, 3}) + _, err = ParsePSK(shortPSK, 32) + if err == nil { + t.Error("expected error for wrong PSK length") + } +} + +func TestParseMultiUserPSK(t *testing.T) { + // Create valid 32-byte PSKs + iPSKBytes := make([]byte, 32) + uPSKBytes := make([]byte, 32) + for i := range iPSKBytes { + iPSKBytes[i] = byte(i) + uPSKBytes[i] = byte(i + 32) + } + iPSKBase64 := base64.StdEncoding.EncodeToString(iPSKBytes) + uPSKBase64 := base64.StdEncoding.EncodeToString(uPSKBytes) + + // Test multi-user format: method:ipsk:upsk + password := "2022-blake3-aes-256-gcm:" + iPSKBase64 + ":" + uPSKBase64 + iPSK, uPSK, err := ParseMultiUserPSK(password, 32) + if err != nil { + t.Fatalf("ParseMultiUserPSK failed: %v", err) + } + if len(iPSK) != 32 { + t.Errorf("expected iPSK length 32, got %d", len(iPSK)) + } + if len(uPSK) != 32 { + t.Errorf("expected uPSK length 32, got %d", len(uPSK)) + } + + // Test single-user format (should return nil, nil, nil) + password = "2022-blake3-aes-256-gcm:" + iPSKBase64 + iPSK, uPSK, err = ParseMultiUserPSK(password, 32) + if err != nil { + t.Fatalf("ParseMultiUserPSK for single-user failed: %v", err) + } + if iPSK != nil || uPSK != nil { + t.Error("expected nil for single-user mode") + } +} + +func TestGetCipherConfig(t *testing.T) { + tests := []struct { + method string + keyLen int + wantErr bool + }{ + {"2022-blake3-aes-128-gcm", 16, false}, + {"2022-blake3-aes-256-gcm", 32, false}, + {"2022-blake3-chacha20-poly1305", 32, false}, + {"unknown-cipher", 0, true}, + } + + for _, tt := range tests { + config, err := GetCipherConfig(tt.method) + if tt.wantErr { + if err == nil { + t.Errorf("GetCipherConfig(%s) expected error", tt.method) + } + continue + } + if err != nil { + t.Errorf("GetCipherConfig(%s) unexpected error: %v", tt.method, err) + continue + } + if config.KeyLen != tt.keyLen { + t.Errorf("GetCipherConfig(%s) keyLen = %d, want %d", tt.method, config.KeyLen, tt.keyLen) + } + } +} + +func TestCipherConfigUDPMode(t *testing.T) { + tests := []struct { + method string + expectedMode UDPCipherMode + udpNonceLen int + }{ + {"2022-blake3-aes-128-gcm", UDPModeAES, 12}, + {"2022-blake3-aes-256-gcm", UDPModeAES, 12}, + {"2022-blake3-chacha20-poly1305", UDPModeChaCha, 24}, + } + + for _, tt := range tests { + config, err := GetCipherConfig(tt.method) + if err != nil { + t.Fatalf("GetCipherConfig(%s) failed: %v", tt.method, err) + } + + if config.UDPMode != tt.expectedMode { + t.Errorf("GetCipherConfig(%s) UDPMode = %d, want %d", tt.method, config.UDPMode, tt.expectedMode) + } + + if config.UDPNonceLen != tt.udpNonceLen { + t.Errorf("GetCipherConfig(%s) UDPNonceLen = %d, want %d", tt.method, config.UDPNonceLen, tt.udpNonceLen) + } + + // Verify NewUDPCipher works + key := make([]byte, config.KeyLen) + cipher, err := config.NewUDPCipher(key) + if err != nil { + t.Errorf("GetCipherConfig(%s) NewUDPCipher failed: %v", tt.method, err) + continue + } + + // Verify nonce size matches expected + if cipher.NonceSize() != tt.udpNonceLen { + t.Errorf("GetCipherConfig(%s) cipher.NonceSize() = %d, want %d", tt.method, cipher.NonceSize(), tt.udpNonceLen) + } + } +} diff --git a/protocol/shadowsocks2022/dialer.go b/protocol/shadowsocks2022/dialer.go new file mode 100644 index 0000000..e33699d --- /dev/null +++ b/protocol/shadowsocks2022/dialer.go @@ -0,0 +1,154 @@ +package shadowsocks2022 + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + + "github.com/daeuniverse/outbound/netproxy" + "github.com/daeuniverse/outbound/protocol" +) + +func init() { + protocol.Register("shadowsocks2022", NewDialer) +} + +// Dialer implements netproxy.Dialer for SS2022 +type Dialer struct { + nextDialer netproxy.Dialer + proxyAddress string + cipher string + config *CipherConfig + psk []byte // user PSK (or only PSK in single-user mode) + iPSK []byte // identity PSK (nil in single-user mode) +} + +// NewDialer creates a new SS2022 dialer +func NewDialer(nextDialer netproxy.Dialer, header protocol.Header) (netproxy.Dialer, error) { + config, err := GetCipherConfig(header.Cipher) + if err != nil { + return nil, err + } + + // Parse PSK from password + // Password format: base64(psk) or base64(ipsk):base64(upsk) for multi-user + psk, iPSK, err := parsePSKFromPassword(header.Password, config.KeyLen) + if err != nil { + return nil, fmt.Errorf("failed to parse PSK: %w", err) + } + + // ChaCha20-Poly1305 does not support EIH (multi-user mode) + if iPSK != nil && header.Cipher == "2022-blake3-chacha20-poly1305" { + return nil, fmt.Errorf("chacha20-poly1305 does not support EIH (multi-user mode)") + } + + return &Dialer{ + nextDialer: nextDialer, + proxyAddress: header.ProxyAddress, + cipher: header.Cipher, + config: config, + psk: psk, + iPSK: iPSK, + }, nil +} + +// parsePSKFromPassword parses PSK(s) from the password field +// Returns (psk, nil, nil) for single-user mode +// Returns (uPSK, iPSK, nil) for multi-user mode +func parsePSKFromPassword(password string, keyLen int) (psk, iPSK []byte, err error) { + parts := strings.Split(password, ":") + switch len(parts) { + case 1: + // Single PSK: base64(psk) + psk, err = base64.StdEncoding.DecodeString(parts[0]) + if err != nil { + return nil, nil, fmt.Errorf("invalid PSK encoding: %w", err) + } + if len(psk) != keyLen { + return nil, nil, fmt.Errorf("PSK length mismatch: expected %d, got %d", keyLen, len(psk)) + } + return psk, nil, nil + case 2: + // Multi-user: base64(ipsk):base64(upsk) + iPSK, err = base64.StdEncoding.DecodeString(parts[0]) + if err != nil { + return nil, nil, fmt.Errorf("invalid iPSK encoding: %w", err) + } + if len(iPSK) != keyLen { + return nil, nil, fmt.Errorf("iPSK length mismatch: expected %d, got %d", keyLen, len(iPSK)) + } + psk, err = base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return nil, nil, fmt.Errorf("invalid uPSK encoding: %w", err) + } + if len(psk) != keyLen { + return nil, nil, fmt.Errorf("uPSK length mismatch: expected %d, got %d", keyLen, len(psk)) + } + return psk, iPSK, nil + default: + return nil, nil, fmt.Errorf("invalid password format: expected 1 or 2 parts, got %d", len(parts)) + } +} + +// Dial creates a new TCP connection +func (d *Dialer) Dial(network, addr string) (c netproxy.Conn, err error) { + return d.DialContext(context.Background(), network, addr) +} + +// DialContext creates a new connection with context +func (d *Dialer) DialContext(ctx context.Context, network, addr string) (netproxy.Conn, error) { + metadata, err := protocol.ParseMetadata(addr) + if err != nil { + return nil, err + } + metadata.Cipher = d.cipher + metadata.IsClient = true + + switch network { + case "tcp": + return d.dialTCP(ctx, metadata) + case "udp": + return d.dialUDP(ctx, metadata) + default: + return nil, fmt.Errorf("unsupported network: %s", network) + } +} + +// dialTCP creates a TCP connection +func (d *Dialer) dialTCP(ctx context.Context, metadata protocol.Metadata) (netproxy.Conn, error) { + conn, err := d.nextDialer.DialContext(ctx, "tcp", d.proxyAddress) + if err != nil { + return nil, fmt.Errorf("failed to dial proxy: %w", err) + } + + tcpConn, err := NewTCPConn(conn, metadata, d.psk, d.iPSK, d.config) + if err != nil { + conn.Close() + return nil, err + } + + return tcpConn, nil +} + +// dialUDP creates a UDP connection +func (d *Dialer) dialUDP(ctx context.Context, metadata protocol.Metadata) (netproxy.Conn, error) { + conn, err := d.nextDialer.DialContext(ctx, "udp", d.proxyAddress) + if err != nil { + return nil, fmt.Errorf("failed to dial proxy: %w", err) + } + + packetConn, ok := conn.(netproxy.PacketConn) + if !ok { + conn.Close() + return nil, fmt.Errorf("connection is not a PacketConn") + } + + udpConn, err := NewUDPConn(packetConn, d.proxyAddress, metadata, d.psk, d.iPSK, d.config) + if err != nil { + conn.Close() + return nil, err + } + + return udpConn, nil +} diff --git a/protocol/shadowsocks2022/header.go b/protocol/shadowsocks2022/header.go new file mode 100644 index 0000000..84441c0 --- /dev/null +++ b/protocol/shadowsocks2022/header.go @@ -0,0 +1,197 @@ +package shadowsocks2022 + +import ( + "encoding/binary" + "errors" + "fmt" + "net" + "time" + + "github.com/daeuniverse/outbound/pool" + "github.com/daeuniverse/outbound/protocol" +) + +const ( + // Header types + HeaderTypeClientRequest = 0 + HeaderTypeServerResponse = 1 + + // Fixed header size: Type(1) + Timestamp(8) + Length(2) = 11 bytes + FixedHeaderLen = 11 + + // Address types (SOCKS5 format) + AddrTypeIPv4 = 1 + AddrTypeDomain = 3 + AddrTypeIPv6 = 4 + + // Time validation window (±30 seconds) + TimestampValidWindow = 30 * time.Second + + // Max padding length + MaxPaddingLen = 900 + + // Min padding length + MinPaddingLen = 0 +) + +var ( + ErrInvalidTimestamp = errors.New("timestamp validation failed") + ErrInvalidHeaderType = errors.New("invalid header type") + ErrInvalidAddressType = errors.New("invalid address type") + ErrInvalidSalt = errors.New("response salt mismatch") +) + +// FixedHeader represents the fixed-length header in SS2022 +type FixedHeader struct { + Type byte // 0 for request, 1 for response + Timestamp uint64 // Unix timestamp + VarHeaderLen uint16 // Length of variable header +} + +// EncodeFixedHeader encodes a fixed header +func EncodeFixedHeader(headerType byte, timestamp uint64, varHeaderLen uint16) []byte { + buf := make([]byte, FixedHeaderLen) + buf[0] = headerType + binary.BigEndian.PutUint64(buf[1:9], timestamp) + binary.BigEndian.PutUint16(buf[9:11], varHeaderLen) + return buf +} + +// DecodeFixedHeader decodes a fixed header +func DecodeFixedHeader(data []byte) (*FixedHeader, error) { + if len(data) < FixedHeaderLen { + return nil, fmt.Errorf("fixed header too short: %d < %d", len(data), FixedHeaderLen) + } + + return &FixedHeader{ + Type: data[0], + Timestamp: binary.BigEndian.Uint64(data[1:9]), + VarHeaderLen: binary.BigEndian.Uint16(data[9:11]), + }, nil +} + +// ValidateTimestamp validates if the timestamp is within acceptable range +func ValidateTimestamp(timestamp uint64) error { + now := uint64(time.Now().Unix()) + diff := int64(timestamp) - int64(now) + if diff < 0 { + diff = -diff + } + if diff > int64(TimestampValidWindow.Seconds()) { + return fmt.Errorf("%w: diff=%ds", ErrInvalidTimestamp, diff) + } + return nil +} + +// EncodeAddress encodes an address in SOCKS5 format +func EncodeAddress(hostname string, port uint16) ([]byte, error) { + ip := net.ParseIP(hostname) + if ip != nil { + if ipv4 := ip.To4(); ipv4 != nil { + // IPv4 + buf := pool.Get(1 + 4 + 2) + buf[0] = AddrTypeIPv4 + copy(buf[1:5], ipv4) + binary.BigEndian.PutUint16(buf[5:7], port) + return buf, nil + } + // IPv6 + buf := pool.Get(1 + 16 + 2) + buf[0] = AddrTypeIPv6 + copy(buf[1:17], ip.To16()) + binary.BigEndian.PutUint16(buf[17:19], port) + return buf, nil + } + + // Domain + domainLen := len(hostname) + if domainLen > 255 { + return nil, fmt.Errorf("domain name too long: %d", domainLen) + } + buf := pool.Get(1 + 1 + domainLen + 2) + buf[0] = AddrTypeDomain + buf[1] = byte(domainLen) + copy(buf[2:2+domainLen], hostname) + binary.BigEndian.PutUint16(buf[2+domainLen:], port) + return buf, nil +} + +// DecodeAddress decodes a SOCKS5 format address +func DecodeAddress(data []byte) (hostname string, port uint16, addrLen int, err error) { + if len(data) < 1 { + return "", 0, 0, fmt.Errorf("address data too short") + } + + addrType := data[0] + switch addrType { + case AddrTypeIPv4: + if len(data) < 1+4+2 { + return "", 0, 0, fmt.Errorf("IPv4 address too short") + } + hostname = net.IP(data[1:5]).String() + port = binary.BigEndian.Uint16(data[5:7]) + addrLen = 7 + case AddrTypeIPv6: + if len(data) < 1+16+2 { + return "", 0, 0, fmt.Errorf("IPv6 address too short") + } + hostname = net.IP(data[1:17]).String() + port = binary.BigEndian.Uint16(data[17:19]) + addrLen = 19 + case AddrTypeDomain: + if len(data) < 2 { + return "", 0, 0, fmt.Errorf("domain address too short") + } + domainLen := int(data[1]) + if len(data) < 1+1+domainLen+2 { + return "", 0, 0, fmt.Errorf("domain address too short for domain length %d", domainLen) + } + hostname = string(data[2 : 2+domainLen]) + port = binary.BigEndian.Uint16(data[2+domainLen : 2+domainLen+2]) + addrLen = 1 + 1 + domainLen + 2 + default: + return "", 0, 0, fmt.Errorf("%w: %d", ErrInvalidAddressType, addrType) + } + + return hostname, port, addrLen, nil +} + +// AddressLength returns the length of an encoded address +func AddressLength(hostname string) int { + ip := net.ParseIP(hostname) + if ip != nil { + if ip.To4() != nil { + return 1 + 4 + 2 + } + return 1 + 16 + 2 + } + return 1 + 1 + len(hostname) + 2 +} + +// MetadataTypeFromAddrType converts SS2022 address type to protocol.MetadataType +func MetadataTypeFromAddrType(addrType byte) protocol.MetadataType { + switch addrType { + case AddrTypeIPv4: + return protocol.MetadataTypeIPv4 + case AddrTypeDomain: + return protocol.MetadataTypeDomain + case AddrTypeIPv6: + return protocol.MetadataTypeIPv6 + default: + return protocol.MetadataTypeInvalid + } +} + +// AddrTypeFromMetadataType converts protocol.MetadataType to SS2022 address type +func AddrTypeFromMetadataType(t protocol.MetadataType) byte { + switch t { + case protocol.MetadataTypeIPv4: + return AddrTypeIPv4 + case protocol.MetadataTypeDomain: + return AddrTypeDomain + case protocol.MetadataTypeIPv6: + return AddrTypeIPv6 + default: + return 0 + } +} diff --git a/protocol/shadowsocks2022/header_test.go b/protocol/shadowsocks2022/header_test.go new file mode 100644 index 0000000..975fb56 --- /dev/null +++ b/protocol/shadowsocks2022/header_test.go @@ -0,0 +1,110 @@ +package shadowsocks2022 + +import ( + "testing" + "time" +) + +func TestEncodeDecodeFixedHeader(t *testing.T) { + timestamp := uint64(time.Now().Unix()) + varHeaderLen := uint16(100) + + encoded := EncodeFixedHeader(HeaderTypeClientRequest, timestamp, varHeaderLen) + if len(encoded) != FixedHeaderLen { + t.Errorf("expected fixed header length %d, got %d", FixedHeaderLen, len(encoded)) + } + + decoded, err := DecodeFixedHeader(encoded) + if err != nil { + t.Fatalf("DecodeFixedHeader failed: %v", err) + } + + if decoded.Type != HeaderTypeClientRequest { + t.Errorf("Type mismatch: got %d, want %d", decoded.Type, HeaderTypeClientRequest) + } + if decoded.Timestamp != timestamp { + t.Errorf("Timestamp mismatch: got %d, want %d", decoded.Timestamp, timestamp) + } + if decoded.VarHeaderLen != varHeaderLen { + t.Errorf("VarHeaderLen mismatch: got %d, want %d", decoded.VarHeaderLen, varHeaderLen) + } +} + +func TestValidateTimestamp(t *testing.T) { + // Valid timestamp (now) + now := uint64(time.Now().Unix()) + if err := ValidateTimestamp(now); err != nil { + t.Errorf("ValidateTimestamp(now) failed: %v", err) + } + + // Valid timestamp (within window) + withinWindow := uint64(time.Now().Unix()) + 15 + if err := ValidateTimestamp(withinWindow); err != nil { + t.Errorf("ValidateTimestamp(+15s) failed: %v", err) + } + + // Invalid timestamp (too old) + tooOld := uint64(time.Now().Unix()) - 60 + if err := ValidateTimestamp(tooOld); err == nil { + t.Error("ValidateTimestamp(-60s) should fail") + } + + // Invalid timestamp (too new) + tooNew := uint64(time.Now().Unix()) + 60 + if err := ValidateTimestamp(tooNew); err == nil { + t.Error("ValidateTimestamp(+60s) should fail") + } +} + +func TestEncodeDecodeAddress(t *testing.T) { + tests := []struct { + hostname string + port uint16 + }{ + {"127.0.0.1", 8080}, + {"192.168.1.1", 443}, + {"::1", 8080}, + {"2001:db8::1", 443}, + {"example.com", 80}, + {"www.example.org", 443}, + } + + for _, tt := range tests { + encoded, err := EncodeAddress(tt.hostname, tt.port) + if err != nil { + t.Errorf("EncodeAddress(%s, %d) failed: %v", tt.hostname, tt.port, err) + continue + } + + hostname, port, _, err := DecodeAddress(encoded) + if err != nil { + t.Errorf("DecodeAddress for %s:%d failed: %v", tt.hostname, tt.port, err) + continue + } + + if hostname != tt.hostname { + t.Errorf("hostname mismatch: got %s, want %s", hostname, tt.hostname) + } + if port != tt.port { + t.Errorf("port mismatch: got %d, want %d", port, tt.port) + } + } +} + +func TestAddressLength(t *testing.T) { + tests := []struct { + hostname string + expectedLen int + }{ + {"127.0.0.1", 1 + 4 + 2}, // IPv4 + {"::1", 1 + 16 + 2}, // IPv6 + {"example.com", 1 + 1 + 11 + 2}, // Domain + } + + for _, tt := range tests { + got := AddressLength(tt.hostname) + if got != tt.expectedLen { + t.Errorf("AddressLength(%s) = %d, want %d", tt.hostname, got, tt.expectedLen) + } + } +} diff --git a/protocol/shadowsocks2022/identity.go b/protocol/shadowsocks2022/identity.go new file mode 100644 index 0000000..f29717f --- /dev/null +++ b/protocol/shadowsocks2022/identity.go @@ -0,0 +1,169 @@ +package shadowsocks2022 + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/subtle" + "fmt" + + "lukechampine.com/blake3" +) + +const ( + // IdentityHeaderLen is the length of the encrypted identity header + IdentityHeaderLen = 16 +) + +// PSKHash returns the BLAKE3 hash of a PSK truncated to 16 bytes +func PSKHash(psk []byte) [IdentityHeaderLen]byte { + hash := blake3.Sum512(psk) + var result [IdentityHeaderLen]byte + copy(result[:], hash[:IdentityHeaderLen]) + return result +} + +// DeriveIdentitySubkeyWithSalt derives an identity subkey for TCP EIH using BLAKE3 +// Context: "shadowsocks 2022 identity subkey" +// Key material: iPSK || salt +func DeriveIdentitySubkeyWithSalt(iPSK, salt []byte, keyLen int) []byte { + keyMaterial := make([]byte, len(iPSK)+len(salt)) + copy(keyMaterial, iPSK) + copy(keyMaterial[len(iPSK):], salt) + + subkey := make([]byte, keyLen) + blake3.DeriveKey(subkey, IdentitySubkeyContext, keyMaterial) + return subkey +} + +// IdentityHeader handles EIH (Encrypted Identity Header) for multi-user mode +type IdentityHeader struct { + iPSK []byte // identity PSK (server's main key) + uPSK []byte // user PSK + keyLen int +} + +// NewIdentityHeader creates a new IdentityHeader for multi-user mode +func NewIdentityHeader(iPSK, uPSK []byte, keyLen int) *IdentityHeader { + return &IdentityHeader{ + iPSK: iPSK, + uPSK: uPSK, + keyLen: keyLen, + } +} + +// IsMultiUser returns true if this is multi-user mode +func (h *IdentityHeader) IsMultiUser() bool { + return h != nil && h.iPSK != nil && h.uPSK != nil +} + +// GenerateUDPIdentityHeader generates the identity header for UDP +// For UDP, the identity header is: AES-ECB-Encrypt(iPSK, XOR(uPSKHash, separateHeader)) +// Note: Uses iPSK for encryption, not uPSK +func GenerateUDPIdentityHeader(iPSK []byte, uPSKHash [IdentityHeaderLen]byte, separateHeader []byte) ([]byte, error) { + if len(separateHeader) < IdentityHeaderLen { + return nil, fmt.Errorf("separate header too short") + } + + // XOR uPSK hash with separate header + xored := make([]byte, IdentityHeaderLen) + subtle.XORBytes(xored, uPSKHash[:], separateHeader[:IdentityHeaderLen]) + + // Encrypt with AES-ECB using iPSK + // Use appropriate key length based on iPSK length + block, err := CreateECBEncryptorWithKeyLen(iPSK, len(iPSK)) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + + identityHeader := make([]byte, IdentityHeaderLen) + block.Encrypt(identityHeader, xored) + + return identityHeader, nil +} + +// VerifyUDPIdentityHeader verifies and decrypts a UDP identity header +// Returns the index of the matching user PSK, or -1 if not found +func VerifyUDPIdentityHeader(iPSK, separateHeader, encryptedHeader []byte, userPSKHashes [][IdentityHeaderLen]byte) (int, error) { + if len(encryptedHeader) != IdentityHeaderLen { + return -1, fmt.Errorf("invalid identity header length: %d", len(encryptedHeader)) + } + + // Decrypt with AES-ECB using iPSK + block, err := CreateECBEncryptorWithKeyLen(iPSK, len(iPSK)) + if err != nil { + return -1, fmt.Errorf("failed to create AES cipher: %w", err) + } + + decrypted := make([]byte, IdentityHeaderLen) + block.Decrypt(decrypted, encryptedHeader) + + // XOR with separate header to get original uPSK hash + subtle.XORBytes(decrypted, decrypted, separateHeader[:IdentityHeaderLen]) + + // Compare with each user's PSK hash + for i, hash := range userPSKHashes { + if compareConstantTime(decrypted, hash[:]) { + return i, nil + } + } + + return -1, fmt.Errorf("no matching user PSK found") +} + +// compareConstantTime compares two byte slices in constant time +func compareConstantTime(a, b []byte) bool { + return subtle.ConstantTimeCompare(a, b) == 1 +} + +// compareBytes compares two byte slices +func compareBytes(a, b []byte) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare(a, b) == 1 +} + +// GetEffectivePSK returns the PSK to use for encryption +// For multi-user mode, returns uPSK; for single-user mode, returns the only PSK +func (h *IdentityHeader) GetEffectivePSK() []byte { + if h.IsMultiUser() { + return h.uPSK + } + return h.uPSK // In single-user mode, uPSK holds the only PSK +} + +// CreateECBDecryptor creates an AES-ECB decryptor with 16-byte key (AES-128) +func CreateECBDecryptor(key []byte) (cipher.Block, error) { + return aes.NewCipher(key[:16]) +} + +// CreateECBEncryptor creates an AES-ECB encryptor with 16-byte key (AES-128) +func CreateECBEncryptor(key []byte) (cipher.Block, error) { + return aes.NewCipher(key[:16]) +} + +// CreateECBEncryptorWithKeyLen creates an AES-ECB encryptor with appropriate key length +// For AES-128 (keyLen=16): uses first 16 bytes +// For AES-256 (keyLen=32): uses first 32 bytes (AES-256) +func CreateECBEncryptorWithKeyLen(key []byte, keyLen int) (cipher.Block, error) { + switch keyLen { + case 16: + return aes.NewCipher(key[:16]) + case 32: + return aes.NewCipher(key[:32]) + default: + return nil, fmt.Errorf("unsupported key length: %d", keyLen) + } +} + +// CreateECBDecryptorWithKeyLen creates an AES-ECB decryptor with appropriate key length +func CreateECBDecryptorWithKeyLen(key []byte, keyLen int) (cipher.Block, error) { + switch keyLen { + case 16: + return aes.NewCipher(key[:16]) + case 32: + return aes.NewCipher(key[:32]) + default: + return nil, fmt.Errorf("unsupported key length: %d", keyLen) + } +} diff --git a/protocol/shadowsocks2022/replay.go b/protocol/shadowsocks2022/replay.go new file mode 100644 index 0000000..eb88d53 --- /dev/null +++ b/protocol/shadowsocks2022/replay.go @@ -0,0 +1,193 @@ +package shadowsocks2022 + +import ( + "sync" +) + +const ( + // DefaultWindowSize is the default size of the sliding window + DefaultWindowSize = 1024 +) + +// SlidingWindowFilter implements a sliding window filter for replay protection +// It uses a bitmap to track received packet IDs within the window +type SlidingWindowFilter struct { + mu sync.Mutex + lastID uint64 + windowSize uint64 + bitmap []uint64 // Each uint64 can track 64 packet IDs +} + +// NewSlidingWindowFilter creates a new sliding window filter +func NewSlidingWindowFilter(windowSize int) *SlidingWindowFilter { + if windowSize <= 0 { + windowSize = DefaultWindowSize + } + // Round up to next multiple of 64 + bitmapSize := (windowSize + 63) / 64 + return &SlidingWindowFilter{ + windowSize: uint64(windowSize), + bitmap: make([]uint64, bitmapSize), + } +} + +// Check checks if the packet ID is valid (not replayed) +// Returns true if the ID is new and valid, false if it's a replay +func (f *SlidingWindowFilter) Check(id uint64) bool { + f.mu.Lock() + defer f.mu.Unlock() + + // If ID is too old (before the window), reject + if f.lastID > f.windowSize && id <= f.lastID-f.windowSize { + return false + } + + // If ID is newer than lastID, update window + if id > f.lastID { + // Shift the window + shift := id - f.lastID + if shift >= f.windowSize { + // Clear entire bitmap + for i := range f.bitmap { + f.bitmap[i] = 0 + } + } else { + // Shift bitmap + f.shiftBitmap(shift) + } + f.lastID = id + // Mark current ID as seen + f.setBit(0) + return true + } + + // ID is within the window, check if already seen + offset := f.lastID - id + if f.getBit(offset) { + return false // Already seen + } + + // Mark as seen + f.setBit(offset) + return true +} + +// shiftBitmap shifts the bitmap by the given amount +func (f *SlidingWindowFilter) shiftBitmap(shift uint64) { + if shift >= f.windowSize { + for i := range f.bitmap { + f.bitmap[i] = 0 + } + return + } + + wordShift := shift / 64 + bitShift := shift % 64 + + if wordShift > 0 { + // Shift words + for i := len(f.bitmap) - 1; i >= int(wordShift); i-- { + f.bitmap[i] = f.bitmap[i-int(wordShift)] + } + for i := 0; i < int(wordShift); i++ { + f.bitmap[i] = 0 + } + } + + if bitShift > 0 { + // Shift bits within words + var carry uint64 + for i := 0; i < len(f.bitmap); i++ { + newCarry := f.bitmap[i] >> (64 - bitShift) + f.bitmap[i] = (f.bitmap[i] << bitShift) | carry + carry = newCarry + } + } +} + +// setBit sets the bit at the given offset (0 = current position) +func (f *SlidingWindowFilter) setBit(offset uint64) { + wordIndex := offset / 64 + if wordIndex >= uint64(len(f.bitmap)) { + return + } + bitIndex := offset % 64 + f.bitmap[wordIndex] |= 1 << bitIndex +} + +// getBit gets the bit at the given offset +func (f *SlidingWindowFilter) getBit(offset uint64) bool { + wordIndex := offset / 64 + if wordIndex >= uint64(len(f.bitmap)) { + return false + } + bitIndex := offset % 64 + return (f.bitmap[wordIndex] & (1 << bitIndex)) != 0 +} + +// Reset resets the filter +func (f *SlidingWindowFilter) Reset() { + f.mu.Lock() + defer f.mu.Unlock() + f.lastID = 0 + for i := range f.bitmap { + f.bitmap[i] = 0 + } +} + +// SessionFilter manages replay filters for multiple sessions +type SessionFilter struct { + mu sync.RWMutex + filters map[string]*SlidingWindowFilter + maxItems int +} + +// NewSessionFilter creates a new session filter manager +func NewSessionFilter(maxItems int) *SessionFilter { + if maxItems <= 0 { + maxItems = 1024 + } + return &SessionFilter{ + filters: make(map[string]*SlidingWindowFilter), + maxItems: maxItems, + } +} + +// GetOrCreate gets or creates a filter for the given session key +func (sf *SessionFilter) GetOrCreate(sessionKey string) *SlidingWindowFilter { + sf.mu.RLock() + filter, ok := sf.filters[sessionKey] + sf.mu.RUnlock() + + if ok { + return filter + } + + sf.mu.Lock() + defer sf.mu.Unlock() + + // Double check after acquiring write lock + if filter, ok = sf.filters[sessionKey]; ok { + return filter + } + + // Evict old entries if at capacity + if len(sf.filters) >= sf.maxItems { + // Simple eviction: remove first item + for k := range sf.filters { + delete(sf.filters, k) + break + } + } + + filter = NewSlidingWindowFilter(DefaultWindowSize) + sf.filters[sessionKey] = filter + return filter +} + +// Remove removes a filter for the given session key +func (sf *SessionFilter) Remove(sessionKey string) { + sf.mu.Lock() + defer sf.mu.Unlock() + delete(sf.filters, sessionKey) +} diff --git a/protocol/shadowsocks2022/replay_test.go b/protocol/shadowsocks2022/replay_test.go new file mode 100644 index 0000000..275f640 --- /dev/null +++ b/protocol/shadowsocks2022/replay_test.go @@ -0,0 +1,139 @@ +package shadowsocks2022 + +import ( + "testing" +) + +func TestSlidingWindowFilter(t *testing.T) { + filter := NewSlidingWindowFilter(64) + + // First packet should be accepted + if !filter.Check(1) { + t.Error("first packet should be accepted") + } + + // Same packet should be rejected (replay) + if filter.Check(1) { + t.Error("replayed packet should be rejected") + } + + // Next packet should be accepted + if !filter.Check(2) { + t.Error("next packet should be accepted") + } + + // Packet within window should be accepted + if !filter.Check(10) { + t.Error("packet within window should be accepted") + } + + // Old packet (after window shift) should still be tracked + if filter.Check(1) { + t.Error("old packet should be rejected after window shift") + } + + // Much newer packet should shift window + if !filter.Check(100) { + t.Error("much newer packet should be accepted") + } + + // Very old packet (before window) should be rejected + if filter.Check(1) { + t.Error("very old packet should be rejected") + } +} + +func TestSlidingWindowFilterWindowShift(t *testing.T) { + filter := NewSlidingWindowFilter(1024) + + // Accept initial packets + for i := uint64(1); i <= 10; i++ { + if !filter.Check(i) { + t.Errorf("packet %d should be accepted", i) + } + } + + // Jump forward by more than window size + if !filter.Check(2000) { + t.Error("packet 2000 should be accepted") + } + + // Old packets should all be rejected + for i := uint64(1); i <= 10; i++ { + if filter.Check(i) { + t.Errorf("old packet %d should be rejected after large shift", i) + } + } + + // Packets within new window should work + if !filter.Check(1999) { + t.Error("packet 1999 should be accepted") + } + if filter.Check(1999) { + t.Error("replayed packet 1999 should be rejected") + } +} + +func TestSlidingWindowFilterReset(t *testing.T) { + filter := NewSlidingWindowFilter(64) + + // Accept some packets + filter.Check(1) + filter.Check(2) + filter.Check(3) + + // Reset + filter.Reset() + + // Same packets should be accepted again + if !filter.Check(1) { + t.Error("packet 1 should be accepted after reset") + } + if !filter.Check(2) { + t.Error("packet 2 should be accepted after reset") + } +} + +func TestSessionFilter(t *testing.T) { + sf := NewSessionFilter(10) + + // Get filter for session + f1 := sf.GetOrCreate("session1") + if f1 == nil { + t.Fatal("GetOrCreate should return a filter") + } + + // Same session should return same filter + f1b := sf.GetOrCreate("session1") + if f1 != f1b { + t.Error("same session should return same filter") + } + + // Different session should return different filter + f2 := sf.GetOrCreate("session2") + if f1 == f2 { + t.Error("different sessions should return different filters") + } + + // Remove session + sf.Remove("session1") + f1c := sf.GetOrCreate("session1") + if f1 == f1c { + t.Error("after remove, should create new filter") + } +} + +func TestSessionFilterEviction(t *testing.T) { + sf := NewSessionFilter(3) + + // Create 3 sessions + sf.GetOrCreate("s1") + sf.GetOrCreate("s2") + sf.GetOrCreate("s3") + + // Creating 4th should evict one + sf.GetOrCreate("s4") + + // We should still have 3 sessions (one was evicted) + // The test mainly ensures no panic and eviction works +} diff --git a/protocol/shadowsocks2022/tcp_conn.go b/protocol/shadowsocks2022/tcp_conn.go new file mode 100644 index 0000000..28e2ac8 --- /dev/null +++ b/protocol/shadowsocks2022/tcp_conn.go @@ -0,0 +1,434 @@ +package shadowsocks2022 + +import ( + "crypto/cipher" + "crypto/rand" + "encoding/binary" + "fmt" + "io" + "sync" + "time" + + "github.com/daeuniverse/outbound/common" + "github.com/daeuniverse/outbound/netproxy" + "github.com/daeuniverse/outbound/pool" + "github.com/daeuniverse/outbound/protocol" +) + +const ( + // TCPChunkMaxLen is the maximum length of a TCP chunk payload + TCPChunkMaxLen = 0xFFFF // 65535 bytes + + // MaxInitialPayloadLen is the maximum length of initial payload in variable header + // to avoid u16 overflow: varHeaderLen = addrLen + 2 + paddingLen + initialPayloadLen + // We reserve some space for address and padding length field + MaxInitialPayloadLen = 0xFFFF - 256 - 2 +) + +// TCPConn represents a SS2022 TCP connection +type TCPConn struct { + netproxy.Conn + metadata protocol.Metadata + config *CipherConfig + psk []byte // effective PSK for encryption + iPSK []byte // identity PSK (nil for single-user mode) + requestSalt []byte // stored for response validation + + cipherRead cipher.AEAD + cipherWrite cipher.AEAD + onceRead sync.Once + onceWrite sync.Once + nonceRead []byte + nonceWrite []byte + + readMutex sync.Mutex + writeMutex sync.Mutex + + leftToRead []byte + indexToRead int + + // Track if first write has been done (to avoid sending initial data twice) + firstWriteDone bool +} + +// NewTCPConn creates a new SS2022 TCP connection +func NewTCPConn(conn netproxy.Conn, metadata protocol.Metadata, psk, iPSK []byte, config *CipherConfig) (*TCPConn, error) { + c := &TCPConn{ + Conn: conn, + metadata: metadata, + config: config, + psk: psk, + iPSK: iPSK, + nonceRead: make([]byte, config.NonceLen), + nonceWrite: make([]byte, config.NonceLen), + } + + return c, nil +} + +// Close closes the connection +func (c *TCPConn) Close() error { + return c.Conn.Close() +} + +// Write writes data to the connection +func (c *TCPConn) Write(b []byte) (n int, err error) { + c.writeMutex.Lock() + defer c.writeMutex.Unlock() + + // First write: send header with initial payload + if !c.firstWriteDone { + c.firstWriteDone = true + + // Limit initial payload to avoid u16 overflow + initialPayloadLen := common.Min(len(b), MaxInitialPayloadLen) + if err := c.initWrite(b[:initialPayloadLen]); err != nil { + return 0, err + } + n = initialPayloadLen + b = b[initialPayloadLen:] + + // If there's remaining data, send as chunks + if len(b) == 0 { + return n, nil + } + } + + if c.cipherWrite == nil { + return 0, fmt.Errorf("cipher not initialized") + } + + // Write remaining data in chunks + for len(b) > 0 { + chunkLen := common.Min(TCPChunkMaxLen, len(b)) + chunk := b[:chunkLen] + b = b[chunkLen:] + + // Encrypt and write chunk + if err := c.writeChunk(chunk); err != nil { + return n, err + } + n += chunkLen + } + + return n, nil +} + +// initWrite initializes the write side with request header +func (c *TCPConn) initWrite(initialData []byte) error { + // Generate random salt + salt := pool.Get(c.config.SaltLen) + defer pool.Put(salt) + if _, err := rand.Read(salt); err != nil { + return fmt.Errorf("failed to generate salt: %w", err) + } + + // Store salt for response validation + c.requestSalt = make([]byte, c.config.SaltLen) + copy(c.requestSalt, salt) + + // Derive session key + sessionKey := DeriveSessionKey(c.psk, salt, c.config.KeyLen) + defer func() { + for i := range sessionKey { + sessionKey[i] = 0 + } + }() + + // Create cipher + var err error + c.cipherWrite, err = c.config.NewCipher(sessionKey) + if err != nil { + return fmt.Errorf("failed to create cipher: %w", err) + } + + // Build address + addrBytes, err := EncodeAddress(c.metadata.Hostname, c.metadata.Port) + if err != nil { + return fmt.Errorf("failed to encode address: %w", err) + } + defer pool.Put(addrBytes) + + // Calculate padding (0 for simplicity, can add random padding later) + paddingLen := 0 + + // Variable header format: Address + PaddingLen(2B) + Padding + InitialPayload + varHeaderLen := len(addrBytes) + 2 + paddingLen + len(initialData) + + // Safety check for u16 overflow + if varHeaderLen > 0xFFFF { + return fmt.Errorf("variable header too long: %d > 65535", varHeaderLen) + } + + varHeader := pool.Get(varHeaderLen) + defer pool.Put(varHeader) + + offset := 0 + copy(varHeader[offset:], addrBytes) + offset += len(addrBytes) + binary.BigEndian.PutUint16(varHeader[offset:], uint16(paddingLen)) + offset += 2 + offset += paddingLen // Skip padding (zeros) + copy(varHeader[offset:], initialData) + + // Build fixed header: Type(1) + Timestamp(8) + VarHeaderLen(2) = 11 bytes + timestamp := uint64(time.Now().Unix()) + fixedHeader := EncodeFixedHeader(HeaderTypeClientRequest, timestamp, uint16(varHeaderLen)) + + // Calculate identity headers length + identityHeadersLen := 0 + if c.iPSK != nil { + identityHeadersLen = IdentityHeaderLen + } + + // Total: Salt + IdentityHeaders(optional) + EncryptedFixedHeader + EncryptedVarHeader + totalLen := c.config.SaltLen + identityHeadersLen + + (FixedHeaderLen + c.config.TagLen) + + (varHeaderLen + c.config.TagLen) + + buf := pool.Get(totalLen) + defer pool.Put(buf) + + writeOffset := 0 + + // Copy salt + copy(buf[writeOffset:], salt) + writeOffset += c.config.SaltLen + + // Generate and copy identity header if multi-user mode + if c.iPSK != nil { + identityHeader, err := generateTCPIdentityHeader(c.iPSK, c.psk, salt, c.config.KeyLen) + if err != nil { + return fmt.Errorf("failed to generate identity header: %w", err) + } + copy(buf[writeOffset:], identityHeader) + writeOffset += IdentityHeaderLen + } + + // Encrypt fixed header + c.cipherWrite.Seal(buf[writeOffset:writeOffset], c.nonceWrite, fixedHeader, nil) + writeOffset += FixedHeaderLen + c.config.TagLen + common.BytesIncLittleEndian(c.nonceWrite) + + // Encrypt variable header + c.cipherWrite.Seal(buf[writeOffset:writeOffset], c.nonceWrite, varHeader, nil) + writeOffset += varHeaderLen + c.config.TagLen + common.BytesIncLittleEndian(c.nonceWrite) + + // Write to connection + _, err = c.Conn.Write(buf[:writeOffset]) + return err +} + +// generateTCPIdentityHeader generates the identity header for TCP +// Format: AES-ECB-Encrypt(identitySubkey, BLAKE3(uPSK)[:16]) +func generateTCPIdentityHeader(iPSK, uPSK, salt []byte, keyLen int) ([]byte, error) { + // Derive identity subkey: BLAKE3-DeriveKey("shadowsocks 2022 identity subkey", iPSK || salt) + identitySubkey := DeriveIdentitySubkeyWithSalt(iPSK, salt, keyLen) + + // Hash user PSK: BLAKE3(uPSK)[:16] + userPSKHash := PSKHash(uPSK) + + // Encrypt with AES-ECB (use appropriate key length) + block, err := CreateECBEncryptorWithKeyLen(identitySubkey, keyLen) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + + identityHeader := make([]byte, IdentityHeaderLen) + block.Encrypt(identityHeader, userPSKHash[:]) + + return identityHeader, nil +} + +// writeChunk writes an encrypted chunk +func (c *TCPConn) writeChunk(data []byte) error { + // Chunk format: EncryptedLength(2+Tag) + EncryptedPayload(len+Tag) + chunkLen := 2 + c.config.TagLen + len(data) + c.config.TagLen + buf := pool.Get(chunkLen) + defer pool.Put(buf) + + // Encrypt length + lenBuf := []byte{byte(len(data) >> 8), byte(len(data))} + c.cipherWrite.Seal(buf[:0], c.nonceWrite, lenBuf, nil) + common.BytesIncLittleEndian(c.nonceWrite) + + // Encrypt payload + c.cipherWrite.Seal(buf[2+c.config.TagLen:2+c.config.TagLen], c.nonceWrite, data, nil) + common.BytesIncLittleEndian(c.nonceWrite) + + _, err := c.Conn.Write(buf) + return err +} + +// Read reads data from the connection +func (c *TCPConn) Read(b []byte) (n int, err error) { + c.readMutex.Lock() + defer c.readMutex.Unlock() + + var initErr error + c.onceRead.Do(func() { + initErr = c.initRead() + }) + if initErr != nil { + return 0, initErr + } + + // Return buffered data first + if c.indexToRead < len(c.leftToRead) { + n = copy(b, c.leftToRead[c.indexToRead:]) + c.indexToRead += n + if c.indexToRead >= len(c.leftToRead) { + pool.Put(c.leftToRead) + c.leftToRead = nil + c.indexToRead = 0 + } + return n, nil + } + + // Read new chunk + chunk, err := c.readChunk() + if err != nil { + return 0, err + } + + n = copy(b, chunk) + if n < len(chunk) { + c.leftToRead = chunk + c.indexToRead = n + } else { + pool.Put(chunk) + } + + return n, nil +} + +// initRead initializes the read side by processing response header +func (c *TCPConn) initRead() error { + // Read response salt + salt := pool.Get(c.config.SaltLen) + defer pool.Put(salt) + if _, err := io.ReadFull(c.Conn, salt); err != nil { + return fmt.Errorf("failed to read response salt: %w", err) + } + + // Derive session key from response salt + sessionKey := DeriveSessionKey(c.psk, salt, c.config.KeyLen) + defer func() { + for i := range sessionKey { + sessionKey[i] = 0 + } + }() + + // Create cipher + var err error + c.cipherRead, err = c.config.NewCipher(sessionKey) + if err != nil { + return fmt.Errorf("failed to create cipher: %w", err) + } + + // Response header length: Type(1) + Timestamp(8) + RequestSalt(SaltLen) + Length(2) + responseHeaderLen := 1 + 8 + c.config.SaltLen + 2 + + // Read and decrypt response header + encResponseHeader := pool.Get(responseHeaderLen + c.config.TagLen) + defer pool.Put(encResponseHeader) + if _, err := io.ReadFull(c.Conn, encResponseHeader); err != nil { + return fmt.Errorf("failed to read response header: %w", err) + } + + responseHeader := pool.Get(responseHeaderLen) + defer pool.Put(responseHeader) + if _, err := c.cipherRead.Open(responseHeader[:0], c.nonceRead, encResponseHeader, nil); err != nil { + return fmt.Errorf("failed to decrypt response header: %w", err) + } + common.BytesIncLittleEndian(c.nonceRead) + + // Parse response header + // Type + if responseHeader[0] != HeaderTypeServerResponse { + return fmt.Errorf("%w: expected %d, got %d", ErrInvalidHeaderType, HeaderTypeServerResponse, responseHeader[0]) + } + + // Timestamp + timestamp := binary.BigEndian.Uint64(responseHeader[1:9]) + if err := ValidateTimestamp(timestamp); err != nil { + return err + } + + // Request salt validation + responseSalt := responseHeader[9 : 9+c.config.SaltLen] + if !compareBytes(responseSalt, c.requestSalt) { + return ErrInvalidSalt + } + + // Length of first payload chunk + firstPayloadLen := binary.BigEndian.Uint16(responseHeader[9+c.config.SaltLen:]) + if firstPayloadLen == 0 { + return fmt.Errorf("zero payload length in response header") + } + + // Read and decrypt first payload chunk + encFirstPayload := pool.Get(int(firstPayloadLen) + c.config.TagLen) + defer pool.Put(encFirstPayload) + if _, err := io.ReadFull(c.Conn, encFirstPayload); err != nil { + return fmt.Errorf("failed to read first payload: %w", err) + } + + firstPayload := pool.Get(int(firstPayloadLen)) + if _, err := c.cipherRead.Open(firstPayload[:0], c.nonceRead, encFirstPayload, nil); err != nil { + pool.Put(firstPayload) + return fmt.Errorf("failed to decrypt first payload: %w", err) + } + common.BytesIncLittleEndian(c.nonceRead) + + // Store first payload for later reading + if firstPayloadLen > 0 { + c.leftToRead = firstPayload + c.indexToRead = 0 + } else { + pool.Put(firstPayload) + } + + return nil +} + +// readChunk reads and decrypts a single chunk +func (c *TCPConn) readChunk() ([]byte, error) { + // Read encrypted length + encLen := pool.Get(2 + c.config.TagLen) + defer pool.Put(encLen) + if _, err := io.ReadFull(c.Conn, encLen); err != nil { + return nil, err + } + + // Decrypt length + lenBuf := pool.Get(2) + defer pool.Put(lenBuf) + if _, err := c.cipherRead.Open(lenBuf[:0], c.nonceRead, encLen, nil); err != nil { + return nil, fmt.Errorf("failed to decrypt length: %w", err) + } + common.BytesIncLittleEndian(c.nonceRead) + + payloadLen := binary.BigEndian.Uint16(lenBuf) + if payloadLen == 0 { + return nil, fmt.Errorf("zero length chunk") + } + + // Read encrypted payload + encPayload := pool.Get(int(payloadLen) + c.config.TagLen) + defer pool.Put(encPayload) + if _, err := io.ReadFull(c.Conn, encPayload); err != nil { + return nil, err + } + + // Decrypt payload (returned buffer must be freed by caller) + payload := pool.Get(int(payloadLen)) + if _, err := c.cipherRead.Open(payload[:0], c.nonceRead, encPayload, nil); err != nil { + pool.Put(payload) + return nil, fmt.Errorf("failed to decrypt payload: %w", err) + } + common.BytesIncLittleEndian(c.nonceRead) + + return payload, nil +} diff --git a/protocol/shadowsocks2022/udp_conn.go b/protocol/shadowsocks2022/udp_conn.go new file mode 100644 index 0000000..0748098 --- /dev/null +++ b/protocol/shadowsocks2022/udp_conn.go @@ -0,0 +1,559 @@ +package shadowsocks2022 + +import ( + "crypto/cipher" + "crypto/rand" + "encoding/binary" + "fmt" + "net" + "net/netip" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/daeuniverse/outbound/netproxy" + "github.com/daeuniverse/outbound/pool" + "github.com/daeuniverse/outbound/protocol" +) + +const ( + // UDP separate header for AES mode: Session ID(8B) + Packet ID(8B) + UDPSeparateHeaderLen = 16 + + // UDP client message header: Type(1) + Timestamp(8) + PaddingLen(2) + UDPClientMessageHeaderFixedLen = 1 + 8 + 2 + + // UDP server message header: Type(1) + Timestamp(8) + ClientSessionID(8) + PaddingLen(2) + UDPServerMessageHeaderFixedLen = 1 + 8 + 8 + 2 + + // Max UDP payload size + MaxUDPPayloadSize = 65535 + + // UDP session ID length for ChaCha20 mode (part of 24B nonce) + UDPChaChaSessionIDLen = 8 +) + +// UDPConn represents a SS2022 UDP connection +type UDPConn struct { + netproxy.PacketConn + + proxyAddress string + metadata protocol.Metadata + config *CipherConfig + psk []byte + iPSK []byte + + // Session state (common for both modes) + clientSessionID uint64 + packetID atomic.Uint64 // Starts from 1 (first Add returns 1) + + // ============= AES Mode Fields ============= + // AEAD cipher for this session (derived from session ID) + sessionCipher cipher.AEAD + + // Block cipher for separate header encryption (AES mode only) + // For single-user: uses psk (uPSK) + // For multi-user client->server: uses iPSK + // For multi-user server->client: uses uPSK + clientHeaderBlockCipher cipher.Block // For encrypting client packets + serverHeaderBlockCipher cipher.Block // For decrypting server packets (uses uPSK) + + // PSK hash for identity header + uPSKHash [IdentityHeaderLen]byte + + // Server session tracking for replay protection (AES mode) + serverSessionMu sync.Mutex + currentServerSessionID uint64 + currentServerSessionCipher cipher.AEAD + currentServerFilter *SlidingWindowFilter + oldServerSessionID uint64 + oldServerSessionCipher cipher.AEAD + oldServerFilter *SlidingWindowFilter + oldServerLastSeen time.Time + + // ============= ChaCha20 Mode Fields ============= + // For ChaCha20 mode: cipher is derived from PSK directly (no session key derivation) + chachaCipher cipher.AEAD + // ChaCha20 mode uses per-packet random nonce, no separate header + // Server session tracking for ChaCha20 (simpler - just packet ID filter) + chachaServerFilter *SlidingWindowFilter + + tgtAddr string +} + +// NewUDPConn creates a new SS2022 UDP connection +func NewUDPConn(conn netproxy.PacketConn, proxyAddress string, metadata protocol.Metadata, psk, iPSK []byte, config *CipherConfig) (*UDPConn, error) { + // Generate random client session ID + var sessionIDBuf [8]byte + if _, err := rand.Read(sessionIDBuf[:]); err != nil { + return nil, fmt.Errorf("failed to generate session ID: %w", err) + } + clientSessionID := binary.BigEndian.Uint64(sessionIDBuf[:]) + + c := &UDPConn{ + PacketConn: conn, + proxyAddress: proxyAddress, + metadata: metadata, + config: config, + psk: psk, + iPSK: iPSK, + clientSessionID: clientSessionID, + tgtAddr: net.JoinHostPort(metadata.Hostname, strconv.Itoa(int(metadata.Port))), + } + + var err error + + if config.UDPMode == UDPModeChaCha { + // ChaCha20 mode: use XChaCha20-Poly1305 with 24B random nonce + // Use PSK directly (no session key derivation), consistent with Rust/Sing. + c.chachaCipher, err = config.NewUDPCipher(psk) + if err != nil { + return nil, fmt.Errorf("failed to create XChaCha20 cipher: %w", err) + } + c.chachaServerFilter = NewSlidingWindowFilter(DefaultWindowSize) + } else { + // AES mode: use 16B separate header + AES-ECB + AES-GCM + // Derive session key using session ID as salt + sessionKey := DeriveSessionKey(psk, sessionIDBuf[:], config.KeyLen) + + // Create session AEAD cipher + c.sessionCipher, err = config.NewUDPCipher(sessionKey) + if err != nil { + return nil, fmt.Errorf("failed to create session cipher: %w", err) + } + + // Create header block ciphers + // For client->server separate header: + // - Single-user: use psk (uPSK) + // - Multi-user: use iPSK + var clientHeaderBlockCipher cipher.Block + if iPSK != nil { + // Multi-user mode: use iPSK for client->server + clientHeaderBlockCipher, err = CreateECBEncryptorWithKeyLen(iPSK, config.KeyLen) + } else { + // Single-user mode: use psk + clientHeaderBlockCipher, err = CreateECBEncryptorWithKeyLen(psk, config.KeyLen) + } + if err != nil { + return nil, fmt.Errorf("failed to create client header cipher: %w", err) + } + c.clientHeaderBlockCipher = clientHeaderBlockCipher + + // For server->client separate header: always use psk (uPSK) + serverHeaderBlockCipher, err := CreateECBEncryptorWithKeyLen(psk, config.KeyLen) + if err != nil { + return nil, fmt.Errorf("failed to create server header cipher: %w", err) + } + c.serverHeaderBlockCipher = serverHeaderBlockCipher + + // Pre-compute uPSK hash for identity headers + c.uPSKHash = PSKHash(psk) + } + + return c, nil +} + +// Close closes the connection +func (c *UDPConn) Close() error { + return c.PacketConn.Close() +} + +// Read reads data from the connection +func (c *UDPConn) Read(b []byte) (n int, err error) { + n, _, err = c.ReadFrom(b) + return +} + +// Write writes data to the connection +func (c *UDPConn) Write(b []byte) (n int, err error) { + return c.WriteTo(b, c.tgtAddr) +} + +// WriteTo writes data to the specified address +func (c *UDPConn) WriteTo(b []byte, addr string) (int, error) { + if c.config.UDPMode == UDPModeChaCha { + return c.writeToChaCha(b, addr) + } + return c.writeToAES(b, addr) +} + +// writeToChaCha writes data using ChaCha20 mode (24B random nonce + XChaCha20-Poly1305) +// ChaCha20 message body contains SessionID and PacketID (unlike AES mode where they're in separate header) +func (c *UDPConn) writeToChaCha(b []byte, addr string) (int, error) { + // Parse target address + mdata, err := protocol.ParseMetadata(addr) + if err != nil { + return 0, err + } + + // Get current packet ID (starts from 1) + packetID := c.packetID.Add(1) + + // Build address + addrBytes, err := EncodeAddress(mdata.Hostname, mdata.Port) + if err != nil { + return 0, fmt.Errorf("failed to encode address: %w", err) + } + defer pool.Put(addrBytes) + + // No padding for simplicity + paddingLen := 0 + + // ChaCha20 message body format (different from AES!): + // SessionID(8) + PacketID(8) + Type(1) + Timestamp(8) + PaddingLen(2) + Padding + Address + Payload + messageLen := 8 + 8 + UDPClientMessageHeaderFixedLen + paddingLen + len(addrBytes) + len(b) + message := pool.Get(messageLen) + defer pool.Put(message) + + offset := 0 + // SessionID and PacketID are inside encrypted body for ChaCha20 + binary.BigEndian.PutUint64(message[offset:], c.clientSessionID) + offset += 8 + binary.BigEndian.PutUint64(message[offset:], packetID) + offset += 8 + message[offset] = HeaderTypeClientRequest + offset++ + binary.BigEndian.PutUint64(message[offset:], uint64(time.Now().Unix())) + offset += 8 + binary.BigEndian.PutUint16(message[offset:], uint16(paddingLen)) + offset += 2 + offset += paddingLen // Skip padding + copy(message[offset:], addrBytes) + offset += len(addrBytes) + copy(message[offset:], b) + + // ChaCha20 mode packet format: + // Nonce(24B, pure random) + EncryptedMessage + Tag(16B) + totalLen := XChaCha20NonceLen + messageLen + c.config.TagLen + packet := pool.Get(totalLen) + defer pool.Put(packet) + + // Generate pure random 24B nonce (no embedded session/packet ID) + nonce := packet[:XChaCha20NonceLen] + if _, err := rand.Read(nonce); err != nil { + return 0, fmt.Errorf("failed to generate random nonce: %w", err) + } + + // AEAD seal the message + c.chachaCipher.Seal(packet[XChaCha20NonceLen:XChaCha20NonceLen], nonce, message, nil) + + return c.PacketConn.WriteTo(packet[:totalLen], c.proxyAddress) +} + +// writeToAES writes data using AES mode (16B separate header + AES-ECB + AES-GCM) +func (c *UDPConn) writeToAES(b []byte, addr string) (int, error) { + // Parse target address + mdata, err := protocol.ParseMetadata(addr) + if err != nil { + return 0, err + } + + // Get current packet ID (starts from 1) + packetID := c.packetID.Add(1) + + // Build address + addrBytes, err := EncodeAddress(mdata.Hostname, mdata.Port) + if err != nil { + return 0, fmt.Errorf("failed to encode address: %w", err) + } + defer pool.Put(addrBytes) + + // No padding for simplicity + paddingLen := 0 + + // Build message: Type(1) + Timestamp(8) + PaddingLen(2) + Padding + Address + Payload + messageLen := UDPClientMessageHeaderFixedLen + paddingLen + len(addrBytes) + len(b) + message := pool.Get(messageLen) + defer pool.Put(message) + + offset := 0 + message[offset] = HeaderTypeClientRequest + offset++ + binary.BigEndian.PutUint64(message[offset:], uint64(time.Now().Unix())) + offset += 8 + binary.BigEndian.PutUint16(message[offset:], uint16(paddingLen)) + offset += 2 + offset += paddingLen // Skip padding + copy(message[offset:], addrBytes) + offset += len(addrBytes) + copy(message[offset:], b) + + // Calculate identity headers length + identityHeadersLen := 0 + if c.iPSK != nil { + identityHeadersLen = IdentityHeaderLen + } + + // Total packet: SeparateHeader(16) + IdentityHeaders(optional) + EncryptedMessage + Tag + totalLen := UDPSeparateHeaderLen + identityHeadersLen + messageLen + c.config.TagLen + packet := pool.Get(totalLen) + defer pool.Put(packet) + + // Build separate header: SessionID(8) + PacketID(8) + separateHeader := packet[:UDPSeparateHeaderLen] + binary.BigEndian.PutUint64(separateHeader[:8], c.clientSessionID) + binary.BigEndian.PutUint64(separateHeader[8:], packetID) + + // Nonce is bytes [4:16] of the separate header (before encryption) + nonce := make([]byte, c.config.NonceLen) + copy(nonce, separateHeader[4:16]) + + writeOffset := UDPSeparateHeaderLen + + // Generate identity headers if multi-user mode + if c.iPSK != nil { + identityHeader, err := GenerateUDPIdentityHeader(c.iPSK, c.uPSKHash, separateHeader) + if err != nil { + return 0, fmt.Errorf("failed to generate identity header: %w", err) + } + copy(packet[writeOffset:], identityHeader) + writeOffset += IdentityHeaderLen + } + + // AEAD seal the message + c.sessionCipher.Seal(packet[writeOffset:writeOffset], nonce, message, nil) + writeOffset += messageLen + c.config.TagLen + + // Encrypt separate header with AES-ECB (using clientHeaderBlockCipher) + c.clientHeaderBlockCipher.Encrypt(packet[:UDPSeparateHeaderLen], separateHeader) + + return c.PacketConn.WriteTo(packet[:writeOffset], c.proxyAddress) +} + +// ReadFrom reads data from the connection +func (c *UDPConn) ReadFrom(b []byte) (n int, addr netip.AddrPort, err error) { + if c.config.UDPMode == UDPModeChaCha { + return c.readFromChaCha(b) + } + return c.readFromAES(b) +} + +// readFromChaCha reads data using ChaCha20 mode (24B random nonce + XChaCha20-Poly1305) +// ChaCha20 message body contains SessionID and PacketID (unlike AES mode where they're in separate header) +func (c *UDPConn) readFromChaCha(b []byte) (n int, addr netip.AddrPort, err error) { + buf := pool.Get(MaxUDPPayloadSize) + defer pool.Put(buf) + + n, addr, err = c.PacketConn.ReadFrom(buf) + if err != nil { + return 0, netip.AddrPort{}, err + } + + // Minimum packet size: 24B nonce + tag + minSize := XChaCha20NonceLen + c.config.TagLen + if n < minSize { + return 0, netip.AddrPort{}, fmt.Errorf("packet too short: %d < %d", n, minSize) + } + + packet := buf[:n] + + // Extract 24B nonce (pure random, no embedded session/packet ID) + nonce := packet[:XChaCha20NonceLen] + ciphertext := packet[XChaCha20NonceLen:] + + // Decrypt message using the same cipher (server uses same session key) + plaintext, err := c.chachaCipher.Open(nil, nonce, ciphertext, nil) + if err != nil { + return 0, netip.AddrPort{}, fmt.Errorf("failed to decrypt: %w", err) + } + + // ChaCha20 server message body format: + // SessionID(8) + PacketID(8) + Type(1) + Timestamp(8) + ClientSessionID(8) + PaddingLen(2) + Padding + Address + Payload + minBodyLen := 8 + 8 + UDPServerMessageHeaderFixedLen + if len(plaintext) < minBodyLen { + return 0, netip.AddrPort{}, fmt.Errorf("plaintext too short for server header") + } + + offset := 0 + + // Server session ID (from encrypted body) + serverSessionID := binary.BigEndian.Uint64(plaintext[offset:]) + offset += 8 + _ = serverSessionID // Server session ID is for session tracking if needed + + // Packet ID (from encrypted body) - used for replay protection + serverPacketID := binary.BigEndian.Uint64(plaintext[offset:]) + offset += 8 + + // Check replay using packet ID extracted from body + if !c.chachaServerFilter.Check(serverPacketID) { + return 0, netip.AddrPort{}, fmt.Errorf("replay detected: packet %d", serverPacketID) + } + + // Type + if plaintext[offset] != HeaderTypeServerResponse { + return 0, netip.AddrPort{}, fmt.Errorf("invalid header type: expected %d, got %d", HeaderTypeServerResponse, plaintext[offset]) + } + offset++ + + // Timestamp + timestamp := binary.BigEndian.Uint64(plaintext[offset:]) + offset += 8 + if err := ValidateTimestamp(timestamp); err != nil { + return 0, netip.AddrPort{}, err + } + + // Client session ID + responseClientSessionID := binary.BigEndian.Uint64(plaintext[offset:]) + offset += 8 + if responseClientSessionID != c.clientSessionID { + return 0, netip.AddrPort{}, fmt.Errorf("client session ID mismatch: expected %d, got %d", c.clientSessionID, responseClientSessionID) + } + + // Padding length + paddingLen := int(binary.BigEndian.Uint16(plaintext[offset:])) + offset += 2 + offset += paddingLen // Skip padding + + // Address (for server response, this is the source address) + _, _, addrLen, err := DecodeAddress(plaintext[offset:]) + if err != nil { + return 0, netip.AddrPort{}, fmt.Errorf("failed to decode address: %w", err) + } + offset += addrLen + + // Copy payload + payload := plaintext[offset:] + n = copy(b, payload) + + return n, addr, nil +} + +// readFromAES reads data using AES mode (16B separate header + AES-ECB + AES-GCM) +func (c *UDPConn) readFromAES(b []byte) (n int, addr netip.AddrPort, err error) { + buf := pool.Get(MaxUDPPayloadSize) + defer pool.Put(buf) + + n, addr, err = c.PacketConn.ReadFrom(buf) + if err != nil { + return 0, netip.AddrPort{}, err + } + + // Minimum packet size: separate header + tag + minSize := UDPSeparateHeaderLen + c.config.TagLen + if n < minSize { + return 0, netip.AddrPort{}, fmt.Errorf("packet too short: %d < %d", n, minSize) + } + + packet := buf[:n] + + // Decrypt separate header with AES-ECB (using serverHeaderBlockCipher = uPSK) + separateHeader := make([]byte, UDPSeparateHeaderLen) + c.serverHeaderBlockCipher.Decrypt(separateHeader, packet[:UDPSeparateHeaderLen]) + + // Extract session ID and packet ID + serverSessionID := binary.BigEndian.Uint64(separateHeader[:8]) + serverPacketID := binary.BigEndian.Uint64(separateHeader[8:]) + + // Nonce is bytes [4:16] of the decrypted separate header + nonce := make([]byte, c.config.NonceLen) + copy(nonce, separateHeader[4:16]) + + ciphertext := packet[UDPSeparateHeaderLen:] + + // Determine which server session cipher to use + c.serverSessionMu.Lock() + var serverCipher cipher.AEAD + var filter *SlidingWindowFilter + var isNewSession bool + + switch { + case serverSessionID == c.currentServerSessionID && c.currentServerSessionCipher != nil: + serverCipher = c.currentServerSessionCipher + filter = c.currentServerFilter + case serverSessionID == c.oldServerSessionID && c.oldServerSessionCipher != nil: + serverCipher = c.oldServerSessionCipher + filter = c.oldServerFilter + c.oldServerLastSeen = time.Now() + case time.Since(c.oldServerLastSeen) < time.Minute && c.currentServerSessionID != 0: + c.serverSessionMu.Unlock() + return 0, netip.AddrPort{}, fmt.Errorf("server session changed too frequently") + default: + // New server session - derive key + sessionKey := DeriveSessionKey(c.psk, separateHeader[:8], c.config.KeyLen) + serverCipher, err = c.config.NewUDPCipher(sessionKey) + if err != nil { + c.serverSessionMu.Unlock() + return 0, netip.AddrPort{}, fmt.Errorf("failed to create server cipher: %w", err) + } + isNewSession = true + } + c.serverSessionMu.Unlock() + + // Check replay + if filter != nil && !filter.Check(serverPacketID) { + return 0, netip.AddrPort{}, fmt.Errorf("replay detected: session %d packet %d", serverSessionID, serverPacketID) + } + + // Decrypt message + plaintext, err := serverCipher.Open(nil, nonce, ciphertext, nil) + if err != nil { + return 0, netip.AddrPort{}, fmt.Errorf("failed to decrypt: %w", err) + } + + // Parse server message header: Type(1) + Timestamp(8) + ClientSessionID(8) + PaddingLen(2) + Padding + Address + Payload + if len(plaintext) < UDPServerMessageHeaderFixedLen { + return 0, netip.AddrPort{}, fmt.Errorf("plaintext too short for server header") + } + + offset := 0 + + // Type + if plaintext[offset] != HeaderTypeServerResponse { + return 0, netip.AddrPort{}, fmt.Errorf("invalid header type: expected %d, got %d", HeaderTypeServerResponse, plaintext[offset]) + } + offset++ + + // Timestamp + timestamp := binary.BigEndian.Uint64(plaintext[offset:]) + offset += 8 + if err := ValidateTimestamp(timestamp); err != nil { + return 0, netip.AddrPort{}, err + } + + // Client session ID + responseClientSessionID := binary.BigEndian.Uint64(plaintext[offset:]) + offset += 8 + if responseClientSessionID != c.clientSessionID { + return 0, netip.AddrPort{}, fmt.Errorf("client session ID mismatch: expected %d, got %d", c.clientSessionID, responseClientSessionID) + } + + // Padding length + paddingLen := int(binary.BigEndian.Uint16(plaintext[offset:])) + offset += 2 + offset += paddingLen // Skip padding + + // Address (for server response, this is the source address) + _, _, addrLen, err := DecodeAddress(plaintext[offset:]) + if err != nil { + return 0, netip.AddrPort{}, fmt.Errorf("failed to decode address: %w", err) + } + offset += addrLen + + // Update server session tracking + c.serverSessionMu.Lock() + if isNewSession { + if c.currentServerSessionID != 0 { + c.oldServerSessionID = c.currentServerSessionID + c.oldServerSessionCipher = c.currentServerSessionCipher + c.oldServerFilter = c.currentServerFilter + c.oldServerLastSeen = time.Now() + } + c.currentServerSessionID = serverSessionID + c.currentServerSessionCipher = serverCipher + c.currentServerFilter = NewSlidingWindowFilter(DefaultWindowSize) + } + if c.currentServerSessionID == serverSessionID { + c.currentServerFilter.Check(serverPacketID) // Add to filter + } else if c.oldServerSessionID == serverSessionID { + c.oldServerFilter.Check(serverPacketID) + } + c.serverSessionMu.Unlock() + + // Copy payload + payload := plaintext[offset:] + n = copy(b, payload) + + return n, addr, nil +}