Developers

Kiosk update protocol

On this page

This page describes, from end to end, how a new version of the kiosk app gets from your build machine onto a frame: what the server offers, what the frame checks before it installs, what happens if the new version does not work, and what the frame reports back. It is for developers who change the updater, the release endpoints or the boot script, and for reviewers who want to check the security claims.

If you only want to publish an update, follow Update the kiosk. For why each check exists, read Security.

The idea in four lines

  1. The server only offers. It stores signed builds and tells each frame which one to take.
  2. The device decides. The frame installs a build only if it passes every check on the device itself.
  3. Two keys, both off the server. Android's package signing key keeps the app lineage. A separate, offline release key approves each specific build.
  4. A build that does not prove itself is undone. A root script on the frame reinstalls the last build that ran well.

Who does what

Part Where Role
Build script (kiosk/build.sh) Your machine Builds and signs the APK; writes the detached release signature
Release endpoints (routes/admin_kiosk.php, kiosk_update.php) Server Check, store and offer releases; track what frames report
Updater (Updater.java) Frame, app Downloads, verifies, installs, reports
UpdateResultReceiver, ReplacedReceiver Frame, app (not exported) Learn the installer's verdict; restart the app after a replace
Boot script (device/frame-fixmac.sh) Frame, root The rollback watchdog

End-to-end sequence

sequenceDiagram
    participant B as Build machine
    participant A as Admin
    participant S as Server
    participant K as Kiosk (Updater)
    participant P as Android installer
    participant W as Boot script (root)
    B->>B: build APK, sign it (v1 and v2)
    B->>B: sign "sha256:versionCode" with the release key
    A->>S: POST /api/kiosk-releases (APK, X-Release-Signature)
    S->>S: check, store as var/kiosk/sha256.apk
    A->>S: PATCH /api/frames/id {kiosk_target_id}
    Note over S: or POST /api/kiosk-releases/id/rollouts
    K->>S: GET /api/v1/frames/id (Authorization header)
    S-->>K: kiosk_update {version_code, sha256, size, signature, attempt}
    K->>K: check signature, size, device owner
    K->>S: GET /api/v1/frames/id/kiosk-releases/sha256
    S-->>K: the APK
    K->>K: check hash, ZIP layout, package, version, certificates
    K->>K: wait for a quiet moment, check again
    K->>K: write files/update/state
    K->>P: session, write APK, commit
    P->>P: verify certificate, replace app
    P-->>K: app restarts (ReplacedReceiver)
    K->>S: self GET, dashboard loads
    K->>K: write files/update/healthy, delete state
    K->>S: PUT telemetry {version_code}
    Note over S: release is now proven
    W->>W: every 10 s: compare healthy, state, installed

1. Publish a release

Build

kiosk/build.sh makes the APK and, when RELEASE_KEY is set, a detached signature in frame-kiosk.apk.sig.

  • The APK is signed with both the v1 (JAR) and v2 schemes, with --min-sdk-version 23, and the script verifies both. Android 6 checks only v1; newer versions prefer v2.
  • The release signature is ECDSA P-256 with SHA-256 over these ASCII bytes: <sha256 hex of the APK>:<versionCode in decimal>, for example ab12…ef:15. The file holds the base64 of the signature's DER encoding.
  • The matching public key (RELEASE_PUBKEY) is compiled into the kiosk as BuildCfg.RELEASE_PUBKEY (the base64 of its X.509 encoding). With no public key in the build, the kiosk refuses every update.
  • versionCode only ever goes up. To roll back, rebuild the old code with a higher number.

Upload

POST /frames/admin/api/kiosk-releases (admin session, CSRF token, password re-entered in the last five minutes) with:

  • Content-Type: application/vnd.android.package-archive
  • X-Release-Signature: <base64 DER>
  • the raw APK as the body, at most 16 MiB (Content-Length over the cap is refused before reading)

The server (kiosk_release_store()):

  1. Streams the body to a temp file in var/kiosk/ (mode 0640) with a running byte count, hashing as it goes.
  2. Reads the file with a pure-PHP reader. It must start with a ZIP local header; the central directory must end exactly at the end-of-central-directory record; every entry's header must lie inside the file body and one must be at offset 0; at most 10,000 entries. That blocks "Janus" tricks. Only the manifest is inflated (1 MiB cap) and its binary XML is read with every offset checked. A file that is not a plain ZIP is 415 not_apk; one that is a ZIP but unusable is a 422.
  3. Requires the package name com.calevans.frame, a version code from 1 to 2,100,000,000, and a version name of 1 to 64 bytes without control characters.
  4. If kiosk_release_pubkey is set in config.php, verifies the signature against it and rejects the upload on failure. Without it the signature is stored as given, and the kiosk verifies it.
  5. In one transaction, rejects a hash or version code that already exists (409 duplicate_sha256, 409 duplicate_version_code), inserts the row, and atomically renames the file to var/kiosk/<sha256>.apk.

The server never judges the APK's signing certificate. The device does.

Point frames at it

  • PATCH /api/frames/{id} with kiosk_target_id sets or clears one frame's target.
  • POST /api/kiosk-releases/{id}/rollouts targets every active frame that reports a lower version and is not already aimed at this version or a newer one.
  • Claiming a new frame aims it at the newest proven release, unless the same request names one.

Staged rollout. A release is proven when some frame fetched it in full (a row in kiosk_downloads) and later reported a version_code at least as high. A frame that has no rollback watchdog, and any "roll out to everyone", may only target a proven release (409 release_not_proven). So a release first goes to a frame that can undo it.

2. The offer

The frame's self GET carries a kiosk_update object when all of these hold:

  • the request used the Authorization header (never the cookie),
  • the frame has a target release,
  • the frame has not reported a version_code, or reported a lower one.
json
"kiosk_update": {
  "version_code": 15, "version_name": "15", "sha256": "ab12…", "size": 2201344,
  "signature": "MEUCIQ…", "attempt": 0
}

There is no URL in the offer, on purpose. attempt rises each time an admin clears a failure for the frame.

The kiosk calls Updater.offer() after every successful self GET. A missing object clears any prepared update. A malformed object, or a version_code that is not above the installed one, is ignored. An offer is also ignored while an install is being committed, when the same hash already failed permanently and the offer's attempt is not higher than the one recorded with the failure, or when that release is already prepared.

3. Download and verify, on the device

Updater.process() runs on its own thread, in this order. The first failing step ends it.

# Check Failure reason Permanent
1 The hash was marked failed and the attempt is not higher (skip)
2 The previous attempt is still backing off for this hash (skip)
3 size is 1 byte to 32 MiB size Yes
4 The build has a release public key release_sig Yes
5 ECDSA P-256 signature over <sha256>:<version_code> verifies release_sig Yes
6 The app is the device owner not_owner No
7 Old *.part and *.apk files for other hashes are deleted
8 Download (below), unless <sha256>.apk already exists download, size, hash See below
9 Verify (below) See below Yes

Download

The kiosk builds the address itself: SERVER_URL from its build, then api/v1/frames/<its own id>/kiosk-releases/<sha256>. Any address the server sent is ignored.

  • Requires free space of at least twice the size plus 32 MiB, and an https:// server address.
  • Authorization: Frame <id>.<key>, Accept-Encoding: identity, 15-second connect and 30-second read timeouts, no redirects, no cache.
  • The status must be 200 (on 429 it honors Retry-After), the content type must be exactly application/vnd.android.package-archive, and a declared length must equal the offered size.
  • The body goes to <sha256>.part while the SHA-256 is computed. It stops with size if more than the offered size arrives, and with download after a 10-minute deadline or if fewer bytes arrive. After fsync, a hash mismatch is hash, and a match is renamed to <sha256>.apk.

Verify

verify() runs after the download and again just before the commit:

  1. The file length equals the offered size (size) and its SHA-256 equals the offer (hash).
  2. The ZIP layout check (plainZip): begins with PK\3\4; one disk; no ZIP64; at most 10,000 entries and a 4 MiB central directory; the central directory ends exactly where the end record begins; every entry's local header lies before it and is a real local header; one entry is at offset 0 (not_apk).
  3. Android can read the archive (getPackageArchiveInfo) (not_apk).
  4. The package name is the app's own (package).
  5. The archive's versionCode equals the offered one (version_mismatch) and is higher than the installed one (not_newer).
  6. The archive's signing certificates are exactly the installed app's: the same set, both ways, never empty (cert).

All of these failures are permanent for that hash. The downloaded file is deleted.

Back-off

A transient failure (download, not_owner, a missed deadline, a lack of space) retries after 5 minutes, doubling up to 6 hours, or the Retry-After the server sent if that is longer. The delay resets after a success.

4. Install when nobody is looking

A verified build waits in ready. Every 30 seconds the main thread asks the activity whether it is quiet:

  • the network is up,
  • the admin panel is closed, and
  • the screen is dimmed (bedtime, or the Dim button), or nobody has touched it for 30 minutes.

When quiet, install():

  1. Checks device owner and runs verify() again.
  2. Adds one to the per-hash try counter (attempts, at most 3 tries).
  3. Opens a PackageInstaller session in MODE_FULL_INSTALL, writes the APK, and calls fsync.
  4. Writes files/update/state atomically (from, to, sha, attempt, and the kernel's boot id).
  5. Creates an explicit-component PendingIntent for UpdateResultReceiver and commits.

Android checks the new package's certificate against the installed one, replaces the app and kills it. ReplacedReceiver starts the activity again. The kiosk never shows the installer's confirmation screen: a session that wants the user is abandoned and counted as a failed install.

If the installer reports a failure, UpdateResultReceiver writes the status to files/update/result, and the updater records install_<status>. It retries with back-off until the third try, then holds the failure permanently.

5. Proving health

After the restart the new version must show it works. It writes files/update/healthy (its own versionCode) and deletes state and result when either:

  • the first self GET returned 200 and the dashboard has loaded, or
  • the activity has been resumed for five minutes without crashing, whatever the network does.

Health therefore does not depend on the server being reachable.

On every later start, startup() looks at state:

What it finds What it concludes
state.to is not above the running version The update succeeded; health will clear it
state.to is above the running version and result holds a status The installer failed: record install_<status>
state.to is above the running version, no result, and the boot id is the same or three tries are used up The new build never ran well and the watchdog rolled back: record unhealthy, permanent
state.to is above the running version, otherwise Power was cut mid-install: the offer comes again and counts as another try
stateDiagram-v2
    [*] --> Idle
    Idle --> Checking: offer in self GET, version higher
    Checking --> Held: format bad, release_sig, size
    Checking --> Downloading: signature ok, device owner
    Checking --> Backoff: not_owner
    Downloading --> Backoff: network, space, deadline
    Downloading --> Held: size, hash
    Downloading --> Verifying: file complete
    Verifying --> Held: not_apk, package, version, cert
    Verifying --> Ready
    Ready --> Committing: quiet moment, verified again
    Committing --> Probation: installer replaces the app
    Committing --> Backoff: install failed, tries left
    Committing --> Held: install failed, third try
    Probation --> Healthy: self GET and dashboard, or 5 minutes
    Probation --> Held: rolled back by the watchdog (unhealthy)
    Backoff --> Checking: delay over
    Held --> Checking: admin clears the failure (attempt rises)
    Healthy --> Idle

Held is a permanent failure for one hash and one attempt number. It lives in files/update/failed, so it survives restarts. It ends when the admin clears the failure (the offer's attempt rises) or when the server offers a different hash.

6. The rollback watchdog

device/frame-fixmac.sh runs as root from boot. Every 10 seconds, once sys.boot_completed is 1 and a timeout command exists, it runs the watchdog tick. It trusts nothing the app wrote.

Files

File Owner Written by Holds
…/files/update/healthy App App The version that proved itself
…/files/update/state App App from, to, sha, attempt, boot
…/files/update/result, failed, attempts App App Installer status; permanent failure; try count. Root never reads these
/data/misc/frame/kiosk-good.apk Root (0700 dir) Root A copy of the last build that ran well
/data/misc/frame/kiosk-good.vc Root Root Its version code
Property debug.frame.watchdog Root Root 1 while the watchdog can really work, else 0. Apps cannot set debug.* properties
Property debug.frame.rolledback Root Root The version code that was rolled back; not persistent

Root reads healthy and the to= line of state only after checking each is a regular file, not a symlink, with no symlink in the path above it, owned by the app's user id, and read through timeout dd with a size cap so a named pipe cannot hang the script. Each number must be digits only, with no leading zero, at most 10 characters, and at most 2,100,000,000, before it is used in any comparison or arithmetic. Root writes nothing under the app's data directory.

The tick

  1. Read H (healthy), T (the to in state) and G (the saved good version).
  2. Publish debug.frame.watchdog as 1 if G exists, else 0.
  3. No good build saved yet. Save the installed one, at most 3 tries per boot, but only if it has proven itself (H equals the installed version) or no update is in progress at all (no state file). The copy is taken from pm path, and only an expected path (/data/app/<package>-*/base.apk) is accepted.
  4. Nothing to do if H is empty or equals G, and T is empty or equals H. Reset the counter and clear the rollback marker.
  5. If H equals the installed version and differs from G, the new build earned its place: save it as G.
  6. Suspect if T equals the installed version, T differs from H, and G is lower than the installed version. Count one bad tick. Otherwise reset the counter.
  7. At 90 bad ticks in a row (about 15 minutes), and only if the rollback marker is not already T: set the marker, copy kiosk-good.apk to a fresh unique name in /data/local/tmp, run pm install -r -d on it, delete the copy, and start the kiosk.
stateDiagram-v2
    [*] --> Unarmed
    Unarmed --> Armed: a proven build is saved as the good one
    Armed --> Armed: healthy equals good, no update pending
    Armed --> Suspect: update installed, not healthy, good is lower
    Suspect --> Armed: app writes healthy
    Suspect --> RollingBack: 90 bad ticks in a row
    RollingBack --> Armed: good build reinstalled, marker set

The restored build is exactly the file root saved, and Android still checks its signature. The most a hostile server can cause is a return to the last build that ran well on that frame. Once the old build runs, its startup() sees an unfinished update with the same boot and records unhealthy; the failure is permanent, so the frame does not try that build again until an admin clears it.

7. What the frame reports

The kiosk's telemetry carries three update fields (PUT /api/v1/frames/{id}/telemetry):

Field Source
version_code The installed build's versionCode
watchdog true only if /system/bin/frame-fixmac.sh contains the line # frame-watchdog: 1 and root has set debug.frame.watchdog to 1. Checked every 10 minutes
kiosk_update_failure {sha256, reason} while a failure is held. Omitted when there is none

Telemetry is a replacement, so an omitted kiosk_update_failure clears the stored failure. The server keeps a failure only if its sha256 is the frame's current target; anything else is dropped. It validates the reason against the list below (install_ followed by an optionally negative number of up to three digits is also allowed).

Reason Meaning Retried by the kiosk
download Network error, bad status, wrong content type, deadline, fewer bytes than offered, too little space, or a server address that is not https Yes, with back-off
size Offered size outside 1 byte to 32 MiB, a declared length that differs, more bytes than offered, or a stored file of the wrong length No
hash The downloaded SHA-256 does not match the offer No
not_apk The file is not a plain ZIP, or Android cannot read it No
package Not the kiosk's package No
version_mismatch The version inside the file is not the offered one No
not_newer The version is not above the installed one No
cert The signing certificates differ from the installed app's No
release_sig No release public key in the build, or the signature does not verify No
unhealthy The new build did not become healthy and was rolled back, or three tries ran out No
not_owner The app is not the device owner, so it may not install silently Yes, with back-off
install_<status> The installer's status code Up to 3 tries

"No" means held until the admin clears the failure (or the server offers a different hash).

The attempt counter

The admin clears a held failure with DELETE /api/frames/{id}/kiosk-update-failure (password re-entry required). The server deletes the stored failure and raises the frame's kiosk_attempt by one. The next offer carries the larger attempt, and the kiosk, seeing a number above the one it recorded with the failure, clears the failure, resets its try counter and back-off, and tries once more.

What the admin sees

The frame's kiosk.status is derived, not stored:

Status When
none The frame has no target
failed A failure for the target's hash is held, and the reported version is not already at or above the target's
unknown It has a target, has not yet reported a version, and holds no failure
current It reports a version at or above the target's
pending Otherwise: waiting for the download or a quiet moment

Rules for changing this

  • Never put a URL, a path or a credential in the offer, and never send the offer on the cookie path.
  • Never give the server a way to lower the version the device accepts.
  • Never add a restriction to the device-owner policy that blocks package installs (DISALLOW_INSTALL_APPS): updates and rollbacks both need them.
  • Keep the watchdog's rules: read only validated digits from the app's files, write nothing there, restore only the copy root made, and keep the rollback marker non-persistent.
  • Keep the receivers exported="false".
  • A new kiosk-side failure reason needs three edits: the REASON pattern in Updater.java, KIOSK_FAILURE_REASONS in kiosk_update.php, and the table above.
  • A release must not write preferences or files that the previous release cannot read, because a rollback keeps the app's data.
  • Every change here needs a security review. See Security.