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

  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 выше не меняется.

Модель безопасности:

  • Адрес сайта приходит из 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 - сервер не расшифровывает реквизиты, пока отправитель не доказал владение ключом адреса:

  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 остановит только этот экран.

Тестовый режим. Чтобы на демонстрационную или отладочную площадку не ушли настоящие реквизиты, 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 слот освобождает. Тело загрузки сервер пишет во временный файл и переименовывает только после проверки подписи по фактическому хэшу: подделанное тело объектом не станет.

Синхронизация. Облако - общий архив всех устройств одного сида, а не копия одного из них. Клиент сводит себя с ним циклом «забрать, слить, выгрузить» и запускает его сам: при входе в приложение, после изменений данных (с задержкой), при уходе в фон; кнопка в настройках - тот же цикл вручную.

  1. list. Объекты, которых нет локально или чей etag отличается от локального sha256 и ещё не виден этому устройству, скачиваются и читаются тем же импортом, что читает .skb (§ 6): отметки удалений применяются и запоминаются, контакты сливаются по updatedAt, сообщения - по sha с ИЛИ флагов передачи, записи Сейфа - по id и updatedAt. Леджер каждого затронутого чата переписывается в канонический вид (объединение, порядок по at, sha), поэтому устройства сходятся к одинаковым файлам байт в байт. Строка леджера с контейнером, которого локально ещё нет, не вливается: её заберёт следующий цикл вместе с контейнером.
  2. Если что-то менялось - локально или слиянием, - meta.sk1e и .storage/records.sk1e переснимаются.
  3. Файлы, чей 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), ключи и нонсы случайные - при перегенерации файл меняется целиком, это нормально.

Скачать