How Counted encrypts a shared ledger when the link is the key
Every Counted project has a random 32-byte key that lives in the URL fragment, which browsers never send to a server. Everything a user types is encrypted on the device with XChaCha20-Poly1305 before it leaves. Accounts are optional; when there is one, the password never leaves the device either. The client is AGPL and reproducibly built, so anyone can check that what counted.fr serves is what the source says.
Updated 9 October 2026
The link is the key
Creating a project draws a random 32-byte key on the device. The share link carries it, base64url-encoded, after the #:
https://counted.fr/projects/<uuid>#<key>
The fragment is client-only by the HTTP spec: it is not sent in the request, so it never shows up in server logs or in a Referer header. On open, the client stores the key locally and removes the fragment from the address bar. That is the whole access model for people without an account: whoever holds the link can read and edit the project. Treat it like a house key.
What is encrypted, and how
Each record is serialized to JSON and encrypted as one blob with XChaCha20-Poly1305, using a fresh random 24-byte nonce per call. The 16-byte Poly1305 tag means a wrong key or a tampered byte fails decryption outright.
| Record | Encrypted fields |
|---|---|
| Project | name, currency, description |
| Participant | name |
| Expense | name, amount, date, type, description, category, original currency and rate |
| Payment line | amount, paid or owes |
| Recurring rule | template, schedule, cursor |
| Account | display name, preferences, payment details |
Why not AES-256-GCM: WebAssembly has no AES-NI, so ChaCha20 is both faster and constant-time in software, and the 24-byte nonce makes random-nonce collisions a non-issue. The crypto runs in Rust (the chacha20poly1305 crate), compiled to WebAssembly for the web and natively for the iOS and Android apps. Balances, settle-up and charts are computed on the device too: the server never needs an amount.
Optional accounts, and what key escrow costs
A key that lives on one device is one lost phone away from gone. So for people who choose to create an account, each project key is also stored on the server, wrapped under an account key derived on the device with Argon2id (64 MiB, t=3) from the password and a per-account salt. The server stores those bytes and cannot read them.
That is a deliberate trade. Without escrow, someone holding the database and a user’s password got nothing from their projects. With escrow, they get every project that account holds. The alternative was no recovery at all, which is worse for most people. Anonymous members get no escrow: their only copies are the link and their device.
The password never leaves the device
Login does not send the password. The client sends a proof, a second Argon2id of the password under a separate login salt, and the server keeps only SHA-256(proof), compared in constant time. Nothing the server receives or stores derives the account key, and the server runs no key derivation of its own. The price is one extra round trip to fetch the login salt.
It also means a forgotten password cannot be reset. Nobody, us included, can decrypt the account’s projects without it.
What the server still sees
Saying only “zero knowledge” would be dishonest. The server sees:
- For accounts: the email, a hash of the login proof, the key-derivation salts, which account is in which project, friend links between accounts, SHA-256 hashes of invited emails, public keys, and, if notifications are on, the push token and its language.
- For everyone: IP address, user agent and time in logs kept at most 30 days, the size and timing of encrypted writes, and whole-table daily counts.
- The shape of each ledger, by number only: how many participants and expenses, which participants are in which expense and who entered it, and whether the project is closed or archived. For an account that picked who it is in a project, that ties its email to those expenses. Removing that link is planned.
- Ciphertext length: a stream cipher keeps the plaintext length, so the server can tell an expense with a description, or one entered in a foreign currency, from one without. Never the content.
It never sees names, amounts, dates, descriptions or project names. Every plaintext column is listed and justified in the design notes, and a release check fails if the schema gains one that is not.
You can check the code
The client, everything that ever sees plaintext, is published under the AGPL at github.com/counted-labs/counted. The build is reproducible: CI rebuilds it from a cold start, compares every file’s SHA-256, and signs the list with Sigstore. counted.fr/en/verify shows the fingerprints of what is served and how to rebuild them yourself.
The server is closed source, to protect the paid business features planned there. It only ever holds ciphertext, so that choice changes nothing for the confidentiality of a ledger.
Known limits
- Web end-to-end encryption still trusts the page you load. /verify proves a release matches its source; it does not prove the page you just loaded is that release. A public monitor comparing what counted.fr serves with the signed releases is the next step. The iOS and Android apps ship their code at install, so a single request cannot swap it.
- No independent audit yet. Five internal audit passes have been written up, with their fixes.
- A leaked link leaks the project. There is no per-member access control and no key rotation, by design.