Anonymous network in 100 lines of Go code

It has been more than a year since I wrote the article - Anonymous network in 200 lines of Go code. Reviewing it one autumn evening, I realized how terrible everything in it was - starting with the very behavior of the code logic and ending with its redundancy. Sitting down at my laptop and spending at most 20 minutes, I managed to write a network in just 100 lines of code, using only the standard library of the language.

Beginning

If we look at most of the anonymous networks of today, we can see that their code base is constantly increasing, they are becoming more and more difficult to understand, and the likelihood of bugs and vulnerabilities in them is constantly increasing. As a result, I set myself a challenge - to write such an anonymous network so that its logic could be understood even by a novice programmer, and its security could be checked even by a novice cryptographer. The network should be simple, understandable, minimalist and ... dead? Yes, exactly, not developing, not improving, not complicating, but frozen in its initial and only form.

Task selection

In order to write a minimalist anonymous network, it is necessary to choose the simplest anonymization task so that it provides as many guarantees of anonymity and security as possible. Two such 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 on the part of the condition in 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 is likely to be insecure, since the task itself is the weakest among the entire list of such tasks. The second anonymization task from our consideration, on the contrary, is the least fastidious, since it does not care about such conditions as: the level of centralization, the number of nodes and the connection between nodes. In addition, it is theoretically provable, where any passive observations, including observations from a global observer, will be meaningless.

QB-task

The 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 in the period = T to all network participants,

  3. The period T of one participant is independent of the periods T1, T2, ..., Tn of other participants,

  4. If there is no message for the period T, then a fake message without a recipient is sent to the network,

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

With this model, a global observer will only see the fact of ciphertext generation during a certain period of time = T without the ability to further distinguish the truth or falsity of the ciphertexts chosen by them.

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

Implementation

The program code can be conditionally divided into three parts:

  1. Execution of the QB task,

  2. Receiving messages from the network,

  3. Launch point.

Execution of the QB task

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

    // Generate fake ciphertexts if the queue is empty
    go func() {
        // Generate a pseudo-recipient key once
		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 })
				}
			}
		}
	}()

    // Generate real ciphertexts if we can read from 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 })
			}
		}
	}()

    // Send generated ciphertexts every 5 seconds to all nodes in the network
	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()
}

Entry Point

// Example:
// 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 Run

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

After generating pairs of asymmetric keys, you can start launching nodes. Each node will run an HTTP server to receive ciphertexts from the network via POST requests. When starting, each node specifies its private key first, and then the public key of the interlocutor. After this action, each node enters a list of IP addresses of all other nodes it wants to connect with.

As soon as both nodes are launched, one of them can write something and this message will be successfully delivered, approximately within 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 (input)
> hello

# Terminal-2 (output)
> hello

Security

The above implementation does indeed anonymize communication well, but only if the observer, including the global one, remains passive. If the observer becomes active, then a range of interesting possibilities opens up.

The simplest attack by an active observer would be to DoS/DDoS the network, as there is no F2F (friend-to-friend) communication, which means any user can start spamming messages (if they know the public key) and clog the queue, no proof of work, which means any user can accumulate a large number of ciphertexts, causing all participants to spend their processing power only on decryption, and the presence of io.ReadAll in the function for receiving messages from the network also does not bode well for fault tolerance and can clog all the RAM with one large sent message.

With DoS/DDoS everything is clear, but what about de-anonymizing active observations? Here things get much more interesting. If the observer does not know our public key, it will be problematic for him to carry out any active attack. On the other hand, if he does get the public key, he will gain access to changing the state of our queue. However, this will not be enough for the observer, not because QB networks protect against such an attack, but because our application (chat) lacks an automatic "request-response" connection. If the chat were not a chat, but for example a file sharing service, the situation would be more deplorable, as it would allow the attacker to measure the response time relative to the ciphertext generation periods. Because of this, the anonymity of the fact of sending and receiving messages would be compromised, and with the collusion of active observers on several nodes, the anonymity of the connection between the sender and the recipient would be compromised. The impact of such an attack on the QB network can be reduced either by implementing F2F, or by creating multiple queues tied to specific nodes, or by 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 perfect. If the subscriber actively communicates with several interlocutors at once, among whom there will also be an observer, the message queue will constantly accumulate, and the response time will increase. As a result, the observer (being 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 not respond to his message "about choosing a cake for a birthday" for so long.

In addition, it is also worth considering the fact that QB networks do not anonymize the communication between interlocutors - they hide such communication from all other participants, but not from the subscribers themselves participating in 1-on-1 communication. Therefore, this network cannot be used in situations where one or both interlocutors must necessarily be anonymous to each other / 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 of the anonymous network can be found in the Github repository or simply in the spoiler below.

Anonymous network M-A
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()
	}
}

 

 

Comments