InkVault
Менеджер паролей, которому нужны только клиент и учетная запись XMPP. Учетная запись может быть на любом сервере с PEP, в том числе на публичном, которым вы не владеете. Записи шифруются на устройстве одним ядром на Rust, общим для web, Android и терминального клиентов, а сервер хранит только шифротекст.
attributes
- role
- автор
- web
- закрытый репозиторий
- license
- AGPL-3.0
- load_test
- отчет о нагрузочной проверке
- load_test_repo
- https://github.com/tzx1z/inkvault-loadtest
Задача
Менеджеры паролей, которые разворачиваются на своей инфраструктуре (on-premises) или требуют отдельного сервера под продукт, приносят собственную систему учетных записей, API-сервер и базу данных, и все это нужно развернуть, обновлять и резервировать.
InkVault не нужен свой сервер. Достаточно клиента и учетной записи на любом XMPP-сервере: на своем, на сервере компании или на публичном, где вы обычный пользователь и ничего не администрируете. Учетная запись уже дает аутентификацию, постоянное соединение с доставкой изменений и хранилище в PEP.
InkVault работает со стандартным XMPP-сервером без каких-либо модификаций и ничего не требует от его администратора. Записи хранятся через PEP, который поддерживают многие популярные серверы, в том числе Prosody, ejabberd и Openfire.
PEP (XEP-0163, Personal Eventing Protocol) - это персональный PubSub: сервисом публикации становится сама учетная запись пользователя, ее JID, например user@example.com. Узлы создаются автоматически в рамках учетной записи для трансляции личной информации: настроения, геолокации, текущей музыки. InkVault добавляет в учетную запись свои узлы: учетная запись хранилища - это JID, своего серверного кода нет. Сервер не видит открытые данные.
На чужом сервере его владелец видит только шифротекст и метаданные: число записей, их размер и время изменений. Узлы хранилища закрыты для других пользователей сервера. Стандартного лимита узла в 256 записей хватает для личного хранилища, больший лимит задает администратор сервера. Каждое устройство хранит зашифрованный кэш, а зашифрованный экспорт можно держать отдельно: если сервер потеряет данные, устройство опубликует хранилище заново.
Архитектура
Криптография, модель данных, разрешение конфликтов и движок синхронизации собраны в одной библиотеке на Rust. Ядро не работает с сетью: клиент передает ему полученные от сервера данные и получает команды для отправки, поэтому XMPP-стек у каждой платформы свой.
- Web: TypeScript без фреймворка, ядро в WebAssembly, xmpp.js по WebSocket (wss://), после первого входа вход по токену FAST
- Android: Kotlin и Jetpack Compose, ядро через биндинги UniFFI, Smack по TLS, разблокировка отпечатком через Android Keystore
- Терминал: Rust, ядро подключено крейтом, tokio-xmpp по STARTTLS. Агент по схеме ssh-agent держит хранилище разблокированным за unix-сокетом
- Сервер: любой XMPP-сервер с PEP, свой или чужой, проверено на Prosody 13. Узлы создаются с access_model = whitelist, поэтому контакты с подпиской на присутствие их не читают
Шифрование
Мастер-пароль не покидает устройство. Ключи образуют иерархию, на сервер попадают только обернутые ключи и шифротекст.
- Argon2id: 64 МиБ памяти, t = 3, p = 4. Параметры с сервера проверяются по границам, поэтому сервер не может ни ослабить вывод ключа, ни заставить устройство выделить гигабайты памяти
- Записи шифруются XChaCha20-Poly1305. AAD связывает шифротекст с vault_id, item_id и версией ключа: сервер не может подменить одну запись другой или перенести ее в другое хранилище
- Смена мастер-пароля меняет только соль и обертку user key. Ключи хранилищ, записи и локальный кэш остаются прежними
- Локальный кэш зашифрован ключом, выведенным из vault key, и открывается только после разблокировки
Синхронизация
У каждого поля, тега и признака удаления есть метка гибридных логических часов (HLC). Запись шифруется и публикуется целиком, сервер хранит последнюю публикацию и рассылает ее остальным устройствам учетной записи.
- Слияние по полям: побеждает более поздняя метка HLC, а пароль, проигравший при одновременной правке, остается в истории паролей. Слияние коммутативно, ассоциативно и идемпотентно, поэтому устройства сходятся к одному состоянию
- Новая сессия ничего не публикует, пока не загрузит весь узел. Сессия, восстановленная через XEP-0198, получает только уведомления
- Удаление - это запись с признаком удаления, а не retract: иначе устройство, которое было без сети, вернуло бы удаленную запись. Очистка корзины заменяет записи заглушками, которые побеждают при любом слиянии
- Если сервер потерял данные или восстановлен из старого бэкапа, устройство с кэшем публикует учетную запись и записи заново
Клиенты
Три клиента используют один формат хранилища и один формат зашифрованного экспорта. Ядро, Android и терминальный клиенты открыты под AGPL-3.0, код web-клиента закрыт.
Web
Android
Окно Android-клиента защищено FLAG_SECURE, поэтому скриншоты сняты с отладочной сборки той же версии без этого флага.
Терминал
Терминальный клиент держит хранилище разблокированным в агенте, выводит пароли в stdout для скриптов и не принимает секреты аргументами.
$ printf '%s\n' "$MASTER" | inkvault unlock --password-stdin
$ inkvault ls
Bank alice.smith bank.example.com
GitHub alice-dev github.com
GitLab alice.dev gitlab.com
Grafana alice grafana.example.org
Home router admin http://192.168.1.1
Proton Mail alice@proton.me mail.proton.me
VPS root ssh://203.0.113.10
$ inkvault get GitHub -f username
alice-dev
$ inkvault get GitHub
1^yb,+{tiG,uC7X%[^zfJ|[5
$ inkvault otp GitHub
566836
$ inkvault gen -l 32 --no-symbols --avoid-ambiguous
RNHE5mSrzmnwKnSmc7hA4P6B3uvKV3G5
$ inkvault lock
Стек
Ядро: Rust, RustCrypto (chacha20poly1305, argon2, hkdf), zeroize, serde, wasm-bindgen, UniFFI. Web: TypeScript, Vite, xmpp.js. Android: Kotlin, Jetpack Compose, Smack 4.5, BiometricPrompt, Android Keystore. Терминал: Rust, tokio-xmpp, rustls, clap. Сервер: Prosody 13.
Моя роль
Автор: спроектировал протокол хранения поверх PEP, иерархию ключей и движок синхронизации, написал ядро, три клиента и их тесты.
Результат
Опубликована версия 0.2 ядра, Android и терминального клиентов: APK, подписанные автором, статические бинарники для Linux на x86_64 и aarch64, пакет в Fedora COPR и крейт на crates.io.
- Property-based тест слияния: три устройства с расхождением часов до 4 секунд, 20 000 случайных сценариев правок, удалений, восстановлений и синхронизаций
- Симуляция синхронизации: сервер PEP в памяти и три устройства с офлайн-правками, обрывами сессий и задержанными уведомлениями, 5000 сценариев
- Проверка на открытые данные: после сквозного теста в базе Prosody (prosody.sqlite) ищутся тестовые пароли и названия записей. Совпадений нет: на сервер попадает только шифротекст
- Общий набор тестовых векторов проверяет, что шифрование и слияние дают побайтно одинаковый результат во всех трех сборках ядра: нативной на Rust, WebAssembly в браузере и Kotlin-биндингах на Android. Запись, сохраненная одним клиентом, читается остальными без расхождений
Дальше: автозаполнение на Android, OMEMO 2 для подтверждения нового устройства с уже подключенного и компонент хранилища для совместного доступа к хранилищам, в том числе для пользователей других доменов через федерацию.
Нагрузочная проверка
Как XMPP-сервер справляется с клиентами InkVault: Prosody 13 с SQLite, 100 пользователей по 200 записей, до 3000 операций в секунду. Отчет для владельцев своего сервера: сколько пользователей выдержит VPS, что нагружает сервер и что проверить в настройках.
- 0ошибок на 307 977 операций
- 100пользователей на VPS с 1 ядром и 1 ГиБ, с большим запасом
- 260-280операций в секунду, предел одного процесса Prosody
- 30 МиБпамяти занимает Prosody с 50 клиентами
Установка
Android: через F-Droid из репозитория inkov.dev или APK со страницы релизов на GitHub.
Терминальный клиент на Fedora 43 и новее, x86_64 и aarch64: из COPR, обновления приходят с dnf upgrade.
sudo dnf copr enable inkov/inkvault && sudo dnf install inkvault-cli
На других дистрибутивах Linux: статический бинарник для x86_64 или aarch64 со страницы релизов. Скачайте архив для своей архитектуры и SHA256SUMS, проверьте архив и положите inkvault в каталог из PATH:
sha256sum --ignore-missing -c SHA256SUMStar -xzf inkvault-cli-<version>-x86_64-unknown-linux-musl.tar.gzinstall -m 755 inkvault-cli-<version>-x86_64-unknown-linux-musl/inkvault ~/.local/bin/
На macOS и других платформах: из crates.io, нужен Rust 1.85 или новее.
cargo install --locked inkvault-cli
Сервер учетной записи: Prosody 13 или другой XMPP-сервер с PEP (XEP-0163) и publish-options, Android-клиент также использует Stream Management (XEP-0198). Web-клиенту нужен WebSocket через TLS; с модулями SASL2 и FAST из prosody-modules он входит по токену и не спрашивает пароль XMPP при каждом входе.
Все клиенты работают со стандартным лимитом узла 256 записей, записи в корзине входят в него. Для больших хранилищ администратор сервера поднимает pep_max_items, например до 10 000, затем лимит узла хранилища один раз поднимается в Android-клиенте или командой inkvault max-items.
Ссылки
- Prosody: XMPP-сервер, на котором проверены клиенты
- mod_pep: PEP в Prosody, его настройки и pep_max_items
- XEP-0163: Personal Eventing Protocol, версия 1.0