Skip to main content

Architecture

dash-ota is built around one idea: divide trust so that a compromise of any single component (the backend, the network, or even the running JS bundle) cannot forge an update or get one applied that you didn't sign.

The pieces​

Divided trust · top to bottomsign → serve → orchestrate → verify
CI / Release machineHolds the private key
@dash-ota/cli

Bundles JS, compiles Hermes HBC, AES-256-GCM-encrypts, and Ed25519-signs the manifest. The signing key lives only here.

Your backendNever holds the key
@dash-ota/backend

Verifies device-key (ECDSA) requests, applies targeting + rollout, and serves the pre-signed manifest + ciphertext. It can store and serve, but never forge.

react-native-dash-ota · JSUntrusted
JS orchestration

Drives check → download → apply and handles retries. Treated as untrusted for security: a tampered bundle cannot disable its own verification.

Native · Kotlin / SwiftTrust-critical
verify · decrypt · stage · apply

Before any JS runs: verifies the Ed25519 signature against the embedded key, AES-GCM-decrypts, checks every file hash, stages atomically, and rolls back on crash-loop.

A compromised backend, a broken TLS channel, or a tampered JS bundle each independently fail to forge or apply an update — signing happens only in CI, and verification happens only in native.

Division of trust​

  • Networking and orchestration run in JS. The /enroll, /check and /confirm requests go through JS fetch. Optional TLS pinning, off by default, is native and covers blob downloads only; those three requests are pinned only if you pass your own transport. JS is treated as untrusted for security decisions.
  • Trust-critical steps run in native code: signature verification, decryption, per-file hash checks, file swap and rollback, before and independent of JS. A compromised bundle cannot disable its own verification.
  • Signing happens only in the CLI. The backend stores and serves pre-signed data.

The result: a compromised backend, a broken TLS channel or a tampered JS bundle cannot forge an update or get an unsigned one applied.

Where the keys live​

KeyLives inPurpose
Ed25519 private signing keyyour CI secrets or release machine (.keys/)sign manifests; never on the backend or device
Ed25519 public key(s)embedded in the app binary (per flavour)native signature verification
Device key (EC P-256, private)Android Keystore / Secure Enclave on each device (iOS falls back to a software key by default)sign requests; never leaves the device
Device key (public)registered on the backend at enrollverify the device's requests
AES-256-GCM content keyinside the signed manifest; the backend stores it and returns it on /check to enrolled installsdecrypt the payload natively

Data flow, end to end​

  1. Build and sign (CLI): bundle JS, compile it to Hermes bytecode (with --hermes), encrypt each file with AES-256-GCM, build a manifest of per-file SHA-256 hashes, Ed25519-sign it, upload it.
  2. Enroll (client, once): the app generates a device key and registers its public half. Your backend's verifyEnrollToken hook decides whether to accept it; by default any non-empty token is accepted, so check a real app session there.
  3. Check (client): a device-key-signed request asks for an eligible update. The backend applies targeting and rollout and returns the pre-signed manifest plus a download token scoped to that release. The token travels in the x-ota-download-token header and can be reused for 30 minutes by default.
  4. Verify and download (native): check the Ed25519 signature against the public keys compiled into the app, then the manifest's appId, runtimeVersion, channel, platform, minimum native build and bundleVersion (the channel, platform and minimum-build checks are in 0.5.1 and later). Then download each blob the device doesn't already hold, check its hash, decrypt it and check the plaintext hash before staging. Nothing is written until the signature verifies.
  5. Apply (native): the next cold start runs the new bundle on trial.
  6. Confirm (client): once the app is usable, markHealthy() ends the trial and the bundle becomes last-known-good. A launch that crashes before that counts against the bundle; after two such launches the next one disables it and reverts.

Read the full lifecycle for the state machine, and native vs JS for the trust split in detail.