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...>и канонический URIsk: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.js → test_vectors.json |
test/protocol_cross_test.dart, extension/src/crypto/*.test.ts |
| Приложение пишет | flutter test tools/test_vectors/generate_app_fixtures.dart → extension/test/fixtures/app_fixtures.json |
extension/src/crypto/app_fixtures.test.ts |
Фикстуры приложения снимаются его собственным кодом (конверты, .skf,
копии .skb обоих форматов из ArchiveStore), ключи и нонсы случайные -
при перегенерации файл меняется целиком, это нормально.