
Привет! Задача возникла банальная: нужно передать коллеге пароль, API-ключ или конфиг. Телега — не хочется, почта — тем более. Существующие решения от OneTimeSecret до PasswordPusher и прочих — либо закрытый код и доверяй на слово, либо требуют своего сервера. Одни требуют регистрации, другие — напичканные всем подряд комбайны. Захотелось сделать так: открытый код, шифрование в браузере, сервер физически не может прочитать содержимое и, разумеется, бесплатно.
Так появился BurnAfterRead — self-destructing E2E encrypted drops на Cloudflare Workers.
Стек получился полностью serverless:
Cloudflare Workers — HTTP API и раздача статики
Cloudflare D1 — SQLite для метаданных (id, TTL, счётчик просмотров)
Cloudflare R2 — хранение зашифрованного blob
Durable Objects — атомарное управление доступом к дропу
React + Web Crypto API — фронт, всё шифрование в браузере
Никакого своего сервера, никаких баз данных для обслуживания — всё на Cloudflare edge.
Ключевая идея: сервер никогда не видит ключ расшифровки.
Схема работает так:
1. Браузер генерирует 256-битный ключ через crypto.subtle.generateKey
2. Текст или файл шифруется AES-GCM 256 с рандомным 12-байтовым IV
3. На сервер уходит только ciphertext
4. Ключ добавляется в ссылку как URL-фрагмент: https://burnafterread.casablanque.com/d/AbCdEf#k=<base64url-key>
Главный вопрос который обычно возникает: а разве браузер не отправляет фрагмент на сервер?
Нет . Это явно прописано в RFC 9110 §4.2.3 : фрагмент (часть после # ) никогда не включается в HTTP-запрос. Cloudflare Workers, D1, R2 и все логи никогда не увидят значение после #k= .
Проверить это легко: открыть DevTools → Network, открыть любую ссылку с дропом и убедиться что в запросах нет фрагмента.
AES-GCM выбран не случайно. В отличие от AES-CBC, GCM обеспечивает не только конфиденциальность, но и аутентификацию — к ciphertext добавляется 16-байтовый auth tag. Это означает что любая модификация зашифрованных данных в транзите или на сервере будет обнаружена при расшифровке, и операция упадёт с ошибкой.
IV генерируется заново для каждого сообщения — это важно для безопасности GCM.
Самая интересная часть с точки зрения Cloudflare специфики.
Воркеры работают в распределённой среде. Без координации возможна гонка: два одновременных запроса к дропу с views=1 оба прочитают views_left=1 , оба уменьшат до 0, но оба получат данные — что нарушает гарантию single-use.
Решение — Durable Object DropAccessCoordinator . DO гарантирует single-threaded execution для конкретного объекта. Вся логика consume обернута в blockConcurrencyWhile :
Пока выполняется этот коллбэк, любой другой запрос к тому же DO-инстансу ждёт. read → decrement → delete становятся атомарными.
Paranoid mode
Обычный режим: дроп возвращает expired или burned когда истёк или просмотрен. Это удобно для UX, но раскрывает информацию — атакующий понимает что дроп существовал.
Paranoid mode: сервер всегда возвращает not_found — неважно истёк дроп, просмотрен или никогда не существовал. Никакого timing oracle.
Revoke endpoint
Одна из вещей которой нет у большинства аналогов — возможность отозвать дроп до того как его прочитали.
При создании генерируется случайный 32-байтовый токен. В D1 хранится только его SHA-256 хэш. Отправитель получает токен в ответе.
DELETE /api/drops/:id принимает токен, хеширует его и сравнивает через constant-time compare чтобы не было timing attack:
XOR по всем байтам без early exit — любое отличие аккумулируется в diff , сравнение всегда занимает одинаковое время.
Веб-интерфейс хорош, но ключ в URL-фрагменте — всё равно потенциальная утечка через историю браузера или мессенджер с превью. Для параноиков сделал CLI:
receive парсит фрагмент локально, делает GET на API, расшифровывает через node:crypto — ключ никогда не попадает в браузер.
Security headers и CSP
Для privacy-инструмента CSP обязателен — если в React-код когда-нибудь попадёт XSS через зависимость, без CSP атакующий сможет прочитать фрагмент URL и exfiltrate ключ.
Referrer-Policy: no-referrer здесь особенно важен: если пользователь кликнет на внешнюю ссылку со страницы с открытым дропом, браузер не отправит текущий URL в заголовке Referer . Фрагмент браузер и так не шлёт, но это дополнительный уровень.
Rate limiting
Хотел использовать встроенный Workers Rate Limiting API, но оказалось он недоступен на фри тарифе. Платить я не хотел и сделал через второй Durable Object:
Singleton DO — все IP обслуживаются одним инстансом, каждый IP хранит свой массив timestamps. Состояние in-memory, сбрасывается при гибернации DO (приемлемо для рейтов).
/security страничка
Отдельная страница /security с описанием и живой демкой — можно зашифровать текст прямо в браузере, увидеть IV + ключ + ciphertext, нажать "Tamper" чтобы испортить последние байты и убедиться что GCM не расшифрует изменённые данные. Всё работает на Web Crypto API, никаких сетевых запросов.
Там же — инструкция как расшифровать дроп вручную через Node.js без доверия к сайту, и ссылки на конкретные файлы в исходниках.
Что не сделано (но планируется)
Уведомление отправителю когда дроп открыли
Уведомление отправителю когда дроп открыли
Защита ссылки паролем как второй фактор поверх ключа
Защита ссылки паролем как второй фактор поверх ключа
API-ключи для интеграций
API-ключи для интеграций
- Код: в репо
- Live: тут
- Verify: security страничка
Буду рад вопросам в комментариях — особенно если найдёте дыры в логике.
Опережая некоторые вопросы
Почему не XChaCha20-Poly1305?
Короткий ответ — потому что весь проект строится на стандартном Web Crypto API без сторонних библиотек.
AES-GCM поддерживается браузерами нативно, использует аппаратное ускорение и хорошо изучен. XChaCha20-Poly1305 тоже является отличным алгоритмом, однако сегодня он не входит в стандартный Web Crypto API и потребовал бы использования сторонней библиотеки (например, libsodium).
Цель проекта была не найти самый модный алгоритм, а использовать стандартную крипту, доступную в любом современном браузере без дополнительных зависимостей.
Почему ключ передаётся в URL fragment, а не через ECDH?
Потому что не было задачи безопасно договориться о ключе между двумя сторонами.
Ключ генерируется отправителем случайным образом и должен попасть к получателю вместе со ссылкой. URL fragment ( #... ) идеально подходит для этой задачи, поскольку согласно RFC 9110 он никогда не включается в http-запрос и не попадает на сервер.
Использование ECDH потребовало бы генерации ключевых пар, обмена паблик ключами и доп. протокола поверх простого обмена ссылкой, что значительно усложнило бы сервис без выигрыша для данного сценария.
Почему IV именно 12 байт?
Потому что это рекомендуемый размер nonce для AES-GCM.
При длине IV 96 бит алгоритм использует наиболее эффективный режим работы без дополнительного вычисления GHASH для преобразования nonce. Именно этот размер рекомендуют NIST и документация Web Crypto API.
Главное требование — IV никогда не должен повторяться для одного и того же ключа. В проекте он генерируется случайно заново для каждого сообщения.
Что насчёт XSS и supply chain?
Zero-knowledge относится только к серверной стороне.
Если злоумышленник сможет выполнить произвольный js в браузере пользователя (например, через XSS или компрометацию цепочки), он потенциально сможет получить доступ к ключу до шифрования или после расшифровки.
Поэтому проект дополнительно использует строгую CSP, не загружает сторонние скрипты и минимизирует количество зависимостей. Тем не менее защита от XSS — это отдельная задача, которую невозможно решить одной криптографией.
Почему Durable Objects, а не транзакции D1?
Основная задача — гарантировать атомарность операции чтения.
При одновременном открытии ссылки двумя пользователями оба запроса могут увидеть views_left = 1 и оба успеть получить данные до изменения счётчика.
Durable Objects гарантируют последовательное выполнение запросов для одного объекта. Благодаря этому проверка TTL, уменьшение счётчика, получение ciphertext из R2 и удаление выполняются как единая критическая секция.
Даже если в будущем D1 будет поддерживать более мощные транзакционные механизмы, они не смогут атомарно включить обращение к внешнему хранилищу R2. Здесь требуется координация сразу нескольких сервисов, а не только базы данных.
Почему R2 отдельно от D1?
D1 используется только для метаданных:
TTL;
TTL;
количество просмотров
количество просмотров
режим работы
режим работы
ссылки на объект
ссылки на объект
хэш delete токена
хэш delete токена
Сам ciphertext хранится в R2
Сам ciphertext хранится в R2
Это позволяет не раздувать БД большими бинарными объектами, использовать объектное хранилище по назначению и независимо масштабировать хранение файлов и метаданных. Кроме того, чтение больших файлов из R2 естественнее, чем хранение blob в SQLite.