← Blog

A ride's raw measurements, 3,201,331 bytes, as the backup seals themchunk 0: 1,048,576 Bchunk 1: 1,048,576 Bchunk 2: 1,048,576 Bchunk 3: 55,603 B4 chunks, each sealed on its own. Overhead: 228 bytes, 0.007 %Move, drop or add a chunk, and it no longer decrypts
How a backup seals one ride's raw satellite measurements: four chunks of at most 1 MiB, each sealed on its own and bound to its place. Sealing adds 228 bytes in all.
Under the hood

An app with no server

walker has no server, no account and no sync. How it keeps everything on the phone, how its encrypted backups work, and what that costs.

· Malik · 11 min read

What "no server" rules out

An app with no server has no login screen, no password reset and no "we'll restore it for you". For the developer it means no backend to run, no user table to secure and no bill that grows with users, but also no dashboard that says what is going wrong on someone else's phone. That rules out more than it sounds.

I chose it anyway. A location history says where you live, where you work and where you spend your time, and I didn't want to hand mine to anyone. That is why walker, the walk, hike and ride tracker I make for Android, has a short first principle: all state lives on the phone. There is no account, no server and no sync, and no telemetry or crash reporting either. This post is about what replaces those things.

Where the state lives

Everything walker keeps is in the app's private storage. The database is SurrealDB, embedded in the app: it runs in process on SurrealKV, a key-value engine written in Rust, with its files in the app's directory. No port, no daemon, and its client protocols are compiled out. The rest of the stack is in Writing a whole Android app in Rust, the post to start with.

Two kinds of data stay out of the database. A recording in progress goes to a journal file, each fix appended as it arrives, so the database sees one write per saved walk. And a walk's raw satellite measurements go to a separate file, LZ4-compressed and checked by SHA-256. LZ4 took one bike ride's raw data from 8.5 MB to 3.2 MB.

One design choice comes straight from having no server: the raw fixes are the record, and the cleaned track is derived from them. I can't migrate anyone's data, but I can ship a better cleaner, and every old walk improves with it.

The network, only when you ask

walker does go online, but only for downloads the user asks for or has allowed:

A map download tells its provider the user's internet address and which region they want. The orbit download tells its host the user's internet address and the date. Neither ever carries a position or a route.

All of this goes through one HTTP client, in one source file. Every operation is written to a network log, which Privacy & storage shows: date, host, region, number of requests and bytes. The logging rule reached into TLS as well. Certificates are checked against Android's trust store. Whether one has been revoked, walker learns only from the OCSP response the server staples to its certificate, that is, sends along with it. Letting Android fetch that answer itself would mean requests that never appear in the log.

Android's backup, switched off

Android backs up app data to Google Drive by default. For apps targeting Android 12 or later, turning that off with allowBackup="false" may still leave device-to-device transfers on, depending on the phone's maker. Either would copy the database, with every track, off the phone. walker sets both: allowBackup="false", and data extraction rules that exclude every domain from cloud backup and from device transfer.

To be fair to Google: Auto Backup is end-to-end encrypted from Android 9 with the phone's screen lock. I still wanted the copy to be a file the user sees and moves. That leaves walker responsible for backups itself.

Backups as encrypted files

A backup is one file, walker-<date>.walkerbackup. It holds settings, every activity with its track and raw measurements, steps, the names given to routes, badges and the network log. The user sets a passphrase of at least 10 characters and chooses where the file goes: a folder, a memory card or a cloud drive, through the save dialog or the share sheet. That is the hiker's view, and Own your walks tells it; this is what happens inside the file.

The file starts with a small header that names its key derivation, its cipher and their parameters. Each file carries them, so I can make them harder later and old files keep opening.

The key

The key comes from the passphrase through Argon2id. I chose it because it is memory-hard: each guess at a passphrase costs memory as well as time.

K=Argon2id(P,S; m,t,p,ℓ)K = \text{Argon2id}(P, S;\ m, t, p, \ell)
(1)

KK is the key. PP is the passphrase, exactly as typed, so a trailing space counts. SS is the salt, random and fresh for every file. mm is the memory it fills, tt the number of passes over it, pp the number of lanes, and ℓ\ell the key's length. walker's settings are close to RFC 9106's recommendation for memory-constrained environments, and on a phone they take about a second.

Because a file carries its parameters, it also tells walker how much memory to take, and a hostile file could ask for far more than any phone has. walker checks the parameters against sane bounds before it allocates anything, and refuses a file outside them as damaged.

The passphrase is wiped from memory after use, and so are the copies kept by egui, the UI library walker is built on: its text field keeps an undo history, even for a masked field, so walker clears that history every frame.

The cipher

The cipher is XChaCha20-Poly1305, an AEAD: it encrypts and authenticates in one step.

(C,T)=XChaCha20-Poly1305(K,N,A,M)(C, T) = \text{XChaCha20-Poly1305}(K, N, A, M)
(2)

MM is the plaintext, here the settings and tracks. NN is a random 192-bit nonce, AA the associated data, and CC the ciphertext, as long as MM. TT is a tag over AA and CC. AA is authenticated but not encrypted, and walker makes it the whole header, so a changed parameter or salt fails exactly like a changed ciphertext. A wrong passphrase and a damaged file give the same message, because the cipher can't tell them apart: either way, the tag doesn't match.

Why 192 bits? A nonce must never repeat under one key. If qq messages are sealed under one key with random 192-bit nonces, the chance that two share a nonce is at most

q(q−1)2⋅2−192<q22193\frac{q(q-1)}{2} \cdot 2^{-192} < \frac{q^2}{2^{193}}
(3)

Here qq is the number of messages and 21922^{192} the number of possible nonces. Even q=264q = 2^{64} gives less than 2−652^{-65}. With plain ChaCha20-Poly1305 (RFC 8439) the nonce has 96 bits, the bound is q2/297q^2/2^{97}, and four billion messages reach 2−332^{-33}. For walker the bound hardly matters, though: every file gets a fresh salt, so no two files share a key.

Streaming

A year of raw measurements can run to hundreds of megabytes, and it shouldn't sit in memory. After the settings and tracks, the raw files follow in chunks of up to 1 MiB, each sealed separately under the same key. Each chunk is bound to its position in the stream, to the backup's header, to the raw file it belongs to, and to whether it is the last.

Why a chunk can't be moved, dropped or added

Each attack on the stream runs into one of those bindings:

This is close to STREAM, the construction Hoang, Reyhanitabar, Rogaway and Vizár proposed in 2015 for encrypting a stream in segments, and which they prove secure whenever the underlying AEAD is. walker's scheme differs from it in small ways, so I don't call it STREAM, and I haven't written a proof for walker's variant. The argument above rests on the two facts behind STREAM's proof: no two chunks share a nonce, and the AEAD's tag can't be forged. walker's tests check each case in the list but the splice.

The overhead

The overhead is a few dozen bytes per 1 MiB chunk: the 3.2 MB bike ride is four chunks, which add 228 bytes, about 0.007 per cent. In a test, sealing twenty 3 MiB files raised the app's peak memory by 10 MiB and took 0.3 seconds each way.

Restore only adds

A restore never overwrites. It shows a preview first: how many activities the file holds, their date range, and how many are new on this phone. Then it adds only what is missing:

So a restore adds the activities, step days and log entries the phone doesn't have yet. For an activity, the id alone decides; for a step day, the date.

Two properties follow, and I rely on both. Write R(D,F)R(D, F) for what the phone stores after restoring the file FF onto the phone's data DD. The first is that restoring twice changes nothing:

R(R(D,F),F)=R(D,F)R(R(D, F), F) = R(D, F)
(4)

After the first restore, every id, day and log entry in FF is on the phone, so the second time there is nothing left to add. The only settings a restore may change are the date of the last backup and whether the phone counts as set up, and only when that date moves later. By the second restore the date is equal, so they stay.

The second is that two phones' backups combine in either order, with two exceptions. Restoring one file and then the other leaves the phone with the same activities, days and log entries as restoring them the other way round. What order can change is which copy wins when both files hold an activity or a day this phone doesn't have: the file restored first. If one phone renamed that walk, order decides which name stays. The date of the last backup can differ too, because it moves only when a file holds everything the phone has at that moment.

Both properties need ids that are unique across phones. They are UUIDv7 (RFC 9562), with about 73 random bits after the start time, so a clash across phones is vanishingly unlikely: ten thousand activities, a walk a day for more than 27 years, give about 5×10−155 \times 10^{-15}.

Because nothing is changed or deleted, a restore needs no scary confirmation. Replacing everything is two deliberate steps: Delete all data, then restore.

Moving to a new phone is a backup on the old one and a restore on the new one, through Backup & restore; the help has the steps. Map regions aren't in the backup. They are public data, and the new phone downloads them again in Map packs.

One practical detail for anyone restoring into SurrealKV: a transaction has to fit in one memtable, an in-memory buffer of fixed size, and one transaction holding every track can outgrow it. walker writes the tracks first, in small batches, then the activities that point to them. A track is only ever read through its activity, so what the user sees is all or nothing.

What it costs

Local-first is a trade, and the costs are real. The first is that there is no cloud safety net. If the phone is lost and there is no backup, the walks are gone. walker reminds you on the map when the last backup is over 30 days old, and you can change that. It can't do more than remind.

The file and the passphrase are yours to keep. A forgotten passphrase can't be recovered, by me or anyone else. When a backup goes to another app through the share sheet, walker says "Handed to the app you chose. walker can't tell whether it arrived." That is literally true.

walker is one phone at a time. There is no sync between devices: a backup can carry walks from one phone to another, but two phones don't stay in step. Sync is on the "maybe later" list, and only if it is end-to-end encrypted.

The counterpart of "no bill that grows with users" is how walker is paid for. There is no server bill to recover, so walker is bought once, with no subscription, no in-app purchases and no ads.

For the developer, the format is forever. Every file ever written has to keep opening. A change to the format bumps its version, and walker still reads format 1. Enum values unknown to an older walker fall back to a default instead of failing.

Bugs are harder to see, too. With no crash reports, I learn about a problem from my own walks, from logs on a phone on my desk, and from people who write to me.

For me, that trade is the point. Nothing I could leak is on a server, because nothing is on a server.

walker Record walks, hikes and rides with offline maps. Nothing leaves your phone. Coming soon to Google Play.