Введение
Прошло уже более года с тех пор как я написал статью — Анонимная сеть в 200 строк кода на Go. Пересмотрев её однажды осенним вечером я понял насколько всё в ней было ужасно — начиная с самого поведения логики кода и заканчивая его избыточностью. Сев за ноутбук и потратив от силы 20 минут у меня получилось написать сеть всего в 100 строк кода, используя лишь и только стандартную библиотеку языка.
Начало
Если мы посмотрим на большинство анонимных сетей современности, то можно заметить, что их кодовая база постоянно увеличивается, в них становится всё сложнее разбираться, а вероятность внесения в них багов и уязвимостей постоянно увеличивается. Вследствие этого, самим собой мне был поставлен вызов — написать такую анонимную сеть, чтобы её логику смог понять даже начинающий программист, а безопасность смог проверить даже начинающий криптограф. Сеть должна быть простой, понятной, минималистичной и … мёртвой? Да, именно таковой, не развивающейся, не совершенствующейся, не усложняющейся, а застывшей в своей начальной и единственной форме.
Выбор задачи
Для того, чтобы написать минималистичную анонимную сеть — необходимо выбрать наиболее простую задачу анонимизации, чтобы она давала как можно больше гарантий анонимности и безопасности. Из таких задач можно выделить две: Proxy и QB (queue based). Первая задача предполагает либо использование готовых proxy-серверов, что уже априори становится немонолитным решением и каким-то хаком со стороны условия в 100 строк кода, либо написание собственных, но в таком случае код может увеличиться на достаточно сильную величину. При этом, даже если мы сможем уложить Proxy задачу в реализацию, то сам итог скорее всего получится мало-безопасным, т.к. сама же задача является наиболее слабой среди всего списка таковых задач. Вторая же задача анонимизации из нашего рассмотрения — напротив, наименее привередлива, т.к. ей не важны такие условия как: уровень централизации, количество узлов и связь между узлами. Плюс к этому, она является теоретически доказуемой, где любые пассивные наблюдения, включая наблюдения со стороны глобального наблюдателя, будут являться бессмысленными.
QB-задача
Задача на базе очередей может быть описана следующим списком действий:
-
Каждое сообщение шифруется ключом получателя,
-
Сообщение отправляется в период = T всем участникам сети,
-
Период T одного участника независим от периодов T1, T2, …, Tn других участников,
-
Если на период T сообщения не существует, то в сеть отправляется ложное сообщение без получателя,
-
Каждый участник пытается расшифровать принятое им сообщение из сети.
При такой модели глобальный наблюдатель будет видеть лишь факт генерации шифртекстов в определённо заданный период времени = T без возможности дальнейшего различия истинности или ложности выбираемых им шифртекстов.
Более подробный анализ безопасности задачи и её качества анонимности можно найти в первом разделе работы: Анонимная сеть «Hidden Lake».
Реализация
Программный код условно можно разделить на три части:
-
Исполнение QB-задачи,
-
Принятие сообщений из сети,
-
Точка запуска.
Исполнение 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() } }
ссылка на оригинал статьи https://habr.com/ru/articles/849552/
Добавить комментарий