Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pad the first 5 frames to mitigate encapsulated TLS handshakes detection #283

Merged
merged 2 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 29 additions & 37 deletions internal/multiplex/obfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -27,8 +35,6 @@ type Obfuscator struct {
payloadCipher cipher.AEAD

sessionKey [32]byte

maxOverhead int
}

// obfuscate adds multiplexing headers, encrypt and add TLS header
Expand All @@ -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)
Expand All @@ -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)
}

Expand Down Expand Up @@ -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[:])
Expand All @@ -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])
Expand All @@ -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)
}
Expand Down
14 changes: 1 addition & 13 deletions internal/multiplex/obfs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ func TestObfuscate(t *testing.T) {
o := Obfuscator{
payloadCipher: nil,
sessionKey: sessionKey,
maxOverhead: salsa20NonceSize,
}
runTest(t, o)
})
Expand All @@ -98,7 +97,6 @@ func TestObfuscate(t *testing.T) {
o := Obfuscator{
payloadCipher: payloadCipher,
sessionKey: sessionKey,
maxOverhead: payloadCipher.Overhead(),
}
runTest(t, o)
})
Expand All @@ -111,7 +109,6 @@ func TestObfuscate(t *testing.T) {
o := Obfuscator{
payloadCipher: payloadCipher,
sessionKey: sessionKey,
maxOverhead: payloadCipher.Overhead(),
}
runTest(t, o)
})
Expand All @@ -122,7 +119,6 @@ func TestObfuscate(t *testing.T) {
o := Obfuscator{
payloadCipher: payloadCipher,
sessionKey: sessionKey,
maxOverhead: payloadCipher.Overhead(),
}
runTest(t, o)
})
Expand Down Expand Up @@ -150,7 +146,6 @@ func BenchmarkObfs(b *testing.B) {
obfuscator := Obfuscator{
payloadCipher: payloadCipher,
sessionKey: key,
maxOverhead: payloadCipher.Overhead(),
}

b.SetBytes(int64(len(testFrame.Payload)))
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -222,7 +214,6 @@ func BenchmarkDeobfs(b *testing.B) {
obfuscator := Obfuscator{
payloadCipher: payloadCipher,
sessionKey: key,
maxOverhead: payloadCipher.Overhead(),
}

n, _ := obfuscator.obfuscate(testFrame, obfsBuf, 0)
Expand All @@ -241,7 +232,6 @@ func BenchmarkDeobfs(b *testing.B) {
obfuscator := Obfuscator{
payloadCipher: payloadCipher,
sessionKey: key,
maxOverhead: payloadCipher.Overhead(),
}
n, _ := obfuscator.obfuscate(testFrame, obfsBuf, 0)

Expand All @@ -256,7 +246,6 @@ func BenchmarkDeobfs(b *testing.B) {
obfuscator := Obfuscator{
payloadCipher: nil,
sessionKey: key,
maxOverhead: salsa20NonceSize,
}
n, _ := obfuscator.obfuscate(testFrame, obfsBuf, 0)

Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/multiplex/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down