50,000 free calls a month, card-free. Get an API key →

news / territory-packages-ota · raw .md
Infrastructure7 min readMapMap engineering
A sealed rectangular map package drawn as a stack of content layers with a wax seal and key motif, beside a smaller delta fragment aligning into a gap.

Signed territory packages, and only the delta over the air

Every MapMap territory package is signed with ed25519, and every layer inside it is content-addressed with a BLAKE3 hash recorded in that signed manifest. A device verifies both before it installs a single byte of map data, and because the layers are content-addressed, an update fetches only the layers that changed instead of re-downloading the continent. The trust anchor is the map factory's 64-hex verifying key, pinned in your app at build time, which puts the CDN, the gateway and any mirror in between firmly outside the trust boundary.

Why is an unsigned map package a route-injection attack?

Because a map package is not data the application displays, it is data the application obeys. Offline routing reads the routing tiles in the package and returns the route it computes from them. Change the tiles and you change the route, with no bug in the routing engine and nothing visibly wrong on screen. An attacker who can modify a package in flight does not need to compromise the device or the engine. They only need to edit the map and let the engine do exactly what it was built to do.

On a vehicle carrying dangerous goods, that stops being a security problem and becomes a safety one. Territory packages are built by our map factory (snfactory) from OSM extracts and ADR restriction overlays, and ADR tunnel codes B to E decide which tunnels a given hazmat load may legally enter. We enforce that restriction inside costing rather than filtering routes afterwards, which is the whole point of ADR tunnel compliance. If the restriction overlay in the package can be altered on its way to the device, the enforcement is enforcing a lie. The route comes back clean, the driver follows it, and a tanker arrives at a tunnel it is not permitted to use. The signature is what turns "this route respects the tunnel category" into a statement about the data rather than a hope about the network.

What does the signature actually cover?

The manifest, and through the manifest, every layer byte in the package. The chain runs from the key you pinned at build time down to the blobs on disk, and each link is checked on device.

ArtefactHow it is protected
Channel index (index.json)Detached ed25519 signature at GET /territories/index.sig, verified over the exact served bytes
Package manifestDetached ed25519 signature at GET /territories/{id}/{version}/manifest.sig
Each layer blobBLAKE3 hash listed inside the signed manifest; the blob address is the first 16 lowercase hex characters of that hash

The consequence is that the update channel is dumb by design. It is nothing but static files: servable by any web server or CDN, mirrorable into an air-gapped network with rsync, inspectable with ls and tar. No server-side logic participates in the trust model, and the gateway hop adds authentication, metering and HTTP niceties only. Point a device at a raw mirror instead of the gateway and nothing changes about what it will accept. The public precedents for this shape, signed metadata over content-addressed blobs on an untrusted store, are The Update Framework and OSTree. We did not invent it and we would be suspicious of anyone claiming to.

Why does a device download the delta and not the continent?

Because content addressing makes "unchanged" free. Any two package versions that share a layer share a blob, so an unchanged multi-gigabyte routing tile tree costs no extra disk on the channel and no extra download on the device. Layer blobs run to roughly a gigabyte each, so this is the difference between a fleet update that runs overnight on a depot's wifi and one that does not run at all.

The device side is TerritoryManager in the navigation core, exported to Kotlin and Swift over UniFFI. Devices poll the signed channel index on their own schedule, and you supply a LayerFetcher callback (fetch(rel_path, expected_blake3, dest)) backed by whatever HTTP stack you already use, or by a plain file copy from a mirror. The expected_blake3 argument is advisory: the core re-verifies everything itself.

rust
// 1. Fetch the signed index: GET /territories and /territories/index.sig.
// 2. Ask the manager whether an installed territory has an update.
let update = manager.check_for_update("uk", &index_json, &index_sig, &fetcher)?;

// 3. None means the channel already agrees with the installed version.
//    Otherwise it carries the changed layers and a download estimate.
if let Some(update) = update {
    println!("update to {}: ~{} bytes", update.version, update.download_bytes);
    manager.apply_update("uk", &update.manifest_json, &update.manifest_sig, &fetcher)?;
}

apply_update authenticates the manifest before it acts on any of it, stages the new version with unchanged layers hardlinked from the current install and changed layers fetched, unpacked and BLAKE3-verified against the signed manifest, runs full package verification on the staged directory, then atomically swaps it in. A crash, a transport failure, a hash mismatch or a bad signature at any point leaves the previous version installed and untouched. On Android the same property is stated as verify-then-promote: a package that fails verification leaves no trace on disk.

Two smaller details matter more than they sound. Every response carries a strong BLAKE3 ETag ("b3-<hex>"), and layer blobs support single-range Range / If-Range requests, so an interrupted gigabyte download resumes instead of restarting.

The trust anchor is a pinned verifying key, not a server

Each app pins the factory's 64-hex verifying key, the public half of the signing pair, and passes it to the territory store at construction. This is the pinning call:

kotlin
import ai.mapmap.territory.TerritoryStore

// 64-hex factory verifying key, baked in at build time (e.g. BuildConfig).
// Never fetched at runtime.
val store = TerritoryStore(
    rootDir = File(context.filesDir, "territories"),
    verifyingKeyHex = BuildConfig.MAPMAP_FACTORY_PUBKEY_HEX,
)

Bake the key in at build time as a build config field or a bundled resource. Never fetch it over the same channel as the packages it validates, because a key delivered by the thing you are trying to distrust is not a trust anchor, it is decoration. Hosted customers receive the factory verifying key with SDK early access. Self-host operators run snfactory keygen and pin their own public key, which is the mechanism behind sovereign navigation: if you hold the signing key, you decide who may publish maps to your fleet, and the private key never leaves the factory host. The gateway and the CDN hold no secrets at all.

How do you fetch a territory over the API?

Five endpoints serve the channel behind the normal API-key auth. Set your shell up once:

sh
export BASE=https://api.mapmap.ai
export API_KEY=snk_your_key_here

curl -fsS "$BASE/territories" -H "Authorization: Bearer $API_KEY"
json
{
  "format_version": 1,
  "generated_at": "2026-07-13T12:00:00Z",
  "territories": [
    {
      "id": "uk",
      "display_name": "United Kingdom",
      "latest_version": "2.0.0",
      "versions": [
        {
          "version": "2.0.0",
          "data_timestamp": "2026-07-01T00:00:00Z",
          "manifest_path": "territories/uk/2.0.0/manifest.json",
          "total_bytes": 123456789
        }
      ]
    }
  ]
}
EndpointReturns
GET /territoriesThe signed channel index, exact signed bytes
GET /territories/index.sigDetached base64 ed25519 signature over the index
GET /territories/{id}/{version}/manifestThe version's signed package manifest, exact bytes
GET /territories/{id}/{version}/manifest.sigDetached manifest signature
GET /territories/{id}/{version}/layers/{addr}/{file}A content-addressed layer blob (tar + zstd)

The index and manifests are served byte-exact, which is what lets the detached signatures verify over the response body. The channel now covers the whole world alongside the original per-country territories. The live index is the source of truth for what a given deployment actually publishes. The territories documentation is authoritative on the full endpoint contract, and the SDK documentation covers device-side install.

What this does not do

There is no freshness guarantee by default, and that is a trade-off rather than an oversight. Devices accept any signed version, because rollback-by-republish is a deliberate operator feature: a channel version older than the installed one is read as an instruction to downgrade. The cost is that a stale or hostile mirror can hold devices on an older legitimate version. It cannot forge a version, invent a layer or alter a byte, but it can deny service and it can serve yesterday. Where that matters, a signed index TTL can be layered on top.

The other real limits:

LimitDetail
Free-tier download allowance2 GiB a month, byte-metered per identity. Blobs run to about a gigabyte, so it covers one region on one device for a proof of concept, not a fleet. Starter raises it to 20 GiB, Growth to 100 GiB, Scale to 500 GiB; the per-vehicle SDK licence covers production fleet downloads
Provisional keys403 on layer downloads. Territory downloads need a verified account
MetadataNever metered against the allowance, and neither are 304 or 416 responses
Mobile SDK availabilityThe Android AAR and iOS XCFramework ship from sdk-v* tags, but the source repository is private today, so installing needs early access
WebThe web SDK streams tiles from the API rather than verifying packages in the browser, so package pinning applies to the mobile SDKs only
Key rotationShips as an app update carrying the new pinned public key, then a channel republished with the new key. There is no runtime key-rotation path, on purpose

Try it

The territories documentation carries the self-host publishing commands (snfactory build, publish and channel-verify), and the SDK documentation covers TerritoryStore on Android and iOS. If you want the wider argument for why any of this is built the way it is, offline navigation covers what runs with no network at all.

Routing and map data derive from OpenStreetMap. Credit "© OpenStreetMap contributors" when you render or republish it.