Security - Kush-Kelaiya22/imPress GitHub Wiki

Security

How imPress protects accounts, data, devices and firmware, what you must turn on before production, and what is still open. References: docs/design/security-model.md, docs/engineering/SECURITY_REVIEW.md.

Contents


Production checklist

# Do this Why
1 IMPRESS_DEBUG=false, and real IMPRESS_JWT_SECRET and IMPRESS_DEVICE_API_KEY (scripts/install.sh generates them) the server refuses to start otherwise
2 Change the first admin's password at first sign-in
3 Serve the web app over HTTPS, with access_log off for /ws/ at the proxy sessions, and the teacher's token that travels in the WebSocket URL
4 Turn on signed firmware only your images can run on hubs and gateways
5 Turn on device TLS answers, enrollment numbers and keys aren't readable on the network; devices know they talk to your server
6 Once every device shows own key: IMPRESS_DEVICE_KEYS_REQUIRED=true the shared key can then only register devices
7 Keep the firmware signing key offline, backed up in two places losing it means updating every device over USB
8 Keep backend/.env readable by the service only (install.sh makes it mode 600), and back it up separately it holds secrets
9 If the repository is public: purge the databases committed before v2 from git history, and rotate their passwords see Git history

Threat overview

Threat Protection Status
Default credentials or published secrets random first admin password; the server refuses the published default secrets outside DEBUG โœ…
Stolen session tokens from a database copy or backup only SHA-256 hashes of tokens are stored โœ…
Privilege escalation (admin โ†’ super admin) a closed set of roles, and grant rules enforced on the server โœ…
A teacher reading or changing other teachers' classes one class-access rule everywhere: primary teacher, co-faculty or admin โœ…
An unauthenticated route a test calls every route without credentials and fails on any that answers โœ…
Ballot stuffing through the HTTP routes device key, a registered device, one submission per device โœ…
Duplicate or forged answers through the gateway known student, active quiz, valid option, one answer per student (unique index) โœ…
Impersonating a device with the shared key per-device keys โœ…
Malicious firmware validated, immutable registry; download only of the version deployed to that device; app rollback; signed images โœ… with signing on
Eavesdropping on device traffic HTTPS/WSS for devices โœ… with TLS on
Path traversal through firmware files files are named by SHA-256 only โœ…
CSV formula injection on export dangerous cell prefixes are escaped โœ…
Radio spoofing (a rogue module claims an enrollment number) answers count only for enrolled, registered students, once each โš ๏ธ accepted risk: there is no cryptography on the mesh
Radio jamming bounded queues keep nodes alive โš ๏ธ can't be prevented on 2.4 GHz
Physical access to a device (reading its flash, replacing its bootloader) none: hardware Secure Boot and flash encryption aren't enabled โš ๏ธ

Accounts and sessions

Property Detail
Passwords bcrypt
Session tokens opaque random impress_โ€ฆ tokens; the server stores only their SHA-256, so a database copy contains no usable tokens
Expiry 60 min idle, 6 h absolute (configurable); a warning 5 min before the hard limit
Activity refreshed once per request; status checks and polling pages never refresh it
Independent sessions one per sign-in; signing out ends that one
Deactivated users their sessions stop working immediately
Teacher WebSocket the same validation as the REST API; opening a socket doesn't extend the session

Access control

Every rule is enforced on the server, never just by hiding buttons:

Guard Who
public sign-in, health, two CSV templates, the web app
session any signed-in user
teacher+ teacher, admin, super admin
class access the class's primary teacher, its co-faculty, or an admin
admin admin or super admin
super admin super admin only (granting admin rights)
device a valid device key, for the device's own MAC

backend/tests/test_route_guards.py requests every registered route without credentials (and every device route with a wrong key) and fails if one answers. Adding a route without a guard breaks the build.

Secrets

Secret Where Rotate by
IMPRESS_JWT_SECRET backend/.env a new random value and a restart (legacy JWT helpers only)
IMPRESS_DEVICE_API_KEY (shared provisioning key) backend/.env; every device's NVS api_key a new value on the server and on every device that still has no active key of its own
Device keys the server stores only their SHA-256; each device keeps its own in NVS dev_key Reset key on the Modules page
Firmware signing key offline (private); the backend's IMPRESS_FIRMWARE_SIGNING_KEY (public) see key rotation
TLS certificates the proxy, or IMPRESS_SSL_*; devices trust a public CA or an embedded site CA your CA's process; with an embedded CA, deploy firmware with the new CA before switching the server

Per-device keys

The problem: with one shared key, anyone who had it (a leaked .env, or a key read out of any device's flash) could act as any device: forge answers, report fake firmware results, or impersonate a gateway.

How it works now:

sequenceDiagram
    participant D as Device
    participant B as Backend
    D->>B: POST /register (X-API-Key: shared key)
    B->>B: issue a key, store only its SHA-256
    B-->>D: {"device_id": 12, โ€ฆ, "device_key": "kq3Vโ€ฆ"}
    D->>D: store it in NVS dev_key
    D->>B: POST /heartbeat (X-API-Key: its own key)
    B->>B: first use โ†’ the key is active
    Note over B: from now on the shared key is refused for this MAC,<br/>and this key is refused for any other MAC
Loading
Situation Response What the firmware does
missing key header 422 โ€“
unknown, replaced or reset key 401 drops its key, goes back to the shared key, registers again, gets a new key
a known key that may not do this (another device's MAC, a disabled device, the shared key for a device with its own key, the shared key outside registration in required mode) 403 keeps its key

Edge cases handled:

  • A lost response: a key that was issued but never used is replaced when the device registers again with the shared key, so a lost registration response can't lock a device out.
  • A reflashed device: its flash, and so its key, was erased. Reset key on the Modules page lets it enrol again.
  • A stolen device: disable it. Every request for its MAC is refused, whichever key is used.
  • Batches: each relayed message is accepted only for a gateway the caller may act for. Answers and votes, which carry no gateway MAC, count as the caller's own.

Required mode: IMPRESS_DEVICE_KEYS_REQUIRED=true limits the shared key to registration. Turn it on once every device runs v2.1 firmware with per-device keys, and shows own key. Older firmware stops working in this mode.

Residual risk: a device that has no key yet (or whose key was reset) can be claimed by whoever presents the shared key first.

Signed firmware

The problem: without signatures, a device installs any image that passes the integrity checks. An attacker who can get a file accepted, or who sits between a device and the server, could run their own code on hubs and gateways.

The scheme: ESP-IDF's signed app images without hardware Secure Boot: RSA-3072 in the Secure Boot V2 format.

Property Detail
Who checks the running app checks each new image in esp_ota_end(). The bootloader is unchanged
eFuses none burned. Reversible
Delivery one ordinary OTA update moves a device to the signed profile
Backend check with IMPRESS_FIRMWARE_SIGNING_KEY set, the backend verifies the RSA-PSS signature at upload and never deploys other images
Protects against network attackers; a compromised upload path
Doesn't protect against someone with physical access and a USB cable: that needs hardware Secure Boot, which is irreversible and not enabled

Setting it up

  1. Create the site key, once:

    scripts/firmware_key.sh                      # โ†’ ~/.impress/firmware-signing/
    • signing_key.pem is the private key. Keep it offline and backed up, and never commit it.
    • signing_key.pub is the public key. Give it to the backend.

    The script refuses to overwrite an existing key, and prints the key's digest (the same value espsecure digest-sbv2-public-key gives).

  2. Tell the backend, then restart it:

    # backend/.env
    IMPRESS_FIRMWARE_SIGNING_KEY=/home/impress/.impress/firmware-signing/signing_key.pub
  3. Build signed images:

    IMPRESS_SIGNING_KEY=~/.impress/firmware-signing/signing_key.pem scripts/build_signed.sh class_c6
    IMPRESS_SIGNING_KEY=~/.impress/firmware-signing/signing_key.pem scripts/build_signed.sh class_s3
    scripts/verify_firmware.py firmware/class_c6/build/signed/impress_class_c6.bin --key ~/.impress/firmware-signing/signing_key.pub

    build_signed.sh starts from the project's committed sdkconfig, so partitions, flash settings and rollback are unchanged. It adds firmware/sdkconfig.defaults.signed and the key path, and writes into firmware/<project>/build/signed/.

  4. Upload and deploy as usual. The Firmware page shows signed โœ“ for images signed with the site key, other key for images signed with another key, and unsigned in red for images that will never be deployed.

The transition: the device that installs the first signed-profile image doesn't check that one yet. Only the images after it are checked. So run that first deployment from a trusted machine.

Key rotation and turning it off

  • Rotate: a device checks updates against the key compiled into the image it is running. ESP-IDF allows up to three signature blocks per image (espsecure sign-data --version 2 --append-signatures):

    1. Deploy an image built with the new key and also signed with the old key.
    2. Switch the backend to the new public key.

    This has not been rehearsed on hardware yet: try it on one bench device first.

  • Turn it off: sign a normal-profile build with the current key (espsecure sign-data --version 2 --keyfile signing_key.pem), deploy it, then clear IMPRESS_FIRMWARE_SIGNING_KEY.

  • Key lost: devices on the signed profile can only be updated over USB (Recovery Procedures).

The backend's verifier uses only Python's standard library. It is tested against images signed by the real espsecure.py (backend/tests/fixtures/signing), including nine kinds of tampering. CI builds and verifies a signed gateway image with a throwaway key on every change.

Device traffic over TLS

The problem: by default, hubs and gateways talk to the backend over plain http:// and ws://. Answers, enrollment numbers, device keys and firmware downloads cross the school network in clear, and a device can't tell whether it is talking to the real server.

Turning it on:

  1. Serve TLS: a reverse proxy (Installation โ†’ TLS), or uvicorn directly:
    IMPRESS_SSL_CERTFILE=/etc/impress/server.crt IMPRESS_SSL_KEYFILE=/etc/impress/server.key \
      IMPRESS_HOST=0.0.0.0 IMPRESS_PORT=443 scripts/start.sh
    Check it:
    scripts/smoke_test.py --base https://impress.example.edu --password โ€ฆ [--cafile ca.pem]
  2. Configure the gateway and hub firmware: idf.py menuconfig โ†’ Backend Server:
    • enable Use HTTPS / WSS to reach the backend;

    • set BACKEND_HOST to the name in the certificate, and BACKEND_PORT to the TLS port;

    • choose how the device trusts the server:

      Choice When You provide
      Public certificate authorities the server's certificate comes from a public CA (e.g. Let's Encrypt) nothing: ESP-IDF's CA bundle is built in
      certs/backend_ca.pem a private CA, or a self-signed certificate the CA certificate at firmware/<project>/certs/backend_ca.pem before building; it is embedded in the image (.pem files are git-ignored)
  3. Build and deploy over the air (signed, if signing is on). Deploy only after the server serves TLS on the configured port. Otherwise the gateway can't reach the backend, fails its health check and rolls back: that's safe, but noisy.

Behaviour:

  • Every request to the backend uses it: registration, heartbeats, batches, the WebSocket, firmware checks and downloads.
  • Certificate checks: the device checks the server's certificate and host name. A mismatch is a connection error, logged and retried, never a fallback to HTTP.

CI checks both sides on every change:

  • Server: it starts the server with a throwaway certificate and runs the full smoke test over HTTPS, then checks that a client without the CA is refused.
  • Firmware: it builds the gateway with public-CA trust and the hub with an embedded CA.

Uploads

Firmware CSV
Size limit 4 MB (the largest app slot): 413 1 MB and 500 rows
Content validation parsed as an ESP-IDF app image: chip, project, version, appended hash, slot size, signature strict header (unknown columns refused), per-row validation, all-or-nothing
Storage <sha256>.bin, never a user-supplied name not stored
Access downloaded only by the device it was deployed to, and only that version โ€“
Exports โ€“ formula prefixes escaped

Data protection

Data Where Notes
Students' enrollment numbers and names the database; modules hold only their own enrollment number names are cached on modules but never transmitted
Answers and votes the database hard-deleting a student keeps answers anonymised, so results keep their totals
Location โ€“ the inventory records which gateway last heard a module, never a location history
Logs the server log; the activity log SQL logging (IMPRESS_SQL_ECHO) logs personal data: never on in production
Backups migration backups next to the database (*.db.bak-*) git-ignored; they contain personal data: delete old ones

The mesh

ESP-NOW frames are not authenticated or encrypted. A rogue module can claim any enrollment number and answer for that student. The backend limits the damage:

  • answers count only for registered students;
  • only once per question;
  • only for an active quiz.

It can't tell a rogue module from a real one. Making the mesh tamper-proof (for example a per-class key with an HMAC over each frame) is a possible future step, not part of v2.1.

Git history

Databases containing user and session data were committed to the repository before v2. Since v2 they are git-ignored and session tokens are stored hashed, but the old commits still hold them. If the repository is or will be public:

pip install git-filter-repo
git filter-repo --invert-paths --path backend/impress.db --path backend/impress.db.bak-pre-ist --path impress.db
git push --force --all && git push --force --tags      # coordinate with everyone: they must re-clone

Then rotate every password those databases held. This rewrites history and needs a force-push, which is the repository owners' decision.

What is not protected

  • Physical attacks on devices: reading flash (keys, Wi-Fi passwords) or replacing the bootloader. That needs flash encryption and hardware Secure Boot, which are irreversible and not enabled.
  • The radio mesh: see The mesh.
  • Insiders with admin rights: admins can deploy firmware and see every class. Use as few admin accounts as you can, and read the activity log.
  • Denial of service on the network or the radio.

Reporting a vulnerability

Please don't open a public issue with exploit details. Contact the repository owners privately. A public issue can describe the area without the details (for example, #66 was filed that way).

โš ๏ธ **GitHub.com Fallback** โš ๏ธ