Содержание Показать Скрыть
- DR-1: Детерминизм вместо хранения
- DR-2: Stateless-сессии вместо серверных
- DR-3: Два режима верификации
- DR-4: Аттестации вместо ролей в БД
- DR-5: Каноничность имён
- DR-6: Свежий salt для cacheKey при каждом логине
- DR-7: OTK обязателен — zero-fallback запрещён
- Что мы бы сделали иначе — сводка
- Что читать дальше
Этот пост — честный разбор ключевых дизайн-решений протокола Me2eM. Не «как мы построили» (это есть в туториалах), а почему мы построили именно так — с контекстом, альтернативами и тем, что мы бы сделали иначе.
Мы стартовали с идеи библиотеки идентичности и закончили протоколом с аттестационными цепочками, двумя режимами верификации и тремя пакетами. Между этими точками — серия решений, каждое из которых открывало одни возможности и закрывало другие. Этот документ фиксирует ключевые развилки для будущих контрибьюторов и для нас самих — через год, когда детали сотрутся.
DR-1: Детерминизм вместо хранения
Решение: все ключи выводятся детерминированно из seed-фразы. Ничего не хранится.
Контекст: первая версия хранила сгенерированные ключи в localStorage. Это работало — до первого вопроса «а что если пользователь откроет приложение на втором устройстве?»
Альтернатива, которую мы отвергли: серверная синхронизация ключей (как у большинства password manager’ов). Она решает мультидевайсность, но создаёт сервер-как-хранилище — единственную точку компрометации для всех пользователей.
Решение: seed-фраза (BIP39) → детерминированное HKDF-дерево. Тот же seed + то же имя → те же ключи. На любом устройстве, в любой точке мира.
Что это открыло:
- Мультидевайсность бесплатно (никакой sync)
- Автономность устройств (дерево вычисляется локально)
- Seed = единственный носитель идентичности (бумага)
Что это потребовало:
- Каноничность имён (без NFKC-нормализации “Work” и “work” дали бы разные ключи — отдельная история, DR-5)
- Версионирование derivation paths (info = “me2em/handle/v1/{name}” — версия внутри строки)
- Дисциплину wipe (транзиторный материал — сырые байты ключей в RAM)
Что мы бы сделали иначе: внедрили каноничность имён с первого дня, а не после недели дебага рассинхронизации (подробнее — DR-5).
DR-2: Stateless-сессии вместо серверных
Решение: сессия — это подписанный токен, несущий все данные внутри. Сервер верифицирует подпись без БД-lookup.
Контекст: классический подход — сервер выдаёт session token (random ID) и хранит сессию в Redis. Проверка = lookup в Redis по ID.
Проблема: в Me2eM клиент подписывает токен сам. Это значит: сервер не может хранить сессию до её создания — потому что сервер не знает о сессии до первого запроса.
Решение: токен содержит всё необходимое (handleId, audience, scopes, exp, iat, jti). Сервер проверяет подпись против pubkey хэндла (пересчитанного детерминированно из имени в токене).
Что это открыло:
- Сервер без БД для верификации
- Мультидевайсность без server-side session sync
- Тестовые окружения без Redis
Что это потребовало:
- Отзыв через RevocationChecker (опциональный, для тех, кому нужен)
- Пересчёт pubkey из имени (возможен благодаря DR-1)
- Обязательность
iatв payload (иначе «future-dated» проверка ломалась)
Что мы бы сделали иначе: сразу заложить iat как обязательное поле. Изначально его не было — проверка «токен из будущего» работала через жёсткое окно в будущем, что молча ограничивало TTL сессии одним часом. Баг был найден только при написании тестов с TTL > 3600.
DR-3: Два режима верификации
Решение: два способа проверки сессии — Mode 1 (verifyStateless, прямой) и Mode 2 (verifyAttested, через аттестационную цепочку).
Контекст: до появления аттестаций был только Mode 1. Он работал, но имел ограничение: верификатор должен держать Identity — приватный корневой ключ. Для стороннего приложения это неприемлемо.
Что потребовало Mode 2: внешний сервис (приложение водителя, корпоративный мессенджер) должен верифицировать сессии без получения приватного корня. Аттестационная цепочка позволяет это: каждый уровень подписан родителем, цепочка восходит к публичному корню.
Что это открыло:
- Сторонние верификаторы (не нужны приватные ключи)
- Принуждение грантов на верификации (scopes, TTL, audiences)
- Ветвевой отзыв (revoke attestation jti — убивает всю ветку)
Что это потребовало:
- Модуль attestation.ts (issue, decode, verifySignature)
- Расширенную верификацию в Session.verifyAttested (nesting, name patterns, TTL caps)
- RevocationChecker, который принимает jti аттестаций (не только сессий)
Что мы бы сделали иначе: заложить двухрежимность с самого начала. Мы добавили Mode 2 в 0.6.0, когда стало ясно, что сторонний верификатор — ключевое требование. Если бы это было в v0.1, attestation.ts был бы чище.
DR-4: Аттестации вместо ролей в БД
Решение: вместо классических ролей в БД (user.role = 'admin') — аттестационные цепочки с грантами.
Контекст: в классической системе права хранятся в БД. Изменение прав = UPDATE в БД. Отзыв = DELETE из БД.
Проблема: в децентрализованной системе нет единой БД. Кто хранит права? Как верификатор узнаёт о правах пользователя?
Решение: права — подписанные родителем, встроенные в аттестационную цепочку, предъявляемые при каждом запросе. Верификатор проверяет chain: «root утверждает, что этот ключ принадлежит этому имени с этим грантом».
Что это открыло:
- Верификация без БД (все права в цепочке)
- Иерархическое делегирование (child grant ⊆ parent grant)
- Ветвевой отзыв (revoke attestation jti = kill всей ветки)
Что это потребовало:
- Дисциплину в дизайне грантов:
scopes— точные строки,subNamePatterns— trailing ’*’ only - Расширенную валидацию:
AUDIENCE_NOT_PERMITTED,SCOPE_EXCEEDED,TTL_EXCEEDED,SESSION_OUTLIVES_ATTESTATION - Приёмку того, что Mode 1 не принуждает гранты (ограничение, не баг)
Что мы бы сделали иначе: мы бы не смешивали авторизационные scope’ы (charge:start) с permission-именами (billing:admin) в одном namespace. Это создало потенциальную путаницу при расширении. В v1.0 стоит разделить.
DR-5: Каноничность имён
Решение: все имена нормализуются NFKC → lowercase → trim → валидация паттерном /^[a-z0-9][a-z0-9._@-]{0,62}$/.
Контекст: без нормализации "Station-001" и "station-001" давали бы разные ключи на разных платформах. Ещё хуже: "a/b" и "a.b" создавали бы потенциальные коллизии путей.
Проблема: расхождение ключей в кросс-реализационных тестах — две независимые реализации с «одинаковыми» именами получали разные ключи. Детерминизм работал, но входы не были каноничными.
Решение: единая функция normalizeName(raw: string): string — NFKC → lowercase → trim → валидация. Все входы деривации проходят через неё. Паттерн / запрещён — разделитель путей.
Что это открыло:
- Детерминизм на всех платформах
- Защита от homoglyph-атак (NFKC нормализует Unicode)
- Защита от path traversal (запрет
/)
Что это потребовало:
- Отказ от поддержки имён с Unicode-буквами, длиннее 64 символов и с
/ - Тесты на каждый Unicode edge case
Что мы бы сделали иначе: внедрить с первого коммита. Мы добавили каноничность в 0.6.0 — до этого "Work" и "work" были разными хэндлами.
DR-6: Свежий salt для cacheKey при каждом логине
Решение: cacheKey выводится с новой случайной солью при каждом логине: cacheKey = deriveCacheKey(seed, freshSalt). Соль не константа и не детерминирована от seed.
Контекст: cacheKey шифрует кэш контекста в IndexedDB. Обсуждались два варианта:
- SaltA: fresh salt per login → компрометация cacheKey эпохи N не помогает в эпохе N+1
- SaltB: deterministic salt от seed → cacheKey константен; одна компрометация читает все эпохи кэша
Что выбрали и почему: SaltA. Кэш содержит только производные материалы — не seed и не приватные ключи, — но «несекретность кэша» не должна становиться единственной линией обороны. Свежая соль делает компрометацию cacheKey локальной во времени: украденный ключ читает кэш одной эпохи и умирает на границе следующего логина.
Цена: кэш обесценивается при смене ключа — но он и задуман как кэш: всё его содержимое пересчитывается из seed за миллисекунды.
Что мы бы сделали иначе: разделили бы кэш на классы материалов (производные-несекретные / эфемерные-чувствительные) с первого дня — сейчас это зафиксировано правилами, но появилось эволюционно.
DR-7: OTK обязателен — zero-fallback запрещён
Решение: если в PreKeyBundle нет OTK, X3DH бросает ошибку, а не подставляет нули.
Контекст: первая реализация имела fallback:
// ❌ Было:
let dh4: Uint8Array;
if (myOneTimeKey) {
dh4 = await computeX25519SharedSecret(myOTK_x, theirEK_x);
} else {
dh4 = new Uint8Array(32); // Fallback: zeros
}
Проблема: протокол не падает, но каждый чат без OTK теряет freshness-свойство — секрет вычисляется без одноразового вклада. Компрометация identity key + SPK после факта раскрывает переписку.
Решение: OTK обязателен. Если PreKeyBundle не содержит OTK — X3DH бросает ошибку, а не подставляет нули.
Что мы бы сделали иначе: то же самое, только раньше. Мы нашли это отклонение в чужой реализации, а не в нашей — но принцип универсален: протокол должен падать, а не деградировать. Полный разбор этой ловушки с тестовыми уроками — в статье E2EE без хранения приватных ключей.
Что мы бы сделали иначе — сводка
| Решение | Когда внедрено | Когда нужно было |
|---|---|---|
| NFKC-нормализация имён | 0.6.0 | Первый коммит |
iat в payload | 0.7.0-alpha.1 | Первый коммит |
| Двухрежимность верификации | 0.6.0 | 0.1.0 |
| X3DH с каноническим порядком | 0.1.0-alpha.1 (@me2em/crypto) | Первый коммит |
| OTK обязательный | 0.1.0-alpha.1 (@me2em/crypto) | Первый коммит |
| Свежий salt при каждом логине | 0.1.0-alpha.1 (@me2em/react) | 0.1.0-alpha.1 ✓ |
| Non-extractable persisted CryptoKey | 0.1.0-alpha.1 (@me2em/react) | 0.1.0-alpha.1 ✓ |
Общий паттерн: криптографическая корректность должна быть заложена с первого коммита, потому что каждый рефакторинг «сделаем потом» — это окно уязвимости. Две последние строки — подтверждение, что так тоже бывает.
Что читать дальше
- Me2eM: идентичность из seed-фразы — детерминированное дерево
- Аттестационные цепочки: делегирование доверия без сервера — делегирование доверия (DR-3, DR-4 в действии)
- E2EE без хранения приватных ключей — session-bound профиль (DR-1, DR-6, DR-7 с тестовыми уроками)
- DevSecOps для криптографического monorepo — как эти решения охраняются в CI
- BACKLOG.md — текущий бэклог и планы (advisory)