Привет! Задача возникла банальная: нужно передать коллеге пароль, 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 message

const 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-referrer
X-Frame-Options: DENY
X-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-memory
async 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.

Комментарии (0)