Secret Keeper Protocol v1
Спецификация криптопротокола приложения, браузерного плагина и сайта.
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 - как есть, в копировании и системном шаринге - черезaddressShareText: подпись «Мой адрес в SecretKeeper.net» отдельной строкой перед payload (чтобы получатель понимал, что за строка ему пришла), а после payload - пустая строка, разделитель--и приписка-инструкция «Чтобы начать переписку, скопируйте это сообщение целиком, откройте Secret Keeper и нажмите «+».» (сам получатель не догадается, что текст надо перенести в SK целиком). Читаем терпимо: любая из форм распознаётся внутри пояснительного текста («Мой адрес в 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-маркерам в теле ответа; тело без конверта (пустое, любой другой текст) - ошибка сервиса: одношагового варианта нет, без challenge у пользователя не было бы точки подтверждения. - 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 тоже показывает код - ручной ввод кода в клиенте сервиса доказывает то же самое тем же верификатором. Это единственный кейс ручного ввода: сервер без собственной доставки в клиент (браузер сам опрашивает статус, как lashin.su){"sent":false}не отдаёт - после него заявка уже подтверждена, и ручной ввод ей не нужен.
Контекст заявки в challenge (v2). Против подмены QR (QRLjacking:
атакующий показывает жертве QR своей заявки) сервер может положить в
challenge, помимо кода, факты о том, кто получил sid: откуда пришла
заявка, какой браузер. Подделать их атакующий не может - они внутри
конверта, зашифрованного на адрес жертвы и собранного сервером из
данных о браузере атакующего. Согласование версий:
- SK в
meta.dataзаявки шага 1 добавляет"challenge": 2- максимальную версию challenge, которую понимает. Нет поля - клиент понимает только v1 (text= код), сервер отвечает как описано выше. - Сервер, получив
challenge >= 2, отвечает challenge-конвертом с"challenge": 2вmeta.data(рядом сtarget,v,sid) иtext= JSON{"code": "123456", "items": [{"name": "Откуда", "value": "Москва, Россия"}, {"name": "Браузер", "value": "Chrome, macOS"}]}.code- строка;items- массив пар строкname/value, состав и язык (поAccept-Languageзапроса) решает сервер. Полеvв meta остаётся версией протокола (1). - Перед шагом 3 SK всегда показывает подтверждение: первой строкой
сервис (куда входим), затем
itemsкак есть (не разбирает и не сравнивает); при v1 или пустомitems- только своя строка. Код пользователю не показывает; шаг 3 уходит только после «Подтвердить». Fallback п. 3 (POST шага 3 не прошёл) при v2 тот же: код показывается для ручного ввода - подтверждение уже дано, сломан только канал. Ответ с версией выше заявленной, не-JSON или безcode- ошибка сервиса, шаг 3 не уходит.
Отказ пользователя. «Отмена» на подтверждении (а также новая
заявка, вытеснившая неподтверждённую) кода не отправляет. Серверу,
ответившему challenge v2, SK шлёт конверт отмены на тот же endpoint:
meta.type = sk-login-cancel, meta.data = {"target","v","sid"},
text пустой. Ответа SK не ждёт и ошибок не показывает (fire-and-forget).
Сервер принимает отмену только от отправителя заявки и только в
состоянии «ждём код»: гасит sid и сообщает странице сервиса, что вход
отклонён, чтобы она не ждала TTL. Серверу с challenge v1 конверт не
уходит - там заявка доживает TTL. «Нет» на consent под локом (§ 4.5,
до шага 1) сигнала не даёт: заявка серверу не уходила.
Обязательные проверки сервера: успешный decrypt (это и есть аутентификация
отправителя), type и target в meta соответствуют шагу, sid из meta
живой и одноразовый, код одноразовый, с TTL и лимитом попыток (короткий
код перебираем), привязан к своему sid и отправителю. Разные type у
шагов не дают скормить конверт одного шага другому. SK, в свою очередь,
принимает challenge только от identity сервера из встроенного списка и
только с data.sid своей заявки (страховка при параллельных логинах).
Отказ сервера (шаг 1 или 3): статус 4xx, тело - JSON
{"error": <код>, "message": <текст для пользователя>}. error -
стабильный код: bad-envelope (тело не конверт / не расшифровался),
bad-meta (не тот type, target или v), in-progress (заявка уже
в работе), sid-expired (sid неизвестен, протух или использован),
code-invalid (код не сошёлся или попытки исчерпаны) - на них SK
показывает свой текст «код устарел, обновите QR», а message не
показывает; access-denied (вход подтверждён, но сервис этот адрес не
пускает: нет в списке, заблокирован) - SK показывает свой заголовок и
message сервера; незнакомый код - «сервис отклонил вход» и message.
message сервер пишет на языке Accept-Language запроса (SK шлёт локаль
интерфейса одним тегом: ru, en, …), для незнакомого языка -
по-английски; содержимое конверта и криптодетали провала в message не
попадают. Тело 4xx не по этому формату (голый текст, пустое) SK
трактует как «обновите QR» и показывает сырой ответ. access-denied на
шаге 3 код для ручного ввода не даёт: тот же верификатор откажет так же.
Семантика 2xx и 5xx выше не меняется.
Модель безопасности:
- Подпись не нужна. Аутентификация отправителя - свойство конструкции
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.
4.6. Запрос данных сайтом (sk-data)
Сервис просит у пользователя данные заданного типа - реквизиты карты,
логин с паролем, персональные данные - и получает их из Сейфа SK
(§ 6.1) зашифрованным конвертом, минуя ручной ввод. Сценарий тот же, что
у входа: сервис показывает QR/ссылку
https://secretkeeper.net/request?v=1&sid=<...>&target=<id>&kind=<kind>
(или sk://request?...), пользователь сканирует её или открывает на том
же устройстве, SK показывает экран согласия с именем сервиса и типом
данных, пользователь выбирает запись Сейфа (единственная запись типа
выбирается сама), и SK отправляет её значения на сервер сервиса.
Из payload читаются только v, sid, target, kind. URL endpoint'а
и sk1-адрес сервера - только из встроенного списка клиента, как в
§ 4.5. kind - тип записи Сейфа из § 6.1; сервис не может запросить
произвольный набор полей: тип и есть словарь. В v1 запрашиваемые типы:
kind |
ключи fields |
|---|---|
login-password |
site, login, password |
card-details |
holder, pan, exp, cvv, billingAddress |
personal-data |
name, birthdate, phone, email, address |
Транспорт и форма конвертов - как в § 4.5: сырые armored-конверты в
POST text/plain на endpoint target'а, meta.data каждого шага несёт
{"target":<id>,"v":1,"sid":<sid>,"kind":<kind>} внутри AEAD. Обмен
двухшаговый, данные уходят только после challenge - сервер не
расшифровывает реквизиты, пока отправитель не доказал владение ключом
адреса:
- SK → сервер: конверт заявки,
textпустой, meta.type =sk-data-request. - Сервер → SK (тело 2xx): challenge-конверт,
text= одноразовый код, meta.type =sk-data-challenge,data.sid- тот жеsid. SK принимает его только от identity сервера из встроенного списка и только для своей заявки. Одношагового варианта нет: тело ответа без конверта - ошибка сервиса. - SK → сервер: конверт данных, meta.type =
sk-data,meta.data={"target","v","sid","kind","code"}(код из challenge лежит рядом сsidвнутри AEAD),text= JSON-объект значений полей выбранной записи по словарюkind, например{"holder":"IVAN IVANOV","pan":"4111 1111 1111 1111","exp":"12/29","cvv":"123"}. Значения передаются как хранятся в Сейфе (нормализацию - убрать пробелы изpan, разобратьexp- делает получатель); пустые поля и имя записи (title) не передаются. Ответ 2xx с пустым телом - принято.
Оба POST'а SK делает подряд без участия пользователя (кроме
подтверждения контекста заявки, если сервер прислал challenge v2 - тот
же механизм, что в § 4.5: "challenge": 2 в заявке и в ответе,
text challenge = JSON {code, items}; отказ на подтверждении - конверт
sk-data-cancel с {"target","v","sid"}, как sk-login-cancel в
§ 4.5); ручного ввода кода, как в
§ 4.5, нет - если шаг 3 не дошёл (сеть, 5xx), SK показывает ошибку,
сервис предлагает обновить QR.
Обязательные проверки сервера - те же, что в § 4.5 (успешный decrypt =
аутентификация отправителя, type/target/v по шагу, живой
одноразовый sid, код с TTL и лимитом попыток, привязанный к sid и
отправителю), плюс kind в meta обоих конвертов равен тому, что выдан
на этот sid, а ключи text шага 3 входят в словарь kind. Значения
сервер держит только до передачи своему клиенту (странице), в журналы
не пишет.
Отказ сервера - статус 4xx, тело - JSON {"error", "message"} по
правилам § 4.5 с теми же кодами (bad-envelope, bad-meta,
in-progress, sid-expired, code-invalid, access-denied) и одним
новым: kind-mismatch - тип в meta не тот, что запрошен для sid. На
bad-*, in-progress, sid-expired, code-invalid, kind-mismatch
SK показывает свой текст «обновите QR»; на access-denied и незнакомые
коды - заголовок «сервис отклонил» и message сервера. access-denied
принимается на шаге 3, когда адрес отправителя уже доказан.
Модель безопасности - § 4.5 целиком; challenge здесь нужен по той же причине: сервис принимает решение о допуске по адресу отправителя, и гарантия подлинности адреса должна быть той же, что у входа, независимо от секретности серверного ключа. Дополнительно: тип данных в ссылке исключает «дозапрос» лишних полей - экран согласия показывает пользователю ровно то, что уйдёт; чужой QR остановит только этот экран.
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[], chats{}, settings, vault[]} |
.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 (ndjson-леджеры с контейнерами и аватарки
игнорирует); свои истории он переносит через .sk1.
.sk1 пишет браузерный плагин (у приложения формат копий - .skb),
читают оба клиента. chats - карта «адрес собеседника → массив
сообщений»; запись сообщения - JSON ChatMessage.toJson приложения
(lib/models/chat_message.dart): id, text, outgoing, at (мс),
опциональные sentAt (мс), meta, armored, armorSha,
handedOff/delivered (пишутся только не-дефолтные значения). Файловые
записи плагина идут с fileName/fileSize, но без байт контейнера и
без skfName - приложение такие записи при импорте пропускает
(отрисовать без контейнера нечего), плагин переносит как след операции.
Слияние в обоих клиентах - с дедупликацией по armorSha.
6.1. Записи Сейфа
Типизированные записи (пароли, карты, персональные данные) и
пользовательские папки попадают в копию двумя путями: в .sk1 -
массивами vault и vaultFolders, в архив .skb - файлом
.storage/records.sk1e. Это armored-конверт, зашифрованный себе
(как meta.sk1e); его plaintext:
{"version": 1, "folders": [{...}, ...], "records": [{...}, ...]}
Запись:
{"id": "<ms>-<rnd>", "kind": "login-password", "title": "...",
"fields": {"site": "...", "login": "...", "password": "..."},
"tags": ["..."], "favorite": true, "folder": "<id папки>",
"createdAt": ms, "updatedAt": ms}
Папка:
{"id": "<ms>-<rnd>", "name": "...", "parent": "<id папки>",
"createdAt": ms, "updatedAt": ms}
kind - неизменяемый тип записи, определяет словарь ключей fields.
Типы v1: login-password (site, login, password), card-details
(holder, pan, exp, cvv, billingAddress), personal-data (name, birthdate,
phone, email, address), text-note (text - текст заметки с той
же markdown-разметкой, что у text пейлоада, §4.1.1), checklist
(items - пункты чек-листа как GFM task list: строка на пункт,
- [ ] текст невыполненный и - [x] текст выполненный, выполненные
после невыполненных; строку без маркера читатель считает невыполненным
пунктом), links (items - список ссылок как GFM-список
markdown-ссылок: строка на пункт, - [название](адрес) либо - адрес
без названия, порядок строк - порядок показа; читатель без названия
показывает хост адреса), file (name, size, sha256 - см. ниже). Неизвестные kind и ключи fields
читатель сохраняет как есть, не показывая: запись из более нового
клиента не должна теряться при цикле «импорт - экспорт». Пустые tags,
ложный favorite и отсутствующая folder не пишутся. favorite
(«по умолчанию для типа») - не более одной записи в пределах kind.
Запись типа file хранит только метаданные: name - исходное имя
файла, size - размер плейнтекста в байтах (строкой), sha256 -
hex-хэш плейнтекста. Содержимое лежит рядом с индексом отдельным
SKF-контейнером, зашифрованным себе:
.storage/files/<id записи>.skf
Блоб неизменяемый: перемещение записи между папками Сейфа - правка
метаданных, файл не трогается. В .skb блобы попадают вместе с
архивом; .sk1 переносит только записи-метаданные - после такого
импорта читатель показывает запись, а вместо содержимого сообщает,
что файл в этой копии недоступен.
Папки - ортогональная ось организации: тип несёт запись, а не папка.
Вложенность задаёт поле parent папки (отсутствует - папка в корне).
Запись ссылается на папку полем folder; ссылка на несуществующую
папку - что у записи, что у папки в parent - читается как корень
(так, без каскадов, работает и удаление папки: её записи и подпапки
возвращаются в корень).
Слияние при импорте - по id (у записей и папок одинаково): новая
добавляется, при совпадении id побеждает более свежая updatedAt.
Старые клиенты, не знающие о Сейфе, каталог .storage в архиве
игнорируют, а копии без него читаются с пустым списком записей.
Секреты (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), ключи и нонсы случайные -
при перегенерации файл меняется целиком, это нормально.