Today

Signal usernames изнутри

В феврале 2024 года мессенджер представил долгожданную функцию - никнеймы для своих пользователей. Они позволяют делиться своим профилем Signal не разглашая свой номер телефона. Также была добавлена поддержка ссылок и генерации QR-кодов для никнеймов. В данной статье попробуем разобраться, как всё это устроено "под капотом"


Для начала давайте взглянем на сам никнейм:

axolotl.99

Он состоит из двух частей, собственной самого имени пользователя, а также из числа, которое называется дискриминатором. Данный дискриминатор позволяет использовать одно и то же имя пользователя разным людям.

Как устроены ссылки

Теперь давайте взглянем на ссылку, генерирующуюся для никнейма:

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