Введение
Прошло уже более года с тех пор как я написал статью - Анонимная сеть в 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()
}
}
Комментарии (9)
PrinceKorwin
10.10.2024 05:55Больше понравилось место про анализ стойкости, возможных векторов атак и их нивелирования. Интересная статья, и реализация изящная!
php_freelancer
10.10.2024 05:55Недавно что то подобное на Golang реализовывал для обмена webrtc пакетами в децентрализованной гео распределенной сети через libp2p есть пример для чата (pub\sub + kv storage)
Использовали авторизацию по ETHereum ключу и PoF cuckoo-cycle алгоритм защиты, заставляющий клиент вычислить хэш (потратить ресурсы CPU + памяти для подключения к сети), дабы не задудосили.
Проект Dtelecom.org
mikegordan
10.10.2024 05:55А в чем анонимность что кто отправил найти сложно, но при этом последнего тот кто опубликовал можно легко взять за хвост хотя он не виновен?
Number571 Автор
10.10.2024 05:55Анонимность QB-задачи в скрытии факта общения. Наблюдателям сложно определить состояние анализируемого узла: отправляет ли он какие-нибудь сообщения, принимает их или вовсе бездействует. Вследствие этого, становится сложно определить существование реальной коммуникации между несколькими узлами.
markus621
10.10.2024 05:55Задумка может быть и интересная, но вот реализация подкачал, по факту это тот же https с но самописный и не продуманный.
К тому же в таком подходе если больше 1й ноды, придется передавать ключи всех НОД, расшифровка сообщений будет по экспоненте.
Number571 Автор
10.10.2024 05:55по факту это тот же https с но самописный и не продуманный
С HTTPS здесь очень мало схожего, разве что есть асимметричный алгоритм, и на этом собственно все схожести заканчиваются.
если больше 1й ноды, придется передавать ключи всех НОД
Не придётся, ноды могут быть несвязаны между собой вовсе асимметричными ключами. Если существует три узла, то может существовать вероятность того, что только два узла всегда коммуницируют между собой, в то время как третий постоянно генерирует ложный трафик и не знает публичные ключи других узлов.
расшифровка сообщений будет по экспоненте
Расшифровка линейна (от количества участников в сети), но точно не экспонциональна.
MAXH0
Хм... А прецедента Торнадо не боитесь. Кто-то доведет до ума Сеть, запустит что-то нехорошее, а иски , в условных Амстердаме или Париже, Вам будут предъявлять, как автору идеи?
Если отбросить иронию, то БОЛЬШОЕ ВАМ СПАСИБО. Тема важная. И чем дальше, тем более повсеместно важная.
rPman
Это глупость, претензии предъявляют тем кто распространяет и зарабатывает. Например разработчику etherdelta (децентрализованной биржи на базе ethereum) предъявили иск формально за то что он своими токенами там торговал.
MAXH0
Итак! В суде было доказано, что Алексей Перцев зарабатывал и распространял?