24 мая 2026
Когда мы начинали делать Эхофон, перед нами стоял выбор: передавать уведомления как есть (как делает Pushbullet) или шифровать их так, чтобы даже мы не могли их прочитать. Выбрали второе. В этой статье — технический разбор архитектуры сквозного шифрования (E2EE) от телефона до браузера.
Уведомления с телефона — это чувствительные данные:
Если сервер хранит такие данные в открытом виде, это огромный риск: утечка БД, взлом сервера, subpoena от третьих лиц. Сквозное шифрование решает эту проблему радикально: сервер никогда не видит текст сообщений.
У нас три стороны:
Ключевой принцип: ключ шифрования известен только телефону и браузеру. Сервер — просто транспорт.
Мы используем PBKDF2 (Password-Based Key Derivation Function 2) для генерации ключа из пароля пользователя:
Соль хранится на сервере и передаётся клиенту при логине. Без пароля соль бесполезна. Без соли пароль недостаточен. Вместе они дают ключ.
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");
}
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. Дальше:
IV + ciphertext + tagString 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 как есть. Никакой обработки, никакой расшифровки. Сервер не может прочитать сообщение, потому что у него нет пароля пользователя.
Когда пользователь открывает веб-интерфейс, происходит следующее:
encryption_salt с сервера (при логине)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-GCM по трём причинам:
ChaCha20-Poly1305 был бы предпочтительнее на устройствах без AES-NI, но таких почти не осталось.
В планах:
Первые 7 дней бесплатно. Весь код шифрования открыт в репозитории на GitHub.
Попробовать →