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.- Известные конвенции meta:
type: "sk-login"/"sk-login-challenge"/"sk-login-code"- вход в сторонний сервис через Secret Keeper (§ 4.5).
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 - обёртка срезается до расшифровки/хранения.
4.5. Вход через SK (sk-login)
Secret Keeper выступает аутентификатором стороннего сервиса: сервис
показывает QR/ссылку https://secretkeeper.net/auth?v=1&sid=<...>&target=<id>
(или sk://auth?...), SK после явного согласия пользователя доказывает
серверу сервиса владение ключом своего адреса. URL endpoint'а и sk1-адрес
сервера берутся только из встроенного списка клиента, никогда из
payload - подменить их через QR (QRLjacking) нельзя, из payload читаются
лишь v, sid, target.
Транспорт минимальный: стороны обмениваются сырыми armored-конвертами
(POST с Content-Type: text/plain; charset=utf-8, тело - конверт как
есть), без JSON-обёрток и их экранирования. Вся семантика шага - внутри
конверта: meta.data каждого шага несёт
{"target":<id>,"v":1,"sid":<sid>} - sid лежит внутри AEAD
(аутентифицирован), открытым полем транспорта не передаётся.
Шаги (все конверты - обычные envelope § 4.1, различаются meta.type):
- SK → сервер: POST конверта заявки на endpoint target'а. Получатель -
адрес сервера,
textпустой (все данные шага в meta), meta.type =sk-login. Сервер берётsidиз meta, аккаунт - по отправителю. - Сервер → SK (тело HTTP-ответа 2xx): challenge-конверт. Получатель -
отправитель шага 1,
text= короткий одноразовый код, meta.type =sk-login-challenge,data.sid- тот жеsid. SK распознаёт challenge по armor-маркерам в теле ответа; тело без конверта (пустое, любой другой текст) - одношаговый успех (сервер счёл шаг 1 достаточным). - SK → сервер: POST конверта кода на тот же endpoint.
text= код из challenge, meta.type =sk-login-code,data.sid- тот жеsid. Сервер сверяет код с выданным для этогоsidи активирует сессию сервиса. Ответ: 2xx - готово; 2xx с JSON-телом{"sent":false}- код принят, но доставить событие в клиент сервиса не удалось (push/SignalR) - SK показывает код пользователю (любое другое тело - доставлено); 4xx - код/sidпротух. Если POST шага 3 не удался (сеть/5xx), SK тоже показывает код - ручной ввод кода в клиенте сервиса доказывает то же самое тем же верификатором.
Обязательные проверки сервера: успешный decrypt (это и есть аутентификация
отправителя), type и target в meta соответствуют шагу, sid из meta
живой и одноразовый, код одноразовый, с TTL и лимитом попыток (короткий
код перебираем), привязан к своему sid и отправителю. Разные type у
шагов не дают скормить конверт одного шага другому. SK, в свою очередь,
принимает challenge только от identity сервера из встроенного списка и
только с data.sid своей заявки (страховка при параллельных логинах).
Модель безопасности:
- Подпись не нужна. Аутентификация отправителя - свойство конструкции
KEK (§ 4.2, модель NaCl crypto_box): слот, который сервер сумел
развернуть, мог собрать только владелец
sender_static_priv(иначе не сойдётсяshared2, это задача CDH на Curve25519). Вписать чужойsender_pubнельзя - расшифровка не сойдётся. Подпись добавила бы лишь неотрицаемость, которая логину не нужна: сервер - единственный проверяющий. - KCI и зачем шаг 2. С украденным приватным ключом сервера атакующий
может изготовить конверт шага 1 «от имени» любого адреса (хватает
ECDH(server_priv, victim_pub)). Но challenge шага 2 ему не открыть: для KEK нуженECDH(eph, victim), аeph_privодноразовый и известен только серверу. Роундтрип кода доказывает владение ключом адреса способом, не зависящим от секретности серверного ключа. - Пиннинг сервера. SK сверяет отправителя challenge-конверта с sk1-адресом сервера из встроенного списка - identity сервера аутентифицирована поверх TLS.
- Социнженерию криптография не лечит: сканирование чужого QR останавливает только обязательный экран согласия в SK.
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>"}}
settings — словарь настроек профиля. Известные ключи: themeMode,
locale, displayName, pinWipeThreshold, relockSeconds. Неизвестные
читатель пропускает. Если выбрана своя папка копий, добавляются
backupDir (абсолютный путь или Android content:// дерева SAF) и на
macOS/iOS backupDirBookmark (base64 security-scoped bookmark). Путь с
другой ОС обычно не существует — читатель не создаёт каталог и не применяет
мёртвый путь; закладка чужой машины не открывается; Android-URI без живого
persistable-доступа игнорируется. Браузерный плагин эти ключи игнорирует.
Не расшифровался своим ключом - копия снята с другого профиля (по 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) обязано попасть в оба
генератора: вектора, снятые только одной реализацией, расхождение
реализаций не обнаруживают.
| Направление | Генератор | Читатели |
|---|---|---|
| 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), ключи и нонсы случайные -
при перегенерации файл меняется целиком, это нормально.