Security
Security by design — the layers, what is shipped and what is planned
Security is part of PNeX's DNA: every layer was designed encrypted, isolated and verifiable from day one — including on a Raspberry Pi in a workshop. Each layer below shows its real state; nothing is claimed that is not shipped.
The eight layers
| # | Layer | What it guarantees | State |
|---|---|---|---|
| 1 | Transport | TLS everywhere; dedicated device endpoint over mutual TLS (client certificate per device); CA pinned in firmware | Shipped |
| 2 | Device link | Authenticated, anti-replay Noise channel inside TLS (per-device key, post-quantum layer), anti-clone, Ed25519-signed OTA with anti-downgrade | Shipped |
| 3 | Identity | Dedicated OIDC provider (Rauthy), PKCE, passkeys; one-time tickets for browser websockets | Shipped |
| 4 | Authorization | Per-organisation isolation, owner/admin/member/viewer roles, one write source per pin, read-only AI | Shipped |
| 5 | Execution | Sandboxed JavaScript and hermetic Starlark, outbound requests through an egress guard, fenced custom firmware builds | Shipped |
| 6 | Network | Only nginx faces the network (ports 80, 443, and 4443 for devices); databases and identity stay on the private network | Shipped — production installer; the development stack still publishes database ports |
| 7 | Supply chain | Rust end to end, Wolfi (Chainguard) images, audited dependencies | Shipped |
| 8 | Data at rest | Per-organisation secrets vault, XChaCha20-Poly1305, key ring with rotation | Shipped |
The device link described below (mutual TLS, Noise, signed OTA, device endpoint)
ships since 0.1.0-beta.8. A device flashed with 0.1.0-beta.7 or older is refused:
moving past beta.7 means rebuilding and USB-reflashing every device, see
Upgrades.
TLS everywhere, two doors
nginx terminates TLS for everything that talks to PNeX, on two separate doors:
- 443: browsers and the native apps (desktop and Android) — UI, API, browser websockets, identity provider;
- the device endpoint: a dedicated nginx
server(port 4443 on aninstall.sh/Compose install,devices.<publicHost>behind a LoadBalancer with the Helm chart). It serves only/ws/device,/ws/cameraand/api/v1/ota/firmware/, and asks every device for its client certificate (mutual TLS). Any other route answers 404.
A device never goes through a reverse proxy, a CDN or an Ingress: TLS would end
there and the client certificate would be lost. The name compiled into firmware is
the server variable PNEX_DEVICE_HOST (host[:port], or just :port on a LAN).
TLS 1.2 stays enabled because the ESP8266 has no TLS 1.3; small TLS records are
negotiated for its 2 KB buffer. ws:// is gone: firmware speaks only wss:// and
https://, with no setInsecure, and the server closes a device that did not come
through the TLS edge with 4013. nginx adds a shared secret to every request
(X-Pnex-Edge, generated by the installer): without it the server ignores the TLS
and certificate headers, so a client reaching the server port directly cannot pose
as the edge.
Pinned authority. The CA of the device endpoint is injected into every build and
checked, with the hostname, for the WebSocket channel and OTA alike. It depends on
the mode: local CA (Compose --tls local), ISRG Root X1 (Compose --tls cloud, the
endpoint serves the same Let's Encrypt certificate as the UI), private CA generated
by the chart (Helm, default). Renewing the certificate under the same authority is
transparent; changing the authority means a rebuild and reflash. See
Self-hosting.
Browser websockets: one-time tickets
Live views in the browser (notifications, camera live view, collaborative page editing) never put your session token in a URL, where it would end up in proxy and access logs. The app first asks the API for a ticket: valid 60 seconds, bound to your user and to the active organization, and usable once. The websocket opens with that ticket; a replayed or expired ticket is refused.
Device identity: X.509 and mutual TLS
- One CA per organisation: ECDSA P-256, valid 30 years, created at the first build; its private key lives in the organisation's secrets vault.
- One certificate per build: a client key and certificate issued at every compilation (CN = device id, valid 10 years), compiled into the firmware; the private key lives only in the image. Every certificate is recorded in a registry (fingerprint, serial) that lets you revoke it.
- Server check: chain against the CA of the device's organisation, fingerprint
against the registry, revocation, and that it belongs to this device. Without
a valid certificate: close
4014. - CA rotation: not tooled in V1. A compromised CA today means a new CA, then rebuilding and reflashing every device of the organisation.
Encrypted device link (Noise)
Under TLS, the device and the server open a second encrypted channel:
Noise_NNpsk0_25519_ChaChaPoly_SHA256, the pattern ESPHome also uses.
- Shared key: 32 random bytes generated when the device is registered and baked into its firmware at build time; it never travels on the wire. Mixed in from the first message, it gives post-quantum confidentiality: TLS exchanges keys with ECDHE, which a quantum computer will break, not a 256-bit symmetric key. This layer is therefore not redundant.
- Authenticated and anti-replay: ChaCha20-Poly1305, nonces = Noise counters per
direction, prologue bound to the device id. A frame that is modified, replayed or
taken from another connection gets
ERROR:decryption_failed; a refused handshake (wrong key, other device, older firmware) closes with4011. - Scope: device channel (announce, pin commands, custom metrics and commands, OTA orders), telemetry ingestion and cameras. Edge agents use the same link.
- Size limits: a frame carries at most 4 KB of plaintext on ESP32 and 1 KB on ESP8266; the firmware refuses to send anything larger.
- No plaintext mode: the old unauthenticated ChaCha20 format is removed everywhere; firmware built before is refused and must be reflashed.
Token and anti-clone
The device token travels raw in the Authorization: Bearer header — never in
the URL (no device_id query parameter, no base64 on the wire), so it never lands in
access logs. Tokens are unique per device, never reused, and can be deactivated: the
server revalidates live sessions (every 30 seconds by default) and closes a session
whose token was revoked with 4005.
Two boards flashed from the same binary carry the same identity. The server therefore enforces a first-live-wins lease, held in Valkey and shared by every server instance. It is taken only after a successful Noise handshake: a stolen token without the key cannot lock out the real device.
- while the original device is connected, a second connection with the same identity
is rejected (
4003); - a clean disconnect frees the lease immediately;
- after a silence longer than the lease (10 seconds by default, two missed heartbeats) the lease expires and a reconnection is accepted.
The honest limit: an exact copy of the image (certificate, key and token included) can take the place of a silent original. The per-build certificate binds identity to an image, not to a physical chip.
Device link close codes
| Code | Cause | What to do |
|---|---|---|
4001 | Unknown or inactive token (including a base64-encoded token from an old firmware) | Rebuild and reflash the device |
4002 | No Authorization: Bearer header | Firmware older than the current contract: rebuild and reflash |
4003 | Device already connected (lease held) | Look for a clone; wait for the lease to expire |
4005 | Token revoked during the session | Reactivate the token or rebuild |
4011 | Noise handshake refused (wrong key, other device, old firmware) | Rebuild and reflash |
4013 | Did not come through the TLS edge | Target the device endpoint, never the server port |
4014 | Client certificate missing or from another device | Target the device endpoint (not 443, no proxy); rebuild if the certificate was revoked |
Signed OTA
Every OTA image is signed with Ed25519 over the device id, the version and the image SHA-256. The device checks the signature before switching; an unsigned or badly signed image is refused, and so is a lower version (anti-downgrade). The download goes through the device endpoint, with the token and the client certificate. Details: Firmware & OTA.
Secrets vault
Each organisation has one vault for every secret it uses: notification channel tokens and passwords, credentials of HTTP flow nodes, Wi-Fi passwords, LLM provider keys, geo provider keys and the access secrets of audio streams. Day-to-day use is described on the Secrets page.
- Encryption: XChaCha20-Poly1305 with a fresh 24-byte nonce on every write. The organisation and the secret id are bound as associated data: a row copied to another organisation or another secret no longer decrypts.
- Key outside the database: the master key ring comes from the environment
(
PNEX_SECRETS_KEYS, generated by the installer). The server refuses to start without it. A database dump alone reveals no secret. - References, not values: a form field that holds a secret stores a typed reference to a vault entry. No secret value appears in deployed flows, in the job queue or between server instances.
- Flow runtime: it receives only references and resolves each one at start-up, and only for secrets used by a flow deployed for that organisation. The master key never reaches the runtime, and function nodes (JavaScript, Starlark) have no access to secrets.
- Write-only: the Secrets page lists every secret with what uses it, never its value. No role can read a value back. Owners and admins create and replace secrets and attach them to a field; members keep the secrets already attached to what they edit but never attach one, and cannot point a node or channel holding a secret to another address; viewers see names only. A secret still in use cannot be deleted.
- Rotation: put a new key at the head of the ring and restart, then run the re-encryption from the platform status page. Re-encryption never runs at boot, so instances being upgraded one by one all keep reading the vault. The status page counts secrets still under an old key; remove that key when the count is zero.
Changing a secret value cascades nothing: flows pick it up at their next deploy, and devices keep the Wi-Fi password they were flashed with until you rebuild them.
Out of the vault on purpose: infrastructure secrets (database URL, storage keys, the key ring itself) stay in the environment, and device tokens and Noise keys are machine credentials not yet encrypted at rest. Each organisation's CA key and the instance's OTA signing key, on the other hand, live in the vault.
Least-privilege boundaries
- The server never writes pins on its own initiative.
- The AI assistant reads, never writes to devices.
- Outbound requests chosen by users (HTTP fetch node, notification webhooks, LLM and geo providers) go through an egress guard: the server's own addresses, cloud metadata and internal service names are refused.
- Custom firmware builds run from a server-generated project, with a source check, a library catalog and an optional network-less sandbox — and the feature is off by default. See Firmware & OTA.
Threat model notes
A device proves its identity three times: a client certificate issued by its organisation's CA, the token in the header, and the shared key of the Noise link. OTA accepts only an image signed by the instance. The identity provider runs as a separate component (Rauthy), so human identity stays manageable and auditable independently of the platform server.

