Signal usernames изнутри
В феврале 2024 года мессенджер представил долгожданную функцию - никнеймы для своих пользователей. Они позволяют делиться своим профилем Signal не разглашая свой номер телефона. Также была добавлена поддержка ссылок и генерации QR-кодов для никнеймов. В данной статье попробуем разобраться, как всё это устроено "под капотом"
Для начала давайте взглянем на сам никнейм:
Он состоит из двух частей, собственной самого имени пользователя, а также из числа, которое называется дискриминатором. Данный дискриминатор позволяет использовать одно и то же имя пользователя разным людям.
Как устроены ссылки
Теперь давайте взглянем на ссылку, генерирующуюся для никнейма:
https://signal.me/#eu/xxxxx.....xxxxxx
eu - сокращение от encrypted username, т.е в самой ссылке нет никакого никнейма в чистом виде, есть только набор байтов в base64url-nopad
Данные байты представляют следующую структуру:
- Первые 32 байта - энтропийный ключ расшифровки данных о никнейме
- Остальные 16 байт - UUID записи, хранящейся на серверах Signal
Когда пользователь переходит по ссылке, клиент Signal выполняет запрос к серверу:
https://chat.signal.org/v1/accounts/username_link/{UUID_записи}Сервер возвращает зашифрованный никнейм в следующем формате:
IV(Initialization Vector) - вектор инициализации для шифрования (16 байт)CTEXT- зашифрованные данные (переменной длины)HMAC- код проверки целостности (последние 16 байт)
Для расшифровки никнейма используется следующий алгоритм (псевдокод):
1. Генерируется ключ аутентификации с помощью HKDF (HMAC-based Key Derivation Function) и ключа из ссылки:
K_auth = HKDF-SHA256(
key=KeyFromLink,
label="Signal Username Link Authentication Key",
length=32
)После чего происходит повторное вычисление HMAC от IV+CTEXT и сравнение с тем, что вернул сервер.
2. Генерируется ключ расшифровки (также с помощью HKDF и ключа из ссылки)
K_enc = HKDF-SHA256(
entropy=KeyFromLink,
label="Signal Username Link Encryption Key",
length=32
)И происходит расшифровка CTEXT с помощью AES в CBC режиме:
PTEXT = AES-CBC.decrypt(
key=K_enc,
iv=IV,
ciphertext=CTEXT
)3. Полученные данные (PTEXT) представлены в формате Protobuf:
Никнейм -> аккаунт
Теперь, когда у нас есть никнейм, возникает вопрос: как найти пользователя на сервере, не раскрывая его никнейм в открытом виде?
Signal решает эту задачу с помощью хеширования на эллиптической кривой Ristretto255:
1. Каждый символ никнейма (до точки) преобразуется в число по следующей схеме:_ -> 1
от a до z -> от 2 до 27 соответственно
от 0 до 9 -> от 28 до 37 соответственно
2. Дальше данные числа переводятся в Base37 (с особенностью в виде того, что первое число умножается на 27, а не на 37) и редуцируются по модуля порядка кривой, тем самым получаем скаляр:
3. Вычисляется SHA-512 от никнейма и дискриминатора, и также редуцируется:
sha_scalar = SHA512(username || 0x00 || discriminator ) mod n
4. Выполняется мультискалярное умножение username_scalar, sha_scalar, discriminator_scalar на заранее заданные точки (обозначим как G1, G2, G3):
hash = sha_scalar * G1 + username_scalar * G2 + discriminator_scalar * G3
Результатом данных вычислений будет точка на эллиптической кривой, которая является хэшем никнейма
5. Хэш кодируется в base64url-nopad и отправляется на сервер:
https://chat.signal.org/v1/accounts/username_hash/{hash}Сервер же возвращает идентификатор аккаунта (ACI, Account Identifier):
Заключение
Рассмотренная система никнеймов в Signal является ярким примером применения принципов Zero Knowledge:
- Для никнеймов: Сервер не знает исходного никнейма, а хранит только связку
хэш -> ACI. Даже имея доступ к базе, потенциальный злоумышленник не сможет восстановить никнеймы из хэшей - Для ссылок: Сервер также не знает исходного никнейма и хранит только связку
UUID -> зашифрованный никнейм. Сам ключ расшифровки находится в ссылке и известен только пользователю
Для данной статьи также был написан код на TypeScript, можете ознакомиться с ним на Github