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...> и канонический URI sk: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):

  1. SK → сервер: POST конверта заявки на endpoint target'а. Получатель - адрес сервера, text пустой (все данные шага в meta), meta.type = sk-login. Сервер берёт sid из meta, аккаунт - по отправителю.
  2. Сервер → SK (тело HTTP-ответа 2xx): challenge-конверт. Получатель - отправитель шага 1, text = короткий одноразовый код, meta.type = sk-login-challenge, data.sid - тот же sid. SK распознаёт challenge по armor-маркерам в теле ответа; тело без конверта (пустое, любой другой текст) - ошибка сервиса: одношагового варианта нет, без challenge у пользователя не было бы точки подтверждения.
  3. 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 - сервер не расшифровывает реквизиты, пока отправитель не доказал владение ключом адреса:

  1. SK → сервер: конверт заявки, text пустой, meta.type = sk-data-request.
  2. Сервер → SK (тело 2xx): challenge-конверт, text = одноразовый код, meta.type = sk-data-challenge, data.sid - тот же sid. SK принимает его только от identity сервера из встроенного списка и только для своей заявки. Одношагового варианта нет: тело ответа без конверта - ошибка сервиса.
  3. 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), ключи и нонсы случайные - при перегенерации файл меняется целиком, это нормально.

Попробуйте на своей платформе

iOS, Android, macOS, Windows - и браузерное расширение.

Скачать Secret Keeper