
Привет! Задача возникла банальная: нужно передать коллеге пароль, 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.
Zero-knowledge
Ключевая идея: сервер никогда не видит ключ расшифровки.
Схема работает так:
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. Это означает что любая модификация зашифрованных данных в транзите или на сервере будет обнаружена при расшифровке, и операция упадёт с ошибкой.
// Вся крипта — Web Crypto API, никаких сторонних библиотекconst key = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);const iv = crypto.getRandomValues(new Uint8Array(12)); // random per messageconst ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, plaintext);
IV генерируется заново для каждого сообщения — это важно для безопасности GCM.
Durable Objects
Самая интересная часть с точки зрения Cloudflare специфики.
Воркеры работают в распределённой среде. Без координации возможна гонка: два одновременных запроса к дропу с views=1 оба прочитают views_left=1, оба уменьшат до 0, но оба получат данные — что нарушает гарантию single-use.
Решение — Durable Object DropAccessCoordinator. DO гарантирует single-threaded execution для конкретного объекта. Вся логика consume обернута в blockConcurrencyWhile:
return this.state.blockConcurrencyWhile(async () => { const drop = await this.env.DB.prepare( `SELECT * FROM drops WHERE id = ?` ).bind(id).first<DropRow>(); // проверка TTL // проверка views_left // decrement // fetch from R2 // if paranoid | last view → delete // return ciphertext});
Пока выполняется этот коллбэк, любой другой запрос к тому же 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:
private timingSafeEqual(a: string, b: string): boolean { const enc = new TextEncoder(); const ab = enc.encode(a); const bb = enc.encode(b); if (ab.length !== bb.length) return false; let diff = 0; for (let i = 0; i < ab.length; i++) { diff |= ab[i] ^ bb[i]; } return diff === 0;}
XOR по всем байтам без early exit — любое отличие аккумулируется в diff, сравнение всегда занимает одинаковое время.
CLI
Веб-интерфейс хорош, но ключ в URL-фрагменте — всё равно потенциальная утечка через историю браузера или мессенджер с превью. Для параноиков сделал CLI:
# отправить файлburnafter send secret.env --ttl 3600 --views 1# получить и расшифровать в терминалеburnafter receive "https://burnafterread.casablanque.com/d/AbCdEf#k=..."
receive парсит фрагмент локально, делает GET на API, расшифровывает через node:crypto — ключ никогда не попадает в браузер.
Security headers и CSP
Для privacy-инструмента CSP обязателен — если в React-код когда-нибудь попадёт XSS через зависимость, без CSP атакующий сможет прочитать фрагмент URL и exfiltrate ключ.
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; object-src 'none'Referrer-Policy: no-referrerX-Frame-Options: DENYX-Content-Type-Options: nosniff
Referrer-Policy: no-referrer здесь особенно важен: если пользователь кликнет на внешнюю ссылку со страницы с открытым дропом, браузер не отправит текущий URL в заголовке Referer. Фрагмент браузер и так не шлёт, но это дополнительный уровень.
Rate limiting
Хотел использовать встроенный Workers Rate Limiting API, но оказалось он недоступен на фри тарифе. Платить я не хотел и сделал через второй Durable Object:
// sliding window, 20 req / 60s per IP, in-memoryasync fetch(request: Request): Promise<Response> { const { ip } = await request.json(); const now = Date.now(); const windowStart = now - 60_000; const timestamps = (this.hits.get(ip) ?? []) .filter(t => t > windowStart); timestamps.push(now); this.hits.set(ip, timestamps); return Response.json({ allowed: timestamps.length <= 20 });}
Singleton DO — все IP обслуживаются одним инстансом, каждый IP хранит свой массив timestamps. Состояние in-memory, сбрасывается при гибернации DO (приемлемо для рейтов).
/security страничка
Отдельная страница /security с описанием и живой демкой — можно зашифровать текст прямо в браузере, увидеть IV + ключ + ciphertext, нажать «Tamper» чтобы испортить последние байты и убедиться что GCM не расшифрует изменённые данные. Всё работает на Web Crypto API, никаких сетевых запросов.
Там же — инструкция как расшифровать дроп вручную через Node.js без доверия к сайту, и ссылки на конкретные файлы в исходниках.
Что не сделано (но планируется)
-
Уведомление отправителю когда дроп открыли
-
Защита ссылки паролем как второй фактор поверх ключа
-
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;
-
количество просмотров
-
режим работы
-
ссылки на объект
-
хэш delete токена
-
Сам ciphertext хранится в R2
Это позволяет не раздувать БД большими бинарными объектами, использовать объектное хранилище по назначению и независимо масштабировать хранение файлов и метаданных. Кроме того, чтение больших файлов из R2 естественнее, чем хранение blob в SQLite.
ссылка на оригинал статьи https://habr.com/ru/articles/1053686/