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
core
https://github.com/tzx1z/inkvault-core
android
https://github.com/tzx1z/inkvault-android
cli
https://github.com/tzx1z/inkvault-cli
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.

How clients reach the dataWebTypeScript, WASMcore: encrypt, mergeWebSocket, wss://AndroidKotlin, UniFFIcore: encrypt, mergeXMPP, TLSTerminalRust, agentcore: encrypt, mergeXMPP, STARTTLSAny XMPP server with PEPPEP (XEP-0163), tested on Prosody 13access_model = whitelistPEP of the account alice@example.orgurn:inkvault:account:0KDF parameters, saltwrapped user key and vault keysurn:inkvault:vault:0records, ciphertext onlylimit: pep_max_items, 256 by default
Each client talks to the XMPP server over its own transport. Encryption and merge happen in the core on the device, the server keeps two PEP nodes with ciphertext.
  • 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.

Key hierarchyon the device, in memoryon the server, in PEPMaster passwordentered by the userArgon2id → master keym = 64 MiB, t = 3, p = 4HKDF-SHA256 → wrapping keylabel user-keyUser keyrandom, 256 bitVault keyone per vaultXChaCha20-Poly1305AAD: vault_id, item_id, key_versionurn:inkvault:account:0salt, KDF parametersuser key, wrappedvault keys, wrappedurn:inkvault:vault:0records, ciphertext
Key hierarchy. The left column exists only in device memory, the right column is what the server stores.
  • 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.

Sync between two devicesPhone (Android)XMPP server (PEP)Laptop (web)change a fieldHLC stamp, encryptpublish item, id = recordstore, replace by idsees only ciphertext+notify eventdecrypt, merge by HLCsave encrypted cacherefresh the listafter being offlinenew session: fetch all itemsitems → merge → publish
Sync between two devices of one account. A device that was offline downloads the whole node on reconnect, merges it and publishes what the server is missing.
  • 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

Web client: the vault with the tag tree and a selected record, the password is hidden
The vault in the web client: the tag tree on the left, the record list and the selected record. The password stays hidden until it is shown.
Web client: a PIN and code password with the full password and a countdown
A PIN + code password: the full password is the PIN followed by the current TOTP code, with a countdown to the next code.
Web client: the password generator dialog
The password generator runs in the core: length, character classes, exclusion of look-alike characters and an entropy estimate.
Web client: the server is unavailable, one change is not sent yet
The server is unavailable: the vault stays open from the encrypted cache, and the edited record waits to be sent.
Web client in the dark theme
The dark theme follows the system setting.

Android

Android client: unlock with the master password
Unlock with the master password, or with a fingerprint after it is enabled.
Android client: the record list with search and tag filters
The record list with search and tag filters.
Android client: the GitHub record, the password is hidden
A record: show, copy and generate buttons sit inside the fields.
Android client: a PIN and code password with a countdown ring
PIN + code: the full password with a countdown ring to the next code.
Android client: the password generator dialog
The password generator, the same as in the web client.
Android client in the dark theme
The dark theme follows the system setting.

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

Read the load test report

Installation

Android: through F-Droid from the inkov.dev repository, or as an APK from the GitHub releases page.

Add to F-Droid

Terminal client on Fedora 43 and newer, x86_64 and aarch64: from COPR, updates come with dnf upgrade.

  1. 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:

  1. sha256sum --ignore-missing -c SHA256SUMS
  2. tar -xzf inkvault-cli-<version>-x86_64-unknown-linux-musl.tar.gz
  3. install -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.

  1. 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.

All projects