TacMap Threat Model

Status: working draft · Audience: users, unit security staff, code auditors Scope: what information TacMap can expose, to whom, and where its guarantees stop.

This document is deliberately plain. If you use TacMap to hold or share unit information, you should be able to read this in five minutes and know exactly what leaves your device, what a hostile party could learn, and what the app does not protect you from.

Nothing here is marketing. Where a guarantee has a limit, the limit is stated.


1. What TacMap is

An offline-first tactical mapping tool for iOS and Android: MGRS grid, drawing and measurement, military symbology, waypoints and track logs, import/export, and an optional unit sync feature that shares live presence and map objects between devices that share a join code.

Everything except sync works with no network at all. Sync is off until a user joins a room.


2. Critical information (what an adversary wants)

If TacMap is holding real data, the sensitive items are:

The last two matter even when the first three are encrypted. Keep reading.


3. Trust boundaries

TacMap treats the following as untrusted once data crosses into them:

BoundaryTrusted?Why it matters
Your deviceTrusted (see §7 caveats)Holds the at-rest key, and can decrypt mission data.
The sync relayUntrustedRoutes encrypted traffic; can see metadata.
Basemap / lookup providersUntrustedSee the coordinates you request.
The network path (ISP, Wi-Fi, carrier)UntrustedSees who you talk to and when.
Other members of your sync roomTrusted by youThey hold the room key and see everything you share.

The design goal is that only your device and your own room members ever see critical information in cleartext. Everything else on this list sees ciphertext or coordinate requests, never unit data.


4. What the sync relay can and cannot see

This is the core statement. Read it before trusting sync.

One-paragraph version. When you join a sync room, your device turns the human join code (e.g. bravo-tonight) into three separate values using a slow password-stretch (PBKDF2-HMAC-SHA256, 210,000 iterations). One value is a routing ID the relay uses to connect you to your room. A second is the encryption key, which never leaves your device. A third is a write token that only travels inside the connection handshake. Every map object and every presence update is sealed with AES-256-GCM using the encryption key before it is sent. The relay only ever forwards sealed blobs. It cannot reverse the routing ID straight back to the key. But the routing ID and the key are both derived from the same join code, so the protection is only ever as strong as that code: a weak or human-memorable code can be guessed offline (see Your join code is the whole ballgame, below). With a strong, generated code the relay can route your traffic but cannot read it.

What the relay CAN see:

What the relay CANNOT see:

Why a hostile relay still cannot cheat. Each sealed object binds its own routing metadata (object ID, version, kind) into the encryption as associated data. A relay that tries to replay an object under a different ID, or move it between rooms, produces an authentication failure and the client rejects it. A leaked routing ID alone cannot write to a room either, because writing requires the separate write token.

The honest limit. Content is protected. Metadata is not. A relay operator, or anyone who compromises or coerces the relay or its host, can learn that a group of IPs form a unit, roughly where they are, and when they are active. That is enough to infer association and operational tempo. See §7.

Your join code is the whole ballgame. The routing ID, the encryption key, and the write token are all derived from the join code through one fixed, app-wide salt. There is no per-room salt on purpose: the relay has to route by a value anyone with the code can compute. Two things follow. Because the salt is a constant, an attacker can precompute a code -> (routing ID, key, token) dictionary once and reuse it against every room, forever. And because the relay sees every routing ID, that ID is a free offline confirmation oracle: guess codes, derive their routing IDs, match them against observed rooms, and any hit hands over the encryption key (read) and the write token (write). PBKDF2 at 210,000 iterations makes each guess cost real work, but a short or memorable code - a couple of dictionary words like bravo-tonight - still falls to a GPU rig in minutes. So sync is only private if your join code has real entropy. The app generates a strong ~78-bit code for you and refuses codes under 14 characters; use the generator and pass the code out-of-band. A code you invent yourself, especially a memorable one, is guessable and is not covered by the guarantees above. (A memory-hard KDF - Argon2id/scrypt - would raise the per-guess cost further and is a planned hardening; it does not remove the need for an entropic code.)

The relay is auditable: it only ever handles sealed blobs plus routing IDs. You do not have to trust ours. You can self-host it (see §8).

Crypto reference for auditors: SyncCrypto.kt / SyncCrypto.swift and their test suites. Android and iOS produce byte-identical wire format (iv(12) || ciphertext || tag(16)).


5. Network egress table

Every outbound connection TacMap can make, what triggers it, what the far end learns, and its default state. If a row is off by default, TacMap makes no such request until you opt in.

EndpointPurposeTriggered byWhat the provider learnsDefaultMitigation
ibasemaps-api.arcgis.com (Esri World Imagery)Satellite basemap tiles (the default style)Viewing the map with online basemaps enabledYour IP + the tile coordinates/zoom you view = your area of interest, over timeOff (online basemaps gate)Leave the gate off; use offline packs, see §6
static-map-tiles-api.arcgis.com (Esri)Topographic + OSM-street basemap tiles (licensed)Selecting those styles with online basemaps enabledYour IP + requested tile coordinates = your AOOff (online basemaps gate)Leave the gate off; use offline packs
a.tile.opentopomap.org, b.tile.opentopomap.org, c.tile.opentopomap.orgOpenTopoMap community topo tiles (the one keyless style)Selecting the OSM-Topo style with online basemaps enabledYour IP + requested tile coordinates = your AOOff (online basemaps gate)Leave the gate off; use offline packs
api.open-meteo.com/v1/forecastWeather lookupOpening the weather dialogYour IP + the exact coordinates queriedOff (online lookups gate)Leave online lookups off
api.open-meteo.com/v1/elevationElevation + terrain heatmapElevation/terrain featuresYour IP + the exact coordinates queriedOff (online lookups gate)Leave online lookups off
Place-name search — iOS MKLocalSearch (Apple), Android Geocoder (Google/OEM on GMS devices)Turning a typed place name into a coordinateTyping 2+ characters in SearchYour IP + the search string, which can itself reveal your AO (searching a FOB/village name)Off (online lookups gate)Leave online lookups off; navigate by MGRS/grid instead
Sync relay (default: tacmap-sync.<...>.workers.dev)Encrypted unit sync transportJoining a sync roomCiphertext + routing ID + your IP + traffic timing (see §4)Off until you join a roomSelf-host the relay; see §8
play.google.com/redeem / apps.apple.com/redeemVoucher / licence redemptionYou tapping "redeem"Standard store request; no map or unit dataUser-initiated onlyn/a

The three basemap rows apply to both platforms — iOS and Android draw the same Esri/OSM raster tiles, gated off by default. There is no Apple-Maps row and no Google-Maps row any more: both platforms render the map themselves now (§6). On iOS, Apple's geod daemon is never invoked and fetches nothing (measured zero, §6). On Android, the Google Maps SDK has been removed from the app entirely — there is no map SDK left to check in with Google on launch (§6).

Read this table as the whole story. If an endpoint is not listed here, the app does not contact it. We add no analytics SDK, no crash telemetry, no ad network; our own crash reports are written to local storage only and shared by you manually. Neither platform links a map SDK any more — iOS dropped MapKit and Android dropped the Google Maps SDK, so there is no longer any third-party map-engine telemetry on either one. The only Google dependency left on Android is Play Billing, which binds to the Play Store service and exchanges data only on a user-initiated purchase or restore (no map or unit data, ever); see §6.


6. The area-of-interest problem (basemaps and lookups)

Even with sync fully off, requesting online map tiles or online weather/elevation tells the provider which ground you care about. Panning to a grid square fetches tiles for that square from your IP. A provider, or anyone with access to its logs, can reconstruct your area of interest and how it moves over time. This is the same class of exposure as the 2018 fitness-app heatmap incident.

TacMap's controls:

The two platforms, both now closed

Both platforms draw the same Esri/OSM raster tiles, gate them off by default, and now render through the same in-app tile renderer — no Apple MapKit, no Google Maps SDK. Neither platform links a map engine that can phone a map provider on launch. The map is a Compose/Canvas renderer on Android and a Metal/CoreGraphics renderer on iOS, both of which draw only the raster source you chose: an Esri/OSM online style when the basemap gate is on, an offline pack/GeoPDF when you've imported one, or nothing when both are off.

That is a change from earlier builds, where Android hosted the map in Google's SDK. With online basemaps off, that SDK still fetched no basemap tiles — but the SDK itself phoned home on launch (~280 KB cold / ~24 KB warm on a Pixel emulator) for provisioning and telemetry, whatever the tile gate said. That check-in carried your IP and the fact that a Google-Maps app had started (not the coordinates you viewed — that's what the tile gate stops). That is now gone. The Google Maps SDK has been removed from the app: the three Maps dependencies are dropped, the com.google.android.geo.API_KEY manifest entry and MapsInitializer call are deleted, and the built APK's dex contains zero com.google.android.gms.maps or com.google.maps.android classes (verifiable with dexdump on any release build). There is no longer any code path that performs the provisioning check-in, because the code that performed it is not in the binary.

So on Android, as on iOS, with the basemap gate off the app makes no basemap tile request of any kind and a stationary map settles to zero; with it on, tiles go to Esri/OpenTopoMap (your choice, behind the persistent red banner). The one honest caveat that remains is not ours: on a device with Google Play Services installed, the OS's own GMS processes have their own ambient network chatter, and Play Billing (still linked, for the paid unlock) binds to the Play Store service. Neither is TacMap requesting anything, and neither carries map or unit data. For a guaranteed-dark launch, airplane mode still removes every vector at once.

The iOS side, now closed

For most of this app's life, iOS had a hole here we could not close from inside MapKit. MapKit has no "no basemap" mode; the only way to suppress Apple's basemap was to cover it with a canReplaceMapContent overlay, which stops MapKit drawing the basemap but not fetching it. Apple's tiles are pulled by geod, a system daemon outside our sandbox, which kept fetching tiles for the on-screen region no matter what we drew on top. Measured on a freshly erased iPhone 17 Pro simulator, sitting on the map for 35 seconds grew geod's tile store (Caches/com.apple.geod/Vault/MapTiles) by ~457 KB whether the basemap toggle was on or off — the same tiles either way.

As of build 33 this is fixed. iOS no longer uses MKMapView at all. The map is rendered by an in-app tile renderer we wrote (TileMapView), which draws only the raster source you chose — an Esri/OSM online style when the basemap gate is on, an offline pack/GeoPDF when you've imported one, or nothing when both are off. There is no MapKit view in the tree, so geod is never asked for a tile.

This is measured, the same WAL way. After checkpointing geod's MapTiles.sqlitedb-wal to zero and then panning the renderer aggressively across fresh ground:

Map enginegeod tile-store growth while panning
Old MKMapView~457 KB (fetched regardless of the gate)
New in-app renderer0 bytes

During that pan the app fetched tiles the whole time — every request went to ibasemaps-api.arcgis.com (the Esri basemap you turned on), with zero basemap-tile contact to any Apple map host (*.ls.apple.com, gspe*, cdn.apple-mapkit). So on iOS the AO no longer leaks to Apple through the basemap. (One thing still can: place-name search uses MKLocalSearch, which sends the query you type to Apple's servers - but only if you enable online lookups and use the search box. See §5.) What the online-basemaps gate now buys you is the ordinary thing it says: with it off, the app makes no basemap tile request of any kind, and an imported offline pack or GeoPDF genuinely hides your AO — there is no Apple fetch underneath it any more.

The remaining ambient exposure is now symmetric across both platforms and comes only from the providers you can still choose to use: turning the basemap gate on sends your tile coordinates to Esri/OpenTopoMap (your choice, with a persistent red banner while it's active). Neither platform links a map SDK, so there is no map-engine launch check-in on either one.

Rule of thumb: if you can see the internet, the internet can see your AO. Pre-stage offline maps before you need them; the gate and the in-app renderer now make "offline pack loaded, radio on, nothing leaks" true on both platforms.


7. What TacMap does NOT protect you from

Stated plainly, because a tool that hides its limits cannot be trusted.


OPSEC-first defaults (already set):

For real operations, additionally:


9. For auditors

Issues and disclosures welcome via the repository.


This is a living document. Items marked TODO/VERIFY are not yet substantiated in this draft and must not be cited as guarantees until confirmed against the code.