The shape of it
Three pieces talk to each other:
[Android phone] --UDP+AES-256-GCM--> [Go relay] --WebSocket+inner AES-GCM--> [Browser]
(sender, (this server, (viewer,
owns inner key) cannot decrypt) has inner key
from URL fragment)
The Android app captures GPS fixes, encrypts them with a fresh per-session key, and ships them to the relay over UDP. The relay's only job is to fan that encrypted bytestream out to whoever is watching the right WebSocket on the viewer side. The viewer decrypts with the same key - which it pulled from the URL fragment, the bit after the #.
Two layers of crypto, distinct purposes
There are two encryption layers running back-to-back, and the distinction matters:
- Outer layer (sender ↔ relay): P-256 ECDH key exchange, AES-256-GCM, fresh keys every session. This protects the UDP traffic between your phone and the server from passive network observers. The relay terminates this layer - it can read the ciphertext that comes out, but...
- Inner layer (sender ↔ viewer): a separate AES-256-GCM key, generated by the phone at session start. The phone uses it to re-encrypt every position before sending. The viewer decrypts with the same key. The relay never sees this key.
So when the relay opens the outer envelope, what it gets is another sealed envelope it has no key for. The position data is sealed end-to-end between phone and browser.
Why the relay can't open the inner envelope
The inner key has to get from the phone to the browser somehow, without going through the server. The trick: it travels in the URL fragment.
https://your-relay/?id=<accessKey>#<encryptionKey>
│ │
│ └── browsers do NOT send the fragment to servers
└── server sees this; it's just a routing identifier
When the viewer opens the link, their browser keeps everything after the # client-side. The server only sees the request path and query (?id=...), which routes the WebSocket to the right encrypted stream. The encryption key, sitting in the fragment, is never sent anywhere over the wire.
This is a standard browser behavior the project leans on heavily. If you compromise the relay, scrape its logs, or own its operator - you still don't get plaintext positions out of any of those because the relay genuinely never had the key.
Server identity verification (MITM mitigation)
The outer ECDH handshake by itself doesn't prove you're talking to the right server - a sufficiently positioned attacker could intercept the UDP and re-do the exchange with you, themselves. To close that gap:
- The relay generates a long-term Ed25519 identity keypair on first start and keeps it on disk.
- The Android app fetches the relay's public identity once over HTTPS, from a
/identityendpoint. HTTPS is the trust anchor here - TLS's certificate chain validates the server's identity is what it claims. - Every UDP handshake the server sends includes an Ed25519 signature over the session's specifics (the two ECDH public keys + connection IDs). The phone verifies the signature against the long-term key it cached. If it doesn't match, the connection is refused with a "MITM detected" error.
- If the cached identity stops verifying (server rotated its key, or someone really is intercepting), the app re-fetches over HTTPS, retries, and either accepts the rotation or escalates the error.
UDP, on purpose
The wire protocol is UDP, not TCP. Three reasons:
- Lower per-packet overhead. Location fixes are small (~80 bytes payload); TCP's setup + framing tax stops mattering when you're sending one of these per second.
- No head-of-line blocking. If a packet drops, the next one carries fresh data. With TCP, the receiver waits for the missing packet - useless for "current position" where stale data has no value.
- Built-in reliability where it matters. The protocol layers its own ACK + retransmit on top of UDP for control frames (handshake, session-state changes) while letting position frames flow lossy. Best of both worlds.
Two happy consequences of the design that show up the moment you take the app outside:
- Seamless network hopping. UDP is connectionless - there's no TCP socket pinned to one IP/port pair. When your phone switches Wi-Fi to cellular, or hands off between cell towers as you drive, the next packet just arrives at the server from a new source address. The server identifies the session by its connection ID inside the frame, not by your IP, so it updates its routing and keeps going. No reconnect, no fresh handshake, no break visible to the viewer.
- Forgiving dead-zone timeout. The session stays alive for up to ten minutes of complete silence before the server tears it down. Drive into a tunnel, hit a blind spot on the highway, lose signal in a parking garage - as long as connectivity comes back within that window, the same session resumes and the same viewer URL keeps working. The viewer's last-update pill counts up, the LED flips yellow, and when packets start flowing again everything goes green. No expired link, no fresh URL to re-send to your friend.
The maps
The viewer renders Protomaps tiles served from this server - not from Google Maps, not from Mapbox, not from any third party. The tile data is a single static file built from OpenStreetMap, downloaded once by the server operator at install time. After that, every tile request goes to your relay only.
The viewer fetches tiles via HTTP range requests against that file. The server isn't written to log which byte ranges were requested - but the HTTP framework underneath sees the request URI at handler time, so a determined operator could add such logging in a handful of lines. There's no obfuscation; the handler lives in server/main.go, read it yourself. What categorically isn't there, in any form, is a third-party CDN, a Google Fonts pull, or an analytics beacon - the whole map pipeline stays on your server.
What the relay can know
Being honest:
- Can: that a session exists, when it started, when it ended, the access key in the URL query string, source IP addresses of sender + viewer, approximate timing of position updates, which map tiles were fetched.
- Cannot: any plaintext position, speed, heading, accuracy. The inner key never leaves the phone-and-browser pair.
- Could, but doesn't (by code): persistently log any of the above, fingerprint viewers, correlate sessions across days, run analytics. The Go + Echo stack underneath has the primitives; this codebase doesn't wire them up. Read
server/main.goand theserver/packages - if any of that ever creeps in, treat it as a regression. The operator of an instance can change this, of course; that's the trade-off of self-hostable software. See "Self-hosted instances" in the privacy policy.
What the wire shows
- Phone to relay: stream of encrypted UDP packets. To a passive observer: opaque traffic. To an active attacker on the path: the outer ECDH exchange is signed, so they can't impersonate the relay without TLS-anchored identity validation failing.
- Relay to viewer: an HTTPS WebSocket carrying the encrypted inner blobs. Standard TLS protections.
- The map tiles: HTTPS, served from the same server.
On-demand semantics
Nothing runs in the background unless you flip a toggle on the phone. No silent GPS collection, no "background sharing for convenience". When the share timer expires or you toggle the session off, the phone tells the server, the server tells every active viewer "share ended", and the connection is torn down. No retention.
Source
Everything above is verifiable by reading the source. There's no minified JavaScript, no proprietary blob, no third-party SDK pulling in tracking. Pull the repo, read the code, run it yourself.
Licensing
The whole project ships under the Beerware License (Revision 42). In full:
"THE BEERWARE LICENSE" (Revision 42): DubbaThony wrote this code. As long as you retain this notice, you can do whatever you want with this stuff. If we meet someday, and you think this stuff is worth it, you can buy me a beer in return. 🍻
That's the entire license. Read it, agree to it, fork it. There is no Section 14(c), no patent grant carve-out, no "approved use case" clause. Use it, modify it, run your own instance, hand it to your friends. The only optional obligation is a beer, and it's contingent on meeting in person.