В Umbera 2.0 появился «Тайник»: короткое сообщение прячется прямо в пикселях обычной фотографии или видео. Файл остаётся тем же JPEG или MP4 того же размера кадра, его можно переслать через любой мессенджер или выложить. Прочитать сообщение может только тот, у кого есть ключ.

Ниже — как это устроено внутри (ядро на Rust, общее для Android и iPhone), какие цифры получились на настоящих фото и пересылке, и честно — чего эта штука не умеет.

Тайник в приложении: сообщение поверх выбранного фото, проверка невидимости и срок жизни
Тайник в приложении: сообщение поверх выбранного фото, проверка невидимости и срок жизни

Что мы обещаем и чего нет

Сразу о главном, потому что в стеганографии принято обещать лишнее.

  • Обещаем: без ключа сообщение не прочитать. Даже если кто-то заподозрит, что в файле что-то есть.

  • Не обещаем: что никто не догадается. Статистический детектор с обучением находит нашу метку (об этом ниже, с цифрами).

  • Не обещаем: что сообщение переживёт любую обработку. Сильное сжатие, маленький размер, тяжёлая обрезка могут стереть его. Стереть, но не раскрыть.

Первый абзац исходника ядра так и написан: «without the key the message cannot be read. What it does not give: a guarantee that nobody can tell a picture carries something».

Кадр сообщения

Сообщение сначала шифруется, потом прячется. Формат кадра фиксированного размера для каждого профиля, чтобы короткое сообщение выглядело как длинное:

version u8 | nonce[12] | salt[16] (только режим пароля) | AES-256-GCM(plain) | tag[16]
plain = kind u8 | len u24 LE | сообщение | нули до размера кадра

Ключи — только из проверенных библиотек RustCrypto: HKDF-SHA256, Argon2id (m = 64 МиБ, t = 3, p = 4 для режима пароля), AES-256-GCM, ChaCha20 как ключевой поток для раскладки. Своих примитивов нет.

Три режима ключа:

  1. Ключ сообщения — 32 случайных байта, уходят получателям по обычному зашифрованному каналу Umbera. Основной режим.

  2. Пароль, о котором люди договорились сами. Защита целиком зависит от его силы.

  3. «Все с Umbera» — ключ зашит в приложение. Любой может его извлечь, поэтому этот режим не даёт секретности, только защиту от случайного взгляда. Так и написано в интерфейсе.

Почему ключ не кладём в саму картинку: постквантовый шифротекст ML-KEM-1024 — 1088 байт, а стойкая ёмкость фото — около 300 байт. Он туда просто не помещается.

Деталь режима пароля: читатель не может узнать соль файла, пока не знает, где лежат биты. Поэтому раскладка битов выводится из пароля с фиксированной солью (она прячет только позиции), а само сообщение шифруется ключом из второго прогона Argon2id со случайной солью, записанной внутри кадра.

Ещё одна деталь: функция чтения возвращает просто «не для этого ключа», без причины. Сканер перебирает файлы всеми вашими ключами, и различать «чужое сообщение», «пусто» и «повреждено» было бы полезно только атакующему.

Куда прячутся биты: STDM в средних частотах DCT

Картинка режется на блоки 8×8, как в JPEG. Блоки собираются в ячейки 8×8 блоков (64 пикселя сетки). Биты кладутся в средние частоты DCT: низкие заметны глазу, высокие первыми съедает сжатие.

Сам способ — spread-transform dither modulation (STDM). Каждый бит — это проекция N коэффициентов на ключевой ±1 вектор, квантованная с шагом Δ. Значит, каждый отдельный коэффициент сдвигается совсем немного, а бит размазан по многим.

Сколько двигать каждый коэффициент, решает маска текстуры и яркости:

fn block_mask(b: &[f32; 64]) -> f32 {
    let (mean, dev) = block_stats(b);
    let t = (dev / TEXTURE_UNIT).clamp(0.15, 2.5);
    let l = (mean.min(255.0 - mean) / 40.0).clamp(0.25, 1.0);
    t * l
}

Гладкие ячейки (чистое небо, белая стена) вообще не трогаются: метка в небе — первое, что увидит человек. Код коррекции ошибок платит за эти пропуски.

Порог невидимости, который нельзя обойти

pub const MIN_PSNR_DB: f32 = 44.0;
pub const MIN_SSIM: f32 = 0.99;

Если после встраивания картинка не проходит PSNR ≥ 44 дБ и SSIM ≥ 0,99 по яркости, это ошибка. Силу метки ради «влезло» не поднимаем и порог не снижаем. Поэтому слишком гладкая картинка честно отклоняется: «метка была бы видна».

После встраивания приложение сразу читает файл обратно. Если не читается — тоже отказ, человек не отправит пустышку.

Коррекция ошибок

Классическая каскадная схема: внешний Рида–Соломона над GF(256) с перемежением байтов между блоками и внутренний свёрточный код K = 9 с мягким декодированием Витерби. Перемежение нужно, чтобы пачка ошибок от Витерби задела каждый блок РС понемногу. Окончательно правильность расшифровки подтверждает тег AEAD, а не коды.

Обрезка и масштаб: пилот

Ячейки повторяются с периодом, поэтому любое окно картинки содержит копии: обрезка стоит повторов, а не синхронизации, и копии суммируются при чтении.

Чтобы найти метку в обрезанной или уменьшенной картинке, в каждую размеченную картинку добавлен слабый ключевой пилот — шумовая плитка в той же полосе частот с периодом 4 ячейки. Читатель сворачивает высокочастотный остаток картинки на одну плитку и считает взаимную корреляцию через БПФ — так проверяются все сдвиги сразу для одного масштаба. Масштаб перебирается от 0,2 до 2,2 с шагом 0,006.

Чужой ключ или пустая картинка пика не дают, поэтому пилот заодно работает быстрым фильтром для сканера: прежде чем тратить время на декодирование, видно, есть ли тут что-то для вас.

Видео

Одна метка на группу кадров по времени, а не по числу кадров, чтобы узор не мерцал, а чтение усредняло шум сжатия. В каждом кадре два пилота: постоянный (масштаб и положение) и второй, знак которого меняется по ключевой последовательности от группы к группе. По нему находится номер группы после обрезки ролика, и это переживает смену частоты кадров, потому что сверяется по меткам времени. Видео целиком в память не грузится: три прохода по кадрам из аппаратного декодера.

Цифры

Всё ниже — наши замеры, частично на имитации пережатия на ПК, это важно.

  • Фото, стойкий режим: около 300 байт, это примерно 150 русских букв. Изображения 1024 пикселя и меньше почти не выживают.

  • Фото без пережатия (отправка файлом): около 10 КБ.

  • Видео: порядка 50 байт в секунду. Голос в видео почти не помещается, в стойкое фото — около секунды. Голосовые лучше отправлять файлом.

  • Настоящая пересылка через WhatsApp (обычное качество, фото 1600×1200): 6 из 6 фото прочитаны точно, до 450 байт.

  • Обнаружимость: детектор SPAM с обучением находит метку с AUC от 0,75 до 0,996 в разных условиях наших прогонов. Поэтому мы не пишем «необнаружимо».

Срок жизни сообщения

Отправитель выбирает срок: от 10 минут до 30 дней, своё время, без ограничения или один просмотр. Когда время вышло, приложение получателя удаляет ключ, и сообщение больше не открывается. Файл остаётся обычной фотографией.

Честно: это обеспечивает приложение, а не математика. Снимок экрана никто не запретит, изменённая сборка может ключ сохранить.

Сканер

В шапке Тайника есть сканер: он смотрит там, где вы скажете (галерея, файлы из чатов Umbera, папка, прямая ссылка), и пробует ваши ключи на каждом файле. Находит только то, что адресовано вам. Всё происходит на телефоне, сервер не знает, что вы искали и что нашли.

Сканер Тайника: где искать, найденные сообщения и открытое сообщение
Сканер Тайника: где искать, найденные сообщения и открытое сообщение

Что дальше

Проверить пересылку через Telegram и WhatsApp HD, видео после YouTube, и оценить на людях, видна ли метка на движущихся кадрах.

Описание для обычных людей: https://umbera.app/ru/stash

Кроме Тайника, в Umbera 2.0 появились Сообщества — каналы и сообщества, где у вас свой псевдоним в каждом канале и никто не видит, кто ведёт канал и кто его читает. Про их устройство будет отдельная статья, коротко — здесь: https://umbera.app/ru/communities

Буду рад вопросам и критике — особенно от тех, кто занимается стегоанализом.