Anonymous network in 100 lines of code in Go

Introduction

It's been more than a year since I wrote an article – Anonymous network in 200 lines of code in Go. Having reviewed it one autumn evening, I realized how terrible everything about it was – from the very behavior of the code logic to its redundancy. Sitting down at my laptop and spending at most 20 minutes, I was able to write a network in just 100 lines of codeusing only and only the standard library of the language.

Start

If we look at most anonymous networks of our time, we can see that their code base is constantly growing, they are becoming increasingly difficult to understand, and the likelihood of bugs and vulnerabilities being introduced into them is constantly increasing. As a result, I was given a challenge by myself – to write such an anonymous network that even a novice programmer could understand its logic, and even a novice cryptographer could check its security. The network should be simple, understandable, minimalistic and… dead? Yes, exactly like that, not developing, not improving, not becoming more complex, but frozen in its initial and only form.

Selecting a task

In order to write a minimalist anonymous network, you need to choose the simplest anonymization task so that it provides as many guarantees of anonymity and security as possible. Two of these tasks can be distinguished: Proxy And QB (queue based). The first task involves either using ready-made proxy servers, which a priori becomes a non-monolithic solution and some kind of hack from the condition of 100 lines of code, or writing your own, but in this case the code can increase by a fairly large amount. At the same time, even if we can fit the Proxy task into the implementation, the result itself will most likely turn out to be less secure, because the task itself is the weakest among the entire list of such tasks. The second task of anonymization from our consideration, on the contrary, is the least picky, because it does not care about such conditions as: the level of centralization, the number of nodes and the connection between nodes. Plus, it is theoretically provable, where any passive observations, including observations from a global observer, will be meaningless.

QB task

A queue-based task can be described by the following list of actions:

  1. Each message is encrypted with the recipient's key,

  2. The message is sent within the period = T to all network participants,

  3. Period T one participant independent of periods T1T2…, Tn other participants,

  4. If for a period T the message does not exist, then a false message is sent to the network without a recipient,

  5. Each participant tries to decrypt the message he received from the network.

With this model, a global observer will only see the fact of generating ciphertexts in a specific period of time = T without the possibility of further distinguishing the truth or falsity of the ciphertexts he selects.

A more detailed analysis of the security of the task and its quality of anonymity can be found in the first section of the work: Anonymous network “Hidden Lake”.

Implementation

The program code can be divided into three parts:

  1. Execution of the QB task,

  2. Receiving messages from the network,

  3. Start point.

Executing the QB task

func runQBProblem(ctx context.Context, receiverKey *rsa.PublicKey, hosts []string) error {
	queue := make(chan []byte, 256)

    // Генерируем ложные шифртексты, если очередь пуста
    go func() {
        // Разого генерируем ключ псевдо-получателя
		pr, err := rsa.GenerateKey(rand.Reader, receiverKey.N.BitLen())
		doif(err != nil, func() { panic(err) })
		for {
			select {
			case <-ctx.Done():
				return
			default:
				if len(queue) == 0 {
					encBytes, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &pr.PublicKey, []byte("_"), nil)
					doif(err == nil, func() { queue <- encBytes })
				}
			}
		}
	}()

    // Генерируем истинные шифртексты, если можем вычитать из stdin
	go func() {
		for {
			select {
			case <-ctx.Done():
				return
			default:
				input, _, _ := bufio.NewReader(os.Stdin).ReadLine()
				encBytes, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, receiverKey, input, nil)
				doif(err == nil, func() { queue <- encBytes })
			}
		}
	}()

    // Отсылаем сгенерированные шифртексты каждые 5 секунд всем узлам в сети
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(5 * time.Second):
            encBytes := <-queue
			for _, host := range hosts {
				client := &http.Client{Timeout: time.Second}
				_, _ = client.Post(fmt.Sprintf("http://%s/push", host), "text/plain", bytes.NewBuffer(encBytes))
			}
		}
	}
}

Receiving messages from the network

func runMessageHandler(ctx context.Context, privateKey *rsa.PrivateKey, addr string) error {
	mux := http.NewServeMux()
	mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
		encBytes, _ := io.ReadAll(r.Body)
		decBytes, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, encBytes, nil)
		doif(err == nil, func() { fmt.Println(string(decBytes)) })
	})
	server := &http.Server{Addr: addr, Handler: mux}
	go func() {
		<-ctx.Done()
		server.Close()
	}()
	return server.ListenAndServe()
}

Start point

// Пример:
// go run . :8080 ./example/node1/priv.key ./example/node2/pub.key localhost:7070
func main() {
	ctx := context.TODO()
	go func() { _ = runQBProblem(ctx, getReceiverKey(os.Args[3]), os.Args[4:]) }()
	_ = runMessageHandler(ctx, getPrivateKey(os.Args[2]), os.Args[1])
}

Let's launch

For the network to work, we need private and public RSA keys for at least two nodes. To do this, you can use any application that can create PKCS1 format pairs. For this purpose I wrote a short application.

Once the asymmetric key pairs have been generated, you can start launching nodes. Each node will run an HTTP server to accept ciphertexts from the network via POST request. When starting, each node first specifies its private key, and then the public key of the interlocutor. After this action, each node lists the IP addresses of all other nodes with which it wants to communicate.

Once both nodes are running, one of them can write something and this message will be successfully transmitted, after about 5 seconds, to the other subscriber.

# Terminal-1
$ go run . :7070 ./example/node2/priv.key ./example/node1/pub.key localhost:8080

# Terminal-2
$ go run . :8080 ./example/node1/priv.key ./example/node2/pub.key localhost:7070

# Terminal-1 (ввод)
> hello

# Terminal-2 (вывод)
> hello

Safety

The above implementation anonymizes the connection really well, but only on the condition that the observer, including the global one, remains passive. If the observer goes into an active state, then a certain range of interesting possibilities opens up.

The simplest attack by an active observer would be DoS/DDoS'at the network, because Here missing F2F (friend-to-friend) communication, due to which any user can start spamming messages (if he knows the public key) and clog the queue, no proof of workdue to which any user can accumulate a large number of ciphertexts, so that all participants spend their processor power only on decryption, among other things availability io.ReadAll in the function of receiving messages from the network also does not have a very good effect on fault tolerance and can clog up the entire RAM with one large sent message.

WITH DoS/DDoS everything is clear, but what about de-anonymizing active observations? This is where things get much more interesting. If an observer does not know our public key, then it will be problematic for him to carry out any active attack. On the other hand, if he does receive the public key, then he will have access to change the state of our queue queue. Nevertheless, this will not be enough for the observer, but not because QB networks protect against such an attack, but because in our application (chat) there is no automatic communication of the “request-response” type. If the chat were not a chat, but, for example, a file sharing service, then the situation would become more deplorable, because… allowed the attacker to measure the response time relative to the periods of ciphertext generation. Because of this, the anonymity of the fact of sending and receiving messages would collapse, and with the emergence of a conspiracy of active observers on several nodes, the anonymity and connection between the sender and the recipient would collapse. The impact of such an attack on the QB network can be reduced either implementation of F2For by creating several queuestied to specific nodes, or lack of applications requiring “request-response”. Our network, by a happy coincidence, adheres to the latter method. But it is also worth saying that this method is not ideal. If a subscriber actively communicates with several interlocutors at once, among whom there is also an observer, then the queue of messages will constantly accumulate and the response time will increase. As a result, the observer (who is one of the interlocutors) will be able to assume that his subscriber, being a very sociable and talkative person, is unlikely to be able to avoid responding to his message “about choosing a birthday cake” for so long.

In addition to this, it is also worth considering the fact that QB networks do not anonymize communication between interlocutors to each other – they hide such a connection from all other participants, but not from the subscribers themselves participating in 1k1 communication. Therefore, this network cannot be used in situations where one of the interlocutors or both must be incognito to/for each other.

Conclusion

As a result, the anonymous network was successfully rewritten from scratch, reducing the already small amount of code by half, from 200 to 100 lines of code. The source code for the anonymous network can be found in repositories Github or just in the spoiler below.

Anonymous MA Network
package main

import (
	"bufio"
	"bytes"
	"context"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

func main() {
	ctx := context.TODO()
	go func() { _ = runQBProblem(ctx, getReceiverKey(os.Args[3]), os.Args[4:]) }()
	_ = runMessageHandler(ctx, getPrivateKey(os.Args[2]), os.Args[1])
}

func runMessageHandler(ctx context.Context, privateKey *rsa.PrivateKey, addr string) error {
	mux := http.NewServeMux()
	mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
		encBytes, _ := io.ReadAll(r.Body)
		decBytes, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, encBytes, nil)
		doif(err == nil, func() { fmt.Println(string(decBytes)) })
	})
	server := &http.Server{Addr: addr, Handler: mux}
	go func() {
		<-ctx.Done()
		server.Close()
	}()
	return server.ListenAndServe()
}

func runQBProblem(ctx context.Context, receiverKey *rsa.PublicKey, hosts []string) error {
	queue := make(chan []byte, 256)
	go func() {
		pr, err := rsa.GenerateKey(rand.Reader, receiverKey.N.BitLen())
		doif(err != nil, func() { panic(err) })
		for {
			select {
			case <-ctx.Done():
				return
			default:
				if len(queue) == 0 {
					encBytes, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &pr.PublicKey, []byte("_"), nil)
					doif(err == nil, func() { queue <- encBytes })
				}
			}
		}
	}()
	go func() {
		for {
			select {
			case <-ctx.Done():
				return
			default:
				input, _, _ := bufio.NewReader(os.Stdin).ReadLine()
				encBytes, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, receiverKey, input, nil)
				doif(err == nil, func() { queue <- encBytes })
			}
		}
	}()
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(5 * time.Second):
            encBytes := <-queue
			for _, host := range hosts {
				client := &http.Client{Timeout: time.Second}
				_, _ = client.Post(fmt.Sprintf("http://%s/push", host), "text/plain", bytes.NewBuffer(encBytes))
			}
		}
	}
}

func getPrivateKey(privateKeyFile string) *rsa.PrivateKey {
	privKeyBytes, _ := os.ReadFile(privateKeyFile)
	priv, err := x509.ParsePKCS1PrivateKey(privKeyBytes)
	doif(err != nil, func() { panic(err) })
	return priv
}

func getReceiverKey(receiverKeyFile string) *rsa.PublicKey {
	pubKeyBytes, _ := os.ReadFile(receiverKeyFile)
	pub, err := x509.ParsePKCS1PublicKey(pubKeyBytes)
	doif(err != nil, func() { panic(err) })
	return pub
}

func doif(isTrue bool, do func()) {
	if isTrue {
		do()
	}
}

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *