Projects
OpenVireo logo

OpenVireo

An end-to-end encrypted, multiplatform password manager

OpenVireo desktop app showing a Work group with entries and a GitHub entry's details, including a one-time code
The desktop app on macOS. Demo data.

OpenVireo is a fork of KeePassXC, the offline password manager. KeePassXC is excellent at one thing: a single encrypted .kdbx file on one machine. OpenVireo keeps that app working exactly as before and adds what it never had: accounts, live sync across devices, shared vaults, organizations, and service accounts for CI. All of it is end-to-end encrypted, and the data stays in the standard KeePass format.

I designed the system and its protocol, and built it across ten repositories in four languages: a C++/Qt desktop app, a Python auth server, a C++ sync server, a TypeScript SDK and web app, and a Swift iOS app.

Three ways to use it

Local
Plain files, no network. KeePassXC as it always was.
Self-hosted
Run the sync server yourself, with one token per device. No account needed.
Account
The hosted service: sign-in, sharing, organizations, service accounts.

System architecture

In account mode there are two servers with deliberately separate jobs. The auth server knows who people are and who may open which vault, and it stores keys it cannot use. The sync server stores each vault as one encrypted file and pushes change events. Every client does its own encryption and decryption.

OpenVireo system architectureDesktop, web, iOS and CLI clients talk to an auth server for sign-in and keys, and to a sync server for encrypted vault files. The browser extension talks only to the desktop app. The auth server notifies the sync server; the sync server verifies tokens using the auth server's public keys.Browser extensionWebExtensionnative messagingclients · all crypto happens hereDesktopC++ / QtWebNext.js · SDKiOSSwiftov CLI · ActionTypeScript SDKOPAQUE sign-inwrapped keys · accessencrypted .kdbx · ETagslive events (SSE)Auth serverPython / Flask · EdDSA JWTsSync serverC++ / Drogon · ciphertext onlynotifyJWKSStripebillingPostgresaccountsRedislimitsPostgres + S3or a data dir (self-hosted)The sync server never calls the auth server on the request path. It verifies tokens offline.

Sign-in starts with discovery (/.well-known/openvireo-configuration), then OPAQUE against the auth server. The auth server returns a 10-minute JWT that lists every vault the holder may open, along with the user's wrapped keys. The client downloads vaults from the sync server, opens them locally, and holds an event stream open for changes from other devices. When access changes, the auth server pushes an event through the sync server, and clients refresh their token.

Key hierarchy

Nothing the servers hold is useful on its own. Each layer of keys is wrapped by the one above it, and the top of the chain exists only on the user's devices.

OpenVireo key hierarchyThe master password and the Secret Key derive the account unlock key, which unwraps the keyset, which unseals each vault key, which opens the vault file. The servers store only the wrapped keyset, sealed vault keys and encrypted vault files.on the deviceon the serversMasterpasswordSecret Key128-bit · keychainArgon2id + HKDFAccount unlock keymemory onlyunwrapsKeysetX25519 + Ed25519 privateunsealsVault key256-bit · one per vaultopensDecrypted vaultKDBX 4 · in memoryOPAQUE recordauth · checks, never seesWrapped keysetauth · + signed public keysSealed vault keysauth · one per memberEncrypted .kdbxsync · ETag + historydashed: downloaded, then used locally

Sharing is a matter of sealing the vault key once more, to the new member's public key. Removing someone means a client makes a new vault key, seals it to everyone who remains, and re-encrypts the file, because only a client ever holds the plaintext.

Sync and conflicts

The server can't read vaults, so it can't merge them either. Writes use optimistic locking on ETags, and merging happens on the client: a three-way merge per entry, field by field, against the last version both sides had. A same-field conflict keeps the losing value in the entry's history, so nothing is lost.

Concurrent write and mergeThe laptop uploads with an outdated ETag and gets 412. It downloads the newer copy, merges locally and uploads again. The sync server then pushes an event to the phone, which downloads the result.LaptopSync serverPhonePUT If-Match: "e7"412: someone wrote "e8"GET200 ETag: "e8"3-way mergelocal, field by fieldPUT If-Match: "e8"200 ETag: "e9"SSE vault event "e9"GET200 encrypted .kdbxThe server only compares ETags. It never sees the contents.

Screenshots

Desktop sign-in screen with options for an account, your own sync server, or no account
Desktop · Sign in, self-host, or stay local
Database Reports health check listing expired and weak passwords
Desktop · Health check
iOS All Items list with search and filters
iOS · All items
iOS entry detail with username, password strength and one-time code
iOS · Entry with one-time code
iOS organization screen listing members, roles and a pending invitation
iOS · Organization members
iOS Emergency Kit step showing the Secret Key at sign-up
iOS · Emergency Kit at sign-up

Platforms

DesktopC++ / Qt
macOS · Windows · Linux
KeePassXC core plus accounts, sharing, orgs and live sync. Signed builds (Apple notarization, Azure, GPG) with Sparkle / WinSparkle auto-update.
WebNext.js / TypeScript
Any browser
Account vaults and local .kdbx files in the browser, with offline access and auto-lock.
iOSSwift
iPhone · iPad
Native port of the client protocol, with an AutoFill extension and offline unlock. Live on the App Store.
CLI & CITypeScript SDK
Terminal · GitHub Actions
The ov CLI, openvireo-connect and a GitHub Action, so service accounts can inject secrets into builds.
BrowserWebExtension
Chrome · Firefox · Edge
Fills logins and passkeys from the desktop app over native messaging.
ServersPython / Flask · C++ / Drogon
Hosted or self-hosted
An auth server for identity and access, and a sync server that stores only ciphertext. The sync server also runs on its own.

Design decisions

The server never sees secrets
Everything is end-to-end encrypted, vault names included. A full breach of both servers exposes metadata, never passwords.
OPAQUE for sign-in
An asymmetric PAKE: the password never leaves the client, not even at registration. Two OPAQUE implementations (libopaque in C, opaque-ke in Rust/WASM) are interop-tested against each other.
A Secret Key as well as the password
The account key mixes the master password (Argon2id) with a 128-bit device-generated Secret Key, so a stolen database cannot be cracked offline, even for weak passwords.
A vault is a whole KDBX file
Each vault is a standard KDBX 4 file, encrypted with a random 256-bit vault key. That reuses KeePass merging and history, and an exported vault still opens in any KeePass client.
Sharing by sealed keys
The vault key is sealed to each member's X25519 public key (XChaCha20-Poly1305). Public keys are signed, and share dialogs show fingerprints to compare out of band.
Removing a member rotates the key
A client re-encrypts the vault under a new key. A pending key version sits beside the current one, so a crash mid-rotation never leaves a vault unreadable.
Optimistic writes, client-side merge
Uploads carry If-Match. On 412 the client downloads, runs a three-way merge and retries. The C++ and TypeScript merges are tested against the same recorded results.
Short-lived JWTs between two servers
The auth server signs 10-minute EdDSA tokens listing vault access. The sync server checks them offline against the JWKS, so neither server calls the other on the request path.

How it was built

Four implementations (Python, C++, TypeScript and Swift) have to agree byte for byte on the crypto, the wire format and the merge. So the contract came first: a written spec, OpenAPI files for both servers, shared test vectors and a fixture vault, frozen before any implementation began. Each repository was then built against it in parallel. Every client has to pass the same vectors, so drift shows up as a failing test, not a corrupted vault.

Around that sit signed, auto-updating releases on three desktop platforms, nightly and stable channels, a k6 load test against the full stack, and developer docs for the SDK and the self-hostable sync server.

Load test

k6 drives the whole stack with freshly seeded accounts. Each iteration is one client sync check: list vaults on both servers, a conditional download (mostly 304), a 200 KB upload one time in five, and a token refresh when the access token gets old. Every virtual user comes from its own IP, so per-IP rate limits apply as in production, and each container is capped to a small slice of CPU (auth at half a core, sync at 0.15) to approximate a small production host.

Ramp to 120 syncs/sRamp to 600 syncs/s
Requests37,934, 0 errors91,647, 0 errors
Auth p9512 ms1.2 s (saturated)
Sync p951.2 ms1.2 ms
Sync CPU / RAM6% / 33 MB11% / 103 MB

The Python auth server (two gunicorn workers) saturates at about 190 req/s on half a core. The C++ sync server holds 1.2 ms p95 throughout on 0.15 of a core, and Postgres is barely loaded. Most auth traffic is token refreshes, so auth is the part to scale out first; for vault storage, disk space for version history runs out before CPU does.