From 129cb38107033f5ee4beefd80ca6df972ae8725f97730122a9940f556f3acd9b Mon Sep 17 00:00:00 2001 From: Sergey Matveev Date: Wed, 16 Aug 2023 20:12:10 +0300 Subject: [PATCH] Initial commit --- PROTOCOL | 29 ++++++ README | 30 ++++++ go.mod | 7 ++ go.sum | 4 + main.go | 289 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 359 insertions(+) create mode 100644 PROTOCOL create mode 100644 README create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/PROTOCOL b/PROTOCOL new file mode 100644 index 0000000..4cef2f1 --- /dev/null +++ b/PROTOCOL @@ -0,0 +1,29 @@ +Protocol is trivial. Both peers has shared 256-bit key. SHA3 is used to +derive four more keys from it: + + SHAKE128("go.cypherpunks.ru/udpobfs" || key) -> + 256-bit InitiatorEncryptionKey || + 256-bit InitiatorObfuscationKey || + 256-bit ResponderEncryptionKey || + 256-bit ResponderObfuscationKey + +Each side has 64-bit packet number counter, that is used as a nonce. +That counter is kept in memory and only its lower 24 bits are sent. +When remote side receives 24-bit counter with lower value, then it +increments in-memory counter's remaining part. Completely the same +as Extended Sequence Numbers are done in IPsec's ESP. + +ChaCha20 is initialised with corresponding EncryptionKey and nonce equal +to the full sequence number value. Its first 256-bit of output will be +Poly1305's one-time key. Next 256-bits are ignored. Further ones are +XORed with the plaintext (UDP's payload). Poly1305 is calculated over +the full 64-bit sequence number value and the whole ciphertext. Higher +40-bits of the resulting tag with lower 24-bits of the sequence number +are prepended to the ciphertext, encrypted with Blowfish: + + Blowfish(Seq || MAC) || Ciphertext + +Blowfish is initialized with ObfuscationKey. 40-bit MAC is rather weak, +but enough for obfuscation purposes. 24-bit part of sequence number is +enough for medium data-rate transmission where reordering or packets +drops may occur. diff --git a/README b/README new file mode 100644 index 0000000..94dcb52 --- /dev/null +++ b/README @@ -0,0 +1,30 @@ +udpobfs -- simple point-to-point UDP obfuscation proxy + +This is trivial UDP proxy, that obfuscates UDP traffic between two UDP +ports. It does no handshaking, no key agreement, no peer authentication, +weak replay protection. Key setup/renew must be made for example through +OpenSSH, TLS or similar channels. udpobfs's purpose is to obfuscate UDP +traffic between two WireGuard peers with minimal CPU and traffic overhead. + +Assume that WG was running peered with [2001:db8::dc]:1194. +You can run two udpobfs instances to obfuscate traffic between them: + + # udpobfs -keygen > key.txt + + wg0# wg set endpoint [::1]:4911 + wg0# udpobfs -bind [::1]:4911 -dst [2001:db8::ac]:1194 < key.txt + + wg1# wg set listen-port 21194 + wg1# udpobfs -bind [2001:db8::ac]:1194 -dst [::1]:21194 -responder < key.txt + +One of the instances is responder -- it awaits when initiator starts +talking. If UDP binded connection is lost, daemon exits. So it is +advisable to run it under process supervisor. + +udpobfs continuously reads Base32-encoded 256-bit keys from stdin -- you +can renew them without restarting the daemons. Of course there is some +time window when key knowledge differs on both peers and they will loose +the traffic. Not a big deal. + +Obfuscated packet is 8 bytes longer, so you have to slightly lower your +MTU in VPN tunnel. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4f795b6 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module go.cypherpunks.ru/udpobfs + +go 1.21 + +require golang.org/x/crypto v0.12.0 + +require golang.org/x/sys v0.11.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..51b556d --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7c71433 --- /dev/null +++ b/main.go @@ -0,0 +1,289 @@ +/* +udpobfs -- simple point-to-point UDP obfuscation proxy +Copyright (C) 2023 Sergey Matveev + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package main + +import ( + "bufio" + "crypto/rand" + "crypto/subtle" + "encoding/base32" + "flag" + "fmt" + "io" + "log" + "net" + "os" + "os/signal" + "syscall" + + "golang.org/x/crypto/blowfish" + "golang.org/x/crypto/chacha20" + "golang.org/x/crypto/poly1305" + "golang.org/x/crypto/sha3" +) + +const KeyLen = 32 + +func mustWrite(w io.Writer, data []byte) { + if n, err := w.Write(data); err != nil || n != len(data) { + log.Fatal("non full write") + } +} + +func incr(buf []byte) (overflow bool) { + for i := len(buf) - 1; i >= 0; i-- { + buf[i]++ + if buf[i] != 0 { + return + } + } + overflow = true + return +} + +type Keys struct { + ourKey, theirKey []byte + ourObfs, theirObfs *blowfish.Cipher +} + +var Base32Codec *base32.Encoding = base32.StdEncoding.WithPadding(base32.NoPadding) + +func main() { + keygen := flag.Bool("keygen", false, "Generate random key") + responder := flag.Bool("responder", false, "Are we responder?") + bind := flag.String("bind", "[::]:1194", "Address to bind to") + dst := flag.String("dst", "[2001:db8::1234]::1194", "Address to connect to") + flag.Parse() + log.SetFlags(log.Ldate | log.Lmicroseconds | log.Lshortfile) + + if *keygen { + key := make([]byte, KeyLen) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + log.Fatal(err) + } + fmt.Println(Base32Codec.EncodeToString(key)) + return + } + + keys := &Keys{} + hasKeys := make(chan struct{}) + go func() { + first := true + s := bufio.NewScanner(os.Stdin) + h := sha3.NewShake128() + for s.Scan() { + key, err := Base32Codec.DecodeString(s.Text()) + if err != nil { + log.Fatal(err) + } + if len(key) != KeyLen { + log.Fatal("wrong key length") + } + h.Reset() + mustWrite(h, []byte("go.cypherpunks.ru/udpobfs")) + mustWrite(h, key) + iEncKey := make([]byte, chacha20.KeySize) + iBlkKey := make([]byte, 32) + rEncKey := make([]byte, chacha20.KeySize) + rBlkKey := make([]byte, 32) + if _, err := io.ReadFull(h, iEncKey); err != nil { + log.Fatal(err) + } + if _, err := io.ReadFull(h, iBlkKey); err != nil { + log.Fatal(err) + } + if _, err := io.ReadFull(h, rEncKey); err != nil { + log.Fatal(err) + } + if _, err := io.ReadFull(h, rBlkKey); err != nil { + log.Fatal(err) + } + iObfs, err := blowfish.NewCipher(iBlkKey) + if err != nil { + log.Fatal(err) + } + rObfs, err := blowfish.NewCipher(rBlkKey) + if err != nil { + log.Fatal(err) + } + if *responder { + keys.ourKey, keys.theirKey, keys.ourObfs, keys.theirObfs = rEncKey, iEncKey, rObfs, iObfs + } else { + keys.ourKey, keys.theirKey, keys.ourObfs, keys.theirObfs = iEncKey, rEncKey, iObfs, rObfs + } + if first { + close(hasKeys) + first = false + } + } + if s.Err() != nil { + log.Fatal(s.Err()) + } + }() + <-hasKeys + + addr, err := net.ResolveUDPAddr("udp", *bind) + if err != nil { + log.Fatal(err) + } + connBind, err := net.ListenUDP("udp", addr) + if err != nil { + log.Fatal(err) + } + + var connLocal *net.UDPConn + var connRemote *net.UDPConn + var addrLocal *net.UDPAddr + var addrRemote *net.UDPAddr + addr, err = net.ResolveUDPAddr("udp", *dst) + if err != nil { + log.Fatal(err) + } + if *responder { + connLocal, err = net.DialUDP("udp", nil, addr) + } else { + connRemote, err = net.DialUDP("udp", nil, addr) + } + if err != nil { + log.Fatal(err) + } + log.Println(*bind, "->", *dst) + + go func() { + rx := make([]byte, 1<<14) + tx := make([]byte, 1<<14) + var n int + var polyKey [32]byte + var s *chacha20.Cipher + var p *poly1305.MAC + tag := make([]byte, poly1305.TagSize) + var from *net.UDPAddr + nonce := make([]byte, chacha20.NonceSize) + seq := nonce[4:] + for { + if *responder { + n, err = connLocal.Read(rx) + } else { + n, from, err = connBind.ReadFromUDP(rx) + } + if err != nil { + log.Fatal(err) + } + if *responder && addrRemote == nil { + continue + } + if !*responder && (addrLocal == nil || + from.Port != addrLocal.Port || !from.IP.Equal(addrLocal.IP)) { + addrLocal = from + } + if incr(seq[5:]) { + incr(seq[:5]) + } + copy(tx, seq[5:]) + s, err = chacha20.NewUnauthenticatedCipher(keys.ourKey, nonce) + if err != nil { + log.Fatal(err) + } + clear(polyKey[:]) + s.XORKeyStream(polyKey[:], polyKey[:]) + s.SetCounter(1) + s.XORKeyStream(tx[8:], rx[:n]) + p = poly1305.New(&polyKey) + mustWrite(p, seq) + mustWrite(p, tx[8:8+n]) + p.Sum(tag[:0]) + copy(tx[3:8], tag) + keys.ourObfs.Encrypt(tx[:8], tx[:8]) + if *responder { + connBind.WriteTo(tx[:8+n], addrRemote) + } else { + connRemote.Write(tx[:8+n]) + } + } + }() + go func() { + rx := make([]byte, 1<<14) + tx := make([]byte, 1<<14) + var n int + var polyKey [32]byte + var s *chacha20.Cipher + var p *poly1305.MAC + var from *net.UDPAddr + tag := make([]byte, poly1305.TagSize) + ourNonce := make([]byte, chacha20.NonceSize) + ourSeq := ourNonce[4:] + nonce := make([]byte, chacha20.NonceSize) + seq := nonce[4:] + var seqOur, seqTheir uint32 + for { + if *responder { + n, from, err = connBind.ReadFromUDP(rx) + } else { + n, err = connRemote.Read(rx) + } + if err != nil { + log.Fatal(err) + } + if n < 8 { + log.Println("too short") + continue + } + if *responder && (addrRemote == nil || + from.Port != addrRemote.Port || !from.IP.Equal(addrRemote.IP)) { + addrRemote = from + } + keys.theirObfs.Decrypt(rx[:8], rx[:8]) + seqOur = uint32(ourSeq[0])<<16 | uint32(ourSeq[1])<<8 | uint32(ourSeq[2]) + seqTheir = uint32(rx[0])<<16 | uint32(rx[1])<<8 | uint32(rx[2]) + if seqOur == seqTheir { + log.Println("replay") + continue + } + copy(seq, ourNonce[:5]) + copy(seq[5:], rx[:3]) + if seqTheir < seqOur && incr(seq[:5]) { + log.Fatal("seq is overflowed") + } + s, err = chacha20.NewUnauthenticatedCipher(keys.theirKey, nonce) + if err != nil { + log.Fatal(err) + } + clear(polyKey[:]) + s.XORKeyStream(polyKey[:], polyKey[:]) + s.SetCounter(1) + p = poly1305.New(&polyKey) + mustWrite(p, seq) + mustWrite(p, rx[8:n]) + p.Sum(tag[:0]) + if subtle.ConstantTimeCompare(tag[:5], rx[3:8]) != 1 { + log.Print("bad MAC") + continue + } + copy(ourSeq, seq) + s.XORKeyStream(tx, rx[8:n]) + if *responder { + connLocal.Write(tx[:n-8]) + } else { + connBind.WriteTo(tx[:n-8], addrLocal) + } + } + }() + exit := make(chan os.Signal, 1) + signal.Notify(exit, syscall.SIGTERM, syscall.SIGINT) + <-exit +} -- 2.44.0