Signed territory packages, and only the delta over the air

# 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](/news/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.

| Artefact | How it is protected |
|---|---|
| Channel index (`index.json`) | Detached ed25519 signature at `GET /territories/index.sig`, verified over the exact served bytes |
| Package manifest | Detached ed25519 signature at `GET /territories/{id}/{version}/manifest.sig` |
| Each layer blob | BLAKE3 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](/news/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
}
]
}
]
}
```

| Endpoint | Returns |
|---|---|
| `GET /territories` | The signed channel index, exact signed bytes |
| `GET /territories/index.sig` | Detached base64 ed25519 signature over the index |
| `GET /territories/{id}/{version}/manifest` | The version's signed package manifest, exact bytes |
| `GET /territories/{id}/{version}/manifest.sig` | Detached 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](/docs/territories) is authoritative on the full endpoint contract, and [the SDK documentation](/docs/sdks) 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:

| Limit | Detail |
|---|---|
| Free-tier download allowance | 2 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 keys | `403` on layer downloads. Territory downloads need a verified account |
| Metadata | Never metered against the allowance, and neither are `304` or `416` responses |
| Mobile SDK availability | The Android AAR and iOS XCFramework ship from `sdk-v*` tags, but the source repository is private today, so installing needs early access |
| Web | The web SDK streams tiles from the API rather than verifying packages in the browser, so package pinning applies to the mobile SDKs only |
| Key rotation | Ships 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](/news/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.