ON project
Crossing Paths: Finding Where Your Lives Almost Overlapped
A side project about near-misses, privacy-preserving hashing, and the strange comfort of knowing someone was close before you ever met.
A side project about near-misses, privacy-preserving hashing, and the strange comfort of knowing someone was close before you ever met.
The idea
You know that feeling when a friend says “I was at that cafe last Tuesday!” and you reply “Wait, I was literally across the street”? Now imagine discovering that happened dozens of times, across years, before you ever knew each other.
Crossing Paths takes two Google Timeline exports and finds every moment two people were within 100 meters of each other during the same 15-minute window — without either person ever revealing their full location history.
No account, no persistence. Two uploads, one shared passphrase, and the results appear.
How it works (the short version)
- You and a friend share a 5-word passphrase like
ocean-sunset-marble-fish-dream - Each of you uploads your Google Timeline JSON export
- Your browser hashes every location into an opaque fingerprint — coordinates are never sent to the server
- The server compares the two sets of fingerprints and returns the intersection
- Your browser decodes the matches back to GPS points and renders them on a map
The server sees hex tokens. It cannot decode them. It cannot tell whether two sessions are from the same people. It forgets everything after an hour.
The privacy trick: HMAC over a spatial grid
This is the fun part. The challenge: two people need to discover shared locations without either revealing their full trajectory to the server or to each other.
The solution is a keyed hash over a spatial-temporal grid. Here’s the pipeline that runs entirely in your browser:
Step 1: Snap GPS to a 100m grid
Raw GPS coordinates are messy — two people standing next to each other might report positions 20 meters apart. To make comparison possible, every point gets snapped to a 100-meter grid.
The conversion goes: WGS84 lat/lng → UTM (meters) → floor divide by 100 → grid cell ID.
48.8530, 2.3499 → UTM zone 31, (448923, 5411107) → cell 4489_54111_31Two people within the same 100m square produce the same cell ID. The grid is coarse enough to cluster real meetings but fine enough to avoid false positives across a city.
Step 2: Bucket time into 15-minute windows
A GPS point at 10:14 and another at 10:16 should match — but if we use exact timestamps, they won’t. So every timestamp gets bucketed:
bucket = Math.floor(timestamp_ms / (15 * 60 * 1000))This gives us a combined spatial-temporal key: 4489_54111_31 + 1705647 = one cell at one moment in time.
Step 3: The ±1 trick (solving boundary problems)
Here’s a subtlety. If Alice’s GPS point falls at 10:14:59 and Bob’s at 10:15:01, they’re in different 15-minute buckets and would miss each other. The fix: for every point, emit three tokens — for bucket-1, bucket, and bucket+1.
for (const offset of [-1, 0, 1]) {
const token = HmacSHA256(gridKey + (bucket + offset), passphrase);
tokens.push(token);
}This means the token payload is 3x the point count, but it guarantees that two people who are actually co-located will produce at least one matching token, regardless of where their timestamps fall within the bucket boundaries.
Step 4: HMAC with the shared passphrase
The grid cell + bucket combination is hashed using HMAC-SHA256 with the passphrase as the key. The output is a 64-character hex string that reveals nothing about the original location — unless you know both the passphrase and the grid cell.
HMAC-SHA256("4489_54111_311705647", "ocean-sunset-marble-fish-dream")
→ "a3f7c912d8b4e56f..."The server receives thousands of these hex strings. It cannot reverse them. It cannot even tell which continent they’re from.
Step 5: Set intersection on the server
The backend is almost trivially simple. It builds two sets of tokens and returns the intersection:
func findMatches(first, second []Token) []Token {
set := make(map[string]bool)
for _, t := range first { set[t] = false }
for _, t := range second {
if _, exists := set[t]; exists { set[t] = true }
}
// return all tokens marked true
}That’s it. The entire “matching engine” is a set intersection. All the intelligence lives on the client.
Step 6: Decode back to GPS on the client
Your browser kept a map of token → GPS point index in session storage. When the server returns matching tokens, the browser looks up each one and recovers the original {lat, lng, date}. The server never saw these coordinates. Your friend’s browser does the same thing independently — they decode to their own GPS points at the same locations.
What the server actually sees
Let’s make this concrete. Say Alice was at Notre-Dame at 10:20 on January 18th. The server receives:
{ "encoded": "a3f7c912d8b4e56f1234abcd..." }It doesn’t know:
- That this represents Notre-Dame
- That it’s in Paris, or even in France
- What date or time it corresponds to
- Whether Alice was walking, sitting, or on a bus
All it knows is: Bob also submitted the same hex string. So it goes into the intersection.
Security caveats (this is a toy, not a protocol)
I want to be upfront: this is not real cryptographic privacy. It’s obfuscation. The threat model it defends against is:
- A curious server operator who doesn’t know the passphrase (safe)
- A network observer who doesn’t know the passphrase (safe)
The threats it does NOT defend against:
-
Brute-force with a known passphrase: If an attacker knows the passphrase, they can hash every 100m grid cell in a city x every 15-minute bucket over a year and match against the captured tokens. The search space is large (~100M combinations for a city-year) but tractable for a motivated attacker. This is not computationally infeasible.
-
No forward secrecy: A network observer who captures the token payloads and later obtains the passphrase can retroactively decode everything.
-
Server-side correlation: The server could, in theory, log token sets across sessions and correlate users over time (it doesn’t, but there’s no cryptographic guarantee).
The real fix would be a Private Set Intersection (PSI) protocol — something like DDH-based PSI or garbled circuits. That’s on the roadmap but way beyond what a toy project needs.
Beyond the intersection: shared paths
The initial match tells you “you were both at Notre-Dame around 10:20 AM.” That’s cool, but wouldn’t it be better to see how close you actually were — and the paths you each took before and after?
The opt-in model
After seeing results, each user gets a prompt:
See each other’s paths on the map? Share your GPS trail within a 2-hour window around each crossing with your friend. This reveals where you were walking before and after you crossed — not your full history. Both of you need to opt in.
If both accept, their browsers extract the raw GPS trail ±1 hour around each crossing from session storage and upload it. The server holds it until the friend polls.
This is a deliberate privacy escalation:
- Phase 1 (automatic): only hashed fingerprints leave your browser
- Phase 2 (opt-in): actual GPS coordinates around confirmed crossings — but only ±1 hour, only around places you already know you were both at
Precise closest encounter
With both paths available, the app computes the exact closest point pair — a brute-force comparison of every point in your trail against every point in your friend’s trail:
for (const a of myPath) {
for (const b of friendPath) {
const d = haversineMeters(a.lat, a.lng, b.lat, b.lng);
if (d < best.distance) best = { distance: d, timeDiff: |a.date - b.date| };
}
}Instead of ”< 100m, < 15 min” you get “23 meters apart, 3 minutes difference.” The map shows two lines — blue and red — converging to a meeting point.
The result screen
The result page packs a lot of information:
- Hero stats: total crossings, closest distance (precise if paths shared), span of time, same-day overlaps
- Interactive timeline: a horizontal bar showing all crossings over the date range. Click any dot to jump to that crossing.
- Full-screen Mapbox map: shows the selected crossing point, plus blue/red path trails if shared. GPU-rendered circle layers (not DOM markers) so panning is butter-smooth.
- Crossing stories: each crossing as a card with a map thumbnail, date, time range, and distance
- Shared cities: unique cities extracted via Mapbox reverse geocoding, with Unsplash photos fetched through a backend proxy
- Closest moment card: the single nearest encounter across all crossings
Reverse geocoding
Raw coordinates like 48.8530, 2.3499 aren’t very readable. The app calls Mapbox’s reverse geocoding API to turn each crossing into a place name (“Notre-Dame”) and a city (“Paris”). Results are cached client-side so navigating between crossings doesn’t re-fetch.
City images via Unsplash
The “Shared Cities” sidebar shows a photo for each city. The API key is kept server-side — the frontend calls GET /city-image?city=Paris and the Go backend proxies to Unsplash’s search API ({city} travel landmark), caches the result in memory, and returns the URL. No API keys in the browser.
The “before we met” filter
A user creating a session can optionally set a maximum date. This answers the question: “We became friends in 2018 — how many times were we close before that?”
The filter is applied client-side during the HMAC pipeline, before hashing. Points after the cutoff date are discarded entirely — they never generate tokens, never reach the server. The second user doesn’t need to set the date; the first user’s filter determines the boundary.
Demo mode
You can try the full result screen without uploading anything. Navigate to /demo and you’ll see 8 crossings across Paris over 6 months — including two on the same day (March 22: Chatelet at 11:15, Bastille at 13:20, just 2 hours apart).
The demo data comes from a Go program (tools/gendata) that generates two fake Google Timeline files for “Alice” and “Bob.” Each person has a distinct daily route through Paris with ~600 GPS points, but they cross paths at 8 known locations. The demo clusters include pre-computed ±1h paths around each crossing, so the blue/red trail lines and precise closest-encounter stats are visible immediately.
Architecture
Frontend
- React + Vite + Tailwind CSS + shadcn
- Mapbox GL JS for all maps (interactive result map, hero visualization, static thumbnails)
@noble/hashesfor HMAC-SHA256proj4for WGS84 ↔ UTM coordinate conversion- Firebase Hosting
Backend
- Go on Cloud Run
fuegoframework (typed REST on net/http)- Entirely in-memory — no database, no Redis, no disk
- Single instance (
--max-instances=1) because session state is a Go map behind a mutex - A goroutine sweeps stale sessions every 5 minutes (>1 hour old)
- CORS wide open,
--allow-unauthenticated— it’s a toy
The backend is ~300 lines of Go. The frontend is where all the interesting work happens.
What I’d do differently
-
Real PSI protocol: The HMAC scheme is clever but not cryptographically sound. A DDH-based PSI or OPRF-based protocol would make brute-force infeasible even with the passphrase. The server would perform the intersection without ever seeing any party’s tokens in the clear.
-
Persistent sessions: In-memory state on a single Cloud Run instance means a cold start wipes everything. A 24-hour Redis cache would let sessions survive container restarts without adding real complexity.
-
Finer grid: 100m is coarse. Two people on opposite sides of a park would match. A 25m or 50m grid with a multi-resolution fanout (emit tokens at 25m, 50m, and 100m) would give better precision without sacrificing recall.
-
Streaming uploads: Large timeline exports (10+ years of data) can be 50MB+. A streaming parser that hashes on-the-fly instead of loading the entire JSON into memory would help.
Try it
The app is live at crossingpaths.dimitri.land. Export your Google Timeline, share a code with a friend, and find out how many times your lives almost collided.
Or just hit /demo to see Alice and Bob’s 8 crossings across Paris.
Final thoughts
This was a fun little side project — if you're building cooler things than most people, I'd love to hear about it.