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 не используется.
2.1. Ключи passkey (WebAuthn)
Secret Keeper выступает хранилищем passkeys (WebAuthn / FIDO2): сайт (relying party, RP) ничего не знает о протоколе SK, он видит обычный аутентификатор. Ключи passkey выводятся из того же seed, поэтому восстанавливаются по мнемонике без резервной копии; адрес и envelope (§ 3, § 4) при этом не меняются.
Ключ для RP с идентификатором rpId (домен, в нижнем регистре) и
порядковым номером n (десятичное число без ведущих нулей, первый
ключ для домена - 0, следующий - наибольший занятый плюс один):
okm = HKDF-SHA256(ikm = seed, salt = пустой,
info = "secret-keeper/webauthn/v1/" + rpId + "/" + n, L = 48)
d = (int_be(okm) mod (q - 1)) + 1 // q - порядок группы P-256
Q = d * G // публичный ключ
credentialId = 0x01 || HKDF-SHA256(seed, salt = пустой,
info = "secret-keeper/webauthn-id/v1/" + rpId + "/" + n, L = 16)
Кривая P-256 (secp256r1), подпись ECDSA с SHA-256 (COSE alg -7,
ES256) и детерминированным nonce RFC 6979 - одинаковый ввод даёт
одинаковую подпись, что проверяется тест-векторами; s приводится к
нижней половине порядка (low-S). Подпись отдаётся в
ASN.1 DER (SEQUENCE { r INTEGER, s INTEGER }). Других алгоритмов нет:
если RP не принимает -7 в pubKeyCredParams, регистрация
отклоняется. credentialId - 17 байт, детерминирован: зная rpId,
ключи домена восстанавливаются перебором n. Один ключ на аккаунт RP:
повторная регистрация с теми же rpId и user.id заменяет прежнюю
запись новым ключом (следующий n), как makeCredential в CTAP2; старый
ключ больше не используется.
Публичный ключ в authenticatorData - COSE_Key в каноническом CBOR
(CTAP2): {1: 2, 3: -7, -1: 1, -2: x, -3: y} (kty EC2, alg ES256,
crv P-256, координаты по 32 байта с ведущими нулями).
authenticatorData = SHA-256(rpId) (32) || флаги (1) || signCount
(4, всегда 0: синхронизируемый ключ, счётчик не ведётся) ||
[attestedCredentialData]. Флаги: UP, UV, BE, BS всегда установлены
(подтверждение владельца PIN'ом или биометрией; ключ восстановим из
seed, поэтому backup eligible и backed up); AT - только при
регистрации. attestedCredentialData = AAGUID (16) || длина
credentialId (2, big-endian) || credentialId || COSE_Key. AAGUID
Secret Keeper: 46c8e517-2789-481f-a374-3aca8eeb452b. Attestation -
none: attestationObject = CBOR {"fmt": "none", "attStmt": {}, "authData": <bytes>} (канонический порядок ключей: fmt, attStmt,
authData).
Подписывается authenticatorData || SHA-256(clientDataJSON). Когда
clientDataJSON строит SK (вызывающий - нативное приложение), это
{"type":"webauthn.create"|"webauthn.get","challenge":<base64url>, "origin":<origin>,"crossOrigin":false}; браузеры и системные
посредники передают готовый хэш, и в ответе clientDataJSON пустой.
Ответы - в JSON-сериализации WebAuthn Level 3 (id, rawId,
type: "public-key", authenticatorAttachment: "platform",
response.{clientDataJSON, attestationObject | authenticatorData, signature, userHandle}, transports: ["internal", "hybrid"],
clientExtensionResults.credProps.rk: true), поля - base64url без
паддинга. userHandle - user.id, полученный от RP при регистрации;
возвращается в каждом assertion, чтобы работал вход без ввода логина
(discoverable credential).
Запись passkey в Сейфе - тип passkey (§ 6.1).
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);"sk-records"- перенос записей Сейфа (§ 4.7).
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/ссылку, SK после явного согласия пользователя доказывает серверу сервиса владение ключом своего адреса. Две формы ссылки:
- сайт:
https://secretkeeper.net/auth?v=1&sid=<...>&site=<host>&address=<sk1...>(илиsk://auth?...).site- хост сайта (только ASCII; IDN - в punycode),address- sk1-адрес его сервера. Endpoint'ы вычисляются из хоста по соглашению:https://<host>/sk/loginдля входа,https://<host>/sk/requestдля запроса данных (§ 4.6); URL в payload нет и быть не может. Имя сервиса на экране согласия - сам хост. - встроенный target:
...&target=<id>- приложение, у которого SK знает URL, адрес сервера и способ вернуть пользователя (сегодняtetatet). Из payload читается только имя записи.
Из payload читаются лишь v, sid, site, address, target
(и kind у § 4.6). Ссылка с site без address, с хостом не по форме
(порт, IP, localhost, схема, путь) или с site и target одновременно
не обслуживается.
Транспорт минимальный: стороны обмениваются сырыми armored-конвертами
(POST с Content-Type: text/plain; charset=utf-8, тело - конверт как
есть), без JSON-обёрток и их экранирования. Вся семантика шага - внутри
конверта: meta.data каждого шага несёт
{"target":<id>,"v":1,"sid":<sid>} (target - хост сайта или id
встроенной записи; сервер сверяет его со своим) - 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 выше не меняется.
Модель безопасности:
- Адрес сайта приходит из QR, как адрес контакта. Сайт в этой схеме
ничем не отличается от собеседника, показавшего свой QR: SK шифрует на
адрес, который увидел, и двухшаговый обмен доказывает, что на той
стороне держатель ключа именно этого адреса. Привязку адреса к хосту
даёт канал, по которому пришёл ключ, - страница сайта в браузере
пользователя, а не сетевой путь приложения. Поэтому подмена трафика
SK (прокси, чужой корневой сертификат) ничего не даёт: заявка уже
зашифрована на настоящий ключ, challenge от нужного адреса не
подделать. Подмена адреса на странице (XSS, фишинговая копия сайта)
бесполезна сама по себе: endpoint'ы берутся из хоста, конверт уйдёт на
настоящий
https://<host>/sk/login, и сайт его не расшифрует; злоумышленнику нужен одновременно контроль и страницы, и сети - от этого не защищает ничего, кроме заранее известного ключа, и то лишь у встроенных target'ов. Lookalike-домены и невнимательность к хосту на экране согласия остаются, как в вебе; до согласия SK в сеть не ходит. - Подпись не нужна. Аутентификация отправителя - свойство конструкции
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-адресом сервера из ссылки (у встроенных target'ов - из списка) - identity сервера аутентифицирована поверх TLS.
- Социнженерию криптография не лечит: сканирование чужого QR останавливает только обязательный экран согласия в SK.
4.6. Запрос данных сайтом (sk-data)
Сервис просит у пользователя данные заданного типа - реквизиты карты,
логин с паролем, персональные данные - и получает их из Сейфа SK
(§ 6.1) зашифрованным конвертом, минуя ручной ввод. Сценарий тот же, что
у входа: сервис показывает QR/ссылку
https://secretkeeper.net/request?v=1&sid=<...>&site=<host>&address=<sk1...>&kind=<kind>
(или sk://request?...), пользователь сканирует её или открывает на том
же устройстве, SK показывает экран согласия с именем сервиса и типом
данных, пользователь выбирает запись Сейфа (единственная запись типа
выбирается сама), и SK отправляет её значения на сервер сервиса.
Из payload читаются только v, sid, site, address (или
target), kind и необязательный mode (см. «Тестовый режим» ниже). Endpoint - https://<host>/sk/request по хосту из
site, адрес сервера - из address, как в § 4.5 (у встроенного
target'а - из списка клиента; сервис без endpoint'а данных на /request
получает отказ). 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 остановит только этот экран.
Тестовый режим. Чтобы на демонстрационную или отладочную площадку не ушли настоящие реквизиты, SK умеет отправлять значения полей частично замаскированными. Режим включается по одному из двух признаков:
- хост из
siteвходит в список демо-площадок SK (сейчас этоauth.secretkeeper.net): маска включена всегда, QR её не отключит; - в payload
/requestестьmode=test: для стендов интеграторов на время разработки. Иное значениеmode- неподдерживаемая ссылка (экран «обновите приложение»), на/authпараметр игнорируется.
Маска применяется ко всем полям одинаково: буквы и цифры (\p{L},
\p{N}) заменяются на *, остальные символы (пробелы, /, -, @,
., +) остаются на местах; видимыми остаются края значения в
зависимости от его длины в code point'ах: от 8 символов - первые два и
последние два, от 4 до 7 - по одному с каждого края, до 3 - все *.
Примеры: 4111 1111 1111 1111 → 41** **** **** **11, 12/29 →
1*/*9, 123 → ***, ivan@mail.ru → iv**@****.ru. Края
осознанно показывают часть секрета (первые и последние цифры карты): это
режим для демонстрации формата, а не для передачи данных.
В тестовом режиме meta.data обоих конвертов SK (sk-data-request и
sk-data) несёт "masked": true; в обычном режиме ключа нет. Сервер
обязан не считать такие значения настоящими и не сохранять их; страница
может показать пользователю метку «тестовые данные».
4.7. Перенос записей Сейфа (sk-records)
Записи Сейфа (§ 6.1) покидают приложение только конвертом: себе (второе устройство того же профиля) или собеседнику в переписке. Открытым текстом наружу идут лишь отдельное поле записи и QR одноразового кода - сценарии «вставить в форму» и «перенести в сторонний аутентификатор».
Это обычный конверт (§ 4): получатели - свои слоты, text -
человекочитаемый дубль записей (клиент без поддержки покажет его как
сообщение или заметку), meta - сами записи:
{"type":"sk-records","data":{"v":1,"records":[
{"kind":"login-password","title":"Почта",
"fields":{"site":"https://mail.example.com","login":"alice","password":"..."}}
]}}
v- версия формата, сейчас1; иное значение - отказ разбора.records- непустой список. У записиkind- тип из § 6.1,title- название (может быть пустым: получатель выводит его из полей),fields- словарь строк по ключам словаря типа. Идентификатор, папка, даты и прочие свойства записи не переносятся.- Тип
fileне переносится: файловые записи - метаданные блоба, который живёт в архиве отправителя. Запись сkind: "file"или неизвестным типом - отказ разбора всей meta. - Приём: записи ложатся в корень Сейфа получателя (типы с разделом -
в свой раздел), дедуп по типу и полям - повтор того же конверта ничего
не дублирует. Ключ входа (
passkey) принимается только если ключ, выведенный из seed получателя поrpId/keyIndex(§ 2.1), даёт тот жеcredentialId: записи чужого профиля бесполезны и отбрасываются. Одноразовый код (totp) - только с разбираемым секретом. - Конверт себе (адресатов, кроме отправителя, нет) с такой meta кладёт записи в Сейф сразу; в переписке сообщение показывается как текст, а записи сохраняются действием «Сохранить в Сейф».
Канонический JSON (порядок ключей type, data → v, records →
kind, title, fields) и наборы отказов - в тест-векторах (секция
records), фикстура конверта себе - envelopes.to_self_records.
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 и
backupDirBookmark (своя папка копий, снята в 1.1.7): читатели их
игнорируют.
Не расшифровался своим ключом - копия снята с другого профиля (по seed), и это единственный способ это понять: адресов в конверте нет.
Контакт - {"name", "address", "isFavorite", "updatedAt": ms};
updatedAt (момент последней правки, может отсутствовать у старых копий)
решает слияние: при совпадении адреса побеждает более поздний, а копия
без поля проигрывает любой с полем.
Папка адресата содержит messages.ndjson (строка на сообщение:
{"at": ms, "out": bool, "sha": "...", "armored": "..."} либо
{"at": ms, "out": bool, "sha": "...", "skf": "<id>.skf"}) и сами
контейнеры .skf. У исходящих к строке добавляются флаги передачи, когда
они отличаются от значений по умолчанию: "handedOff": false (конверт
ещё не передан собеседнику; отсутствие поля - передан) и
"delivered": true (подтверждено прочтение). Строки леджера
упорядочены по at, затем по sha; при слиянии двух копий одного чата
строки объединяются по sha, флаги складываются по ИЛИ (отметка
«передано» не снимается). Браузерный плагин переносит из архива только
settings и contacts (ndjson-леджеры с контейнерами и аватарки
игнорирует); свои истории он переносит через .sk1.
Удаления ездят между копиями отметками, а не отсутствием файла:
.storage/tombstones.sk1e - конверт себе, plaintext
{"version": 1, "items": [...]}, где отметка - одна из
{"k": "msg", "peer": "sk1…", "sha": "…", "at": ms} сообщение
{"k": "chat", "peer": "sk1…", "at": ms} чат с собеседником
{"k": "record", "id": "…", "at": ms} запись Сейфа
{"k": "folder", "id": "…", "at": ms} папка Сейфа
at - момент удаления. Отметка msg снимает сообщение навсегда; chat
снимает сообщения чата с at не позже отметки и контакт с updatedAt
не позже отметки (написанное или переименованное позже остаётся);
record/folder побеждают версии с updatedAt не позже отметки, более
поздняя правка оживляет запись. Наборы отметок сливаются объединением,
при совпадении ключа берётся более поздняя at. Читатель без поддержки
отметок файл игнорирует.
.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-ссылок: строка на пункт, - [название](адрес) либо - адрес
без названия, порядок строк - порядок показа; читатель без названия
показывает хост адреса), totp (одноразовые коды RFC 6238 - см. ниже),
passkey (ключ входа WebAuthn - см. ниже), file (name, size,
sha256 - см. ниже). Неизвестные kind и ключи fields
читатель сохраняет как есть, не показывая: запись из более нового
клиента не должна теряться при цикле «импорт - экспорт». Пустые tags,
ложный favorite и отсутствующая folder не пишутся. favorite
(«по умолчанию для типа») - не более одной записи в пределах kind.
Запись типа totp - секрет для одноразовых кодов второго фактора
(TOTP, RFC 6238; код = HOTP RFC 4226 от счётчика floor(unixTime / period),
динамическое усечение, ведущие нули сохраняются). Ключи fields:
secret - общий секрет в base32 (RFC 4648; писатель даёт заглавные
буквы без паддинга и пробелов, читатель терпит строчные, пробелы и =),
обязательный; issuer - сервис, account - учётная запись в нём
(подсказки для пользователя, в вычислении кода не участвуют);
algorithm - SHA1 | SHA256 | SHA512; digits - 6 | 8;
period - длина шага в секундах. Отсутствующий или пустой ключ
algorithm/digits/period означает значение по умолчанию: SHA1,
6, 30 - писатель может их не записывать. Источник записи - URI
otpauth://totp/<label>?secret=…&issuer=…&algorithm=…&digits=…&period=…
(QR на странице включения 2FA, ссылка «открыть в приложении», буфер):
label = issuer:account либо только account (URL-кодирование и
%3A допустимы), issuer берётся из параметра, иначе из label.
otpauth://hotp/ (счётчик вместо времени) не поддерживается - читатель
отклоняет такой URI, а не создаёт запись. По запросу сайта (§ 4.6) тип
не отдаётся.
Запись типа passkey - ключ входа WebAuthn (§ 2.1). Секрета в ней нет:
приватный ключ выводится из seed по rpId и keyIndex, запись хранит
то, что нужно, чтобы найти ключ и ответить сайту. Ключи fields:
rpId - идентификатор RP (домен), обязательный; keyIndex - номер
n деривации (десятичное число строкой), обязательный;
credentialId - base64url идентификатора ключа (дублирует вывод из
seed, чтобы отвечать на allowCredentials без пересчёта);
userHandle - base64url user.id от RP; rpName, userName,
userDisplayName - подписи для пользователя, как их прислал RP.
Запись создаёт только провайдер passkeys при регистрации на сайте,
вручную она не заводится и не редактируется (кроме названия). По
запросу сайта (§ 4.6) тип не отдаётся. Читатель без провайдера
passkeys (например, браузерный плагин) показывает запись как
метаданные.
Перенос записи passkey на другое устройство того же профиля (тот же
seed) - URI sk://passkey?…, который приложение показывает QR-кодом
и принимает из QR, буфера, шаринга текста и как ссылку по схеме sk:
sk://passkey?rp=<rpId>&n=<keyIndex>&cid=<credentialId>
[&uh=<userHandle>][&rpn=<rpName>][&un=<userName>]
[&udn=<userDisplayName>][&title=<название записи>]
Параметры - поля записи с тем же смыслом; rp, n и cid
обязательны, пустые необязательные опускаются, порядок фиксирован,
значения кодируются percent-encoding по RFC 2396 (encodeURIComponent,
пробел - %20). Форма sk:passkey?… (без //) равнозначна. Читатель
выводит ключ из своего seed по rp/n и сверяет credentialId с
cid: не совпал - запись снята с другого профиля и не сохраняется;
совпал - запись сохраняется (ключ с тем же rpId и credentialId уже
есть - второй не заводится). Секрета в URI нет, но домен и userHandle
- личные данные: показывать QR только своему устройству.
Запись типа file хранит только метаданные: name - исходное имя
файла, size - размер плейнтекста в байтах (строкой), sha256 -
hex-хэш плейнтекста. Содержимое лежит рядом с индексом отдельным
SKF-контейнером, зашифрованным себе:
.storage/files/<id записи>.skf
Блоб неизменяемый: перемещение записи между папками Сейфа - правка
метаданных, файл не трогается. В .skb блобы попадают вместе с
архивом; .sk1 переносит только записи-метаданные - после такого
импорта читатель показывает запись, а вместо содержимого сообщает,
что файл в этой копии недоступен.
Папки - ортогональная ось организации: тип несёт запись, а не папка.
Вложенность задаёт поле parent папки (отсутствует - папка в корне).
Запись ссылается на папку полем folder; ссылка на несуществующую
папку - что у записи, что у папки в parent - читается как корень
(так, без каскадов, работает и удаление папки: её записи и подпапки
возвращаются в корень). Исключение - типы totp и passkey: их место
в приложении - раздел по типу, а не папка, поэтому поле folder у них
не пишется, а если пришло (старая копия, другой клиент) - читатель его
игнорирует и при следующей записи снимает.
Слияние при импорте - по id (у записей и папок одинаково): новая
добавляется, при совпадении id побеждает более свежая updatedAt.
Старые клиенты, не знающие о Сейфе, каталог .storage в архиве
игнорируют, а копии без него читаются с пустым списком записей.
Секреты (seed-фраза, PIN) в копии нет: профиль восстанавливается seed-фразой.
6.2. Облачная копия (протокол хранилища SKS1)
Облачная копия - зеркало каталога архива (§ 6) на сервере хранения.
Сервер видит только файлы архива, а каждый из них - конверт самому себе,
поэтому хранилище работает с шифртекстом и в криптографии не участвует:
сменить сервер или поднять свой - вопрос адреса в настройках. Эталонная
реализация сервера открыта (secret_keeper_storage), по умолчанию
приложение использует наш экземпляр.
Ключ хранилища. Из того же сида, что и identity (§ 2), но с другим
info, так что из адреса sk1… его не получить и наоборот:
storage_private = HKDF-SHA256(ikm = seed, salt = пусто,
info = "secret-keeper/storage-ed25519/v1", L = 32)
storage_public = Ed25519(storage_private)
storage-id = bech32(hrp = "sks", storage_public) → sks1…
Сервер знает пользователя только по storage-id; связать его с адресом переписки нельзя.
Подпись запроса. Каждый запрос к /v1/* несёт четыре заголовка:
| Заголовок | Значение |
|---|---|
X-SK-Storage |
storage-id |
X-SK-Time |
часы клиента, unix ms |
X-SK-Nonce |
16..128 символов [A-Za-z0-9_=-], уникален на запрос |
X-SK-Sig |
base64 подписи Ed25519 над строкой подписи |
Строка подписи (UTF-8, строки через \n, без завершающего перевода):
SKS1
{METHOD} верхний регистр
{path} путь запроса с query, как отправлен
{time}
{nonce}
{sha256(body) в нижнем hex} sha256("") для запросов без тела
Сервер отвергает запросы, чьё время отличается от его часов больше чем
на окно (по умолчанию 5 минут), и помнит nonce в удвоенном окне: повтор
перехваченного запроса не проходит. В ответах есть X-SK-Server-Time,
по нему клиент с неверными часами поправляется.
Объекты. Путь объекта - относительный путь файла в архиве
(meta.sk1e, .storage/records.sk1e, sk1…/messages.ndjson), до 8
сегментов из [A-Za-z0-9._-], без . и ... В URL сегменты
percent-encoded: /v1/o/.storage/records.sk1e.
| Запрос | Результат |
|---|---|
GET /healthz |
{"ok":true,"time":…}, без подписи |
GET /v1/list |
[{"path","size","etag","mtime"}] по пути |
GET /v1/usage |
{"used","quota","maxObject"} в байтах |
PUT /v1/o/<path> |
записать тело; 201 новый, 200 заменён; If-Match / If-None-Match (* допустим) |
GET /v1/o/<path> |
тело с ETag, Content-Length, Last-Modified; If-None-Match → 304 |
HEAD /v1/o/<path> |
только заголовки |
DELETE /v1/o/<path> |
удалить объект; If-Match |
DELETE /v1/all |
удалить всё под storage-id |
etag - sha256 тела в нижнем hex. Ошибки - JSON {"error","message"}:
401 auth-*, 400 bad-path, 404 not-found, 412 precondition-failed,
413 too-large / 413 quota, 403 ids-limit. Последнее отдаёт хост
с ограничением числа storage-id (семейный сервер на семь сидов): новый
storage-id не принимается ни на одном подписанном запросе, пока занят
последний слот, а storage-id с данными обслуживается всегда;
DELETE /v1/all слот освобождает. Тело загрузки сервер пишет во
временный файл и переименовывает только после проверки подписи по
фактическому хэшу: подделанное тело объектом не станет.
Синхронизация. Облако - общий архив всех устройств одного сида, а не копия одного из них. Клиент сводит себя с ним циклом «забрать, слить, выгрузить» и запускает его сам: при входе в приложение, после изменений данных (с задержкой), при уходе в фон; кнопка в настройках - тот же цикл вручную.
list. Объекты, которых нет локально или чейetagотличается от локального sha256 и ещё не виден этому устройству, скачиваются и читаются тем же импортом, что читает.skb(§ 6): отметки удалений применяются и запоминаются, контакты сливаются поupdatedAt, сообщения - поshaс ИЛИ флагов передачи, записи Сейфа - поidиupdatedAt. Леджер каждого затронутого чата переписывается в канонический вид (объединение, порядок поat,sha), поэтому устройства сходятся к одинаковым файлам байт в байт. Строка леджера с контейнером, которого локально ещё нет, не вливается: её заберёт следующий цикл вместе с контейнером.- Если что-то менялось - локально или слиянием, -
meta.sk1eи.storage/records.sk1eпереснимаются. - Файлы, чей sha256 отличается от
etagв облаке, отправляютсяPUT; контейнеры.skfраньше леджеров и meta, чтобы леджер на сервере никогда не ссылался на отсутствующий контейнер. Объекты, которых нет локально, клиент не удаляет никогда: удаление - отметка в.storage/tombstones.sk1e, которую применят остальные устройства. Единственное явное удаление -DELETE /v1/all.
Клиент помнит etag каждого объекта, который уже забрал или отправил:
без изменений с обеих сторон цикл стоит один list. Настройки из meta
(тема, язык, PIN-пороги) при синхронизации не применяются - они остаются
локальными для устройства; их переносит только восстановление на новом
устройстве (скачать всё и отдать импорту .skb).
Новое устройство. Облако включается только явным согласием: до него
приложение на сервер ничего не отправляет. Новому профилю предложение
показывается один раз, при первом изменении его данных; ответ
запоминается, «не сейчас» - тоже ответ, тумблер в настройках остаётся.
После ввода seed-фразы приложение сразу спрашивает сервер по умолчанию
(storage-id выводится из того же сида): есть копия - она восстанавливается
и облако включается без вопросов; пусто или отказ - предлагается файл
.skb или другой адрес сервера, где поиск повторяется. Запись storage-id
на сервере появляется только с первой выгрузкой, поэтому один поиск слот
семейного сервера не занимает.
Что не входит в эту версию: подписанный манифест (защита от отката и от подмены объекта сервером) и выгрузка одним файлом вместо пофайлового зеркала.
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), ключи и нонсы случайные -
при перегенерации файл меняется целиком, это нормально.