Введение
Прошло уже более года с тех пор как я написал статью - Анонимная сеть в 200 строк кода на Go. Пересмотрев её однажды осенним вечером я понял насколько всё в ней было ужасно - начиная с самого поведения логики кода и заканчивая его избыточностью. Сев за ноутбук и потратив от силы 20 минут у меня получилось написать сеть всего в 100 строк кода, используя лишь и только стандартную библиотеку языка.
Начало
Если мы посмотрим на большинство анонимных сетей современности, то можно заметить, что их кодовая база постоянно увеличивается, в них становится всё сложнее разбираться, а вероятность внесения в них багов и уязвимостей постоянно увеличивается. Вследствие этого, самим собой мне был поставлен вызов - написать такую анонимную сеть, чтобы её логику смог понять даже начинающий программист, а безопасность смог проверить даже начинающий криптограф. Сеть должна быть простой, понятной, минималистичной и ... мёртвой? Да, именно таковой, не развивающейся, не совершенствующейся, не усложняющейся, а застывшей в своей начальной и единственной форме.
Выбор задачи
Для того, чтобы написать минималистичную анонимную сеть - необходимо выбрать наиболее простую задачу анонимизации, чтобы она давала как можно больше гарантий анонимности и безопасности. Из наиболее простых задач можно выделить две: Proxy и QB (queue based). Первая задача предполагает либо использование готовых proxy-серверов, что уже априори становится немонолитным решением и каким-то хаком со стороны условия в 100 строк кода, либо написание собственных, но в таком случае код может увеличиться на достаточно сильную величину. При этом, даже если мы сможем уложить Proxy задачу в реализацию, то сам итог скорее всего получится мало-безопасным, т.к. сама же задача является наиболее слабой среди всего списка таковых задач. Вторая же задача анонимизации из нашего рассмотрения - напротив, наименее привередлива, т.к. ей не важны такие условия как: уровень централизации, количество узлов и связь между узлами. Плюс к этому, она является теоретически доказуемой, где любые пассивные наблюдения, включая наблюдения со стороны глобального наблюдателя, будут являться бессмысленными.
QB-задача
Задача на базе очередей может быть описана следующим списком действий:
Каждое сообщение m шифруется ключом получателя k: c = Ek(m),
Сообщение c отправляется в период = T всем участникам сети,
Период T одного участника независим от периодов T1, T2, ..., Tn других участников,
Если на период T сообщения не существует, то в сеть отправляется ложное сообщение v без получателя (со случайным ключом r): c = Er(v),
Каждый участник пытается расшифровать принятое им сообщение из сети: m = Dk(c).
При такой модели глобальный наблюдатель будет видеть лишь факт генерации шифртекстов C = {c1, c2, ..., cn} в определённо заданные периоды времени = T без возможности дальнейшего различия истинности Ek(m) или ложности Er(v) выбираемых им шифртекстов.
Более подробный анализ безопасности задачи и её качества анонимности можно найти в первом разделе работы: Анонимная сеть «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()
}
}