Beta 0.2.0-beta.2, not yet for critical processes. See the roadmap
PNeX logo
Documentation of the version in development, not released yet. See the stable release (0.1.0)

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.

CLIENTSEDGE · TLSCOREDATAHTTPS · WSSHTTPSWSS · mTLS :4443WSScerts/auth/v1/ · /wsOIDCsuperviseSQL · jobsingest · querylast valuesliveartifacts · mediaWeb appDioxus · WASMNative appsDioxus · same codeDevicesESP32 · ESP8266Your codePublic APITLS edgenginx:443 web · :4443 mTLSCertificatesCA · Let’s EncryptIdentityRauthyPNeX serverRust · Loco + SeaORMFlow engineEdgeLink · RustDatabasePostgreSQLTelemetryOpenObserveLive cacheValkeyObject storageS3-compatible
TLS-encrypted on the wire Private internal networkDocker Compose · Helm 0.3.0 (dedicated device edge) · Wolfi (Chainguard) images · Raspberry Pi → cloud

The eight layers

#LayerWhat it guaranteesState
1TransportTLS everywhere; dedicated device endpoint over mutual TLS (client certificate per device); CA pinned in firmwareShipped
2Device linkAuthenticated, anti-replay Noise channel inside TLS (per-device key, post-quantum layer), anti-clone, Ed25519-signed OTA with anti-downgradeShipped
3IdentityDedicated OIDC provider (Rauthy), PKCE, passkeys; one-time tickets for browser websocketsShipped
4AuthorizationPer-organisation isolation, owner/admin/member/viewer roles, one write source per pin, read-only AIShipped
5ExecutionSandboxed JavaScript and hermetic Starlark, outbound requests through an egress guard, fenced custom firmware buildsShipped
6NetworkOnly nginx faces the network (ports 80, 443, and 4443 for devices); databases and identity stay on the private networkShipped — production installer; the development stack still publishes database ports
7Supply chainRust end to end, Wolfi (Chainguard) images, audited dependenciesShipped
8Data at restPer-organisation secrets vault, XChaCha20-Poly1305, key ring with rotationShipped

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 an install.sh/Compose install, devices.<publicHost> behind a LoadBalancer with the Helm chart). It serves only /ws/device, /ws/camera and /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.

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 with 4011.
  • 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.

CodeCauseWhat to do
4001Unknown or inactive token (including a base64-encoded token from an old firmware)Rebuild and reflash the device
4002No Authorization: Bearer headerFirmware older than the current contract: rebuild and reflash
4003Device already connected (lease held)Look for a clone; wait for the lease to expire
4005Token revoked during the sessionReactivate the token or rebuild
4011Noise handshake refused (wrong key, other device, old firmware)Rebuild and reflash
4013Did not come through the TLS edgeTarget the device endpoint, never the server port
4014Client certificate missing or from another deviceTarget 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.

On this page