]> Cypherpunks.ru repositories - udpobfs.git/commitdiff
Initial commit
authorSergey Matveev <stargrave@stargrave.org>
Wed, 16 Aug 2023 17:12:10 +0000 (20:12 +0300)
committerSergey Matveev <stargrave@stargrave.org>
Wed, 16 Aug 2023 17:13:28 +0000 (20:13 +0300)
PROTOCOL [new file with mode: 0644]
README [new file with mode: 0644]
go.mod [new file with mode: 0644]
go.sum [new file with mode: 0644]
main.go [new file with mode: 0644]

diff --git a/PROTOCOL b/PROTOCOL
new file mode 100644 (file)
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 (file)
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 (file)
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 (file)
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 (file)
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 <stargrave@stargrave.org>
+
+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 <http://www.gnu.org/licenses/>.
+*/
+
+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
+}