Signed tile and config bundles for field updates
An update artefact reaching a field gateway has crossed a cellular network, possibly a customer’s site network, and a filesystem that may be shared with other software. The only property that makes it safe to apply is a signature the device can check against a key it trusted before the transfer began. This guide defines a bundle format that carries that signature, a verification routine that checks everything before anything is applied, and a resumable transfer that survives the link this fleet actually has. It belongs to secure provisioning and OTA updates within Edge Operations & Observability.
The bundle format
A bundle is a manifest, a signature over that manifest, and one or more payload files. The manifest — not the payloads — is what gets signed, and it carries a digest of every payload. That indirection is what makes a 3 GB tile archive verifiable without a 3 GB signature operation, and what lets a transfer be resumed and re-verified piecewise.
bundle-2026-05-04-zones-v42/
manifest.json ← the signed document
manifest.sig ← detached Ed25519 signature over manifest.json
payload/zones-v42.fgb ← 2.4 MB
payload/thresholds.json ← 6 KB
The manifest is deliberately boring:
{
"schema": 1,
"bundle_id": "zones-v42-2026-05-04",
"created_utc": "2026-05-04T09:12:44Z",
"expires_utc": "2026-08-04T00:00:00Z",
"min_firmware": "2.6.0",
"target": {"model": "gw-4", "region": "eu-west"},
"artefacts": [
{"path": "payload/zones-v42.fgb", "kind": "zone_layer",
"bytes": 2517216, "sha256": "9f31…c2a7", "activates": "atomic_swap"},
{"path": "payload/thresholds.json", "kind": "threshold_config",
"bytes": 6144, "sha256": "41ba…8e10", "activates": "reload_signal"}
]
}
Five fields in that document do real work. expires_utc bounds how long a captured bundle can be replayed at a device. min_firmware stops a bundle from being applied to an image that predates the feature it configures. target prevents a bundle intended for one hardware model or region from being applied elsewhere — the mistake that no signature can catch. kind binds each payload to the subsystem allowed to consume it. And activates tells the device which application pattern the artefact needs, so the update path does not have to infer it from the file extension.
The verification and apply routine
# bundle_apply.py — verify a bundle completely, then apply it atomically.
# Nothing touches the live system until every check has passed.
# Runs on the maintenance task; never inside the ingestion path.
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
CHUNK = 1 << 16
MAX_CLOCK_SKEW_S = 86_400 # tolerate a day of drift on a device with no NTP
class BundleRejected(Exception):
pass
def _digest(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for block in iter(lambda: fh.read(CHUNK), b""):
h.update(block)
return h.hexdigest()
def verify_bundle(root: Path, pubkey, device) -> dict:
"""Returns the parsed manifest, or raises. Order is cheapest-first so a
malformed bundle is rejected before any hashing happens."""
man_path, sig_path = root / "manifest.json", root / "manifest.sig"
if not man_path.exists() or not sig_path.exists():
raise BundleRejected("incomplete bundle")
raw = man_path.read_bytes()
try:
pubkey.verify(sig_path.read_bytes(), raw)
except Exception:
raise BundleRejected("signature") # nothing else is worth checking
man = json.loads(raw)
if man.get("schema") != 1:
raise BundleRejected("schema")
now = datetime.now(timezone.utc)
expires = datetime.fromisoformat(man["expires_utc"])
if (now - expires).total_seconds() > MAX_CLOCK_SKEW_S:
raise BundleRejected("expired") # replay of an old bundle
if man["target"]["model"] != device.model:
raise BundleRejected("wrong model")
if man["target"]["region"] != device.region:
raise BundleRejected("wrong region")
if _version_tuple(device.firmware) < _version_tuple(man["min_firmware"]):
raise BundleRejected("firmware too old")
for art in man["artefacts"]:
path = root / art["path"]
if not path.exists() or path.stat().st_size != art["bytes"]:
raise BundleRejected(f"missing or truncated: {art['path']}")
if _digest(path) != art["sha256"]:
raise BundleRejected(f"digest mismatch: {art['path']}")
if art["kind"] not in device.accepted_kinds:
raise BundleRejected(f"unhandled kind: {art['kind']}")
return man
def apply_bundle(root: Path, man: dict, device) -> list[str]:
"""Apply every artefact, each by the pattern its manifest entry declares.
Returns the list applied so the caller can report and, if needed, revert."""
applied = []
for art in man["artefacts"]:
src = root / art["path"]
dest = device.destination_for(art["kind"])
if art["activates"] == "atomic_swap":
tmp = dest.with_suffix(dest.suffix + ".new")
os.replace(src, tmp) # same filesystem: no copy
_fsync_path(tmp)
os.replace(tmp, dest)
_fsync_dir(dest.parent)
elif art["activates"] == "reload_signal":
versioned = dest.parent / f"{dest.stem}-{man['bundle_id']}{dest.suffix}"
os.replace(src, versioned)
_fsync_path(versioned)
_fsync_dir(versioned.parent)
device.request_reload(art["kind"], versioned)
else:
raise BundleRejected(f"unknown activation: {art['activates']}")
applied.append(art["kind"])
device.record_versions(man["bundle_id"], applied)
return applied
def _fsync_path(p: Path):
fd = os.open(p, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
def _fsync_dir(p: Path):
fd = os.open(p, os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
Constraint validation
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| RAM | A 3 GB payload cannot be hashed in memory | Streaming digest at 64 KB per read; peak is one chunk |
| Link reliability | A transfer that restarts never completes on a poor link | Per-payload digests allow chunked, resumable transfer verified piecewise |
| Flash | Two copies exist briefly during the swap | os.replace on the same filesystem moves rather than copies; only the destination pair coexists |
| Power loss | An interrupted apply must not leave a mixture | Every activation is a rename plus a directory sync |
| Clock | A device with no time source cannot check expiry strictly | Bounded skew allowance, with the time-source health reported separately |
| Trust | A hostile network sits between build and device | Signature checked first, on-device, against a key from the firmware image |
Gotchas and edge cases
- Verify before you move, not after. It is tempting to stream a payload directly into its destination and check it afterwards. That leaves a window where the live path holds unverified data, and on a device that reboots during the window, the unverified file is what comes back.
os.replaceacross filesystems copies. If the staging directory and the destination are on different mounts, what looks like an atomic rename is a copy plus a delete, and the atomicity is gone. Stage on the same filesystem as the destination, always.- Expiry needs a clock, and gateways lose theirs. A device whose RTC battery has died reports 1970 and rejects every bundle as expired, or 2038 and accepts anything. Bound the skew, report the time source’s health, and treat a device with no trustworthy clock as one that needs a technician rather than one that should trust bundles freely.
- A bundle can be valid and still wrong for this device. The
targetandmin_firmwarechecks exist because signing infrastructure is fleet-wide and deployments are not. A correctly signed bundle for the wrong region is the most likely bad-apply scenario in a multi-region fleet. - Keep the previous artefact until the new one has proven itself. For a zone layer, that means one duty cycle of normal operation. Deleting the old version at apply time turns a bad zone update into a site visit.
Reporting the outcome
Every apply attempt should produce a record — bundle id, outcome, and on failure the specific rejection reason — spooled like any other event. Fleet-wide, those records are the fastest available signal that a rollout has gone wrong: a rejection reason that suddenly appears across many devices identifies the problem far more precisely than an absence of successful applies. wrong region on 40 devices is a build-pipeline mistake; expired on 3 devices is three dead RTC batteries; signature on any device at all is worth investigating immediately.
Transferring a bundle over a link that drops
The verification model above assumes the bundle arrived. Getting it there is the other half, and on a cellular link with a median uninterrupted window measured in tens of seconds it is the harder half.
Three properties make a transfer survivable. It must be resumable at byte granularity — a range request against the payload, with the offset persisted, so a drop at 82% costs nothing. It must be verifiable in pieces, which the per-payload digests already allow: a payload that fails its digest is re-fetched alone rather than invalidating the bundle. And it must be abandonable, with a bounded number of attempts and a spooled failure record, so a device on a hopeless link stops burning radio time on an update it will never complete.
The transfer also needs its own budget. A basemap update racing the telemetry drain for the same modem produces a device that is neither updated nor reporting. Give the transfer a bandwidth share and a time-of-day window, the same way the drain in store-and-forward buffering is budgeted, and let telemetry win any contention — a device that stops reporting to fetch a map has its priorities backwards.
Keep the bundle format itself boring and versioned. The schema field exists so that a future format change can be introduced without every deployed device rejecting the new bundles outright — a device that understands schema 1 and receives schema 2 should reject it with a specific, countable reason rather than a parse error, so the fleet’s readiness for the new format is measurable before anyone depends on it.
Related
- Secure Provisioning & OTA Updates — the wider update model and its artefact classes.
- A/B partition rollback for geospatial pipelines — the firmware-class equivalent of this apply path.
- Offline Tile & Basemap Storage — the largest payload this bundle format has to carry.