Hoe Counted een gedeeld kasboek versleutelt als de link de sleutel is
Elk Counted-project heeft een willekeurige sleutel van 32 bytes in het URL-fragment, dat browsers nooit naar een server sturen. Alles wat een gebruiker typt, wordt op het apparaat versleuteld met XChaCha20-Poly1305 voordat het vertrekt. Accounts zijn optioneel; als er een is, verlaat ook het wachtwoord het apparaat nooit. De client is AGPL en reproduceerbaar gebouwd, dus iedereen kan controleren dat wat counted.fr levert overeenkomt met de broncode.
Bijgewerkt op 2 november 2026
De link is de sleutel
Bij het aanmaken van een project wordt op het apparaat een willekeurige sleutel van 32 bytes gegenereerd. De deellink draagt hem, base64url-gecodeerd, na de #:
https://counted.fr/projects/<uuid>#<key>
Volgens de HTTP-specificatie blijft het fragment bij de client: het wordt niet meegestuurd in het verzoek, dus het verschijnt nooit in serverlogs of in een Referer-header. Bij het openen slaat de client de sleutel lokaal op en haalt hij het fragment uit de adresbalk. Dat is het hele toegangsmodel voor mensen zonder account: wie de link heeft, kan het project lezen en bewerken. Behandel hem als een huissleutel.
Wat er versleuteld wordt, en hoe
Elk record wordt naar JSON geserialiseerd en als één blob versleuteld met XChaCha20-Poly1305, met een nieuwe willekeurige nonce van 24 bytes per aanroep. Door de Poly1305-tag van 16 bytes mislukt de ontsleuteling meteen bij een verkeerde sleutel of een gewijzigde byte.
| Record | Versleutelde velden |
|---|---|
| Project | naam, valuta, beschrijving |
| Deelnemer | naam |
| Uitgave | naam, bedrag, datum, type, beschrijving, categorie, oorspronkelijke valuta en koers |
| Betaalregel | bedrag, betaald of verschuldigd |
| Herhaalregel | sjabloon, schema, cursor |
| Account | weergavenaam, voorkeuren, betaalgegevens |
Waarom geen AES-256-GCM: WebAssembly heeft geen AES-NI, dus ChaCha20 is in software zowel sneller als constant-time, en door de nonce van 24 bytes zijn botsingen van willekeurige nonces geen probleem. De cryptografie draait in Rust (de crate chacha20poly1305), gecompileerd naar WebAssembly voor het web en native voor de iOS- en Android-apps. Saldi, afrekenen en grafieken worden ook op het apparaat berekend: de server heeft nooit een bedrag nodig.
Optionele accounts, en wat sleutelbewaring kost
Een sleutel die op één apparaat staat, is met één verloren telefoon weg. Daarom wordt voor wie een account aanmaakt elke projectsleutel ook op de server bewaard, verpakt onder een willekeurige accountsleutel, die zelf verpakt is onder een sleutel die op het apparaat met Argon2id (64 MiB, t=3) wordt afgeleid uit het wachtwoord en een salt per account. Een wachtwoordwijziging verpakt alleen die accountsleutel opnieuw; een account dat nooit zijn wachtwoord heeft gewijzigd en geen herstelsleutel heeft aangemaakt, gebruikt nog direct de afgeleide sleutel. Als een beheerder de sleutel van een project wijzigt, gaat de nieuwe naar elk lid met een account in een hybride X25519 + ML-KEM-768-doos; de anderen krijgen een nieuwe link. De server bewaart die bytes en kan ze niet lezen.
Dat is een bewuste afweging. Zonder bewaring kreeg iemand met de database en het wachtwoord van een gebruiker niets van diens projecten. Met bewaring krijgt hij elk project van dat account. Het alternatief was helemaal geen herstel, en dat is voor de meeste mensen erger. Anonieme leden krijgen geen bewaring: hun enige kopieën zijn de link en hun apparaat.
Het wachtwoord verlaat het apparaat nooit
Bij het inloggen wordt het wachtwoord niet verstuurd. De client stuurt een bewijs, een tweede Argon2id van het wachtwoord onder een aparte login-salt, en de server bewaart alleen SHA-256(proof), vergeleken in constante tijd. Niets wat de server ontvangt of bewaart leidt tot de accountsleutel, en de server voert zelf geen sleutelafleiding uit. De prijs is één extra heen-en-terugreis om de login-salt op te halen.
Het betekent ook dat wij een wachtwoord niet voor je kunnen resetten. Een herstelsleutel van 256 bits, één keer getoond en nooit door de server gezien (die bewaart alleen een daarvan afgeleide hash), opent een tweede verpakking van de accountsleutel. Zonder sleutel gaat een reset per e-mail na 72 uur in, en een wachtwoord dat al 72 uur in gebruik is kan hem annuleren; het account begint opnieuw met een nieuwe sleutel, projecten komen terug van de apparaten en via de links, en de vorige generatie wordt 30 dagen bewaard, alleen te openen met het oude wachtwoord of de herstelsleutel.
Wat de server nog ziet
Alleen “zero knowledge” zeggen zou oneerlijk zijn. De server ziet:
- Voor accounts: het e-mailadres, een hash van het loginbewijs, de salts voor sleutelafleiding, welk account in welk project zit, vriendschapslinks tussen accounts, hashes van uitgenodigde e-mailadressen met een servergeheim als sleutel (HMAC-SHA256), publieke sleutels, of het account een herstelsleutel heeft en sinds wanneer, wanneer het wachtwoord is gewijzigd en elke reset, wie elk project beheert, elke wijziging van een projectsleutel, wie die deed en wie werd verwijderd, en, als meldingen aan staan, het pushtoken en de taal ervan.
- Voor iedereen: IP-adres, user agent en tijdstip in logs die maximaal 30 dagen bewaard worden, de grootte en het tijdstip van versleutelde schrijfacties, en dagelijkse tellingen over hele tabellen.
- De vorm van elk kasboek, alleen in aantallen: hoeveel deelnemers en uitgaven, wanneer elke uitgave is toegevoegd, gewijzigd of verwijderd, en, voor een account dat heeft gekozen wie het in een project is, welke deelnemer het koos. Sinds versie 3.2.5 zijn ook wie in een uitgave zit, wie hem invoerde en of het project gesloten is versleuteld. Een project dat het laatst door een oudere versie is opgeslagen, houdt die koppelingen op de server tot een van de leden het opent met een bijgewerkte app, die ze naar de versleutelde gegevens verplaatst; back-ups bewaren ze tot 30 dagen langer.
- Een import uit Tricount: de browser kan Tricount niet rechtstreeks aanroepen, dus de server haalt het gedeelde register voor hem op en geeft het onversleuteld door. Het wordt niet opgeslagen en niet gelogd; het apparaat versleutelt het voordat het in Counted wordt opgeslagen.
- De lengte van de versleutelde tekst: een stroomcijfer behoudt de lengte van de leesbare tekst, dus de server kan een uitgave met een beschrijving, of een in vreemde valuta ingevoerde uitgave, onderscheiden van een zonder. Nooit de inhoud.
Hij ziet nooit namen, bedragen, datums, beschrijvingen of projectnamen. Elke onversleutelde kolom staat in de ontwerpnotities met een rechtvaardiging, en een releasecontrole faalt als het schema er een krijgt die er niet in staat.
Je kunt de code controleren
De client, alles wat ooit leesbare tekst ziet, is gepubliceerd onder de AGPL op github.com/counted-labs/counted. De build is reproduceerbaar: de CI bouwt hem vanaf nul opnieuw, vergelijkt de SHA-256 van elk bestand en ondertekent de lijst met Sigstore. counted.fr/nl/verify toont de vingerafdrukken van wat er geleverd wordt en hoe je het zelf opnieuw bouwt.
De server is closed source, om de betaalde zakelijke functies te beschermen die daar gepland zijn. Hij bevat alleen versleutelde gegevens, dus die keuze verandert niets aan de vertrouwelijkheid van een kasboek.
Bekende beperkingen
- End-to-end-versleuteling op het web vertrouwt nog steeds de pagina die je laadt. /verify bewijst dat een release overeenkomt met de broncode, en een openbare monitor controleert elke zes uur, vanaf GitHub, dat counted.fr een ondertekende release levert en geen andere code draait. Geen van beide kan bewijzen dat de pagina die je net laadde die release is: een vervanging die aan één gebruiker geleverd wordt, zou onopgemerkt blijven. De iOS- en Android-apps krijgen hun code bij de installatie, dus één verzoek kan die niet vervangen.
- Nog geen onafhankelijke audit. Zeven interne auditrondes zijn uitgeschreven, met hun oplossingen.
- Een gelekte link lekt het project tot een beheerder de sleutel wijzigt. Een verwijderd lid houdt wat het al had gelezen of bewaard, en een project dat zonder account is gemaakt, krijgt alleen een beheerder als wie het heeft gemaakt inlogt op het apparaat waarop het is gemaakt; oudere projecten hebben er geen, en om er iemand uit te sluiten maak je een nieuw project.