diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1001587..ff85f31 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,11 +7,85 @@ jobs: matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] steps: - - uses: actions/checkout@v2 - - uses: actions/setup-go@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: '^1.22' # The Go version to download (if necessary) and use. - run: go test -race -coverprofile coverage.txt -coverpkg ./... -covermode atomic ./... - - uses: codecov/codecov-action@v1 + - uses: codecov/codecov-action@v4 with: - file: coverage.txt + files: coverage.txt + token: ${{ secrets.CODECOV_TOKEN }} + + compat-test: + runs-on: ubuntu-latest + strategy: + matrix: + encryption-method: [ plain, chacha20-poly1305 ] + num-conn: [ 0, 1, 4 ] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '^1.22' + - name: Build Cloak + run: make + - name: Create configs + run: | + mkdir config + cat << EOF > config/ckclient.json + { + "Transport": "direct", + "ProxyMethod": "iperf", + "EncryptionMethod": "${{ matrix.encryption-method }}", + "UID": "Q4GAXHVgnDLXsdTpw6bmoQ==", + "PublicKey": "4dae/bF43FKGq+QbCc5P/E/MPM5qQeGIArjmJEHiZxc=", + "ServerName": "cloudflare.com", + "BrowserSig": "firefox", + "NumConn": ${{ matrix.num-conn }} + } + EOF + cat << EOF > config/ckserver.json + { + "ProxyBook": { + "iperf": [ + "tcp", + "127.0.0.1:5201" + ] + }, + "BindAddr": [ + ":8443" + ], + "BypassUID": [ + "Q4GAXHVgnDLXsdTpw6bmoQ==" + ], + "RedirAddr": "cloudflare.com", + "PrivateKey": "AAaskZJRPIAbiuaRLHsvZPvE6gzOeSjg+ZRg1ENau0Y=" + } + EOF + - name: Start iperf3 server + run: docker run -d --name iperf-server --network host ajoergensen/iperf3:latest --server + - name: Test new client against old server + run: | + docker run -d --name old-cloak-server --network host -v $PWD/config:/go/Cloak/config cbeuw/cloak:latest build/ck-server -c config/ckserver.json --verbosity debug + build/ck-client -c config/ckclient.json -s 127.0.0.1 -p 8443 --verbosity debug | tee new-cloak-client.log & + docker run --network host ajoergensen/iperf3:latest --client 127.0.0.1 -p 1984 + docker stop old-cloak-server + - name: Test old client against new server + run: | + build/ck-server -c config/ckserver.json --verbosity debug | tee new-cloak-server.log & + docker run -d --name old-cloak-client --network host -v $PWD/config:/go/Cloak/config cbeuw/cloak:latest build/ck-client -c config/ckclient.json -s 127.0.0.1 -p 8443 --verbosity debug + docker run --network host ajoergensen/iperf3:latest --client 127.0.0.1 -p 1984 + docker stop old-cloak-client + - name: Dump docker logs + if: always() + run: | + docker container logs iperf-server > iperf-server.log + docker container logs old-cloak-server > old-cloak-server.log + docker container logs old-cloak-client > old-cloak-client.log + - name: Upload logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.encryption-method }}-${{ matrix.num-conn }}-conn-logs + path: ./*.log diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b59569..62c9bf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Build run: | export PATH=${PATH}:`go env GOPATH`/bin @@ -19,4 +19,32 @@ jobs: with: files: release/* env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-docker: + runs-on: ubuntu-latest + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + cbeuw/cloak + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v6 + with: + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2a06bff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM golang:latest + +RUN git clone https://github.com/cbeuw/Cloak.git +WORKDIR Cloak +RUN make diff --git a/internal/multiplex/obfs.go b/internal/multiplex/obfs.go index 6821f86..064f0e7 100644 --- a/internal/multiplex/obfs.go +++ b/internal/multiplex/obfs.go @@ -3,10 +3,10 @@ package multiplex import ( "crypto/aes" "crypto/cipher" + "crypto/rand" "encoding/binary" "errors" "fmt" - "github.com/cbeuw/Cloak/internal/common" "golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/salsa20" @@ -15,6 +15,14 @@ import ( const frameHeaderLength = 14 const salsa20NonceSize = 8 +// maxExtraLen equals the max length of padding + AEAD tag. +// It is 255 bytes because the extra len field in frame header is only one byte. +const maxExtraLen = 1<<8 - 1 + +// padFirstNFrames specifies the number of initial frames to pad, +// to avoid TLS-in-TLS detection +const padFirstNFrames = 5 + const ( EncryptionMethodPlain = iota EncryptionMethodAES256GCM @@ -27,8 +35,6 @@ type Obfuscator struct { payloadCipher cipher.AEAD sessionKey [32]byte - - maxOverhead int } // obfuscate adds multiplexing headers, encrypt and add TLS header @@ -49,45 +55,34 @@ func (o *Obfuscator) obfuscate(f *Frame, buf []byte, payloadOffsetInBuf int) (in // to be large enough that they may never happen in reasonable time frames. Of course, different sessions // will produce the same combination of stream id and frame sequence, but they will have different session keys. // - // Salsa20 is assumed to be given a unique nonce each time because we assume the tags produced by payloadCipher - // AEAD is unique each time, as payloadCipher itself is given a unique iv/nonce each time due to points made above. - // This is relatively a weak guarantee as we are assuming AEADs to produce different tags given different iv/nonces. - // This is almost certainly true but I cannot find a source that outright states this. // // Because the frame header, before it being encrypted, is fed into the AEAD, it is also authenticated. // (rfc5116 s.2.1 "The nonce is authenticated internally to the algorithm"). // // In case the user chooses to not encrypt the frame payload, payloadCipher will be nil. In this scenario, - // we pad the frame payload with random bytes until it reaches Salsa20's nonce size (8 bytes). Then we simply - // encrypt the frame header with the last 8 bytes of frame payload as nonce. - // If the payload provided by the user is greater than 8 bytes, then we use entirely the user input as nonce. - // We can't ensure its uniqueness ourselves, which is why plaintext mode must only be used when the user input - // is already random-like. For Cloak it would normally mean that the user is using a proxy protocol that sends - // encrypted data. + // we generate random bytes to be used as salsa20 nonce. payloadLen := len(f.Payload) if payloadLen == 0 { return 0, errors.New("payload cannot be empty") } - var extraLen int - if o.payloadCipher == nil { - extraLen = salsa20NonceSize - payloadLen - if extraLen < 0 { - // if our payload is already greater than 8 bytes - extraLen = 0 - } + tagLen := 0 + if o.payloadCipher != nil { + tagLen = o.payloadCipher.Overhead() } else { - extraLen = o.payloadCipher.Overhead() - if extraLen < salsa20NonceSize { - return 0, errors.New("AEAD's Overhead cannot be fewer than 8 bytes") - } + tagLen = salsa20NonceSize + } + // Pad to avoid size side channel leak + padLen := 0 + if f.Seq < padFirstNFrames { + padLen = common.RandInt(maxExtraLen - tagLen + 1) } - usefulLen := frameHeaderLength + payloadLen + extraLen + usefulLen := frameHeaderLength + payloadLen + padLen + tagLen if len(buf) < usefulLen { return 0, errors.New("obfs buffer too small") } // we do as much in-place as possible to save allocation - payload := buf[frameHeaderLength : frameHeaderLength+payloadLen] + payload := buf[frameHeaderLength : frameHeaderLength+payloadLen+padLen] if payloadOffsetInBuf != frameHeaderLength { // if payload is not at the correct location in buffer copy(payload, f.Payload) @@ -97,14 +92,15 @@ func (o *Obfuscator) obfuscate(f *Frame, buf []byte, payloadOffsetInBuf int) (in binary.BigEndian.PutUint32(header[0:4], f.StreamID) binary.BigEndian.PutUint64(header[4:12], f.Seq) header[12] = f.Closing - header[13] = byte(extraLen) + header[13] = byte(padLen + tagLen) - if o.payloadCipher == nil { - if extraLen != 0 { // read nonce - extra := buf[usefulLen-extraLen : usefulLen] - common.CryptoRandRead(extra) - } - } else { + // Random bytes for padding and nonce + _, err := rand.Read(buf[frameHeaderLength+payloadLen : usefulLen]) + if err != nil { + return 0, fmt.Errorf("failed to pad random: %w", err) + } + + if o.payloadCipher != nil { o.payloadCipher.Seal(payload[:0], header[:o.payloadCipher.NonceSize()], payload, nil) } @@ -166,7 +162,6 @@ func MakeObfuscator(encryptionMethod byte, sessionKey [32]byte) (o Obfuscator, e switch encryptionMethod { case EncryptionMethodPlain: o.payloadCipher = nil - o.maxOverhead = salsa20NonceSize case EncryptionMethodAES256GCM: var c cipher.Block c, err = aes.NewCipher(sessionKey[:]) @@ -177,7 +172,6 @@ func MakeObfuscator(encryptionMethod byte, sessionKey [32]byte) (o Obfuscator, e if err != nil { return } - o.maxOverhead = o.payloadCipher.Overhead() case EncryptionMethodAES128GCM: var c cipher.Block c, err = aes.NewCipher(sessionKey[:16]) @@ -188,13 +182,11 @@ func MakeObfuscator(encryptionMethod byte, sessionKey [32]byte) (o Obfuscator, e if err != nil { return } - o.maxOverhead = o.payloadCipher.Overhead() case EncryptionMethodChaha20Poly1305: o.payloadCipher, err = chacha20poly1305.New(sessionKey[:]) if err != nil { return } - o.maxOverhead = o.payloadCipher.Overhead() default: return o, fmt.Errorf("unknown encryption method valued %v", encryptionMethod) } diff --git a/internal/multiplex/obfs_test.go b/internal/multiplex/obfs_test.go index f07d101..4b2ecf2 100644 --- a/internal/multiplex/obfs_test.go +++ b/internal/multiplex/obfs_test.go @@ -85,7 +85,6 @@ func TestObfuscate(t *testing.T) { o := Obfuscator{ payloadCipher: nil, sessionKey: sessionKey, - maxOverhead: salsa20NonceSize, } runTest(t, o) }) @@ -98,7 +97,6 @@ func TestObfuscate(t *testing.T) { o := Obfuscator{ payloadCipher: payloadCipher, sessionKey: sessionKey, - maxOverhead: payloadCipher.Overhead(), } runTest(t, o) }) @@ -111,7 +109,6 @@ func TestObfuscate(t *testing.T) { o := Obfuscator{ payloadCipher: payloadCipher, sessionKey: sessionKey, - maxOverhead: payloadCipher.Overhead(), } runTest(t, o) }) @@ -122,7 +119,6 @@ func TestObfuscate(t *testing.T) { o := Obfuscator{ payloadCipher: payloadCipher, sessionKey: sessionKey, - maxOverhead: payloadCipher.Overhead(), } runTest(t, o) }) @@ -150,7 +146,6 @@ func BenchmarkObfs(b *testing.B) { obfuscator := Obfuscator{ payloadCipher: payloadCipher, sessionKey: key, - maxOverhead: payloadCipher.Overhead(), } b.SetBytes(int64(len(testFrame.Payload))) @@ -166,7 +161,6 @@ func BenchmarkObfs(b *testing.B) { obfuscator := Obfuscator{ payloadCipher: payloadCipher, sessionKey: key, - maxOverhead: payloadCipher.Overhead(), } b.SetBytes(int64(len(testFrame.Payload))) b.ResetTimer() @@ -178,7 +172,6 @@ func BenchmarkObfs(b *testing.B) { obfuscator := Obfuscator{ payloadCipher: nil, sessionKey: key, - maxOverhead: salsa20NonceSize, } b.SetBytes(int64(len(testFrame.Payload))) b.ResetTimer() @@ -192,7 +185,6 @@ func BenchmarkObfs(b *testing.B) { obfuscator := Obfuscator{ payloadCipher: payloadCipher, sessionKey: key, - maxOverhead: payloadCipher.Overhead(), } b.SetBytes(int64(len(testFrame.Payload))) b.ResetTimer() @@ -222,7 +214,6 @@ func BenchmarkDeobfs(b *testing.B) { obfuscator := Obfuscator{ payloadCipher: payloadCipher, sessionKey: key, - maxOverhead: payloadCipher.Overhead(), } n, _ := obfuscator.obfuscate(testFrame, obfsBuf, 0) @@ -241,7 +232,6 @@ func BenchmarkDeobfs(b *testing.B) { obfuscator := Obfuscator{ payloadCipher: payloadCipher, sessionKey: key, - maxOverhead: payloadCipher.Overhead(), } n, _ := obfuscator.obfuscate(testFrame, obfsBuf, 0) @@ -256,7 +246,6 @@ func BenchmarkDeobfs(b *testing.B) { obfuscator := Obfuscator{ payloadCipher: nil, sessionKey: key, - maxOverhead: salsa20NonceSize, } n, _ := obfuscator.obfuscate(testFrame, obfsBuf, 0) @@ -271,9 +260,8 @@ func BenchmarkDeobfs(b *testing.B) { payloadCipher, _ := chacha20poly1305.New(key[:]) obfuscator := Obfuscator{ - payloadCipher: nil, + payloadCipher: payloadCipher, sessionKey: key, - maxOverhead: payloadCipher.Overhead(), } n, _ := obfuscator.obfuscate(testFrame, obfsBuf, 0) diff --git a/internal/multiplex/session.go b/internal/multiplex/session.go index 870346b..436bb79 100644 --- a/internal/multiplex/session.go +++ b/internal/multiplex/session.go @@ -108,7 +108,7 @@ func MakeSession(id uint32, config SessionConfig) *Session { sesh.InactivityTimeout = defaultInactivityTimeout } - sesh.maxStreamUnitWrite = sesh.MsgOnWireSizeLimit - frameHeaderLength - sesh.maxOverhead + sesh.maxStreamUnitWrite = sesh.MsgOnWireSizeLimit - frameHeaderLength - maxExtraLen sesh.streamSendBufferSize = sesh.MsgOnWireSizeLimit sesh.connReceiveBufferSize = 20480 // for backwards compatibility diff --git a/internal/server/auth.go b/internal/server/auth.go index d6941c2..44dbfe1 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -61,7 +61,7 @@ func decryptClientInfo(fragments authFragments, serverTime time.Time) (info Clie var ErrReplay = errors.New("duplicate random") var ErrBadProxyMethod = errors.New("invalid proxy method") -var ErrBadDecryption = errors.New("decryption/authentication faliure") +var ErrBadDecryption = errors.New("decryption/authentication failure") // AuthFirstPacket checks if the first packet of data is ClientHello or HTTP GET, and checks if it was from a Cloak client // if it is from a Cloak client, it returns the ClientInfo with the decrypted fields. It doesn't check if the user