golang

Анонимная сеть в 100 строк кода на Go

  • пятница, 11 октября 2024 г. в 00:00:10
https://habr.com/ru/articles/849552/

Введение

Прошло уже более года с тех пор как я написал статью - Анонимная сеть в 200 строк кода на Go. Пересмотрев её однажды осенним вечером я понял насколько всё в ней было ужасно - начиная с самого поведения логики кода и заканчивая его избыточностью. Сев за ноутбук и потратив от силы 20 минут у меня получилось написать сеть всего в 100 строк кода, используя лишь и только стандартную библиотеку языка.

Начало

Если мы посмотрим на большинство анонимных сетей современности, то можно заметить, что их кодовая база постоянно увеличивается, в них становится всё сложнее разбираться, а вероятность внесения в них багов и уязвимостей постоянно увеличивается. Вследствие этого, самим собой мне был поставлен вызов - написать такую анонимную сеть, чтобы её логику смог понять даже начинающий программист, а безопасность смог проверить даже начинающий криптограф. Сеть должна быть простой, понятной, минималистичной и ... мёртвой? Да, именно таковой, не развивающейся, не совершенствующейся, не усложняющейся, а застывшей в своей начальной и единственной форме.

Выбор задачи

Для того, чтобы написать минималистичную анонимную сеть - необходимо выбрать наиболее простую задачу анонимизации, чтобы она давала как можно больше гарантий анонимности и безопасности. Из таких задач можно выделить две: Proxy и QB (queue based). Первая задача предполагает либо использование готовых proxy-серверов, что уже априори становится немонолитным решением и каким-то хаком со стороны условия в 100 строк кода, либо написание собственных, но в таком случае код может увеличиться на достаточно сильную величину. При этом, даже если мы сможем уложить Proxy задачу в реализацию, то сам итог скорее всего получится мало-безопасным, т.к. сама же задача является наиболее слабой среди всего списка таковых задач. Вторая же задача анонимизации из нашего рассмотрения - напротив, наименее привередлива, т.к. ей не важны такие условия как: уровень централизации, количество узлов и связь между узлами. Плюс к этому, она является теоретически доказуемой, где любые пассивные наблюдения, включая наблюдения со стороны глобального наблюдателя, будут являться бессмысленными.

QB-задача

Задача на базе очередей может быть описана следующим списком действий:

  1. Каждое сообщение m шифруется ключом получателя k: c = Ek(m),

  2. Сообщение c отправляется в период = T всем участникам сети,

  3. Период T одного участника независим от периодов T1, T2, ..., Tn других участников,

  4. Если на период T сообщения не существует, то в сеть отправляется ложное сообщение v без получателя (со случайным ключом r): c = Er(v),

  5. Каждый участник пытается расшифровать принятое им сообщение из сети: m = Dk(c).

QB-сеть с тремя узлами A, B, C
QB-сеть с тремя узлами A, B, C

При такой модели глобальный наблюдатель будет видеть лишь факт генерации шифртекстов C = {c1, c2, ..., cn} в определённо заданные периоды времени = T без возможности дальнейшего различия истинности Ek(m) или ложности Er(v) выбираемых им шифртекстов.

Более подробный анализ безопасности задачи и её качества анонимности можно найти в первом разделе работы: Анонимная сеть «Hidden Lake».

Реализация

Программный код условно можно разделить на три части:

  1. Исполнение QB-задачи,

  2. Принятие сообщений из сети,

  3. Точка запуска.

Исполнение QB-задачи

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))
			}
		}
	}
}

Принятие сообщений из сети

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()
}

Точка запуска

// Пример:
// 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])
}

Запускаем

Для работы сети нам потребуются приватные и публичные RSA ключи минимум для двух узлов. Для этого можно воспользоваться любым приложением, которое может создавать пары формата PKCS1. С этой целью я написал небольшое приложение.

После сгенерированных пар асимметричных ключей можно приступать к запуску узлов. Каждый узел будет запускать у себя HTTP-сервер для принятия шифртекстов из сети по POST запросу. При запуске каждый узел указывает сначала свой приватный ключ, а далее публичный ключ собеседника. После этого действия каждый узел вносит список IP-адресов всех других узлов с которыми он хочет связаться.

Как только оба узла запущены, один из них может что-либо написать и это сообщение будет успешно передано, примерно через 5 секунд, другому абоненту.

# 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

Безопасность

Вышеописанная реализация действительно хорошо анонимизирует связь, но лишь при условии, что наблюдатель, в том числе и глобальный, остаётся пассивным. Если наблюдатель переходит в активное состояние, то в этом случае открывается некоторый спектр интересных возможностей.

Наиболее простая атака активного наблюдателя будет сводиться к DoS/DDoS'у сети, т.к. здесь отсутствует F2F (friend-to-friend) коммуникация, из-за чего любой пользователь может начать спамить сообщениями (если знает публичный ключ) и засорять очередь, отсутствует доказательство работы, из-за чего любой пользователь может аккумулировать у себя большое количество шифртекстов, чтобы все участники тратили свои процессорные мощности лишь на расшифровку, помимо прочего наличие io.ReadAll в функции принятия сообщений из сети также не очень хорошо сказывается на отказоустойчивости и может засорить всю оперативную память одним большим отправленным сообщением.

С DoS/DDoS всё понятно, а что насчёт деанонимизирующих активных наблюдений? Вот здесь всё куда интереснее. Если наблюдатель не будет знать нашего публичного ключа, то осуществить какую бы то ни было активную атаку ему будет проблематично. С другой стороны, если он всё же получит публичный ключ, то он получит доступ к изменению состояния нашей очереди queue. Тем не менее этого наблюдателю будет мало, но не из-за того, что QB-сети защищают от такой атаки, а от того, что в нашем прикладном приложении (чате) отсутствует автоматическая связь вида: «запрос-ответ». Если бы чат был не чатом, а например файлообменником, то ситуация стала бы более плачевной, т.к. позволяла злоумышленнику измерять время ответа относительно периодов генерации шифртекстов. Из-за этого рушилась бы анонимность факта отправления и получения сообщений, а с появлением сговора активных наблюдателей на нескольких узлах, рушилась бы анонимность и связи между отправителем и получателем. Влияние такой атаки на QB-сеть возможно уменьшить либо внедрением F2F, либо созданием нескольких очередей, привязанных к конкретным узлам, либо отсутствием прикладных приложений требующих «запрос-ответ». Наша сеть, по счастливому стечению обстоятельств, придерживается последнего способа. Но стоит также сказать, что этот способ неидеален. Если абонент будет активно общаться сразу с несколькими собеседниками, среди которых будет также наблюдатель, то очередь сообщений будет постоянно накапливаться, а время ответа увеличиваться. Вследствие этого, наблюдатель (являющийся одним из собеседников) сможет предположить, что его абонент, будучи очень общительным и разговорчивым человеком, вряд-ли сможет так долго не отвечать на его сообщение «о выборе тортика на день рождения».

Также стоит учитывать тот факт, что QB-сети не анонимизируют связь собеседников друг к другу - они скрывают таковую связь от всех остальных участников, но не от самих абонентов участвующих в коммуникации 1к1. Поэтому данную сеть нельзя использовать в ситуациях, когда один из собеседников или оба обязательно должны быть инкогнито друг к другу / друг для друга.

Заключение

В результате анонимная сеть была успешно переписана с нуля, с сокращением и без того малого количества кода в два раза, с 200 до 100 строк кода. Исходный код анонимной сети можно найти в репозитории Github'a или просто в спойлере ниже.

Анонимная сеть 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()
	}
}