mirror of
https://github.com/by-sonic/tglock.git
synced 2026-11-05 15:21:55 +03:00
8ce9355243
* fix(tunnel): медиа и «прокси не настроен»: один пакет на кадр, настоящие фронты, kwsN-1 только для медиа Три причины, каждая проверена на живом Telegram запросом req_DH_params: сервер сверяет номер DC внутри него с собой и отвечает -444 на чужой. - WebSocket-фронт разбирает один пакет на входящий кадр, остаток ждёт следующего. Клиент пишет запросы пачками, TGLock слал их одним кадром, и всё, что шло вторым, застревало: текст проходил, файлы вставали. Теперь поток режется на пакеты, как в Flowseal/tg-ws-proxy. Пачка через beta.18 висит на всех DC, через эту сборку принимается на всех. - Кластер kwsN-1 отвергает обычное соединение с -444, а Telegram Desktop в ответ выключает прокси как настроенный неверно. При сбое kwsN основное соединение откатывалось именно туда. Теперь kwsN-1 только для медиа. - Адреса из списка DC не говорят на TLS ни из какой сети (проверено с чистой сети), поэтому первый маршрут каждого DC не работал никогда. Закреплены настоящие фронты, и DC1, DC3, DC5 стали доступны по WebSocket, чего не было. У CDN203 своего фронта нет: остались TCP, Worker и CF-прокси. Обрыв туннеля, через который уже шли данные, больше не ставит маршрут на паузу. Таймаут соединения 8 с, как в tg-ws-proxy. probe_proxy.mjs получил --identity и --batched: они и нашли эти баги. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: журнал ошибок в диагностике, --log-file для роутеров; 2.0.0-beta.19 «Скопировать диагностику» отдавала только счётчики, и по ней нельзя было понять, какой маршрут не ответил. Теперь в отчёте есть строки сбоев туннелей, а домены Worker и CF-прокси в них заменены на <домен>. --log-file и --log-max-mb (log_file, log_max_mb в настройках), как в tg-ws-proxy: журнал идёт в файл с ротацией, а не в системный журнал роутера. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(routes): запасной WebSocket на порту 80 родных адресов, включая CDN203 Родные адреса Telegram принимают WebSocket без TLS на порту 80. Измерено req_DH_params: DC определяется адресом, имя хоста не важно, а 91.105.192.100 отдаёт ключи CDN. Для CDN203, откуда часто идёт видео, это первый прямой WebSocket: на 443 фронта у него нет, и там, где провайдер закрыл только 443, до него раньше было не достать без Worker. Защита та же, что у родного TCP: внутри тот же обфусцированный MTProto. В Host идёт адрес, имени Telegram в открытом виде нет. Маршрут разрешён только до закреплённых адресов и пробуется последним. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(routes): паузы маршрутов по результатам ревью, пределы и журнал - Обрыв не наказывает маршрут, только если туннель работал и получал ответы 15 с. Одного ответа мало: маршрут, который ответил раз и умер, оставался предпочтительным навсегда. - Клиент спросил, не дождался ответа за 5 с и ушёл: маршрут считается неработающим. Иначе CDN, принявший TCP и молчащий, держал первое место, и очередь до порта 80 не доходила. - Предел пакета 2 МиБ вместо 16: чужое устройство в LAN-режиме не заставит роутер копить десятки мегабайт на соединение. - Журнал: разовая ошибка записи его больше не выключает, неудачная ротация не теряет бэкап и держит предел, права 0600 и на уже существующем файле. - В отчёт диагностики не попадает Worker, заданный адресом. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: babin <Thartewerner536e@engineer.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1058 lines
40 KiB
Rust
1058 lines
40 KiB
Rust
use aes::Aes256;
|
||
use cipher::{KeyIvInit, StreamCipher};
|
||
use rand::{rngs::OsRng, RngCore};
|
||
use sha2::{Digest, Sha256};
|
||
use std::path::{Path, PathBuf};
|
||
|
||
type AesCtr = ctr::Ctr128BE<Aes256>;
|
||
|
||
const INIT_LEN: usize = 64;
|
||
const KEY_START: usize = 8;
|
||
const KEY_END: usize = 40;
|
||
const IV_END: usize = 56;
|
||
const TAG_START: usize = 56;
|
||
const DC_START: usize = 60;
|
||
|
||
const ABRIDGED: [u8; 4] = [0xef; 4];
|
||
const INTERMEDIATE: [u8; 4] = [0xee; 4];
|
||
const PADDED_INTERMEDIATE: [u8; 4] = [0xdd; 4];
|
||
|
||
pub struct ClientInit {
|
||
pub dc: u16,
|
||
pub media: bool,
|
||
pub relay_init: [u8; INIT_LEN],
|
||
pub crypto: CryptoContext,
|
||
}
|
||
|
||
pub struct CryptoContext {
|
||
client_decrypt: AesCtr,
|
||
client_encrypt: AesCtr,
|
||
telegram_encrypt: AesCtr,
|
||
telegram_decrypt: AesCtr,
|
||
}
|
||
|
||
impl CryptoContext {
|
||
pub fn client_to_telegram(&mut self, data: &mut [u8]) {
|
||
self.client_decrypt.apply_keystream(data);
|
||
self.telegram_encrypt.apply_keystream(data);
|
||
}
|
||
|
||
pub fn telegram_to_client(&mut self, data: &mut [u8]) {
|
||
self.telegram_decrypt.apply_keystream(data);
|
||
self.client_encrypt.apply_keystream(data);
|
||
}
|
||
|
||
/// Разделить шифры по направлениям, чтобы туннель шёл в обе стороны сразу.
|
||
///
|
||
/// Направления независимы: это два потока AES-CTR со своими ключами, и ни
|
||
/// один байт одного не влияет на другой.
|
||
pub fn split(self) -> (Upstream, Downstream) {
|
||
(
|
||
Upstream {
|
||
client_decrypt: self.client_decrypt,
|
||
telegram_encrypt: self.telegram_encrypt,
|
||
},
|
||
Downstream {
|
||
telegram_decrypt: self.telegram_decrypt,
|
||
client_encrypt: self.client_encrypt,
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Шифры направления «клиент -> Telegram».
|
||
pub struct Upstream {
|
||
client_decrypt: AesCtr,
|
||
telegram_encrypt: AesCtr,
|
||
}
|
||
|
||
impl Upstream {
|
||
pub fn apply(&mut self, data: &mut [u8]) {
|
||
self.client_decrypt.apply_keystream(data);
|
||
self.telegram_encrypt.apply_keystream(data);
|
||
}
|
||
}
|
||
|
||
/// Шифры направления «Telegram -> клиент».
|
||
pub struct Downstream {
|
||
telegram_decrypt: AesCtr,
|
||
client_encrypt: AesCtr,
|
||
}
|
||
|
||
impl Downstream {
|
||
pub fn apply(&mut self, data: &mut [u8]) {
|
||
self.telegram_decrypt.apply_keystream(data);
|
||
self.client_encrypt.apply_keystream(data);
|
||
}
|
||
}
|
||
|
||
pub fn generate_secret() -> [u8; 16] {
|
||
let mut secret = [0; 16];
|
||
OsRng.fill_bytes(&mut secret);
|
||
secret
|
||
}
|
||
|
||
/// Секрет прокси и то, лежит ли он на диске.
|
||
pub struct StoredSecret {
|
||
pub value: [u8; 16],
|
||
/// Ошибка, из-за которой секрет не удалось сохранить.
|
||
///
|
||
/// Если она есть, при следующем запуске секрет будет другим, ссылка
|
||
/// `tg://proxy` перестанет совпадать с сохранённой в Telegram, и Telegram
|
||
/// скажет «прокси настроен неверно и будет отключён». Раньше запись
|
||
/// провалившись молчала, и понять причину было невозможно
|
||
/// (by-sonic/tglock#37).
|
||
pub write_error: Option<String>,
|
||
}
|
||
|
||
impl StoredSecret {
|
||
/// Секрет действительно переживёт перезапуск.
|
||
pub fn is_persistent(&self) -> bool {
|
||
self.write_error.is_none()
|
||
}
|
||
}
|
||
|
||
/// Взять секрет из файла, создав его, если файла нет или он испорчен.
|
||
///
|
||
/// Секрет — половина ссылки `tg://proxy`, поэтому сервис, придумывающий новый
|
||
/// при каждом старте, отключает всех уже настроенных клиентов.
|
||
pub fn load_or_create_secret_at(path: &Path) -> StoredSecret {
|
||
load_or_create_secret_at_with_migration(path, None)
|
||
}
|
||
|
||
/// Preserve desktop proxy links when the GUI moves to Tauri's app directory.
|
||
/// The legacy file remains intact, including when copying it fails.
|
||
pub fn load_or_create_secret_at_with_migration(path: &Path, legacy: Option<&Path>) -> StoredSecret {
|
||
if let Ok(value) = std::fs::read_to_string(path) {
|
||
if let Some(value) = parse_secret_hex(value.trim()) {
|
||
return StoredSecret {
|
||
value,
|
||
write_error: None,
|
||
};
|
||
}
|
||
}
|
||
|
||
let value = legacy
|
||
.and_then(|legacy| std::fs::read_to_string(legacy).ok())
|
||
.and_then(|value| parse_secret_hex(value.trim()))
|
||
.unwrap_or_else(generate_secret);
|
||
let write_error = store_secret(path, &secret_hex(&value))
|
||
.err()
|
||
.map(|error| format!("{}: {error}", path.display()));
|
||
StoredSecret { value, write_error }
|
||
}
|
||
|
||
fn store_secret(path: &Path, value: &str) -> std::io::Result<()> {
|
||
if let Some(parent) = path.parent() {
|
||
if !parent.as_os_str().is_empty() {
|
||
std::fs::create_dir_all(parent)?;
|
||
}
|
||
}
|
||
write_secret_file(path, value)
|
||
}
|
||
|
||
#[cfg(not(test))]
|
||
pub fn load_or_create_secret() -> StoredSecret {
|
||
match legacy_secret_path() {
|
||
Some(path) => load_or_create_secret_at(&path),
|
||
None => StoredSecret {
|
||
value: generate_secret(),
|
||
write_error: Some("не удалось определить папку для секрета в этой системе".to_owned()),
|
||
},
|
||
}
|
||
}
|
||
|
||
pub fn legacy_secret_path() -> Option<PathBuf> {
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
std::env::var_os("APPDATA")
|
||
.map(PathBuf::from)
|
||
.map(|path| path.join("TGLock").join("secret"))
|
||
}
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
std::env::var_os("HOME")
|
||
.map(PathBuf::from)
|
||
.map(|path| path.join("Library/Application Support/TGLock/secret"))
|
||
}
|
||
#[cfg(all(unix, not(target_os = "macos")))]
|
||
{
|
||
if let Some(path) = std::env::var_os("XDG_CONFIG_HOME") {
|
||
return Some(PathBuf::from(path).join("tglock").join("secret"));
|
||
}
|
||
std::env::var_os("HOME")
|
||
.map(PathBuf::from)
|
||
.map(|path| path.join(".config/tglock/secret"))
|
||
}
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn write_secret_file(path: &Path, value: &str) -> std::io::Result<()> {
|
||
use std::io::Write;
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
let mut file = std::fs::OpenOptions::new()
|
||
.create(true)
|
||
.truncate(true)
|
||
.write(true)
|
||
.mode(0o600)
|
||
.open(path)?;
|
||
file.write_all(value.as_bytes())
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
fn write_secret_file(path: &Path, value: &str) -> std::io::Result<()> {
|
||
std::fs::write(path, value)
|
||
}
|
||
|
||
pub fn secret_hex(secret: &[u8; 16]) -> String {
|
||
let mut output = String::with_capacity(32);
|
||
for byte in secret {
|
||
use std::fmt::Write;
|
||
let _ = write!(output, "{:02x}", byte);
|
||
}
|
||
output
|
||
}
|
||
|
||
/// Разобрать секрет, записанный человеком.
|
||
///
|
||
/// Принимает и 32 hex-символа, и форму с префиксом `dd` — именно так секрет
|
||
/// выглядит в ссылке `tg://proxy`, откуда его и копируют.
|
||
pub fn parse_secret(value: &str) -> Option<[u8; 16]> {
|
||
let trimmed = value.trim();
|
||
let hex = trimmed
|
||
.strip_prefix("dd")
|
||
.filter(|rest| rest.len() == 32)
|
||
.unwrap_or(trimmed);
|
||
parse_secret_hex(hex)
|
||
}
|
||
|
||
fn parse_secret_hex(value: &str) -> Option<[u8; 16]> {
|
||
if value.len() != 32 || !value.is_ascii() {
|
||
return None;
|
||
}
|
||
let mut secret = [0; 16];
|
||
for (index, byte) in secret.iter_mut().enumerate() {
|
||
*byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).ok()?;
|
||
}
|
||
Some(secret)
|
||
}
|
||
|
||
pub fn telegram_secret(secret: &[u8; 16]) -> String {
|
||
format!("dd{}", secret_hex(secret))
|
||
}
|
||
|
||
pub fn parse_client_init(init: &[u8; INIT_LEN], secret: &[u8; 16]) -> Option<ClientInit> {
|
||
let client_dec_key = secret_key(&init[KEY_START..KEY_END], secret);
|
||
let client_dec_iv: [u8; 16] = init[KEY_END..IV_END].try_into().ok()?;
|
||
let mut client_decrypt = AesCtr::new((&client_dec_key).into(), (&client_dec_iv).into());
|
||
|
||
let mut decrypted = *init;
|
||
client_decrypt.apply_keystream(&mut decrypted);
|
||
let protocol_tag: [u8; 4] = decrypted[TAG_START..DC_START].try_into().ok()?;
|
||
if !matches!(protocol_tag, ABRIDGED | INTERMEDIATE | PADDED_INTERMEDIATE) {
|
||
return None;
|
||
}
|
||
|
||
let dc_index = i16::from_le_bytes([decrypted[DC_START], decrypted[DC_START + 1]]);
|
||
let dc = dc_index.unsigned_abs();
|
||
if !matches!(dc, 1..=5 | 203) {
|
||
return None;
|
||
}
|
||
|
||
let relay_init = generate_relay_init(protocol_tag, dc_index);
|
||
let crypto = build_crypto_context(init, secret, &relay_init)?;
|
||
Some(ClientInit {
|
||
dc,
|
||
media: dc_index < 0,
|
||
relay_init,
|
||
crypto,
|
||
})
|
||
}
|
||
|
||
fn secret_key(prekey: &[u8], secret: &[u8; 16]) -> [u8; 32] {
|
||
let mut hash = Sha256::new();
|
||
hash.update(prekey);
|
||
hash.update(secret);
|
||
hash.finalize().into()
|
||
}
|
||
|
||
fn generate_relay_init(protocol_tag: [u8; 4], dc_index: i16) -> [u8; INIT_LEN] {
|
||
loop {
|
||
let mut init = [0; INIT_LEN];
|
||
OsRng.fill_bytes(&mut init);
|
||
if is_reserved_init(&init) {
|
||
continue;
|
||
}
|
||
|
||
let key: [u8; 32] = init[KEY_START..KEY_END].try_into().unwrap();
|
||
let iv: [u8; 16] = init[KEY_END..IV_END].try_into().unwrap();
|
||
let mut cipher = AesCtr::new((&key).into(), (&iv).into());
|
||
let mut encrypted = init;
|
||
cipher.apply_keystream(&mut encrypted);
|
||
|
||
let mut tail = [0; 8];
|
||
tail[..4].copy_from_slice(&protocol_tag);
|
||
tail[4..6].copy_from_slice(&dc_index.to_le_bytes());
|
||
OsRng.fill_bytes(&mut tail[6..]);
|
||
for index in 0..8 {
|
||
init[TAG_START + index] ^= tail[index] ^ encrypted[TAG_START + index];
|
||
}
|
||
return init;
|
||
}
|
||
}
|
||
|
||
fn is_reserved_init(init: &[u8; INIT_LEN]) -> bool {
|
||
init[0] == 0xef
|
||
|| &init[..4] == b"HEAD"
|
||
|| &init[..4] == b"POST"
|
||
|| &init[..4] == b"GET "
|
||
|| &init[..4] == b"OPTI"
|
||
|| init[..4] == [0xee; 4]
|
||
|| init[..4] == [0xdd; 4]
|
||
|| init[..4] == [0x16, 0x03, 0x01, 0x02]
|
||
|| init[4..8] == [0; 4]
|
||
}
|
||
|
||
fn build_crypto_context(
|
||
client_init: &[u8; INIT_LEN],
|
||
secret: &[u8; 16],
|
||
relay_init: &[u8; INIT_LEN],
|
||
) -> Option<CryptoContext> {
|
||
let client_dec_key = secret_key(&client_init[KEY_START..KEY_END], secret);
|
||
let client_dec_iv: [u8; 16] = client_init[KEY_END..IV_END].try_into().ok()?;
|
||
let mut client_decrypt = AesCtr::new((&client_dec_key).into(), (&client_dec_iv).into());
|
||
client_decrypt.apply_keystream(&mut [0; INIT_LEN]);
|
||
|
||
let reversed_client: Vec<_> = client_init[KEY_START..IV_END]
|
||
.iter()
|
||
.rev()
|
||
.copied()
|
||
.collect();
|
||
let client_enc_key = secret_key(&reversed_client[..32], secret);
|
||
let client_enc_iv: [u8; 16] = reversed_client[32..].try_into().ok()?;
|
||
let client_encrypt = AesCtr::new((&client_enc_key).into(), (&client_enc_iv).into());
|
||
|
||
let relay_enc_key: [u8; 32] = relay_init[KEY_START..KEY_END].try_into().ok()?;
|
||
let relay_enc_iv: [u8; 16] = relay_init[KEY_END..IV_END].try_into().ok()?;
|
||
let mut telegram_encrypt = AesCtr::new((&relay_enc_key).into(), (&relay_enc_iv).into());
|
||
telegram_encrypt.apply_keystream(&mut [0; INIT_LEN]);
|
||
|
||
let reversed_relay: Vec<_> = relay_init[KEY_START..IV_END]
|
||
.iter()
|
||
.rev()
|
||
.copied()
|
||
.collect();
|
||
let relay_dec_key: [u8; 32] = reversed_relay[..32].try_into().ok()?;
|
||
let relay_dec_iv: [u8; 16] = reversed_relay[32..].try_into().ok()?;
|
||
let telegram_decrypt = AesCtr::new((&relay_dec_key).into(), (&relay_dec_iv).into());
|
||
|
||
Some(CryptoContext {
|
||
client_decrypt,
|
||
client_encrypt,
|
||
telegram_encrypt,
|
||
telegram_decrypt,
|
||
})
|
||
}
|
||
|
||
/// Длина, больше которой пакет считается не настоящим. Файлы грузятся частями
|
||
/// по 512 КиБ. Предел держит и память: в LAN-режиме чужое устройство могло бы
|
||
/// заставить копить «пакет» на роутере. Если настоящий пакет окажется длиннее,
|
||
/// поток просто пойдёт дальше без нарезки.
|
||
const MAX_PACKET: usize = 2 * 1024 * 1024;
|
||
|
||
#[derive(Clone, Copy)]
|
||
enum Framing {
|
||
Abridged,
|
||
Intermediate,
|
||
}
|
||
|
||
/// Режет поток клиента на транспортные пакеты MTProto.
|
||
///
|
||
/// WebSocket-фронт Telegram разбирает один пакет на входящий кадр, а остаток
|
||
/// кадра держит до следующего. Клиент пишет запросы пачками, и всё, что шло
|
||
/// вторым в той же записи TCP, застревало на фронте: текст проходил, а файлы
|
||
/// вставали. Проверено на живом фронте: `req_DH_params` вторым пакетом в кадре
|
||
/// остаётся без ответа, пока не придёт следующий кадр, а отдельным кадром
|
||
/// принимается сразу. Так же режет поток Flowseal/tg-ws-proxy.
|
||
///
|
||
/// Заголовок читается по копии: ключи `init` открыты, поэтому режим с секретом
|
||
/// и SOCKS5 разбираются одинаково, а наружу уходят исходные байты.
|
||
pub struct PacketSplitter {
|
||
framing: Option<Framing>,
|
||
decrypt: AesCtr,
|
||
wire: Vec<u8>,
|
||
plain: Vec<u8>,
|
||
}
|
||
|
||
impl PacketSplitter {
|
||
/// Разбирать поток, начатый этим `init`. Неизвестный протокол не режется.
|
||
pub fn from_init(init: &[u8; INIT_LEN]) -> Self {
|
||
let key: [u8; 32] = init[KEY_START..KEY_END].try_into().unwrap();
|
||
let iv: [u8; 16] = init[KEY_END..IV_END].try_into().unwrap();
|
||
let mut decrypt = AesCtr::new((&key).into(), (&iv).into());
|
||
let mut plain_init = *init;
|
||
decrypt.apply_keystream(&mut plain_init);
|
||
let framing = match [
|
||
plain_init[TAG_START],
|
||
plain_init[TAG_START + 1],
|
||
plain_init[TAG_START + 2],
|
||
plain_init[TAG_START + 3],
|
||
] {
|
||
ABRIDGED => Some(Framing::Abridged),
|
||
INTERMEDIATE | PADDED_INTERMEDIATE => Some(Framing::Intermediate),
|
||
_ => None,
|
||
};
|
||
Self {
|
||
framing,
|
||
decrypt,
|
||
wire: Vec::new(),
|
||
plain: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Принять зашифрованные байты, вернуть готовые к отправке пакеты.
|
||
pub fn push(&mut self, wire: &[u8]) -> Vec<Vec<u8>> {
|
||
let Some(framing) = self.framing else {
|
||
return vec![wire.to_vec()];
|
||
};
|
||
let start = self.plain.len();
|
||
self.wire.extend_from_slice(wire);
|
||
self.plain.extend_from_slice(wire);
|
||
self.decrypt.apply_keystream(&mut self.plain[start..]);
|
||
|
||
let mut packets = Vec::new();
|
||
let mut offset = 0;
|
||
while offset < self.plain.len() {
|
||
match packet_len(framing, &self.plain[offset..]) {
|
||
PacketLen::Incomplete => break,
|
||
PacketLen::Complete(length) => {
|
||
if self.plain.len() - offset < length {
|
||
break;
|
||
}
|
||
packets.push(self.wire[offset..offset + length].to_vec());
|
||
offset += length;
|
||
}
|
||
// Длина не похожа на настоящую: поток не наш. Дальше он идёт
|
||
// как есть, иначе данные встанут в ожидании несуществующего конца.
|
||
PacketLen::Invalid => {
|
||
packets.push(self.wire[offset..].to_vec());
|
||
offset = self.plain.len();
|
||
self.framing = None;
|
||
}
|
||
}
|
||
}
|
||
self.wire.drain(..offset);
|
||
self.plain.drain(..offset);
|
||
packets
|
||
}
|
||
|
||
/// Хвост незаконченного пакета: клиент закрыл соединение на середине.
|
||
pub fn finish(&mut self) -> Option<Vec<u8>> {
|
||
self.plain.clear();
|
||
(!self.wire.is_empty()).then(|| std::mem::take(&mut self.wire))
|
||
}
|
||
}
|
||
|
||
enum PacketLen {
|
||
Incomplete,
|
||
Complete(usize),
|
||
Invalid,
|
||
}
|
||
|
||
fn packet_len(framing: Framing, plain: &[u8]) -> PacketLen {
|
||
let (header, payload) = match framing {
|
||
Framing::Abridged => match plain {
|
||
[] => return PacketLen::Incomplete,
|
||
[0x7f | 0xff, length @ ..] => match length {
|
||
[a, b, c, ..] => (4, u32::from_le_bytes([*a, *b, *c, 0]) as usize * 4),
|
||
_ => return PacketLen::Incomplete,
|
||
},
|
||
[first, ..] => (1, (*first & 0x7f) as usize * 4),
|
||
},
|
||
Framing::Intermediate => match plain {
|
||
[a, b, c, d, ..] => {
|
||
// Старший бит просит quick ack и к длине не относится.
|
||
(
|
||
4,
|
||
(u32::from_le_bytes([*a, *b, *c, *d]) & 0x7fff_ffff) as usize,
|
||
)
|
||
}
|
||
_ => return PacketLen::Incomplete,
|
||
},
|
||
};
|
||
if payload == 0 || payload > MAX_PACKET {
|
||
return PacketLen::Invalid;
|
||
}
|
||
PacketLen::Complete(header + payload)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn test_client_init(secret: &[u8; 16], dc_index: i16) -> [u8; INIT_LEN] {
|
||
tests::generate_client_init(secret, PADDED_INTERMEDIATE, dc_index)
|
||
}
|
||
|
||
/// One end of an obfuscated2 stream, built the way the real peer builds it.
|
||
///
|
||
/// Lets tests assert on the bytes the peer actually observes rather than on the
|
||
/// proxy's own view of them, so a mistake that is symmetric inside
|
||
/// [`CryptoContext`] still fails the test.
|
||
#[cfg(test)]
|
||
pub(crate) struct TestPeer {
|
||
encrypt: AesCtr,
|
||
decrypt: AesCtr,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
impl TestPeer {
|
||
pub(crate) fn encrypt(&mut self, data: &mut [u8]) {
|
||
self.encrypt.apply_keystream(data);
|
||
}
|
||
|
||
pub(crate) fn decrypt(&mut self, data: &mut [u8]) {
|
||
self.decrypt.apply_keystream(data);
|
||
}
|
||
}
|
||
|
||
/// The Telegram client: its keys come from the init it sent, salted with the
|
||
/// shared secret.
|
||
#[cfg(test)]
|
||
pub(crate) fn test_client_peer(init: &[u8; INIT_LEN], secret: &[u8; 16]) -> TestPeer {
|
||
let key = secret_key(&init[KEY_START..KEY_END], secret);
|
||
let iv: [u8; 16] = init[KEY_END..IV_END].try_into().unwrap();
|
||
let mut encrypt = AesCtr::new((&key).into(), (&iv).into());
|
||
encrypt.apply_keystream(&mut [0; INIT_LEN]);
|
||
|
||
let reversed: Vec<u8> = init[KEY_START..IV_END].iter().rev().copied().collect();
|
||
let decrypt_key = secret_key(&reversed[..32], secret);
|
||
let decrypt_iv: [u8; 16] = reversed[32..].try_into().unwrap();
|
||
let decrypt = AesCtr::new((&decrypt_key).into(), (&decrypt_iv).into());
|
||
|
||
TestPeer { encrypt, decrypt }
|
||
}
|
||
|
||
/// The Telegram relay: no shared secret, keys come straight from the init the
|
||
/// proxy generated for it.
|
||
#[cfg(test)]
|
||
pub(crate) fn test_relay_peer(relay_init: &[u8; INIT_LEN]) -> TestPeer {
|
||
let key: [u8; 32] = relay_init[KEY_START..KEY_END].try_into().unwrap();
|
||
let iv: [u8; 16] = relay_init[KEY_END..IV_END].try_into().unwrap();
|
||
let mut decrypt = AesCtr::new((&key).into(), (&iv).into());
|
||
decrypt.apply_keystream(&mut [0; INIT_LEN]);
|
||
|
||
let reversed: Vec<u8> = relay_init[KEY_START..IV_END]
|
||
.iter()
|
||
.rev()
|
||
.copied()
|
||
.collect();
|
||
let encrypt_key: [u8; 32] = reversed[..32].try_into().unwrap();
|
||
let encrypt_iv: [u8; 16] = reversed[32..].try_into().unwrap();
|
||
let encrypt = AesCtr::new((&encrypt_key).into(), (&encrypt_iv).into());
|
||
|
||
TestPeer { encrypt, decrypt }
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Поток, каким его увидел бы фронт Telegram, и разделитель для него.
|
||
fn splitter_for(tag: [u8; 4], plain: &[u8]) -> (PacketSplitter, Vec<u8>) {
|
||
let init = generate_relay_init(tag, 2);
|
||
let mut wire = plain.to_vec();
|
||
test_relay_peer(&init).decrypt(&mut wire);
|
||
(PacketSplitter::from_init(&init), wire)
|
||
}
|
||
|
||
fn intermediate(payload: &[u8]) -> Vec<u8> {
|
||
let mut packet = (payload.len() as u32).to_le_bytes().to_vec();
|
||
packet.extend_from_slice(payload);
|
||
packet
|
||
}
|
||
|
||
#[test]
|
||
fn glued_packets_leave_one_per_frame() {
|
||
let first = intermediate(&[1; 40]);
|
||
let second = intermediate(&[2; 340]);
|
||
let plain = [first.clone(), second.clone()].concat();
|
||
for tag in [INTERMEDIATE, PADDED_INTERMEDIATE] {
|
||
let (mut splitter, wire) = splitter_for(tag, &plain);
|
||
let packets = splitter.push(&wire);
|
||
assert_eq!(packets.len(), 2);
|
||
assert_eq!(packets[0], wire[..first.len()]);
|
||
assert_eq!(packets[1], wire[first.len()..]);
|
||
assert_eq!(splitter.finish(), None);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_packet_is_held_until_it_is_complete() {
|
||
let packet = intermediate(&[7; 300]);
|
||
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &packet);
|
||
// Границы TCP не совпадают с пакетами: режем в том числе внутри длины.
|
||
assert!(splitter.push(&wire[..2]).is_empty());
|
||
assert!(splitter.push(&wire[2..150]).is_empty());
|
||
assert_eq!(splitter.push(&wire[150..]), vec![wire.clone()]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_tail_of_one_packet_and_the_start_of_the_next_stay_separate() {
|
||
let first = intermediate(&[1; 40]);
|
||
let second = intermediate(&[2; 80]);
|
||
let plain = [first.clone(), second.clone()].concat();
|
||
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &plain);
|
||
let packets = splitter.push(&wire[..first.len() + 10]);
|
||
assert_eq!(packets, vec![wire[..first.len()].to_vec()]);
|
||
let packets = splitter.push(&wire[first.len() + 10..]);
|
||
assert_eq!(packets, vec![wire[first.len()..].to_vec()]);
|
||
}
|
||
|
||
#[test]
|
||
fn quick_ack_bit_is_not_part_of_the_length() {
|
||
let mut packet = intermediate(&[3; 16]);
|
||
packet[3] |= 0x80;
|
||
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &packet);
|
||
assert_eq!(splitter.push(&wire), vec![wire.clone()]);
|
||
}
|
||
|
||
#[test]
|
||
fn abridged_lengths_count_in_words_with_either_header() {
|
||
let short = [vec![2], vec![9; 8]].concat();
|
||
let long = [vec![0x7f, 0x80, 0, 0], vec![9; 512]].concat();
|
||
let quick_ack = [vec![0x81], vec![9; 4]].concat();
|
||
let plain = [short.clone(), long.clone(), quick_ack.clone()].concat();
|
||
let (mut splitter, wire) = splitter_for(ABRIDGED, &plain);
|
||
let lengths: Vec<_> = splitter.push(&wire).iter().map(Vec::len).collect();
|
||
assert_eq!(lengths, [short.len(), long.len(), quick_ack.len()]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_length_that_cannot_be_real_stops_the_splitting_without_losing_bytes() {
|
||
// Если длина мусорная, ждать конца пакета значит заморозить соединение.
|
||
let mut garbage = 0x7fff_fff0_u32.to_le_bytes().to_vec();
|
||
garbage.extend_from_slice(&[5; 32]);
|
||
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &garbage);
|
||
assert_eq!(splitter.push(&wire), vec![wire.clone()]);
|
||
assert_eq!(splitter.push(b"more"), vec![b"more".to_vec()]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_length_over_the_limit_is_never_buffered() {
|
||
let mut oversized = ((MAX_PACKET + 1) as u32).to_le_bytes().to_vec();
|
||
oversized.extend_from_slice(&[5; 32]);
|
||
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &oversized);
|
||
assert_eq!(splitter.push(&wire), vec![wire.clone()]);
|
||
assert_eq!(splitter.finish(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_largest_real_upload_part_still_arrives_whole() {
|
||
let packet = intermediate(&vec![7; 512 * 1024 + 256]);
|
||
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &packet);
|
||
assert!(splitter.push(&wire[..65536]).is_empty());
|
||
assert_eq!(splitter.push(&wire[65536..]), vec![wire.clone()]);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unknown_protocol_passes_through_untouched() {
|
||
let (mut splitter, wire) = splitter_for([0x11; 4], &[0; 16]);
|
||
assert_eq!(splitter.push(&wire), vec![wire.clone()]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_unfinished_tail_is_returned_once() {
|
||
let packet = intermediate(&[1; 40]);
|
||
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &packet);
|
||
assert!(splitter.push(&wire[..10]).is_empty());
|
||
assert_eq!(splitter.finish(), Some(wire[..10].to_vec()));
|
||
assert_eq!(splitter.finish(), None);
|
||
}
|
||
|
||
pub(super) fn generate_client_init(
|
||
secret: &[u8; 16],
|
||
protocol_tag: [u8; 4],
|
||
dc_index: i16,
|
||
) -> [u8; INIT_LEN] {
|
||
let mut init = generate_relay_init(protocol_tag, dc_index);
|
||
let key = secret_key(&init[KEY_START..KEY_END], secret);
|
||
let iv: [u8; 16] = init[KEY_END..IV_END].try_into().unwrap();
|
||
let mut cipher = AesCtr::new((&key).into(), (&iv).into());
|
||
let mut encrypted = init;
|
||
cipher.apply_keystream(&mut encrypted);
|
||
|
||
let mut tail = [0; 8];
|
||
tail[..4].copy_from_slice(&protocol_tag);
|
||
tail[4..6].copy_from_slice(&dc_index.to_le_bytes());
|
||
tail[6..].copy_from_slice(&[17, 23]);
|
||
for index in 0..8 {
|
||
init[TAG_START + index] ^= tail[index] ^ encrypted[TAG_START + index];
|
||
}
|
||
init
|
||
}
|
||
|
||
#[test]
|
||
fn parses_secret_protected_media_init() {
|
||
let secret = [42; 16];
|
||
let init = generate_client_init(&secret, PADDED_INTERMEDIATE, -4);
|
||
let parsed = parse_client_init(&init, &secret).unwrap();
|
||
assert_eq!(parsed.dc, 4);
|
||
assert!(parsed.media);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_wrong_secret() {
|
||
let init = generate_client_init(&[42; 16], INTERMEDIATE, 2);
|
||
assert!(parse_client_init(&init, &[7; 16]).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn telegram_link_secret_has_padded_intermediate_prefix() {
|
||
assert_eq!(
|
||
telegram_secret(&[0xab; 16]),
|
||
"ddabababababababababababababababab"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn accepts_every_supported_protocol_tag() {
|
||
let secret = [7; 16];
|
||
for tag in [ABRIDGED, INTERMEDIATE, PADDED_INTERMEDIATE] {
|
||
let init = generate_client_init(&secret, tag, 2);
|
||
let parsed = parse_client_init(&init, &secret)
|
||
.unwrap_or_else(|| panic!("tag {tag:02x?} must be accepted"));
|
||
assert_eq!(parsed.dc, 2);
|
||
assert!(!parsed.media);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn relay_init_carries_the_clients_protocol_tag_and_dc() {
|
||
let secret = [3; 16];
|
||
for (tag, dc_index) in [
|
||
(ABRIDGED, 1_i16),
|
||
(INTERMEDIATE, -5),
|
||
(PADDED_INTERMEDIATE, 203),
|
||
] {
|
||
let init = generate_client_init(&secret, tag, dc_index);
|
||
let parsed = parse_client_init(&init, &secret).unwrap();
|
||
|
||
// The relay init is freshly generated, never the client's bytes.
|
||
assert_ne!(parsed.relay_init, init);
|
||
|
||
// Decoding the relay init the way Telegram does must recover the
|
||
// same protocol and data centre the client asked for.
|
||
let key: [u8; 32] = parsed.relay_init[KEY_START..KEY_END].try_into().unwrap();
|
||
let iv: [u8; 16] = parsed.relay_init[KEY_END..IV_END].try_into().unwrap();
|
||
let mut cipher = AesCtr::new((&key).into(), (&iv).into());
|
||
let mut decoded = parsed.relay_init;
|
||
cipher.apply_keystream(&mut decoded);
|
||
|
||
assert_eq!(decoded[TAG_START..DC_START], tag);
|
||
assert_eq!(
|
||
i16::from_le_bytes([decoded[DC_START], decoded[DC_START + 1]]),
|
||
dc_index
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_data_centers_outside_the_known_range() {
|
||
let secret = [11; 16];
|
||
for dc_index in [0_i16, 6, -6, 204, -204, 1000] {
|
||
let init = generate_client_init(&secret, INTERMEDIATE, dc_index);
|
||
assert!(
|
||
parse_client_init(&init, &secret).is_none(),
|
||
"DC index {dc_index} must be rejected"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn negative_index_marks_media_and_keeps_the_data_center() {
|
||
let secret = [13; 16];
|
||
for dc in [1_u16, 2, 3, 4, 5, 203] {
|
||
let index = -(dc as i16);
|
||
let parsed =
|
||
parse_client_init(&generate_client_init(&secret, ABRIDGED, index), &secret)
|
||
.unwrap();
|
||
assert_eq!(parsed.dc, dc);
|
||
assert!(parsed.media);
|
||
|
||
let parsed =
|
||
parse_client_init(&generate_client_init(&secret, ABRIDGED, dc as i16), &secret)
|
||
.unwrap();
|
||
assert_eq!(parsed.dc, dc);
|
||
assert!(!parsed.media);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn plaintext_survives_the_trip_to_the_relay_and_back() {
|
||
let secret = [42; 16];
|
||
let init = generate_client_init(&secret, ABRIDGED, 2);
|
||
let mut parsed = parse_client_init(&init, &secret).unwrap();
|
||
let mut client = test_client_peer(&init, &secret);
|
||
let mut relay = test_relay_peer(&parsed.relay_init);
|
||
|
||
let request = b"exactly what Telegram must receive".to_vec();
|
||
let mut wire = request.clone();
|
||
client.encrypt(&mut wire);
|
||
assert_ne!(wire, request, "the wire must not carry plaintext");
|
||
parsed.crypto.client_to_telegram(&mut wire);
|
||
assert_ne!(wire, request, "the upstream wire must not carry plaintext");
|
||
relay.decrypt(&mut wire);
|
||
assert_eq!(wire, request);
|
||
|
||
let response = b"exactly what the client must receive".to_vec();
|
||
let mut wire = response.clone();
|
||
relay.encrypt(&mut wire);
|
||
parsed.crypto.telegram_to_client(&mut wire);
|
||
client.decrypt(&mut wire);
|
||
assert_eq!(wire, response);
|
||
}
|
||
|
||
#[test]
|
||
fn keystream_advances_across_chunks() {
|
||
let secret = [5; 16];
|
||
let init = generate_client_init(&secret, INTERMEDIATE, 3);
|
||
let mut parsed = parse_client_init(&init, &secret).unwrap();
|
||
let mut client = test_client_peer(&init, &secret);
|
||
let mut relay = test_relay_peer(&parsed.relay_init);
|
||
|
||
// A stream cipher is only correct if both ends stay in lockstep across
|
||
// arbitrary chunk boundaries, which is how TCP actually delivers data.
|
||
let chunks: [&[u8]; 4] = [b"one", b"", b"the third chunk is longer", b"4"];
|
||
for chunk in chunks {
|
||
let mut wire = chunk.to_vec();
|
||
client.encrypt(&mut wire);
|
||
parsed.crypto.client_to_telegram(&mut wire);
|
||
relay.decrypt(&mut wire);
|
||
assert_eq!(wire, chunk);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn reserved_prefixes_never_leave_the_generator() {
|
||
// A relay init that starts with an HTTP verb or a protocol tag would be
|
||
// misread by Telegram's frontend.
|
||
for _ in 0..2_000 {
|
||
assert!(!is_reserved_init(&generate_relay_init(ABRIDGED, 2)));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_failed_write_is_reported_instead_of_swallowed() {
|
||
// Раньше ошибка записи выбрасывалась, секрет генерировался заново при
|
||
// каждом запуске, и Telegram говорил «прокси настроен неверно» без
|
||
// единой подсказки почему (by-sonic/tglock#37).
|
||
let blocker = std::env::temp_dir().join(format!(
|
||
"tglock-not-a-dir-{}-{:?}",
|
||
std::process::id(),
|
||
std::thread::current().id()
|
||
));
|
||
std::fs::write(&blocker, "я файл, а не папка").unwrap();
|
||
|
||
// Родитель пути — обычный файл, поэтому создать каталог невозможно.
|
||
let stored = load_or_create_secret_at(&blocker.join("secret"));
|
||
|
||
assert!(
|
||
!stored.is_persistent(),
|
||
"неудачная запись обязана быть видна"
|
||
);
|
||
let error = stored.write_error.expect("должно быть сообщение об ошибке");
|
||
assert!(
|
||
error.contains("secret"),
|
||
"в сообщении должен быть путь, получено: {error}"
|
||
);
|
||
// Секрет всё равно выдан: прокси работает, просто до перезапуска.
|
||
assert_ne!(stored.value, [0; 16]);
|
||
|
||
let _ = std::fs::remove_file(&blocker);
|
||
}
|
||
|
||
#[test]
|
||
fn a_successful_write_reports_no_error() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"tglock-secret-ok-{}-{:?}",
|
||
std::process::id(),
|
||
std::thread::current().id()
|
||
));
|
||
let _ = std::fs::remove_file(&path);
|
||
|
||
let first = load_or_create_secret_at(&path);
|
||
assert!(first.is_persistent(), "запись в temp должна удаваться");
|
||
|
||
// Второй запуск читает готовый файл и тоже не жалуется.
|
||
let second = load_or_create_secret_at(&path);
|
||
assert!(second.is_persistent());
|
||
assert_eq!(
|
||
first.value, second.value,
|
||
"секрет должен переживать перезапуск"
|
||
);
|
||
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
#[test]
|
||
fn accepts_a_secret_copied_from_a_tg_link() {
|
||
let expected = [
|
||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
|
||
0xee, 0xff,
|
||
];
|
||
// Обе формы: как в файле и как в ссылке tg://proxy.
|
||
assert_eq!(
|
||
parse_secret("00112233445566778899aabbccddeeff"),
|
||
Some(expected)
|
||
);
|
||
assert_eq!(
|
||
parse_secret("dd00112233445566778899aabbccddeeff"),
|
||
Some(expected)
|
||
);
|
||
// Пробелы по краям — обычное дело при копировании.
|
||
assert_eq!(
|
||
parse_secret(" dd00112233445566778899aabbccddeeff\n"),
|
||
Some(expected)
|
||
);
|
||
// Секрет, который сам начинается с dd и уже имеет полную длину, не
|
||
// должен потерять первый байт: префикс снимается только если после него
|
||
// остаётся ровно 32 символа.
|
||
assert_eq!(
|
||
parse_secret("dd112233445566778899aabbccddeeff"),
|
||
Some([
|
||
0xdd, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
|
||
0xee, 0xff,
|
||
])
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_malformed_secrets() {
|
||
for bad in [
|
||
"",
|
||
"dd",
|
||
"слишком коротко",
|
||
"00112233445566778899aabbccddee", // 30 символов
|
||
"00112233445566778899aabbccddeeffff", // 34 символа
|
||
"zz112233445566778899aabbccddeeff", // не hex
|
||
"0я00000000000000000000000000000", // 32 bytes, UTF-8 boundary at byte 2
|
||
] {
|
||
assert!(parse_secret(bad).is_none(), "{bad:?} должен быть отвергнут");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn android_obfuscated2_vector_survives_fragmented_translation() {
|
||
// Independently generated with Node/OpenSSL AES-256-CTR and SHA-256,
|
||
// following Telegram Android Connection.cpp sendData/encryptKeyWithSecret.
|
||
// Fixed wire bytes avoid a symmetric mistake in the test peer helpers.
|
||
fn bytes(hex: &str) -> Vec<u8> {
|
||
hex.as_bytes()
|
||
.as_chunks::<2>()
|
||
.0
|
||
.iter()
|
||
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
|
||
.collect()
|
||
}
|
||
let init: [u8; 64] = bytes(concat!(
|
||
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223",
|
||
"2425262728292a2b2c2d2e2f303132333435363738043de6c25808afa3"
|
||
))
|
||
.try_into()
|
||
.unwrap();
|
||
let parsed = parse_client_init(&init, &[42; 16]).unwrap();
|
||
assert_eq!(parsed.dc, 4);
|
||
assert!(parsed.media);
|
||
let mut relay = test_relay_peer(&parsed.relay_init);
|
||
let (mut upstream, mut downstream) = parsed.crypto.split();
|
||
let mut request = bytes("31fc48bfc21f9210a57fd63ac344ae50a3a23620");
|
||
for chunk in request.chunks_mut(3) {
|
||
upstream.apply(chunk);
|
||
relay.decrypt(chunk);
|
||
}
|
||
assert_eq!(request, bytes("10000000112233445566778899aabbccddeeff00"));
|
||
let mut reply = bytes("04000000ecfeffff");
|
||
relay.encrypt(&mut reply);
|
||
for chunk in reply.chunks_mut(1) {
|
||
downstream.apply(chunk);
|
||
}
|
||
assert_eq!(reply, bytes("d5a69e839ec08ebf"));
|
||
}
|
||
|
||
#[test]
|
||
fn parses_persisted_secret() {
|
||
assert_eq!(
|
||
parse_secret_hex("00112233445566778899aabbccddeeff"),
|
||
Some([
|
||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
|
||
0xee, 0xff,
|
||
])
|
||
);
|
||
assert_eq!(parse_secret_hex("not-a-secret"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn secret_migration_keeps_legacy_links_and_prefers_existing_destination() {
|
||
let root = std::env::temp_dir().join(format!(
|
||
"tglock-migration-{}",
|
||
secret_hex(&generate_secret())
|
||
));
|
||
std::fs::create_dir(&root).unwrap();
|
||
let legacy = root.join("legacy");
|
||
let destination = root.join("secret");
|
||
std::fs::write(&legacy, secret_hex(&[17; 16])).unwrap();
|
||
let first = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
|
||
assert_eq!(first.value, [17; 16]);
|
||
assert!(first.is_persistent());
|
||
assert_eq!(
|
||
std::fs::read_to_string(&legacy).unwrap(),
|
||
secret_hex(&[17; 16])
|
||
);
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
assert_eq!(
|
||
std::fs::metadata(&destination)
|
||
.unwrap()
|
||
.permissions()
|
||
.mode()
|
||
& 0o777,
|
||
0o600
|
||
);
|
||
}
|
||
std::fs::write(&legacy, secret_hex(&[23; 16])).unwrap();
|
||
let second = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
|
||
assert_eq!(
|
||
second.value, [17; 16],
|
||
"an existing destination wins on restart"
|
||
);
|
||
std::fs::remove_file(destination).unwrap();
|
||
std::fs::remove_file(legacy).unwrap();
|
||
std::fs::remove_dir(root).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn failed_migration_reports_error_but_does_not_rotate_the_legacy_secret() {
|
||
let root = std::env::temp_dir().join(format!(
|
||
"tglock-migration-fail-{}",
|
||
secret_hex(&generate_secret())
|
||
));
|
||
std::fs::create_dir(&root).unwrap();
|
||
let legacy = root.join("legacy");
|
||
let blocker = root.join("blocker");
|
||
std::fs::write(&legacy, secret_hex(&[29; 16])).unwrap();
|
||
std::fs::write(&blocker, "not a directory").unwrap();
|
||
let destination = blocker.join("secret");
|
||
for _ in 0..2 {
|
||
let stored = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
|
||
assert_eq!(stored.value, [29; 16]);
|
||
assert!(stored.write_error.is_some());
|
||
}
|
||
assert_eq!(
|
||
std::fs::read_to_string(&legacy).unwrap(),
|
||
secret_hex(&[29; 16])
|
||
);
|
||
std::fs::remove_file(blocker).unwrap();
|
||
std::fs::remove_file(legacy).unwrap();
|
||
std::fs::remove_dir(root).unwrap();
|
||
}
|
||
}
|