Это тот же документ docs/protocol.md, по которому тестируются приложение и браузерное расширение.

Secret Keeper Protocol v1

Спецификация криптопротокола приложения, браузерного плагина и сайта. Совместимость с прежней RSA/node-forge схемой намеренно отсутствует.

1. Мнемоника (BIP-39)

  • 12 слов, английский словарь BIP-39 (2048 слов).
  • Генерация: 128 бит энтропии + 4 бита контрольной суммы (SHA-256).
  • Валидация: словарь + контрольная сумма (мгновенно, офлайн).
  • Seed: PBKDF2-HMAC-SHA512(password=NFKD(mnemonic), salt=NFKD("mnemonic"), iterations=2048, dkLen=64).
  • Passphrase: пустая строка (по умолчанию).

2. Деривация ключей

Из 64-байтного BIP-39 seed через HKDF-SHA256 (salt = пустой):

Ключ info длина
X25519 private secret-keeper/x25519/v1 32
Ed25519 private secret-keeper/ed25519/v1 32

Публичные ключи - стандартная деривация X25519 / Ed25519. Для X25519 применяется RFC 7748 clamp к 32 байтам HKDF-вывода перед использованием как приватного скаляра:

key[0] &= 248
key[31] &= 127
key[31] |= 64

Ed25519 сохраняется для будущих фич (подпись); в envelope не используется.

3. Идентичность

  • Адрес = bech32-кодирование 32-байтного X25519 pubkey.
  • HRP (human-readable part): sk.
  • Пример: sk1q3xz... (~60 символов).
  • Адрес обменивается текстом или QR; серверный каталог не нужен.
  • Текстовые формы (lib/crypto/address_text.dart, extension/src/crypto/address_text.ts): голый sk1..., Имя <sk1...> и канонический URI sk:sk1...?name=<url-encoded>&v= (name - неаутентифицированная подсказка, только для предзаполнения; v отсутствует = 1; неизвестные параметры игнорируются). Наружу отдаём только каноническую форму (formatAddressQr): в QR - как есть, в копировании и системном шаринге - с подписью «Мой адрес в SecretKeeper.net» отдельной строкой перед payload (addressShareText), чтобы получатель понимал, что за строка ему пришла. Читаем терпимо: любая из форм распознаётся внутри пояснительного текста («Мой адрес в Secret Keeper: sk1...»), так как адрес люди пересылают с подписью. Алфавит bech32 (без 1, b, i, o) обрывает совпадение на первом символе за адресом, а чексумма отсеивает случайно похожие слова; окружающий текст именем не становится.
  • Safety numbers (визуальная сверка пары): SHA-256(min(pubkey_a, pubkey_b) || max(pubkey_a, pubkey_b)) (лексикографический порядок байтов - у обоих собеседников число одинаковое) → 25 десятичных цифр в 5 группах по 5. Не используется для адресации.

4. Envelope (слоты ключей)

Пейлоад шифруется один раз случайным message_key, а тот «заворачивается» в слот для каждого адресата. Слот отправителя добавляется всегда - поэтому отправитель расшифровывает свои сообщения той же операцией decrypt (история чата, второе устройство). Получателей может быть до 255 (задел под группы).

4.1. Бинарный формат

version   : u8  = 0x02
sender_pub: 32 bytes (X25519)
eph_pub   : 32 bytes (X25519 ephemeral)
slot_count: u8  (>= 1; получатели + отправитель, без дублей)
slots     : 48 bytes x slot_count
nonce     : 24 bytes (random)
ciphertext: variable (payload + 16-byte Poly1305 tag)

Слоты анонимные (адресов в конверте нет), порядок перемешан и ничего не означает.

Версия 0x01 (пейлоад - сырой UTF-8 текст без фрейминга) отклоняется с ошибкой «Unsupported envelope version»: надёжно различить форматы пейлоада внутри одной версии нельзя.

4.1.1. Пейлоад (внутри AEAD)

flags  : u8      bit0 = есть meta; bit1 = есть список адресатов;
                 остальные биты - резерв (0, при ненулевых - отказ разбора)
sentAt : u64 BE  момент формирования конверта: миллисекунды Unix epoch, UTC
metaLen: u32 BE  только при flags & 1
meta   : metaLen байт UTF-8 (только при flags & 1)
toCount: u8      только при flags & 2 (>= 1)
to     : toCount записей (только при flags & 2), каждая -
         len u8 + адрес bech32 в UTF-8
text   : остаток, UTF-8
  • sentAt присутствует всегда; выставляет отправитель, получатель может показывать его как время сообщения.
  • to - адресаты конверта без отправителя: по нему отправитель, читая свой же конверт (история, второе устройство), понимает, в чей чат он относится. Флаг bit1 стоит у любого сообщения, кроме сообщения себе (у него адресатов, кроме самого себя, нет). Список лежит внутри AEAD: снаружи адресаты не видны, читают их только владельцы слотов.
  • text - то, что показывается пользователю. Может содержать markdown-разметку (жирный/курсив, списки, ссылки, GFM-таблицы): клиент либо рендерит её, либо снимает разметку при показе (так делает браузерный плагин).
  • meta - opaque строка для автоматики получателя (конвенция - JSON вида {"type": ..., "data": ...}, поле data опционально - meta может состоять из одного type); в ленте не отображается, доступна через пункт «Метаданные» меню сообщения. Шифруется и аутентифицируется вместе с текстом - снаружи не видна даже её длина, только общий размер ciphertext.

4.2. Слот и KEK

Слот - message_key (32 байта), зашифрованный AEAD под KEK адресата с нулевым 24-байтным nonce (KEK одноразовый - эфемерный ключ на конверт):

слот    = AEAD(message_key, KEK_i, nonce=0)   // 32 + 16 (tag) = 48 байт
shared1 = X25519(eph_priv, R_i_pub)
shared2 = X25519(sender_static_priv, R_i_pub)
KEK_i   = HKDF-SHA256(shared1 || shared2, salt=empty,
                      info="secret-keeper/kek/v1", len=32)

Формула симметрична: читающий считает HKDF(X25519(my_priv, eph_pub) || X25519(my_priv, sender_pub)) и пробует развернуть каждый слот - чужие отсеивает тег AEAD. Слот отправителя - та же формула (self-ECDH), отдельной ветки нет.

Аутентификация отправителя: без sender_static_priv невозможно собрать корректный shared2.

4.3. AEAD

  • Алгоритм: XChaCha20-Poly1305 (и тело, и слоты).
  • AAD: отсутствует (пустой).
  • Nonce тела: 24 случайных байта на каждое сообщение; nonce слота - нулевой.

4.4. Armor (текстовая обёртка)

-----BEGIN SECRET MESSAGE V1-----
<base64(бинарный envelope)>
-----END SECRET MESSAGE V1-----
  • Base64: стандартный, без переносов строк.
  • Пробелы вокруг base64 допускаются при разборе.
  • Чтение терпимо к окружающему тексту: маркеры ищутся внутри произвольной строки (конверт часто пересылают с подписями мессенджера вокруг). Наружу и в историю клиент пишет только канонический блок BEGIN…END - обёртка срезается до расшифровки/хранения.

5. Контейнер файла .skf

Файл шифруется тем же слотовым конвертом, но без armor и с телом чанками - чтобы шифровать/расшифровывать потоково независимо от размера файла.

magic     : "SKF1" (4 байта)
version   : u8 = 0x01
sender_pub: 32 bytes
eph_pub   : 32 bytes
slot_count: u8 (>= 1)
slots     : 48 bytes x slot_count      // формула та же, что у envelope
nonce_pfx : 19 bytes (префикс нонсов чанков)
header_len: u32 BE (16 <= len <= 65536; ciphertext + tag)
header_nnc: 24 bytes
header    : header_len байт - AEAD(message_key) над JSON заголовком
chunks    : на каждый чанк ciphertext + 16-байтный тег

JSON заголовка (шифрованный, поэтому имя файла снаружи не видно):

{"name": "...", "size": 12345, "chunk": 1048576,
 "sentAt": 1784191445123, "note": "...", "to": ["sk1..."]}

note и to опциональны; to - те же адресаты без отправителя, что и в пейлоаде конверта.

Нонс чанка i: nonce_pfx (19) || counter u32 BE || final-байт (0x01 у последнего чанка, иначе 0x00) - конструкция STREAM (age/Tink): счётчик ловит перестановку и дубли чанков, final-байт - усечение файла. Число чанков - ceil(size / chunk), у пустого файла - один пустой final-чанк, так что усечение «в ноль чанков» тоже не проходит. Данные после последнего чанка - признак подмены, разбор обязан отказать.

6. Резервные копии

Формат Что внутри
.sk1 JSON: {type: "secret_keeper_backup", version, contacts[], settings}
.skb (v2, текущий) контейнер .skf (§ 5), зашифрованный себе; plaintext - zip архивной папки (как в legacy)
.skb (legacy) голый zip архивной папки: meta.sk1e в корне + папки по адресам собеседников

Обёртка в контейнер закрывает метаданные zip: имена записей - это адреса собеседников, а в messages.ndjson открыты времена и направления сообщений - граф контактов и тайминги не должны читаться без ключей. Клиенты пишут только v2; чтение различает форматы по содержимому (магия SKF против «PK»), legacy-копии продолжают импортироваться. В шифрованном заголовке контейнера v2 поле name оканчивается на .skb - по нему приложение отличает копию от файлового контейнера при открытии из ОС. Контейнер чужого профиля не разворачивается (ни один слот не наш) - это тот же отказ «копия другого профиля», что и у meta.sk1e в legacy.

meta.sk1e - armored-конверт, зашифрованный себе; его plaintext:

{"version": 1, "settings": {...}, "contacts": [...],
 "avatars": {"sk1...": "<base64 png>"}}

Не расшифровался своим ключом - копия снята с другого профиля (по seed), и это единственный способ это понять: адресов в конверте нет.

Папка адресата содержит messages.ndjson (строка на сообщение: {"at": ms, "out": bool, "sha": "...", "armored": "..."} либо {"at": ms, "out": bool, "sha": "...", "skf": "<id>.skf"}) и сами контейнеры .skf. Клиент без модели истории (браузерный плагин) переносит из архива только settings и contacts, остальное игнорирует.

Секреты (seed-фраза, PIN) в копии нет: профиль восстанавливается seed-фразой.

7. Свойства

  • PFS: эфемерный X25519 на каждое сообщение.
  • Отправитель читает своё: слот отправителя в каждом конверте.
  • Нет состояния: парные ключи и серверное хранение AES не нужны.
  • Нет user-id: адрес = публичный ключ.
  • Кросс-платформа: Dart (cryptography), JS (@noble/*, @scure/*) - общие тест-векторы в tools/test_vectors/.

8. Стек реализации

Платформа Библиотеки
Flutter cryptography, crypto (PBKDF2/HKDF fallback)
Browser plugin / site @scure/bip39, @noble/curves, @noble/ciphers, @noble/hashes, @scure/base, fflate (unzip .skb)

Тест-векторы проверяют формат в обе стороны, и любое изменение формата (новый флаг пейлоада, новое поле заголовка .skf) добавляется в оба генератора - иначе расхождение реализаций проходит незамеченным, как случилось с флагом to: приложение писало его почти неделю, а расширение отвергало такие конверты, потому что автотесты шли только в одну сторону.

Направление Генератор Читатели
JS пишет tools/test_vectors/generate.jstest_vectors.json test/protocol_cross_test.dart, extension/src/crypto/*.test.ts
Приложение пишет flutter test tools/test_vectors/generate_app_fixtures.dartextension/test/fixtures/app_fixtures.json extension/src/crypto/app_fixtures.test.ts

Фикстуры приложения снимаются его собственным кодом (конверты, .skf, копии .skb обоих форматов из ArchiveStore), ключи и нонсы случайные - при перегенерации файл меняется целиком, это нормально.