На днях, как это обычно и бывает перед сном, мой мозг решил, что было бы очень забавно перед экзаменом не спать, а заняться брейнштормом. Как результат я получил слитую сессию и безумное желание сделать свой P2P WEB 228.0 — ну вы знаете…
Вот вы никогда не задумывались, что Tor является весьма экстраординарным способом преодоления трансляторов сетевых адресов? По сути, он позволяет создавать туннель между двумя любыми удалёнными узлами, находящимися за NAT, выдавая им уникальные onion-адреса из очень большого диапазона.
Аналогичную же задачу решает переход на ipv6, но при этом он требует поддержки со стороны самого транслятора, в то время как Tor абсолютно самостоятелен, хоть он и не является настоящим p2p.
Чисто технически (при должном monkey patch`инге) можно даже заставить его работать как пробрасыватель портов для RDP, онлайн-игр и Vиртуальных Pриватных туннелей, которые нынче нельзя называть.
Если вам интересны более традиционные способы преодоления NAT или вы просто считаете, что строить приложения поверх Tor неспортивно, то могу предложить вам свою прошлую статью.
Во-вторых, стоит сразу уладить все возможные непонятки и держать у себя в голове, что Tor в некоторых аспектах ещё и не является реально децентрализованным. Однако в нашем кейсе мы будем считать его достаточно безотказным и одноранговым для подобного приближения.
Итак, если мы хотим выжать из него многопользовательское взаимодействие, то нам стоит разобраться хотя бы с природой onion-адресов. Если вы когда-либо задумывались о внешнем виде адресов каких-либо анонимных сайтов, то даже не зная их настоящей кухни, наверняка замечали, что они выглядят как результат поиска случайной последовательности с необходимым префиксом. С другой стороны, они все одной длины (56 символов), имеют статический алфавит и повторяющийся паттерн (символ d) в конце.
С точки зрения обывателя, адрес именитого и богатого сайта по продаже книг может выглядеть как booksell8f2b2a...., в то время как адрес маленького пользовательского сервиса кажется совершенно случайным.
Т. е. создатель магазина явно нашёл этот префикс неким сложным перебором (как минимум нелинейной сложности), иначе бы было много сайтов с подобными говорящими адресами.
Так как же происходит присваивание домена? На самом деле каждый пользователь самостоятельно генерирует для себя адрес и анонсирует в сеть. Значит кто-то должен определять, что адрес действительно принадлежит магазину книг, а не самозванцу…
В то же время и магазин и наш условный злоумышленник должны оставаться полностью анонимны, чтобы их нельзя было ни идентифицировать, ни дифференцировать по внешним признакам. Так как же решить подобную задачу?
Очевидно, что тут замешан стандартный механизм доверия при помощи асимметричного шифрования — его то мы и будем сегодня препарировать.
Вторым шагом я собираюсь воссоздать или хотя бы использовать данный механизм в собственной программе и реализовать свою распределённую базу знаний для полностью анонимного обмена статьями и сообщениями, где читатель всегда знает, что автор именно тот, за кого он себя выдаёт. Для этого мы используем открытый алгоритм подписей, совместимый с Tor.
Все используемые библиотеки будут cross compile ready и вы сможете стать узлом такой сети где угодно: на компьютере, в colab-облаке или даже на телефоне (termux).
В качестве дополнительных эффектов будут выступать идеальная безотказность такой энциклопедии и возможность оффлайн-чтения с самостоятельной индексацией страниц. В сети не будет дифференцирования прав нод и каждый участник будет одновременно настолько же трекером, насколько много он хорошего контента создал и распространил.
Так что если вы когда-либо хотели себя почувствовать членом элитарного, но полностью анонимного общества, или просто перекинуть большой файл с вашего устройства на другой конец земного шара, оставшись при этом незамеченным, то добро пожаловать.
В своих последних двух статьях я акцентировал внимание на таком понятии, как NAT hole punching, или просто пробитии дыр, и раз уж с этого было начато повествование, то кажется необходимым развернуть тезис о нереальном p2p в Tor.
Является ли Tor p2p?
На самом деле, ваш вход в сеть Tor осуществляется при помощи так называемых directory relay'ев, которые намертво прописаны в каждом Tor-клиенте (не путать с мостами).
Их буквально можно пересчитать на пальцах рук (9 штук) и большинство из них находится в Америке. Да, они были неизменны практически с самого основания Tor и принадлежат создателям сети. Их локация, конечно, способствует их сроку жизни, но не гарантирует их безотказность. Онлайн-монитор всех 9 нод доступен по этой ссылке.
Чисто технически каждый из них является агрегатором DHT-записей, и в случае утраты работоспособности всех централизованных relay нод сеть Tor ляжет.
Так ещё и захват directory ноды сторонним наблюдателем с потрохами выдаёт ему ваш реальный IP, так что Tor не считается ещё и полностью анонимным… Но как вы уже поняли, к нашему сегодняшнему делу это совершенно не относится.
Ладно, что-то я отвлёкся. В общем, сеть Tor имеет централизованные узлы, но трансфер данных между участниками осуществляется практически напрямую.
Я говорю практически, так как нас сейчас даже не интересует та самая луковая маршрутизация, ради которой собственно сеть и задумывалась. Вы и без меня слышали кучу аналогий и красивых описаний её принципа работы.
Нам от неё нужна будет исключительно заранее готовая реализация теоретически доказуемой анонимности IP-адресов между участниками, а следовательно и геолокаций обоих участников синхронизации — ничего более.
Магия асимметричных ключей
Изначально я хотел рассказать об RSA с точки зрения школьной теории чисел, расширив её функцией Эйлера и малой теоремой Ферма. Для этого не нужны специфичные профильные знания, как следствие, я мог бы занять этим неплохой такой блок статьи. Но, как вы уже догадались по стилю повествования, матеши не будет. Да и научпоп более чем справился с подобной задачей, так что теперь даже моя кофеварка знает всё про простые числа и схему Шамира.
Реальной же причиной моего тунеядства является миграция Tor с RSA на ECC и изменение сразу ряда базовых концептов. Название этим изменениям — onion V3.
Т. е. дальнейший материал будет далеко не таким, каким я его ожидал увидеть. Это был своеобразный клиффхэнгер: в дальнейшем я буду вынужден ссылаться на разницу между этими двумя криптографическими системами и выяснять, действительно ли раньше трава была зеленее…
Если вам действительно принципиальна разница между эллиптическими ключами и основанными на простых числах, то вы можете в полной мере осознать сложность первых, мельком оценив данный перевод.
Если вы не на третьем профильном курсе математики или банально не имеете представления о полях и кольцах, то осилить этот перевод полностью вы вряд ли сможете. Так что я попытаюсь объяснить суть на пальцах без каких-либо выкладок.
▍ Криптография на эллиптической кривой
В алгебраическом описании самой функции нет вообще ничего сложного. Ниже вы можете видеть формулу и результат изменения простейших коэффициентов в ней:
Но где же здесь кроется односторонняя функция? Для простейшей асимметричной криптографии нам нужна односторонняя функция как остаток по модулю в аналогии с теорией чисел и хитрый способ использовать её математические свойства для получения одинаковых результатов с разными входными (сейчас поясню).
Вот так выглядит Diffie Hellman, основанный на теории чисел:
В конце получается ключ K, известный нашим героям. А сторонний наблюдатель, контролирующий каналы связи, не может его восстановить!
Вы справедливо заметите: так ведь Diffie Hellman — протокол симметричного шифрования, ключ-то в конце получился одинаковый!
И будете абсолютно правы. Дальше Боб и Алиса будут симметрично шифровать им свои сообщения и им же расшифровывать. Вот только механизм его получения был асимметричен и третье лицо не может его получить (даже пристально наблюдая за всем процессом его создания).
Теперь нам нужно найти функцию с аналогичными свойствами на графике нашей кривой. Это будет функция многократного скалярного умножения точки (dot) на саму себя. И всё-таки проще нарисовать, чем объяснить:
Как мы можем видеть, эта функция 2(A)=B обладает тем же свойством, что и g^b mod p, а именно 2(2(A))=4(A)=D по аналогии (g^b mod p)^a mod p = g^ab mod p. А ещё её очень сложно провернуть назад — это вам докажут математики. Задача обратного выполнения этой функции называется Elliptic Curve Discrete Logarithm Problem и суть в том, что это вычислительно бесполезно на больших числах.
Собственно, вот вам и схема Диффи — Хеллмана для эллиптических кривых:
Роль g и p же выполняют коэффициенты, задающие график конкретной кривой.
Да, я разобрал механизм ECDH, а сам далее буду нагло использовать ECDSA. И, по сути, единственное, что их объединяет — эллиптические кривые. Однако ведь именно о них мы и хотели получить базовое представление).
В общем предлагаю не уходить в дебри объяснений и ограничиться базовыми характеристиками ECC, интересующими нас с практической точки зрения:
Более короткие ключи, при этом большая сложность взлома (384 ключ ECC примерно равен 7680 ключу RSA).
Отсутствие алгоритма взлома ключа для квантовых вычислителей.
Длина ключей не может свободно выбираться, как в случае с RSA1024, RSA2028… RSA8192 и т. д. — для эллиптических пар она фиксирована на 256 битах (что эквивалентно 3072 ключу RSA по сложности подбора).
Поддержка со стороны стандартных криптосистем: GPG, SSL и т. д. (например, новогодний Хабра-чат по SSH использует ED25519 идентификацию).
▍ ED25519
ED25519 — так называется стандарт электронных подписей при помощи эллиптических пар.
Кстати, да, статья эта посвящена именно Python хукам для ECC. Если же вы просто прикола ради сгенерили себе PGP-пару и указали её у себя в профиле, а потом вам на почту пришла анонимная каша из ASCII — дальнейший материал может вас и не заинтересовать.
Если мы говорим о реальном практическом способе применения ECC, то оно, как уже было упомянуто, поддерживается в GPG. Просто пишете в консоли любого из классических дистрибутивов gpg [— encrypt/— decrypt/— sign/— verify] и спокойно общаетесь со своим другом при помощи зашифрованных сообщений прямо в VK, которые теоретически не может взломать ни один товарищ Майор.
Переходим к практике
Соответственно, предлагаю наконец-то отвлечься от матчасти и пойти искать реализацию ed25519 подписей для Python. Конечно, никто не стал бы писать криптографическую библиотеку на чистом Python. Следовательно, считаю правильным упомянуть, что на самом деле мы будем работать с бинарной библиотекой libsodium.
Самая популярная, а, следовательно, и поддерживаемая сообществом библиотека для подключения libsodium.dll к среде питона— PyNaCl (отсылка к криптографическому термину Salt или просто Соль).
Она прямо на главной странице readthedocs показывает нам пример того, как подписывать и верифицировать бинарные данные при помощи эллиптической кривой.
Но если мы реально хотим хоть немного преисполниться, а не просто сделать шифратор-дешифратор на питоне, то нам в любом случае понадобится хоть немного зареверсить принцип работы Tor. Для начала сделаем и запустим простой torrc-конфиг.
Я пропускаю момент скачивания Tor и запуска его как CLI (не как браузер). Полагаю, вы и сами найдёте в его папке tor.exe и запустите из командной строки, если решите повторить все действия в точности за мной.
Создалась папка keys, где наше внимание сразу привлекает файл hostname. Попробуем наивно вписать сюда свой кастомный домен.
Ожидаемо, после перезапуска файл был перезаписан, мы ничего не изменили. Само собой, всё не могло оказаться так просто. Гуглим способ кастомизации домена onion и узнаем, что файл publickey или просто наш публичный ключ используется для генерации домена — и никак иначе.
Высказывание это было и остаётся актуальным для onion V2 и onion V3 в частности. Только если раньше .onion адрес являлся хешем от публичного ключа RSA и не позволял его восстановить из себя, то для ECC адрес является просто перекодированным ключом. Это делает адреса V3 значительно длиннее старых, что позволяет их легко дифференцировать.
«Так сделай такой публичный ключ, чтобы домен был нужный!» — вот только публичный ключ не имеет смысла без приватного, из которого он, собственно, и должен быть сгенерирован. Т. е. мы должны сначала сгенерировать приватный ключ, потом парный к нему публичный, и только из публичного делать домен.
Жаль, что из домена/публичного ключа нельзя восстановить приватный, правда? Нет, ну чисто технически можно подбором…
Короче, если во времена RSA поиск красивого домена сводился к подбору хеша, то теперь непосредственно к подбору асимметричной пары, ведь в V3 .onion однозначно восстанавливается обратно в public key.
Но по вычислительной затратности этот процесс аналогичен подбору — как следствие, он абсолютно бесполезен на огромных ключах.
RSA и ECC оба являются именно такими — взломать длинные ключи, созданные этими алгоритмами практически невозможно. Можно только пытаться перебирать комбинации в поисках наиболее красивой для вашего домена)
Но нас подобный перебор не сильно интересует. Нам нужно научиться управлять ключами Tor из Python. Для начала нужно разобрать формат файлов hs_ed25519_public_key и hs_ed25519_secret_key.
Первым делом избавимся от этих странных заголовков == ed25519v1-public: type0 == и == ed25519v1-secret: type0 ==.
Они длиной ровно 32 байта, так что проблем с этим не должно возникнуть. Сразу прочитаем файлы при помощи стандартного дескриптора и отбросим заголовки.
Теперь оценим наше положение: приватный ключ ECC хранится как 64 байта, публичный же занимает 32. Вот только наша замечательная библиотечка «солевого питона» считает, что приватный ключ должен быть тоже длиной 32 (в два раза короче, чем ожидает Tor).
Но и это ещё не все нестыковки. Объект приватного ключа в этой библиотеке ещё и нельзя создать из самого, непосредственно, бинарного его представления — входной аргумент privatekey это seed длиной 32 байта и никак иначе… После создания экземпляра класса ключа мы можем экспортировать его 32-байтовое представление.
Другими словами, у нас нет удобного api для импорта уже существующего ключа из файла — ну и ладно.
Большей проблемой является невозможность на данный момент экспорта нашего ключа, созданного и используемого для подписей из переменной питона в файл Tor privatekay (в файле ожидается 64-байтовый ключ, а наш приватный всего 32).
Для решения этой проблемы я перерыл массу реализаций и спецификаций. Подробности алгоритмов Tor, кстати, весьма скудно документированы. Чёрт, я уже думал копать в оригинальных исходниках (жаль, что поддержка torpy прекратилась до выхода onion v3), но, к счастью, я наткнулся на другого воина, изучающего мутные схемы с 64 и 32-байтовыми представлениями ed25529.
Оказалось, что есть некое странное преобразование, называемое расширением ключа. Выглядит оно так:
На этом моменте мозг должен вскипеть, ведь выходит, что в файле хранится фактически sha512 хеш от настоящего приватного ключа! Т. е. мы в принципе не можем осуществить импорт приватного ключа из файла Tor в наш runtime, ведь по факту его нет в файле. С другой стороны — не очень-то и хотелось.
Вывод: мы будем генерировать ключ внутри Python и потом дампать его в файл Tor перед подключением.
Благо рядом валялась ещё одна полезная ссылка. Теперь мы знаем, как публичный ключ ED превращается в .onion домен.
Сейчас быстренько реализуем и проверим, что Tor после запуска генерирует в hostname тот же самый домен из public key, что и мы.
Проверим совпадение нашего домена с автоматически сгенерированным:
Я уже было хотел перейти к своей сети, но решил, что сейчас лучше полностью наладить своё общение с Tor, вынести его в отдельный файл tortools.py, чтобы не засорять им повествование дальше.
Вход в сеть
Нет ничего плохого в том, что бы заставлять пользователя везде носить свой private key. Т. е. если на определённой машине есть файл ключа и запущен клиент анонимной сети, то можно использовать этот самый ключ для подписи сообщений. Но удобно ли это?
Простой юзер хочет помнить комбо логин+пароль и везде заходить под ними. Думаю, вы уже понимаете, к чему я клоню…
from config import sub_password
from nacl.signing import SigningKey,VerifyKey
import random
random.seed(input(">")+sub_password)
k=SigningKey(random.randbytes(32))
Воу, это что, криптографическая система, держащаяся на детерминированном built-in рандоме?
Собственно, почему бы и нет. В любом случае для того, что бы найти коллизию, нужно перебирать все возможные «пароли» пользователей. В таком случае просто каждый пользователь самостоятельно выбирает компромисс между безопасностью и запоминаемостью пароля.
Давайте подумаем над возможным поведением участника такой сети и спроектируем для него ожидаемый цикл жизни. Само собой, он может попытаться нарушить гармонию и баланс в нашей сети — такие возможности мы рассмотрим позже.
Итак, основной принцип работы будет такой: никто никому ничего насильно не отправляет, только по запросу. Другими словами, каждый участник может только отвечать или запрашивать.
Как следствие, нет смысла что-либо обособленно отправлять и принимать.
Таким образом, каждый скачает себе только то, что сам пожелал и самостоятельно проверил на валидность. И как бы ты ни старался отправить что-либо другому насильно — он это просто не примет.
На практике всё гораздо проще, чем на словах.
Каждый участник просто шарит наружу свою базу данных, остальные могут обозревать её, и если считают какой-либо файл в ней валидным, скачивать и добавлять в свою базу данных.
Минимальной единицей информации в нашей распределённой сети мы будем считать файл. Тот самый файл, что лежит на вашем рабочем столе. Тот самый, что вы скачиваете из интернета, введя в адресную строку запрос вида домен/директория/имяфайла.txt
Теперь нужно придумать механизм проверки авторства и механизм, соответственно, подписи этих файлов.
Представим себе некого Андрея. Он сгенерировал себе пару PRVT, PUB и подключился с этими ключами к сети Tor.
PUB можно считать именем пользователя Андрея, ведь его можно однозначно превратить в .onion0домен, на котором (внимание!) поднят сервер Андрея!
Пусть каждый файл F содержит в себе данные DATA и имеет название, где просто записан PUB-ключ автора.
Оба этих куска данных подписываются электронной подписью при помощи PRVT-ключа, который автор ни за что не разглашает.
D = (PUB + DATA) и подпись S = SIGN(D)
Теперь дописываем в название файла F (пока что оно выглядит как PUB) нашу подпись S данных D, и теперь название файла F выглядит как S+PUB.
Механизм доверия
Для проверки подписи на файле нужно разбить его название пополам: запомнить подпись S и публичный ключ потенциального автора PUB.
Теперь склеить PUB+DAT и проверить, что подпись S по ключу PUB действительно соответствует этим данным — победа!
Теперь мы можем с уверенностью преобразовать PUB в .onion-строку и сказать, что некий Андрей.onion точно это написал, и его никто не подставил.
Если же Андрей захочет что-либо опубликовать от имени другого участника (Васи) и подменит PUB на его, то он просто не сможет корректно подписать PUB+DAT валидной для данного PUB подписью, ведь для этого нужен PRVT-ключ ВАСИ, а он его хранит в секрете.
Ещё раз механизм цифровых подписей для закрепления:
У нас есть асимметричная пара PRVT, PUB.
PRVT позволяет создавать подпись S для любых данных.
PUB позволяет проверить, что эта подпись S действительно соответствует данным.
Мы же сразу прописываем данные вместе с PUB и передаём их в комплекте с подписями, чтобы можно было с лёгкостью определять, какой же .onion-домен является автором этой связки.
Собрав все эти рассуждения воедино, можно выделить три таких правила:
Каждый может подписать любой файл как «свой», обозначив себя его автором. Любой другой участник, который его скачает, может проверить, что подпись соответствует данным и .onion имени автора и обратиться к автору этого файла за новыми файлами!
Получается, что подпись на файле является не только верификатором авторства, но и одновременно адресом его распространителя (скорее всего, первоисточника — возможность плагиата мы рассмотрим через пару абзацев).
Такая схема пиринга решает сразу несколько проблем, но и добавляет новых. Во-первых, чтобы начать процесс «интеграции нового пользователя в сеть», нужен кто-то, кто в ней уже состоит и поможет с распространением хотя бы одного файла, подписанного новым пользователем. Назовём этот dummy-файл визитной карточкой — такой своеобразный hello world от нового пользователя, который нужно вручную скопировать кому-то, кто уже состоит в сети. Это создаёт примитивный уровень защиты от мёртвых душ. Т. е. для распространения визитных карточек (мёртвых или живых) в любом случае приходится жертвовать доверием к конкретному пользователю, уже состоящему в сети.
Каждый участник будет хранить у себя все файлы сети одновременно и самостоятельно просматривать их, индексировать, рендерить и т. д. При этом каждый участник может быть полностью уверен в валидности подписи на каждом из файлов, что есть в его базе, он ведь их самостоятельно скачал и проверил. Под самостоятельным скачиванием я, конечно, подразумеваю не ручной отбор каждого файла во время синхронизации, а лишь возможность пользователя самостоятельно прописать все правила и фильтры для них.
Например, юзер может осознанно игнорировать файлы больше 100мб, файлы с нестандартными и опасными расширениями, файлы от конкретных авторов и т. д. По сути, саморегуляция сети ложится на плечи каждого пользователя. Если все дружно начинают игнорировать конкретные файлы или участников, то они через какое-то время перестают распространяться (хотя, даже если не перестают, то какое вообще до этого дело конкретному юзеру, у которого они в blacklist?)
Каждый участник открыто раздаёт всю свою базу данных для других, поднимая файловый сервер на своём .onion-адресе.
Для того, чтобы стать «авторитетным» в сети, ты должен распространить как можно больше файлов, подписанных тобой. Тогда твои новые файлы будут практически моментально разлетаться и оседать в чужих базах.
Итак, ничто не мешает Андрею копировать файлы Васи, подписывать их самостоятельно и пиарить себя за их счёт. Защиты от плагиата в такой архитектуре попросту не может быть, ведь для этого нужен консенсус. Кстати, directory relay Tor существуют именно для его принятия.
Так вот, Андрей может нагло копировать всё, что скачивает, и подписывать своё авторство — тогда он, по сути, станет агрегатором! В любом случае, увеличив конверсию информации в сети.
Usability
Итак, у вас скачаны все файлы сети в одну папку. И название каждого из них — огромная строка из букв. По первым буквам можно сверять контрольную сумму содержимого, по последним — автора содержимого. Допустим, кто-то опубликовал файл a1b2c3.html со своей биографией. Вы хотите это как-либо прокомментировать и создаёте свой .html с относительной ссылкой на файл a1b2c3.html. Т. е. у каждого эта ссылка ведёт на файл в его собственной папке. В том числе и у автора биографии.
Через какое-то время ваш файл оседает в базе автора и он видит ваш «комментарий». Вся эта схема может работать в офлайн в любом браузере после синхронизации участников — ссылки-то все относительные (хотя уходящие в интернет тоже никто не запрещал).
Т. е. для того, чтобы просматривать файлы, нам достаточно просто самим открыть свой файловый сервер обычным браузером!
Как это будет выглядеть (скриншот из будущего)
Вид стандартного файлового сервера:
Десериализация бинарных данных
Сначала создадим готовые функции для подписи файла и его валидации. Для этого нам понадобится способ сохранения бинарных подписей и публичных ключей в названии файла.
Самый классический способ представления любых двоичных данных в виде строки — HEX — перевод в шестнадцатеричную систему счисления и представление символами 1-9; a-f
Но, как вы можете заметить, он жутко неэффективен как строковая кодировка: очень маленький диапазон символов и гигантская издержка их количества.
Даже ASCII, восстав из палеозоя, может предложить нам 128 символов на 7 байт — однако нам стоит закопать его обратно, ведь он не позволяет предоставить вообще любую комбинацию байтов (только те, что есть в codetable). Привет от codec cannot decode byte at...
Нам нужна альтернатива жутко громоздкому HEX и неуниверсальному ASCII. Решение можно было бы закостылить из перегруженных кодировок вроде Unicode, ведь именно он зачастую является тем самым bottleneck, который не позволяет осуществлять трансфер двоичных данных в сыром виде. Вопреки нашим ожиданиям ему присущи всё те же проблемы ASCII, так мы ещё и огребём новых там, где невозможен трансфер UTF-данных.
Итак, нам нужен принципиально другой концепт — кодировки с обратным сжатием подобные HEX, но с более оптимизированной таблицей символов.
Это кодировки семейства BASE, или как они обозваны в RFC4880 — ASCII armor. Кодируют они сразу несколько байт несколькими символами и имеют стандартные размеры кодовых таблиц.
Base32
Алфавит: 32 символа A-Z, 2-7, =
Каждые 5 бит -> 1 символ алфавита
Фактическое увеличение на 3/5 (в 1.6 раз)
Так выглядит латинский алфавит в этой кодировке: OF3WK4TUPF2WS33QMFZWIZTHNBVGW3D2PBRXMYTONU======
Base64
Алфавит: 64 символа a-z, A-Z, 2-9, +, /, =
Каждые 3 байт (24 бит) -> 4 символа алфавита
Фактическое увеличение на 1/3 (в 1.3 раз)
Так выглядит латинский алфавит в этой кодировке: cXdlcnR5dWlvcGFzZGZnaGprbHp4Y3Zibm0
Ascii85 — Base85
Алфавит: 85 символов все ASCII символы от 33(!) до 117(u)
Каждые 4 байт (32 бит) -> 5 символов алфавита
Фактическое увеличение на 1/4 (в 1.25 раз)
Так выглядит латинский алфавит в этой кодировке: EHbu7FEr"CDfB-+A7fIfC27X3G[ko+DJ]
Если наращивать размер таблицы дальше, то мы будем получать всё меньший выигрыш — так что ограничимся пока этими кандидатами.
У нас есть достаточно жёсткие критерии выбора кодировки в нашей системе. Файлы в windows имеют ограничение на служебные символы в именах. Но в эти ограничения количественно запросто влезет любой base — главное придумать скрипт для адаптации опасных символов из base в безопасные для записи в имя файла.
А вот в URL пути ограничения чуть более жёсткие. Для начала есть те же служебные символы, которые ни за что нельзя писать в пути. Как минимум это "/', который есть и в b64 и в b85. Вот только URL ещё приватизирует некоторые другие символы вроде скобок, кавычек, знаков препинания.
На самом деле в чистом URL-адресе нативно могут содержаться всего 84 различных символа!
Какое нелепое совпадение…
Суммируем полученную информацию.
Эти два множества символов (пути Windwos и пути URL) практически идеально пересекаются в кодировке base85, но решения для их однозначного соответствия я найти не смог.
Можно было бы сдаться и просто использовать base 32, но тогда имена файлов будут очень длинными, и мы будем передавать неэффективно закодированные данные. Такой вариант работает, но имена файлов получаются ну уж очень длинные…
Даже base64 немного не подходит. В нём используются "/", запрещённые в путях виндовс и в хлам ломающие URL. Но тут способ адаптации напрашивается сам собой: заменить в base64 строке все "/" на какие-нибудь "-" и тогда Windows будет счастлива.
Однако я хотел сделать всё по последнему слову компактности сущностей и максимально современно — я хочу base85.
Однако задача соответствия множества его символов с доступным для URL и Windows не решалась, пока я не вспомнил один забавный факт: как мы уже поняли, UTF в путях Windows ограничивает служебные символы, но практически не затрагивает языки (система-то мультиязычна), т. е. мы можем спроектировать «свою кодировку», переведя спецсимволы base85 в кириллицу (буквы русского алфавита).
Такое решение добавит мнемоники в названия файлов и позволит конечному юзеру легче их дифференцировать. Взгляните сами:
В то же время в URL уже давно решили проблему мультиязычности и юникода в принципе. Для этого придумали percent encoding — функционально, свою альтернативу HEX или Base. Т. е. стандартные функции urlencode urldecode в библиотеке вроде requests любого ЯП будут автоматически решать на нас проблему отправки кириллицы. Конечно, вам придётся реализовывать всё вручную, если решите спроектировать свой http-клиент поверх tcp, но мы возьмём готовую библиотеку.
pip install requests
Честно, я тоже думал, что она built-in, пока однажды не наткнулся на module requests not found.
С проблемой URL разобрались — теперь он нас ни в чем не ограничивает. Теперь пишем вот такой наивный и неэффективный переводчик base в наш костыль-код.
Ладно, кроме шуток, можно реально оставить так, ведь узким местом программы эта итерация всё равно не станет (всё-таки у нас намечается сетевое взаимодействие).
Теперь мы можем хранить base85 в названиях файлов windows и передавать эти строки как названия файла в URL.
Реализация
Уже пора переходить к сердцу нашей сети — алгоритмам подписи и валидации, которые сохранят порядок в нашей сети даже в самые тёмные времена.
На самом деле псевдокодом я их описал ещё далеко вверху (там, где продумывал правила поведения участника).
Теперь остаётся провернуть их с использованием PyСоли:
Приспособим наши sign/verif к работе с файлами и их именами при помощи вот таких обёрток:
Сигнатуры полученных функций оставляют желать лучшего, да и в чистом виде они нам вряд ли понадобятся. Так что теперь сделаю из них две готовых к использованию функции:
Deploy — принимает путь к файлу, создаёт дескриптор, подписывает и добавляет в базу.
Load — принимает содержимое и название потенциального файла, сверяет подпись и добавляет в базу, если всё корректно.
Я, конечно, не носитель, но, кажется, у вас 7 грамматических ошибок в одном предложении. Я, конечно, не дизайнер, но, кажется, получилось довольно мило)
Теперь нужно научиться светить этой красотой во внешний мир при помощи любого fileserver и Tor proxy.
Для начала нам нужно определиться с файловым сервером — той штукой, что отправляет вам в ответ файл (123.txt), когда вы запрашиваете его путём (localhost:1234/dir1/subdir/123.txt). Другими словами: «шарит директорию».
На самом деле нам нужно просто ввести в терминал одну команду:
python -m http.server 80
В нашем случае шарить мы будем вполне конкретную папку, так что добавляем её как аргумент.
python -m http.server 81 --directory storage
Да, выглядит примитивно, но свою функцию выполняет более чем. Теперь будем парсить список файлов с главной страницы сервера. Так выглядит майка стандартного fileserver на python:
Ничего сложного в парсинге такого списка со ссылками нет, так что сделаем всё максимально просто и изящно. В этом нам помогут регулярные выражения или просто regex.
Так выглядит регулярка:
"<li><a.*?>(.*?)</a></li>"
Да, всё действительно так просто.
Реализуем её на питоне и сделаем функцию, которая возвращает список имён файлов на удалённом сервере.
def catalouge(url,tor=True):
res=(rt if tor else requests).get(url)
res.encoding="utf-8"
files=re.findall("<li><a.*?>(.*?)</a></li>",res.text)
return files
Добавим примитивную функцию, которая находит те файлы, которые есть на удалённом сервере, но отсутствуют локально у нас.
def unknown(c):
return set([i for i in c])-set(os.listdir("storage"))
Теперь обернём нашу load в сетевую download и на этом закончим написание основных функций.
Download — скачивает файл, по ходу проверяя, что его размер не превышает лимита и все фильтры дают добро на скачивание.
def download(url,tor=True):
local_filename = url.split('/')[-1]
if len(os.path.basename(local_filename).split(".",1)) == 1:
if not "" in cfg.allowed_res: return False
else:
if not os.path.basename(local_filename).split(".",1)[1] in cfg.allowed_res: return False
b=b""
r = (rt if tor else requests).get(url, stream=True)
for chunk in r.iter_content(chunk_size=1024):
if chunk: b+=chunk
if len(b)>=cfg.max_file_kb_size*1024: return False
#print(local_filename,b)
return load(local_filename,b)
Как вы могли заметить, я использую не requests, а пользовательский форк под названием requests-tor.
pip install requests-tor
Стандартная связка requests + proxy у меня так и не завелась — хоть убейся. Поэтому я и решил использовать готовую библиотеку, где заранее пропатчен socket при помощи pysocks.
Теперь нам предстоит реализация жизненных циклов клиента. Описать рекомендации по проведению клиента можно этими тремя пунктами:
Наш клиент будет просто бродить по файлам в своей базе данных и стучаться на все серверы, поднятые на .onion-доменах авторов этих файлов. Прописываем логику синхронизации, добавляя немного обратной связи с пользователем.
def sync(url,tor=True):
if cfg.blacklist_enabled and url in cfg.blacklist: return False
try:
c=catalouge(url,tor)
except:
print("📛 Unreachable domain",url)
return False
u=list(unknown(c))
print("🔄️ Downloading",len(u),"files from",url,"with",len(c),"files")
for i in u:
if download(url+"/"+i,tor):
print("✅ Loaded",i)
else: print("⚠️ Malformed",i)
def walker(tor=True):
urls=set()
for i in os.listdir("storage"):
urls.update({"http://"+onion(b85_2_b(i[80:120]))})
for i in list(urls): sync(i,tor)
Если на удалённом сервере есть файлы, которых нет у нас, то скачиваем их, проверяя подписи и идентифицируя по ним новых участников для синхронизации.
Клиент должен позволять в удобном виде (желательно из браузера) позволять изучать содержимое локальной базы данных и гулять по ссылкам — и знаете, это за нас уже сделал http.server
Точно! я же ещё обещал реализовать самостоятельный интерфейс поиска нужных нам статей.
▍ Поиско́вый движок
import bisect
import os
import re
def insert(list, n):
bisect.insort(list, n)
return list
def req(q):
reg=re.compile(q,flags=re.IGNORECASE)
bst=[]
for i in os.listdir("storage"):
if ".html" in i:
with open("storage/"+i,"r",encoding="utf8") as f:
c=f.read()
m=reg.findall(c)
insert(bst,(len(m),i,onion(b85_2_b(i[80:120]))))
return list(reversed(bst))
Функция для поиска страниц, наиболее содержащих регулярное выражение. Да, поисковые запросы в моём высокотехнологичном движке поиска — это регулярки с флагом IGNORECASE. А почему бы и Google так не сделать?)
Bisect позволяет, по сути, получить отсортированный список, путём множественных вставок бинарным поиском. Это позволяет сортировать весь набор страниц постепенно, по мере их сканирования. На огромном датасете это должно немного сэкономить время, затрачиваемое на сортировку страниц по релевантности.
Просто так слепить нашу функцию с http.server не получится. Придётся наследовать исходник SimpleHTTPRequestHandler и модифицировать под свои нужды.
from http.server import SimpleHTTPRequestHandler
from http.server import HTTPServer
class HttpGetHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory="storage", **kwargs)
def do_GET(self):
if destroy: exit()
if not urlparse(self.path).query:
print("Default GET filehandler")
super().do_GET()
return
inp=unquote(urlparse(self.path).query)
self.send_response(200)
self.send_header('Content-type', 'text/html; charset=utf-8')
self.end_headers()
self.wfile.write('<html><head><link rel="stylesheet" href="LЮRX46upn4GckfRDi8БetudrRnЛPMЧEoCБd7яНЬpwЙЖЪaGБёЦVЭЮЧLMnДhfLnцЗЦRjas3ЭBZuцЗbЛЗbxjKK3qЗuЧuёЛЛHЯRcfAЦwSц1ЛgknhdoMvPйZSЭX8R.css">'.encode("utf-8"))
self.wfile.write('<title>🌐LeekSearch</title></head>'.encode())
self.wfile.write(f'<div class="row"><div class="column"><h1>🌐Leek<b>Search</b></h1></div><div class="column"><center><h2> Query: [{inp}]</h2></center></div></div>'.encode())
bst=req(inp)
for i in range(min(len(bst),10)):
i=bst[i]
self.wfile.write(f'<hr><center>{i[0]} matches | Name: <a href="{i[1]}">{i[1][:10]}</a> | Author: {i[2]}</center>'.encode())
self.wfile.write(open(f"storage/{i[1]}","r",encoding="utf-8").read().encode())
self.wfile.write('<hr><center>The end</center></html>'.encode())
def run(server_class=HTTPServer, handler_class=HttpGetHandler):
server_address = ('', 80)
httpd = server_class(server_address, handler_class)
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.server_close()
Теперь в GET handler добавлено ветвление, отвечающее за url query. Это стандартный способ передачи аргументов через get вида 127.0.0.1:80/?hahaha — где hahaha отправляется в обработку нашим поисковым движком.
Собственно, генерим длинную простыню из топ 10 лучших файлов и передаём их в браузер юзера, разделяя горизонтальными линиями.
▍ Демо
Ладно, вот такая красота у нас теперь возвращается при вводе поискового запроса в url.
Зум страницы около 75%, что бы больше захватить (в реальности с масштабами всё нормально).
▍ Архитектура
Теперь можно собирать всё воедино. Логика нашего клиента будет состоять из 3 параллельных ветвлений:
1. os.Popen("tor -f torrc") — запустит нам Tor в отдельном процессе, который спокойно закроется по нажатию Ctrl+C и совершенно не будет о себе напоминать про ходу программы.
2. Thread(run_server) — запустит наш кастомный http.server в отдельном потоке, который сможет использовать и редактировать все основные функции и переменные нашего клиента (привет GIL). Живёт, пока флаг деактивации потока не истинен (механизм деактивации есть в коде нашего кастомного http.server).
3. while 1: walker() — занимает основной поток выполнения и циклично выполняет синхронизацию. Ловит нажатие Ctrl+C, и если пора включаться, активирует флаг деактивации потока сервера.
▍ Домен?
Кстати, мы можем реально навалить стиля, если наша система — это windows/linux или в общем случае имеет некий аналог файла hosts. Это такой файл, который банально выступает в роли локального DNS-сервера и может создать для нас иллюзию существования какого либо домена.
Я возьму для примера leek.me, и теперь официально присвою его своему локальному хосту. Да, официально только в рамках моего устройства, зато бесплатно и для каждого клиента будет свой leek.me. Приятным дополнением, он продолжит открываться в браузере и работать по назначению даже в оффлайне.
Для такой модификации hosts нам достаточно запустить данную команду в CMD или как .bat файл.
Теперь мы можем даже поставить наш leek search в качестве поисковой системы в браузере!
Запуск
Пришло время собрать весь код воедино. Вот так выглядит полная реализация клиента:
Leekpeer клиент
from tortools import *
import config as cfg
import requests #pip install requests
from requests_tor import RequestsTor #pip install requests-tor
rt = RequestsTor(tor_ports=(9050,))
import os
import re
import time
basebase=['!', '#', '$', '%', '&', '(', ')', '*', '+', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ';', '<', '=', '>', '?', '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~']
betterbase=['Й', 'Ц', 'Ъ', 'Ж', 'Э', 'Н', 'Г', 'Ш', 'Щ', 'З', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Ф', 'Ы', 'Ч', 'Ю', 'Б', 'Ь', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'ё', 'Л', 'Д', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'ц', 'я', 'Я', 'й']
t_bett={}
t_base={}
for i in range(len(basebase)):
t_bett.update({ord(basebase[i]):betterbase[i]})
t_base.update({ord(betterbase[i]):basebase[i]})
def b_2_b85(b:bytes):
return base64.b85encode(b).decode().translate(t_bett)
def b85_2_b(s:str):
return base64.b85decode(s.translate(t_base))
def make(bin_data:bytes):
publickey=k.verify_key.encode()
return b_2_b85(sign(publickey+bin_data))+b_2_b85(publickey)
def check(maked:str,data:bytes):
try:
sig,pub=b85_2_b(maked[:80]),b85_2_b(maked[80:120])
VerifyKey(pub).verify(pub+data,sig)
return True
except:
return False
def onion(pub):return onion_address_from_public_key(pub)
def deploy(file):
dat=open(file,"rb").read()
if len(os.path.basename(file).split(".",1)) == 1:
res=""
else:
res=os.path.basename(file).split(".",1)[1]
name=make(dat)+"."+res
try: os.mkdir("storage")
except FileExistsError: pass
open(f"storage/{name}","wb").write(dat)
def load(name:str,data:bytes):
if check(os.path.basename(name).split(".",1)[0],data):
try: os.mkdir("storage")
except FileExistsError: pass
open(f"storage/{os.path.basename(name)}","wb").write(data)
return True
return False
def download(url,tor=True):
local_filename = url.split('/')[-1]
if len(os.path.basename(local_filename).split(".",1)) == 1:
if not "" in cfg.allowed_res: return False
else:
if not os.path.basename(local_filename).split(".",1)[1] in cfg.allowed_res: return False
b=b""
r = (rt if tor else requests).get(url, stream=True)
for chunk in r.iter_content(chunk_size=1024):
if chunk: b+=chunk
if len(b)>=cfg.max_file_kb_size*1024: return False
#print(local_filename,b)
return load(local_filename,b)
def catalouge(url,tor=True):
res=(rt if tor else requests).get(url)
res.encoding="utf-8"
files=re.findall("<li><a.*?>(.*?)</a></li>",res.text)
return files
def unknown(c):
return set([i for i in c])-set(os.listdir("storage"))
def sync(url,tor=True):
if cfg.blacklist_enabled and url in cfg.blacklist: return False
try:
c=catalouge(url,tor)
except:
print("📛 Unreachable domain",url)
return False
u=list(unknown(c))
print("🔄️ Downloading",len(u),"files from",url,"with",len(c),"files")
for i in u:
if download(url+"/"+i,tor):
print("✅ Loaded",i)
else: print("⚠️ Malformed",i)
def walker(tor=True):
urls=set()
for i in os.listdir("storage"):
urls.update({"http://"+onion(b85_2_b(i[80:120]))})
for i in list(urls): sync(i,tor)
def tor_serve():
import subprocess as s
s.Popen(["tor","-f","torrc"])
#s.Popen(["python","-m","http.server","8765","-d","storage"])
def cycle():
for i in range(10,0,-1):
time.sleep(1)
print(i)
while 1:
time.sleep(1)
try:
walker()
except Exception as e:
print(e)
try:
os.mkdir("input")
except: pass
for i in os.listdir("input"):
os.rename(f"input/{i}",i)
deploy(i)
os.remove(i)
from urllib.parse import urlparse
from urllib.parse import unquote
import bisect
import os
import re
def insert(list, n):
bisect.insort(list, n)
return list
def req(q):
reg=re.compile(q,flags=re.IGNORECASE)
bst=[]
for i in os.listdir("storage"):
if ".html" in i:
with open("storage/"+i,"r",encoding="utf8") as f:
c=f.read()
m=reg.findall(c)
insert(bst,(len(m),i,onion(b85_2_b(i[80:120]))))
return list(reversed(bst))
from http.server import SimpleHTTPRequestHandler
from http.server import HTTPServer
class HttpGetHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory="storage", **kwargs)
def do_GET(self):
print("GET request!")
if destroy: exit()
if not urlparse(self.path).query:
print("Default GET filehandler")
super().do_GET()
return
print("Parsing query")
inp=unquote(urlparse(self.path).query)
print("Query",inp)
self.send_response(200)
self.send_header('Content-type', 'text/html; charset=utf-8')
self.end_headers()
print("Building output header")
self.wfile.write('<html><head><link rel="stylesheet" href="LЮRX46upn4GckfRDi8БetudrRnЛPMЧEoCБd7яНЬpwЙЖЪaGБёЦVЭЮЧLMnДhfLnцЗЦRjas3ЭBZuцЗbЛЗbxjKK3qЗuЧuёЛЛHЯRcfAЦwSц1ЛgknhdoMvPйZSЭX8R.css">'.encode("utf-8"))
self.wfile.write('<title>🌐LeekSearch</title></head>'.encode())
self.wfile.write(f'<div class="row"><div class="column"><h1>🌐Leek<b>Search</b></h1></div><div class="column"><center><h2> Query: [{inp}]</h2></center></div></div>'.encode())
print("Results...")
bst=req(inp)
print("strretu")
for i in range(min(len(bst),10)):
i=bst[i]
self.wfile.write(f'<hr><center>{i[0]} matches | Name: <a href="{i[1]}">{i[1][:10]}</a> | Author: {i[2]}</center>'.encode())
self.wfile.write(open(f"storage/{i[1]}","r",encoding="utf-8").read().encode())
self.wfile.write('<hr><center>The end</center></html>'.encode())
def run(server_class=HTTPServer, handler_class=HttpGetHandler):
server_address = ('', 80)
httpd = server_class(server_address, handler_class)
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.server_close()
import threading as t
th=t.Thread(target=run)
destroy=False
if __name__=="__main__":
print("Starting...")
th.start()
print("HTTP server hosted...")
tor_serve()
print("Tor connection...")
deploy("businescard.html")
try:
cycle()
except KeyboardInterrupt:
print("Load or reload webpage of HTTP server to kill himnn"*20)
destroy=True
Попробуем запустить его на всех платформах, перечисленных во введении.
▍ Windows
Ну, тут немудрено. Все описанные выше действия были выполнены в среде Windows. Запуск и тестирование, соответственно, не должны взывать особых проблем.
pip install PyNaCl python peer.py
▍ Linux (Colab)
Тут я приведу сразу код ячейки. Суть мало отличается от Windows, за исключением прав на изменение папки keys у пакета Tor, который есть в индексах Debian/Ubuntu
Тут начинаются танцы с бубном. Описанная далее схема заводится на Termux v0.101 (версия из Google Play), но не заводится на последних билдах от разработчика (с его сайта). Тупо где-то посреди сборки make вылетает без какого-либо выхлопа. Так что сейчас речь пойдёт именно про v0.101.
Проблему представляет сборка PyNaCl под термукс — её нет. Конечно, нам нужно попробовать собрать её нашим любимым pip`ом, но для этого его ещё нужно поставить.
Пишем в голом termux банальный pkg update и всё разваливается прямо на глазах:
N: Metadata integrity can't be verified, repository is disabled now.
N: Possible cause: repository is under maintenance or down (wrong sources.list URL?).
Это связано с неактуальными адресами индексов в относительно старой версии termux. Нам нужно поправить всё вручную, для этого заходим в песвдо-gui конфигуратора по команде termux-change-repo.
Кликаем пробелом на каждом из 3 репозиториев так, чтобы они оказались всё выбраны как активные. Тыкаем OK (Enter)
В следующей вкладке настроек переключаемся с официального репозитория на A1batr и тоже подтверждаем изменения.
Теперь индекс успешно обновляется по pkg update и мы можем ставить Python. Правда PyNaCl у нас всё равно пока не соберётся — нам нужно дополнительно установить пару неявных зависимостей. Сделаем это вместе с установкой Python:
pkg install clang python libffi openssl libsodium
Теперь можно собирать солевую библиотеку: SODIUM_INSTALL=system pip install pynacl
Далее действия аналогичны тем, что описаны чуть выше (в пункте для Linux).
Заключение
Тут можно было бы распинаться о том, как я запустил кучу серверов, начать агитировать читателей скачать себе клиенты и т. д. И самое главное — организовать обмен визитными карточками в комментариях ;)
Однако случайный читатель наверняка бы просто промотал это бесполезное «заключение» и пошёл писать своё мнение ниже. Я же прекрасно понимаю, что открытый MVP-скрипт на питоне не имеет будущего как приложение, а потенциально лишь как пачка исходников для ваших экспериментов.
В знак уважения я более не занимаю ваше время и благодарю за чтение. Спасибо!