← Все статьи

Как мы сделали сквозное шифрование для Android-уведомлений

24 мая 2026

Когда мы начинали делать Эхофон, перед нами стоял выбор: передавать уведомления как есть (как делает Pushbullet) или шифровать их так, чтобы даже мы не могли их прочитать. Выбрали второе. В этой статье — технический разбор архитектуры сквозного шифрования (E2EE) от телефона до браузера.

Почему вообще понадобилось шифрование

Уведомления с телефона — это чувствительные данные:

Если сервер хранит такие данные в открытом виде, это огромный риск: утечка БД, взлом сервера, subpoena от третьих лиц. Сквозное шифрование решает эту проблему радикально: сервер никогда не видит текст сообщений.

Архитектура: три участника

У нас три стороны:

Ключевой принцип: ключ шифрования известен только телефону и браузеру. Сервер — просто транспорт.

Как генерируется ключ

Мы используем PBKDF2 (Password-Based Key Derivation Function 2) для генерации ключа из пароля пользователя:

Соль хранится на сервере и передаётся клиенту при логине. Без пароля соль бесполезна. Без соли пароль недостаточен. Вместе они дают ключ.

Android: генерация ключа (Java/Kotlin)

SecretKeySpec generateKey(String password, String salt) throws Exception {
    SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
    KeySpec spec = new PBEKeySpec(
        password.toCharArray(), 
        salt.getBytes(StandardCharsets.UTF_8), 
        10000, 
        256
    );
    SecretKey tmp = factory.generateSecret(spec);
    return new SecretKeySpec(tmp.getEncoded(), "AES");
}

Браузер: та же логика на Web Crypto API (JavaScript)

async function generateKey(password, salt) {
    const encoder = new TextEncoder();
    const keyMaterial = await crypto.subtle.importKey(
        'raw', encoder.encode(password), 
        { name: 'PBKDF2' }, false, ['deriveKey']
    );
    return await crypto.subtle.deriveKey(
        { name: 'PBKDF2', salt: encoder.encode(salt), 
          iterations: 10000, hash: 'SHA-256' },
        keyMaterial,
        { name: 'AES-GCM', length: 256 },
        false, ['encrypt', 'decrypt']
    );
}

Один и тот же пароль + соль дают одинаковый ключ на Android и в браузере. Магия криптографии.

Как шифруется сообщение на телефоне

Android-приложение перехватывает уведомление через NotificationListenerService. Дальше:

  1. Берём текст уведомления
  2. Генерируем случайный IV (Initialization Vector) — 12 байт
  3. Шифруем текст алгоритмом AES-256-GCM
  4. GCM добавляет authentication tag (16 байт) — он гарантирует, что данные не были изменены
  5. Конкатенируем: IV + ciphertext + tag
  6. Кодируем в Base64
  7. Отправляем на сервер
String encrypt(String plainText, SecretKeySpec key) throws Exception {
    Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
    byte[] iv = new byte[12];
    new SecureRandom().nextBytes(iv);
    GCMParameterSpec spec = new GCMParameterSpec(128, iv);
    cipher.init(Cipher.ENCRYPT_MODE, key, spec);
    byte[] ciphertext = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
    
    // Конкатенируем IV + ciphertext
    byte[] encrypted = new byte[iv.length + ciphertext.length];
    System.arraycopy(iv, 0, encrypted, 0, iv.length);
    System.arraycopy(ciphertext, 0, encrypted, iv.length, ciphertext.length);
    
    return Base64.encodeToString(encrypted, Base64.NO_WRAP);
}

Что видит сервер

Сервер получает POST-запрос с телом:

{
    "type": "sms",
    "app_name": "com.android.mms",
    "encrypted_body": "cTCJnPSnipLMog/40BWLoSHktmnNZ2t3k/aCG8ZW83oC2l7zO7U54eO+WO84PlzH..."
}

Сервер сохраняет encrypted_body в PostgreSQL как есть. Никакой обработки, никакой расшифровки. Сервер не может прочитать сообщение, потому что у него нет пароля пользователя.

Как расшифровывается в браузере

Когда пользователь открывает веб-интерфейс, происходит следующее:

  1. Браузер получает encryption_salt с сервера (при логине)
  2. Пользователь вводит пароль (или он сохранён в виде производного ключа в localStorage)
  3. Генерируется тот же AES-ключ через PBKDF2
  4. Для каждого сообщения: берём Base64 → декодируем → отделяем IV (первые 12 байт) → расшифровываем оставшееся
  5. GCM проверяет authentication tag — если данные были изменены, расшифровка не пройдёт
async function decryptMessage(encryptedBase64, key) {
    const encryptedData = Uint8Array.from(
        atob(encryptedBase64), c => c.charCodeAt(0)
    );
    const iv = encryptedData.slice(0, 12);
    const ciphertext = encryptedData.slice(12);
    const decrypted = await crypto.subtle.decrypt(
        { name: 'AES-GCM', iv: iv }, key, ciphertext
    );
    return new TextDecoder().decode(decrypted);
}

Почему именно AES-256-GCM, а не ChaCha20-Poly1305

Выбор пал на AES-GCM по трём причинам:

ChaCha20-Poly1305 был бы предпочтительнее на устройствах без AES-NI, но таких почти не осталось.

Атаки, от которых мы защищаемся

Атаки, от которых НЕ защищаемся (пока)

Что дальше

В планах:

Попробуйте шифрование в действии

Первые 7 дней бесплатно. Весь код шифрования открыт в репозитории на GitHub.

Попробовать →