InkVault
A password manager that needs only a client and an XMPP account. The account can be on any server with PEP, including a public server you do not own. Records are encrypted on the device by one Rust core shared by the web, Android and terminal clients, and the server stores only ciphertext.
attributes
- role
- author
- web
- private repository
- license
- AGPL-3.0
- load_test
- load test report
- load_test_repo
- https://github.com/tzx1z/inkvault-loadtest
Problem
On-premises password managers, or ones that need a dedicated server of their own, bring their own account system, API server and database, and all of it has to be deployed, updated and backed up.
InkVault needs no server of its own. A client and an account on any XMPP server are enough: your own server, a company server or a public one where you are an ordinary user and do not administer anything. The account already provides authentication, a persistent connection that delivers changes, and storage in PEP.
InkVault works with a standard XMPP server without any modifications and needs nothing from its administrator. Records are stored through PEP, which many popular servers support, among them Prosody, ejabberd and Openfire.
PEP (XEP-0163, Personal Eventing Protocol) is a personal PubSub service: the publishing service is the user's account itself, its JID, for example user@example.com. Nodes are created automatically within the account to broadcast personal information such as mood, geolocation or the music being played. InkVault adds its own nodes to the account: the vault account is a JID, and no server code of its own is needed. The server never sees plaintext.
On a server you do not own, the operator sees only ciphertext and metadata: the number of records, their sizes and the time of changes. The vault nodes are closed to other users of the server. The default node limit of 256 records is enough for a personal vault, a larger limit is set by the server administrator. Each device keeps an encrypted cache, and an encrypted export can be stored separately: if the server loses the data, a device publishes the vault back.
Architecture
All cryptography, the data model, conflict resolution and the sync engine live in one Rust library. The core does no networking: a client passes it the payloads received from the server and gets back the commands to send, so each platform keeps its own XMPP stack.
- Web: TypeScript without a framework, the core compiled to WebAssembly, xmpp.js over WebSocket (wss://), sign-in by a FAST token after the first login
- Android: Kotlin and Jetpack Compose, the core through UniFFI bindings, Smack over TLS, unlock by fingerprint through Android Keystore
- Terminal: Rust with the core as a crate, tokio-xmpp over STARTTLS. An agent in the style of ssh-agent keeps the vault unlocked behind a unix socket
- Server: any XMPP server with PEP, your own or someone else's, tested on Prosody 13. Nodes are created with access_model = whitelist, so contacts with a presence subscription cannot read them
Encryption
The master password never leaves the device. Keys form a hierarchy, and only wrapped keys and ciphertext reach the server.
- Argon2id with 64 MiB of memory, t = 3, p = 4. Parameters stored on the server are checked against bounds, so a server cannot weaken the derivation or make a device allocate gigabytes
- Records are encrypted with XChaCha20-Poly1305. The AAD binds the ciphertext to vault_id, item_id and the key version: the server cannot swap one record for another or move it to a different vault
- Changing the master password replaces only the salt and the user key wrapper. Vault keys, records and the local cache stay the same
- The local cache is encrypted with a key derived from the vault key and opens only after unlock
Sync
Every field, every tag and the deletion flag carry a hybrid logical clock (HLC) timestamp. A record is encrypted and published as a whole, the server keeps the last publication and sends it to the other devices of the account.
- Merge is per field: the latest HLC timestamp wins, and a password that lost a concurrent edit stays in the password history. The merge is commutative, associative and idempotent, so devices converge to the same state
- A new session downloads the whole node before publishing anything. A session resumed with XEP-0198 only receives notifications
- Deletions are tombstones, not retracts: otherwise a device that was offline would bring a deleted record back. Emptying the trash replaces records with stubs that win any merge
- If the server lost its data or was restored from an old backup, a device with a cache publishes the account and the records back
Clients
The three clients share the vault format and the encrypted export format. The core, the Android and the terminal clients are open source under AGPL-3.0, the code of the web client is private.
Web
Android
The Android client protects its window with FLAG_SECURE, so these screenshots come from a debug build of the same version with the flag off.
Terminal
The terminal client keeps the vault unlocked in an agent, prints passwords to stdout for scripts and never takes secrets as arguments.
$ 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
Stack
Core: 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. Terminal: Rust, tokio-xmpp, rustls, clap. Server: Prosody 13.
My role
Author: designed the storage protocol on top of PEP, the key hierarchy and the sync engine, wrote the core, the three clients and their test suites.
Outcome
Version 0.2 of the core, the Android and the terminal clients is published: APKs signed by the author, static Linux binaries for x86_64 and aarch64, a package in Fedora COPR and a crate on crates.io.
- Property-based test of the merge: three devices with clocks up to 4 seconds apart, 20 000 random scenarios of edits, deletions, restores and syncs
- Sync simulation: an in-memory PEP server and three devices with offline edits, broken sessions and delayed notifications, 5000 scenarios
- Plaintext check: after the end-to-end test, the Prosody database (prosody.sqlite) is searched for the test passwords and record names. Nothing is found: only ciphertext reaches the server
- One set of test vectors checks that encryption and merge give byte-identical results in all three builds of the core: native Rust, WebAssembly in the browser and the Kotlin bindings on Android. A record saved by one client is read by the others without differences
Next: autofill on Android, OMEMO 2 to approve a new device from an existing one, and a storage component for sharing vaults between users, including users on other federated domains.
Load test
How an XMPP server handles InkVault clients: Prosody 13 with SQLite, 100 users with 200 records each, up to 3,000 operations per second. The report is for people who run their own server: how many users a VPS can serve, what loads the server and which settings to check.
- 0errors in 307,977 operations
- 100users on a VPS with 1 core and 1 GiB, with a large margin
- 260-280operations per second, the limit of one Prosody process
- 30 MiBof memory used by Prosody with 50 clients
Installation
Android: through F-Droid from the inkov.dev repository, or as an APK from the GitHub releases page.
Terminal client on Fedora 43 and newer, x86_64 and aarch64: from COPR, updates come with dnf upgrade.
sudo dnf copr enable inkov/inkvault && sudo dnf install inkvault-cli
On other Linux distributions: a static binary for x86_64 or aarch64 from the releases page. Download the archive for your architecture and SHA256SUMS, check the archive and put inkvault on 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/
On macOS and other platforms: from crates.io, Rust 1.85 or newer.
cargo install --locked inkvault-cli
Server of the account: Prosody 13 or another XMPP server with PEP (XEP-0163) and publish-options, the Android client also uses Stream Management (XEP-0198). The web client needs WebSocket over TLS; with the SASL2 and FAST modules from prosody-modules it signs in by a token instead of asking for the XMPP password each time.
All clients work with the default node limit of 256 records, records in the trash included. For larger vaults, the server administrator raises pep_max_items, for example to 10 000, then the vault node limit is raised once in the Android client or with inkvault max-items.
Links
- Prosody: the XMPP server the clients are tested on
- mod_pep: PEP in Prosody, its settings and pep_max_items
- XEP-0163: Personal Eventing Protocol, version 1.0