Writing · ClearOwe · 6 min read
A ledger its own server can't read
ClearOwe keeps track of money between people. It works offline, follows you to a new phone and shares a balance by link, while the server in the middle learns almost nothing. The three problems that took more thought than the cryptography.
Money between people is private in a way that money with a bank isn't. Who lent whom the deposit for a flat, which friend still owes for the trip, the loan from a parent that nobody mentions out loud. ClearOwe keeps that ledger: who owes you, whom you owe, and who has your things.
I wanted it to do three things that pull against each other. It should work with no signal, because the moment you split a bill is often in a basement restaurant. It should follow you to a new phone. And the other person should be able to see what they owe without installing anything. On top of that, I didn't want to be able to read any of it. Each of those is easy on its own. Together they took some care.
1The phone is the database
ClearOwe is local-first. The source of truth is a SQLite database on the phone. Every screen reads it through live queries and every action writes to it first, so nothing ever waits on the network: recording a debt takes the same two seconds in a lift as on Wi-Fi. The file is encrypted with SQLCipher, under a random key that lives in the iOS Keychain or the Android Keystore.
Every row that syncs carries four extra columns: when it was created, when it last changed, when it was deleted, and whether it has changed since the last sync. Deletes are soft. A row that has simply vanished can't tell your other phone that it's gone, so a deletion is just another edit, one that sets deleted_at.
2Sync the server can't open
Backup is optional. Turning it on creates a 160-bit recovery code, shown once, and the encryption key is derived from it on the phone and never leaves your devices. Every row is sealed with AES-256-GCM before it's uploaded, with the row's kind and id bound in as additional data, so a sealed row can't be quietly swapped into another row's place. This is what the server stores:
records(user_id, kind, id, updated_at, deleted, payload) -- payload: sealed bytes
That's everything it can see: that a row of some kind changed at some time. The honest cost is the recovery code. Lose it and the backup can't be read by anyone, me included, so "start fresh" deletes the old backup and seals a new one from what's on the phone. There is no "forgot password" for data I was never able to read.
3Don't let clocks decide what's new
Incremental sync looks simple. Each phone remembers when it last synced and asks for everything that has changed since. The question hiding in that sentence is: since when, by whose clock?
If the answer is the phone's own clock, two ordinary things break it. Phone clocks drift, and a phone that runs two minutes slow stamps every change two minutes in the past. And a local-first app writes while it's offline, then uploads an hour later, with timestamps from an hour ago. Either way a change can reach the server stamped earlier than another phone's last sync, and that phone will never ask for it. Nothing fails and nothing is logged. The two phones just quietly disagree, for good.
reached phone A –never will –
So the server keeps its own stamp. A trigger on the records table does two things on every write. It keeps whichever version has the newer updated_at, which is last-write-wins between two edits of the same row, and it stamps server_updated_at from the database's own clock. Phones pull by that stamp, never by theirs, with a few seconds of overlap, because a transaction that started earlier can commit later. Clocks still decide which of two edits to one row wins. They no longer decide whether a phone hears about a change at all.
4A link that carries its own key
The other person usually doesn't have ClearOwe, and shouldn't need it. So a balance can be shared as a link: clearowe.com/s/, a random 128-bit id, then a # and a random 256-bit key.
The # is the whole trick. Everything after it is the URL's fragment, and browsers don't send the fragment to the server, not in the request line and not in the Referer header. The page loads, fetches the sealed statement by its id, and opens it in the browser with WebCrypto, using a key that never crossed the network. The server keeps a statement it can't read, exactly like the backup.
key in the server's log –server can open the statement –
The same key seals the other direction. When the other person taps "I've paid", the claim is sealed in their browser and stored until the owner's phone collects it, opens it and checks it; there can be at most 5 an hour and 20 waiting per link. Link previews say only "A balance shared with you", never a name or an amount. The page is noindex, sends no referrer, and its content security policy lets it talk to nothing but the backend. Its pay buttons are ordinary UPI, PayPal, Venmo, Cash App and Revolut links built from the owner's own details, so ClearOwe never touches the money either.
5What's left on the server
The admin screen shows counts: accounts, sign-ups per day, backups, share links, purchases. Look someone up by email and you see when they joined, how they sign in and when they last synced. That is the complete list, and not because of a policy. Everything else is sealed under keys the server never had, so there's nothing else it could show.
None of this is new cryptography. AES-GCM, URL fragments and server-side timestamps are all old ideas. The work was in the plumbing: deciding exactly what the server has to know to do its job, and then making sure it learns nothing else.