counted

← All guides

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.

RecordEncrypted fields
Projectname, currency, description
Participantname
Expensename, amount, date, type, description, category, original currency and rate
Payment lineamount, paid or owes
Recurring ruletemplate, schedule, cursor
Accountdisplay 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.

See it on a real ledger

Open the demo with your browser’s network tab: every write is ciphertext.

Read next

We make Counted. Spotted a mistake in this guide? Tell us.