Byway is a location-history app built so that we cannot read your location data. This document explains how we use End-to-End Encryption to achieve that.
Key points
- Your data is encrypted on your device before it leaves. We only ever store locked blobs we can't open.
- The key that decrypts it is generated on your device and never reaches us in a form we could use.
- All the analysis (visits, trips, stats) runs on your own devices, never on a server.
- You hold three things: your email, one account password (or a passkey), and one Secret Key written in your Recovery Kit.
- A weak password still can't be cracked from stolen data: guessing it offline needs the Secret Key too, and we never hold that.
- The password we receive can't decrypt anything. What reaches our server is a derived value that proves it's you and is useless for reading data.
- Naming the places you've been uses coarse public map tiles, so it never sends your coordinates anywhere.
- Your devices check, using digital signatures, that nobody has tampered with or rolled back your history.
- Data synced between your devices is protected against future quantum computers, so ciphertext captured today can't be unlocked in a decade.
- The trade-off: lose your password (or passkey) and your Secret Key, and the data is gone. We can't recover it for you.
The rest of this document comes in two parts:
- Part 1 — the plain-English version. For anyone who isn't a cryptographer.
- Part 2 — the full detail. The actual primitives, key hierarchy, wire formats, and threat model.
Part 1 — The plain-English version
1.1 What we're promising
Byway records where you've been — a continuous stream of GPS points from your phone, turned into a timeline of visits and trips. That is about as sensitive as personal data gets. Our promise is simple:
We cannot see your location data. Not "we won't look" — we can't. The data leaves your phone already scrambled, and only your devices hold the key to unscramble it.
This isn't a policy or a privacy setting we could quietly change later. It's built into the shape of the system. We literally can't read your history, the same way a self-storage company doesn't have a copy of the key to the padlock you brought.
1.2 How Byway is built differently
Most location apps work like this: your phone sends every GPS point to a server, the server stores it in a database, and the server does all the clever work: figuring out which points are a "visit" to a café, which are a road trip, computing your stats. That's convenient, but it means the server holds a perfectly readable copy of everywhere you've ever been. Anyone who breaks into that server, or subpoenas it, or works there and gets curious, can read it.
Byway is built the other way around:
- Your device does the thinking. All the analysis (detecting visits, trips, travel distances, stats) runs locally: on your phone, and equally in the browser web app and in the desktop command-line tool.
- The server becomes a dumb locker. It stores only sealed, encrypted blobs. It never sees a single readable GPS coordinate. It hands your encrypted blobs back to your other devices when they ask, checks who's allowed to fetch what, and pushes "hey, there's something new" notifications. That's it.
- The key lives with you. The one key that can unscramble your data is generated on your phone and never leaves it in a form the server could use.
So when your second phone, or the web app, wants to show your timeline, it downloads the sealed blobs, unlocks them locally with your key, and does the analysis itself. The server was just a courier carrying locked boxes it can't open.
1.3 Why it's actually unreadable
There are three ways the server could read your data. The design closes all three:
"Could the server just decrypt the blobs?" No. The blobs are encrypted with a key (called a DEK, for Data Encryption Key) that is a random 256-bit number generated on your device. The server never receives it. It only ever receives copies of that key that are themselves locked: by your password, by your Secret Key, by each of your passkeys, and by each of your devices' keys. To the server, every one of those is just more scrambled bytes. There is no copy it can open.
"Could the server read the data on its way up, before it's encrypted?" No. The encryption happens on your phone, before anything is sent. The data is already sealed when it crosses the network, and it's sealed with modern authenticated encryption (AES-256-GCM, the same class of algorithm that protects your bank connection, approved for classified data). The plaintext coordinate never exists anywhere except inside your own devices.
"Could a sneaky or hacked server trick your other devices?" This is where many "E2EE" products are weak. A malicious server can't read your data, but could it tamper: hand your other phone a doctored or rolled-back version of your history, or quietly drop some entries? The design defends against this too: every "table of contents" of your data (we call it the manifest) is digitally signed by one of your own devices, and your other devices refuse to accept anything that isn't correctly signed or that looks like an older version being replayed. So the server can delay you or refuse service, but it can't forge or silently edit your history without being caught.
A fourth question comes up with any password-based encryption: "you receive my password when I sign in — doesn't that give you the key?" No. Your password never leaves your device. Your device mixes it with your Secret Key and splits the result in two: one half unlocks your data and stays on the device, the other is sent to us to prove it's you. The half we receive cannot unlock anything.
1.4 Naming your places without revealing where you are
To turn a cluster of GPS points into "you visited the coffee shop on the corner," the app needs to know what places exist near you. The obvious way to do that — ask a server "what's at these exact coordinates?" — would defeat the point: you'd be handing your precise location, and the time you were there, straight to a server. Byway never does that.
Instead, the world is pre-divided into coarse map tiles. Each tile is a chunk of the map big enough to contain a whole neighbourhood's worth of shops, streets and landmarks. When the app needs to name your places, it works out which tile you're in, downloads that entire tile — the same public tile everyone else in that area gets — and does the matching on your own device. Two things make this private:
- Only the tile leaves, never your coordinate. The download reveals, at most, a broad area, not where you are inside it, and not which of the hundreds of places in the tile you visited. That matching happens locally, and the result (like all your other data) is encrypted before it's stored or synced.
- Mostly untied to when. Tiles are cached, so anywhere you go regularly stops generating requests. Somewhere new is fetched while you're there — but what that reveals is the area, not where you are inside it, and not which of its places you visited.
1.5 The three things you hold
The flip side of the key living with you: if you lose your only way to unlock it, the data is gone. We can't email you a reset link, because we don't have your data. So you hold three things, and no more:
- Your email. Identifies the account. Not a secret.
- One account password, or a passkey (the passwordless kind backed by Face ID, Touch ID or your password manager). This is what you use day to day: it both signs you in and unlocks your data.
- One Secret Key, shown once at sign-up on a printable Recovery Kit. It does two jobs: it makes your password uncrackable even if our servers are breached, and it's the single thing that can restore access if you forget your password.
Every extra secret is another thing to lose, so there are as few as the guarantee allows: one to sign in with, one to recover with.
In everyday use:
- It just opens. After first setup there's no prompt at all. On the phone your key is cached in the system keychain, marked so it never leaves that one device and is unreadable until the device has been unlocked once after a restart. In the browser it is kept in the site's own storage, which is protected by the browser's per-site isolation rather than by the phone's hardware. No password prompt every time, on either. (You can optionally turn on a Face ID lock if you want one.)
- A passkey is a real unlock, not just a login. Where supported, one tap both signs you in and opens your data.
- Adding a new device either signs in with your password and Secret Key, or uses a QR-code handshake with a device you already have. Either way the key is transferred encrypted, so the server relaying it still can't read it.
- Your Recovery Kit is one value. A single Secret Key on one printable page. Keep it in a drawer or a password manager.
Your password is combined with your Secret Key before it's used, which closes the attack that usually makes password-based encryption fragile: someone who steals the encrypted blobs can't sit offline guessing passwords against them, because they're missing the Secret Key and we never hold it.
That isn't a licence to choose a bad one. A guessable password is still guessable by anyone who can reach the sign-in page, or who has your Recovery Kit — it just can't be attacked at leisure with a copy of your data.
1.6 What locks what
There is one real key — the keyring — and it encrypts your location history. Everything else makes locked copies of it, so that any one of the things you own can open it, and nothing the server holds can:
As stages: sign-in takes the top-left path (password + Secret Key → mixer →
authKey for the server, encKEK to open copy ①) or a passkey tap (copy ③);
day-to-day your enrolled devices use copy ④ silently; recovery needs only the
Recovery Kit (copy ②).
Two things follow:
- The server appears exactly once, on the
authKeyarrow, and it's drawn as a dead end because it is one. That value is derived from your password by a one-way function, so it can't be turned back into the one that decrypts. A version of us that logged every sign-in forever would still hold nothing that opens your data. - Four independent locks, one key. Password, Secret Key, each passkey, each device — any one opens the keyring, and losing one doesn't cost you the others. They're alternatives, not a chain, which is what makes three things enough to remember.
1.7 Ready for quantum computers
There's a real attack that doesn't need a quantum computer today: capture encrypted traffic now, store it, and decrypt it in ten or twenty years when the hardware exists. It's called "harvest now, decrypt later," and it matters most for data that stays sensitive for decades — which is exactly what a location history is.
The lock on the data itself is not the worry: the kind of encryption that scrambles your history holds up fine against quantum computers. The exposure is in the brief handshake where your devices pass the key to each other, which is the part that kind of computer would be good at unpicking.
So that handshake is locked twice over, with two different kinds of maths at once: the one in use everywhere today, and a newer one designed to resist quantum computers. An attacker has to break both to get anything, which also means you're covered if the newer one turns out to have a flaw of its own. None of this is visible to you. It's simply why a copy of your data captured today shouldn't be readable in twenty years.
(The specifics are in §2.3 and §2.4.)
Part 2 — The full detail
This is for anyone who wants to audit the claim: the threat model, the primitives, the key hierarchy, the wire formats and their authenticated-associated-data (AAD) bindings, how the untrusted server is kept honest, and the limitations.
On names in the wire formats.
dosojinwas Byway's internal codename and survives in domain-separation strings that predate the rename ("dosojin/seg/…","dosojin/manifest/v1"). They are protocol constants — changing one invalidates every blob sealed under it — so they stay. Strings added by the current credential design usebyway/….
2.1 Threat model
Parties:
DEVICE (trusted; holds plaintext + keys + compute) iOS · web · desktop CLI
local database · detection/analysis engine · recorder (one device) · crypto
│ ciphertext only
RELAY (untrusted; object storage + thin auth/notify service) — never decrypts
│ public data only
STATELESS SERVICES place/POI data · map tiles · geocoding data
- Trusted: the user's own devices. They hold the plaintext, the compute, and the keys.
- Untrusted / honest-but-curious → actively malicious: the relay (the storage and notification service). We assume it can be compromised, subpoenaed, or malicious. The design's job is that a fully malicious relay still cannot read location data, and cannot forge/rollback history undetected. It can deny service (refuse to serve blobs): availability is explicitly not something end-to-end encryption can guarantee against your own storage provider.
- A future quantum adversary, retroactively: an attacker who records ciphertext now and decrypts it once quantum hardware exists ("harvest now, decrypt later"). Addressed for the key-transfer path in §2.4/§2.8; the data layer is resistant regardless.
- Out of scope by design: a compromised client device (if malware owns your unlocked phone, it sees your data, and no client-side encryption can prevent that); forward secrecy / messaging-grade ratchets (counterproductive here: you must retain keys to read your own history forever); and cross-user sharing (there is none).
The guiding invariant: the relay holds only ciphertext plus public data, and must never be able to read location data. This is a design constraint, not a preference.
2.2 What leaves the device
Two logs are the ground truth, and both are encrypted before they leave:
- Points log — append-only, single-writer (the one recording device), the raw GPS stream.
- Edit log — multi-writer, last-writer-wins (LWW) by
(edited_at, device_id), tombstoned; renames, merges/splits, place assignments, trips, dismissals.
Everything derived — visits, travel, trips, stats — is a pure deterministic function
of those logs, recomputed identically on every device. Nothing derived needs to be trusted
from the server; a device can always rebuild it from what the logs contain. The
detection/trip/travel engine is a single implementation compiled to native mobile
libraries (an .xcframework for iOS) and to WebAssembly for the web, so every
platform runs identical detection logic. The one piece not in that shared
engine is the thin projection that replays the edit log over the detected visits — it
lives natively per platform (a hot path where crossing the WASM/FFI boundary per edit
would be too costly), so it is the only place more than one implementation exists. They
are held in agreement by a cross-platform differential test: a corpus of adversarial cases
is generated from the Go implementation, and the Swift and TypeScript ones must reproduce
its output exactly, so they can't silently diverge either.
On disk and on the wire the logs are chunked into segments and periodically compacted into snapshots, indexed by a manifest. All three blob types are AEAD-sealed; the manifest is a signed index (see §2.6). The precise coordinate also never leaves for geocoding: reverse-geocoding and place-matching are done locally against coarse geographic tiles the device fetches, sending only the containing cell, never the point (see §2.9).
2.3 The primitives
All chosen to be native on every target platform — Go (crypto/*), WebCrypto plus
@noble (browser), CryptoKit (Apple) — so one envelope is byte-identical everywhere with
no bespoke crypto and no heavyweight portable library on the hot path.
Every derivation and envelope here is pinned by golden test vectors on each runtime, so a platform computing different bytes fails its own suite.
| Purpose | Primitive | Why |
|---|---|---|
| Bulk data encryption | AES-256-GCM | AEAD (confidentiality + integrity in one), hardware-accelerated everywhere, native on all four runtimes. Not meaningfully threatened by quantum search. |
| Key/subkey derivation | HKDF-SHA256 | Standard extract-and-expand KDF; derives per-segment subkeys, the credential split, and wrap keys. |
| Device-to-device key transfer | X25519 + ML-KEM-768 (hybrid) | Classical ECDH combined with a NIST-standardised post-quantum KEM; both must break for a wrap to open (§2.4). |
| Manifest signatures | Ed25519 | Fast deterministic signatures; verified byte-for-byte across platforms by a known-answer test. |
| Password → key stretching | Argon2id (m=46 MiB, t=1, p=1, 32-byte out) | Memory-hard KDF resistant to GPU/ASIC cracking; the one primitive not in WebCrypto/CryptoKit, so it ships via libsodium (native) / WASM (web). |
| Passkey → key material | WebAuthn PRF extension | Yields a per-passkey secret from an authenticator, so a passkey can wrap the keyring rather than only authenticate. |
Why AES-GCM and not XChaCha20? AES-GCM has a well-known limitation: a single key should encrypt fewer than ~2³² messages (nonce-reuse / birthday-bound concerns). XChaCha20-Poly1305's extended nonce sidesteps this, but it is absent from CryptoKit and WebCrypto, which would force a libsodium dependency into the hot path on iOS and web. The design instead derives a fresh per-segment subkey via HKDF (see §2.5), so no single key ever encrypts more than one segment's worth of records — keeping AES-GCM comfortably under its wall while using only native primitives.
Why ML-KEM only for key transfer? The retroactive risk is entirely in the asymmetric handshake: an eavesdropper who stores a classical ECDH exchange can break it later. The symmetric layer (AES-256-GCM, HKDF-SHA256) has no equivalent exposure, and neither do Ed25519 manifest signatures — forging one after the fact buys nothing, since your devices already accepted the history at the time.
2.4 The key hierarchy
One data key, wrapped several independent ways. The plain-English map of this — what you hold, what your devices hold, and which arrow the server participates in — is §1.6 ("What locks what"); this section gives the same structure precisely.
The precise derivation (two-secret key derivation, "2SKD"):
authSalt = SHA-256("byway/salt/v2" ‖ SecretKey ‖ userSalt)[:16]
rootKey = Argon2id( NFC(password), authSalt, m=46 MiB, t=1, p=1, 32 B )
encKEK = HKDF-SHA256(rootKey, salt=∅, info="byway/kek/v2", 32 B) // never leaves the device
authKey = HKDF-SHA256(rootKey, salt=∅, info="byway/auth/v2", 32 B) // sent to the relay as the "password"
recoveryKey = HKDF-SHA256(SecretKey, salt=∅, info="byway/recovery/v2", 32 B)
- The
SecretKeyis 32 random bytes, shown once in the Recovery Kit and never sent to the relay. Folding it into the Argon2 salt means an attacker who steals the relay-stored wrapped-keyring blob cannot run an offline dictionary attack against a weak password: they are missing the salt input entirely. It is also, on its own, the recovery credential (§2.8). userSaltis 16 non-secret random bytes, freshly minted on every credential change. It is server-visible so it can be served before sign-in (§2.7); it is a salt, not a secret.- The split is the point.
encKEKandauthKeyare two HKDF labels off the samerootKey. The relay receives onlyauthKey; because HKDF is one-way,authKeycannot be walked back torootKeyand therefore cannot reachencKEK. A relay that logged every password it ever received would still hold nothing that decrypts data. - Argon2id parameters are fixed at m=46 MiB, t=1, p=1 — memory-hard enough to blunt GPU cracking, and fast on every target (tens of milliseconds, including in browser WebAssembly, so the web path needs no weakening). Password bytes are NFC-normalized first so combining marks hash identically across platforms.
What gets wrapped, and by what:
- The DEK is a random 256-bit key. It (via per-segment subkeys) encrypts every segment and snapshot. It is generated on the first device, cached only in the hardware-backed keychain, and never persisted unwrapped, never sent to the relay.
- Key epochs — it's really a small keyring. After a device revocation the account
rolls to a fresh DEK under a new epoch without re-encrypting old data, so in
general a device holds a keyring (
epoch → DEK), not a lone key. Writes always seal under the current (highest) epoch's DEK; each stored blob is tagged with the epoch that sealed it, and a reader picks the matching DEK from its keyring. A brand-new account is just a one-entry keyring (epoch 1), encoded on the wire identically to a bare single DEK. Everything that "wraps the DEK" wraps the whole keyring, so each wrap can still open all of your history. How and why epochs are minted is §2.8. - Each wrap is an AES-256-GCM seal with a fixed AAD, versioned for schema evolution. The
password, recovery and passkey wraps use
"byway/dek/v2"; the hybrid device wrap reuses its own"byway/enroll/v2"as both the HKDF info and the AAD, so a device envelope can't be opened as though it were one of the others. The relay stores these wrapped blobs and to it they are opaque ciphertext. - Every account always has the recovery wrap, plus at least one of the password wrap or a passkey wrap. A passkey-only account has no password wrap at all: there is nothing for a password to open, which is why the apps don't ask for one.
- Rewraps are cheap and never re-encrypt data. Changing your password, rotating your Secret Key, or enrolling/revoking a device only re-wraps the same keyring under a new outer key. Gigabytes of segments are untouched.
Passkey wraps (WebAuthn PRF). A passkey assertion with the PRF extension yields a
per-passkey secret; HKDF(prfSecret, info="byway/prf/v2") gives the wrap key, and the
resulting wrap is stored per credential. The PRF salt is versioned
("byway/prf/salt/v2"). This makes a passkey a vault credential, not merely an auth
one: one tap signs in and unlocks. Two caveats, both real:
- PRF must be requested when the passkey is created; a credential registered without it can never produce PRF output and stays sign-in-only. Such a passkey is not a way into your data.
- Support is uneven across authenticators, so the password (or the Secret Key) remains the universal path.
Hybrid post-quantum device wraps. The per-device wrap combines X25519 with ML-KEM-768:
ss_x = X25519(ephemeralPriv, devicePub)
ss_m = ML-KEM-768.Encapsulate(deviceMLKEMPub) → (ct, ss_m)
wrapKey = HKDF-SHA256(ss_x ‖ ss_m, salt=∅, info="byway/enroll/v2", 32 B)
wire = 0x02 ‖ ephemeralPub(32) ‖ ct(1088) ‖ nonce(12) ‖ sealed keyring ‖ tag(16)
- The combiner is concat-then-HKDF over both shared secrets, so the wrap opens only if you hold both. It stays secure if either X25519 or ML-KEM is broken, including if the newer algorithm turns out to be flawed.
- ML-KEM private keys are stored as the 64-byte (d,z) seed rather than the ~2.4 KB
expanded key: keychain-friendly, and every platform expands from the seed. Verified
seed-compatible across Go's
crypto/mlkem,@noble/post-quantumand CryptoKit by shared test vectors. - A version byte (
0x02) prefixes the envelope, so the wrap format can evolve without ambiguity. - The QR enrolment payload is unchanged (
{deviceID, x25519Pub}); the ML-KEM public key travels in the device's registration record, so the extra ~1.1 KB is relay-borne, not crammed into a QR code.
2.5 The data envelope (per-segment subkeys)
Every data blob (segment or snapshot) is sealed like this:
subkey = HKDF-SHA256(ikm = DEK, salt = ∅, info = "dosojin/seg/<segID>", 32 bytes)
sealed = nonce(12) ‖ AES-256-GCM_seal(subkey, nonce, AAD, plaintext) // ct ‖ tag(16)
Per-segment subkey.
segIDis a globally-unique random 64-bit id (snapshots use a disjoint negative-id namespace). Because the AES key is derived per-segment, no single AES-GCM key encrypts more than one segment — this is what keeps AES-GCM safely under its 2³² wall (the reason XChaCha20 isn't needed). Nonces are a fresh 12 random bytes per seal.Epoch selection. Each blob's manifest reference carries the key-epoch that sealed it (§2.4/§2.8); a reader first picks that epoch's DEK from its keyring, then derives the per-segment subkey from it. A ref with no epoch tag means epoch 1 (pre-rekey blobs), so old data stays readable with zero migration.
Wire format is
nonce(12) ‖ ciphertext ‖ tag(16), exactly CryptoKit's combined sealed-box layout, and the same bytes parse on Go / WebCrypto / JCA.AAD binding. The Additional Authenticated Data cryptographically binds context into the tag so a blob can't be silently reinterpreted:
- segments:
"dosojin/seg/v<schemaVersion>/id<segID>" - snapshots:
"dosojin/snap/v<schemaVersion>/seq<throughSeq>"
Because the id and the schema version are authenticated, a segment cannot be replayed as a different segment or a different schema version — the tag check fails on any mismatch (reorder/replay and bit-flip tampering are both rejected on open). Each blob carries the schema version it was sealed under, so bumping the store's schema for new writes never makes old blobs unreadable.
- segments:
Two more sealed families, bound slightly differently. Materialized visit chunks and
the places/trips catalogs are sealed the same way but derive their subkey from the blob's
storage key rather than a client-minted id — HKDF-SHA256(DEK, info = "dosojin/visitblob/<objectKey>"), with AADs "dosojin/visitchunk/v<sv>/<month>/<key>" and
"dosojin/<places|trips>/v<sv>/<key>". The per-blob-uniqueness property is the same; the
input differs because these blobs are addressed by a key the relay assigns.
Snapshots exist purely to make cold-start cheap: a fresh device fetches the latest snapshot plus the tail segments after it, rather than replaying the whole log, so a cold start transfers kilobytes (the compact visits snapshot) rather than the entire raw point history. Confidentiality never depends on the snapshot or the manifest — only on the DEK and the AEAD.
2.6 The signed manifest
The manifest is the index of every blob: what exists, in what order, sealed under which key-epoch. It is the one structure a malicious relay could attack without decrypting anything — by serving an old copy, dropping entries, or inventing them. So it is Ed25519-signed by the writing device, carries a monotonically increasing version, and is verified on every pull:
- A manifest whose signature doesn't verify is rejected, not merged.
- A manifest whose version is lower than the highest already seen is rejected as a rollback attempt, so the relay cannot rewind your history by replaying an older index.
- The signing key is the writing device's Ed25519 key, established on enrolment and trusted on last use; a device revoked from the account can no longer produce manifests the others will accept.
The relay can therefore refuse to serve, or stall, but it cannot forge history, silently delete from it, or roll it back without detection.
2.7 Authorization vs. decryption
These are two entirely independent gates, and conflating them is the usual way "E2EE" claims get weakened:
- Authorization decides whether the relay hands you the ciphertext at all. It is an
ordinary session:
authKey(or a passkey assertion) proves who you are, the relay issues a session, and that session can fetch blobs. - Decryption requires unwrapping the keyring with
encKEK, a passkey PRF secret, a device key, or the Secret Key — none of which the relay ever holds.
So a fully authenticated request, or a stolen session, or a compromised relay handing itself every blob, yields exactly one thing: opaque ciphertext.
Two details on the auth side:
- The relay treats
authKeyas an ordinary password. It receives base64(authKey) and Argon2-hashes it at rest exactly as it would a password, so no server-side password handling had to be trusted to be different. What changed is only what it receives: a value that cannot decrypt. - Pre-login parameters. Deriving
authKeyneedsuserSalt, which the client cannot have before authenticating — a chicken-and-egg the design breaks by serving it fromGET /auth/params?email=…alongside which credential kinds the account has. That endpoint must not become an account-existence oracle, so an unknown email gets a deterministic fake salt derived by HMAC from a server secret — a real-looking answer, stable across repeat probes, that reveals nothing about whether the account exists.
2.8 Enrolment, recovery and revocation
All operate on the same keyring — data is never re-encrypted:
- New device via QR. The new device generates its keypairs and shows its public key as a QR code. The enrolled device computes the hybrid wrap key of §2.4, seals the keyring under it, and passes the envelope back through the relay as ciphertext. The new device recomputes both shared secrets and unwraps. The relay only ever carries the wrapped blob.
- New device via credentials. Sign in with the password + Secret Key (or a PRF passkey) and the device unwraps the relay-stored wrap directly, then registers its public keys so other devices can wrap for it. Silent unlock afterwards comes from the keyring cached locally; the phone additionally receives its own device wrap the first time another device rekeys.
- Recovery. The Secret Key alone unwraps a relay-stored, recovery-wrapped copy of the keyring. A forgotten password recovers this way and then rewraps the same keyring under a new password: instant, no data re-encryption. The order is deliberate: the client proves the Secret Key opens the recovery wrap before anything is mutated, so a wrong Secret Key changes nothing.
- Secret Key rotation rewraps the keyring under a fresh Secret Key and mints a new
userSalt, invalidating the old Recovery Kit immediately. - Device revoke → lazy rekey (key epochs). The web app treats revoking as a
credentialed action: it re-enters the account password and checks it locally, so a
wrong one fails before anything is touched. (That is a client-side gate — the relay's
revoke endpoint itself asks only for a valid session, so it is a guard against mistakes
and against someone at your unlocked browser, not against a stolen session.) It then
mints a fresh DEK under a new epoch and re-wraps the whole keyring
under the password KEK, under the recovery key, and (hybrid) for every surviving
device. Every write after that seals under the new epoch, so the revoked device — which
still holds only the old epoch's DEK — cannot read anything written after its
revocation. History is not re-encrypted (lazy): old-epoch blobs stay readable
because each surviving device keeps the old DEK in its keyring and selects it per-blob
by the epoch tag (§2.5). As the recording device compacts, older-epoch blobs get
re-sealed under the current epoch and swept, so the keyring converges back toward a
single key over time. No new Recovery Kit is minted: the recovery wrap is keyed by
recoveryKey = HKDF(SecretKey)and the Secret Key doesn't rotate here, so the existing Kit keeps working.
Wrap history. The recovery flow overwrites the account's key-wrap record, which holds the only wrap the Secret Key opens, so the relay keeps an append-only archive of every recovery wrap it replaces. The archived blobs are ciphertext plus a public salt, so keeping them reveals nothing a relay operator couldn't already see, but a destroyed recovery wrap becomes restorable instead of fatal. Device and passkey wraps are not archived: each is a per-credential copy of a key the account still holds elsewhere, and re-enrolling regenerates one.
The trade-off: lose the credential and the Secret Key and the data is gone for good. That is the unavoidable cost of the relay holding no readable copy, and we say so up front rather than burying it in fine print.
2.9 Geocoding without revealing location
Turning raw points into named places (reverse-geocoding + POI matching) is where many "private" location apps quietly leak: they send precise coordinates to a geocoding API. That routes your exact position — and the time of the query — through third-party or first-party infrastructure, downgrading "can't see your data" to "won't look at it." Byway closes that side-channel with a static, coarse-tile model:
Pre-computed public tiles. Place/POI, street, city, country and timezone data are baked into immutable per-cell files on a coarse hexagonal grid (H3 resolution 8 — roughly 0.7 km² per cell, on the order of a few city blocks), served one file per cell from object storage on our own domain. Every user gets the identical file for a given cell; the files contain no user data, and the request carries no credentials, so it isn't tied to an account — only to an IP.
Only cell identifiers leave the device, never the coordinate. The device resolves its precise position to the hexagonal cell containing it and fetches that cell plus its six neighbours — seven in total, since the grid is hexagonal — so matching still works when a visit sits near a cell edge. Only those cell ids are ever sent; the exact coordinate never leaves the device.
Someone who correlates a single fresh burst of seven requests could tell which cell the device is geocoding for, because that cell is the only one adjacent to all the others. What that yields is a cell — roughly 0.7 km², a few city blocks — and nothing finer. Not your position within it, and not which of its many places you visited: the matching runs on your device and the answer is never sent back.
Matching is local. Within the fetched cell the device runs place-matching (nearest/best POI) and reverse-geocoding (nearest named street, then city, country and timezone) itself. The result — "this visit was that coffee shop" — is location data, so it is written into the encrypted edit log and synced like everything else; the tile lookup that produced it never carries the answer back to any server.
Partly unlinkable from time. On the phone, cells are cached on disk: an area is fetched once and then works offline, so returning somewhere you know generates no request at all. In the web app the cache is in memory only, so the same area is re-fetched in a later session. A fetch in an unfamiliar area happens while you are there.
An observer of the cell host sees a client, at an IP, downloading a specific area's public tile at a specific time. It does not see a coordinate, which place within the cell was visited, or any answer coming back: the matching is local and the result never leaves as anything but ciphertext. The residual exposure is a coarse area plus timing, for unfamiliar areas — not a timeline of your places.
So the same "can't see, by construction" property that covers your synced history also covers place labelling. The exact-coordinate side-channel that reverse-geocoding usually opens is never created in the first place.
2.10 Why the guarantee holds
- The plaintext never leaves the device unencrypted. Detection and encryption both
run on-device; only
nonce ‖ ct ‖ tagblobs cross the network. - The decryption key never reaches the relay in usable form. Every DEK (there may
be several key-epochs after revocations) is device-generated and only ever leaves
wrapped under
encKEK, the Secret Key's recovery key, a passkey's PRF secret, or a device's hybrid wrap key, all opaque to the relay. - What the relay receives at sign-in is a dead end.
authKeyandencKEKare separate HKDF labels off the samerootKey; HKDF is one-way, so the value the relay sees cannot be walked back to the one that decrypts. - A weak password can't be attacked offline from a server breach. It remains guessable online, and to anyone holding the Secret Key; what the design removes is the attacker's ability to grind against stolen ciphertext. The Argon2id salt folds in a 32-byte Secret Key the relay never holds.
- The relay's storage holds no location content. Every blob it stores is a public key, opaque ciphertext, or a signed index: no coordinate, no unwrapped key. It does hold a manifest index describing the time spans segments cover — a coarse view of when you have data, never of what it says.
- A malicious relay can't forge or rewind history undetected. Ed25519-signed, monotonically-versioned, single-writer manifests with trust-on-last-use.
- Authorization ≠ decryption. Even a fully authenticated fetch yields only ciphertext; unwrapping requires a secret the relay never sees.
- Naming your places narrows you to a cell, never a position. Reverse-geocoding and place-matching run on-device against coarse public tiles. A cell fetch reveals an area of roughly 0.7 km² and no more: never a coordinate, and never which place you visited.
- Captured ciphertext doesn't become readable later. The key-transfer handshake is hybrid X25519 + ML-KEM-768, and the data layer's AES-256-GCM / HKDF-SHA256 has no retroactive quantum exposure.
What the design does not promise: it can't protect you from a compromised device, it can't recover data if you lose both your credential and your Secret Key, and it can't guarantee availability against your own storage provider. Everything else — the location content itself — the server cannot read, by construction.