Перейти к содержанию
Me2eM

Пост-мортем: эволюция @me2em/core — от библиотеки идентичности к протоколу с аттестациями

Честный разбор семи дизайн-решений: детерминизм вместо хранения, stateless-сессии, два режима верификации, аттестации вместо ролей, каноничность имён, свежий salt для кэша и запрет zero-fallback — с альтернативами и тем, что мы бы сделали иначе.

M

Me2eM Organization

7 мин. чтения

Этот пост — честный разбор ключевых дизайн-решений протокола 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 в payload0.7.0-alpha.1Первый коммит
Двухрежимность верификации0.6.00.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 CryptoKey0.1.0-alpha.1 (@me2em/react)0.1.0-alpha.1 ✓

Общий паттерн: криптографическая корректность должна быть заложена с первого коммита, потому что каждый рефакторинг «сделаем потом» — это окно уязвимости. Две последние строки — подтверждение, что так тоже бывает.


Что читать дальше

Поделиться:

Похожие статьи

Почему мы не храним ваш seed — и почему это фича, а не недоработка

Seed-фраза — единственный носитель вашей идентичности. Мы не храним её. Почему это осознанное решение, какую цену вы за него платите, и почему эта цена честнее альтернативы.

M Me2eM Organization
7 мин. чтения
#security #identity #architecture

Me2eM vs JWT vs OAuth: три модели доверия

JWT, OAuth и Me2eM решают одну задачу — авторизация. Но модели доверия фундаментально разные: кто подписывает, кому верит сервер и что происходит при компрометации. Полное сравнение с примерами кода.

M Me2eM Organization
6 мин. чтения
#comparison #security #architecture

Парольный менеджер, который ничего не хранит: детерминированная криптография Me2eM

От хранения к вычислению: как seed-фраза заменяет базу паролей. Изоляция сервисов на уровне дерева ключей, контексты без реестра, ротация через именование — и честная цена отказа от vault.

M Me2eM Organization
7 мин. чтения
#passwords #security #cryptography

Готовы начать?

Установите пакет, пройдите quick start и за считанные минуты запустите свой первый сценарий.