Security - Kush-Kelaiya22/imPress GitHub Wiki
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.
- Production checklist
- Threat overview
- Accounts and sessions
- Access control
- Secrets
- Per-device keys
- Signed firmware
- Device traffic over TLS
- Uploads
- Data protection
- The mesh
- Git history
- What is not protected
- Reporting a vulnerability
| # | 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 | 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 | |
| Radio jamming | bounded queues keep nodes alive | |
| Physical access to a device (reading its flash, replacing its bootloader) | none: hardware Secure Boot and flash encryption aren't enabled |
| 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 |
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.
| 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 |
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
| 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.
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 |
-
Create the site key, once:
scripts/firmware_key.sh # โ ~/.impress/firmware-signing/-
signing_key.pemis the private key. Keep it offline and backed up, and never commit it. -
signing_key.pubis 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-keygives). -
-
Tell the backend, then restart it:
# backend/.env IMPRESS_FIRMWARE_SIGNING_KEY=/home/impress/.impress/firmware-signing/signing_key.pub
-
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.shstarts from the project's committedsdkconfig, so partitions, flash settings and rollback are unchanged. It addsfirmware/sdkconfig.defaults.signedand the key path, and writes intofirmware/<project>/build/signed/. -
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.
-
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):- Deploy an image built with the new key and also signed with the old key.
- 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 clearIMPRESS_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.
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:
-
Serve TLS: a reverse proxy (Installation โ TLS), or uvicorn directly:
Check it:
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
scripts/smoke_test.py --base https://impress.example.edu --password โฆ [--cafile ca.pem]
-
Configure the gateway and hub firmware:
idf.py menuconfigโ Backend Server:-
enable Use HTTPS / WSS to reach the backend;
-
set
BACKEND_HOSTto the name in the certificate, andBACKEND_PORTto 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.pema private CA, or a self-signed certificate the CA certificate at firmware/<project>/certs/backend_ca.pembefore building; it is embedded in the image (.pemfiles are git-ignored)
-
- 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.
| 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 | 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 |
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.
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-cloneThen rotate every password those databases held. This rewrites history and needs a force-push, which is the repository owners' decision.
- 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.
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).