Security
The Iotistica agent is designed to run on devices that may be physically accessible to untrusted parties, connected over public networks, and managed remotely by the cloud. This page documents every security control that is built into the agent runtime, how each one works at the code level, and what it protects against.
Provisioning and Identity
Before an agent can communicate with the Iotistica Cloud it must establish a cryptographically verified identity. Provisioning is a three-phase protocol that happens once at first boot and produces a permanent, tamper-resistant device identity.
Phase 1 — Registration
The agent sends a registration request to POST /agent/register on the cloud API, authenticated with the one-time PROVISIONING_KEY. The payload contains:
| Field | Description |
|---|---|
uuid | Device UUID (randomly generated by the agent with crypto.randomUUID()) |
deviceName | Human-readable device name |
deviceType | Device class (e.g., standalone, gateway) |
deviceApiKey | A freshly generated v2 API key (see below) |
devicePublicKey | Ed25519 public key in PEM format |
macAddress | Physical interface MAC address (hardware fingerprint) |
osVersion | Operating system version |
agentVersion | Agent software version |
An idempotency key (register-{uuid}) is included in the request header. If the agent crashes mid-registration and retries, the cloud deduplicates the second call without creating a duplicate device record.
The cloud responds with:
- Tenant assignment
- MQTT broker configuration (host, port, credentials, TLS settings)
- A short-lived challenge nonce for Phase 2
Phase 2 — Proof-of-Possession Key Exchange
Phase 2 verifies that the agent that registered the public key actually holds the corresponding private key — proof that the registration was not spoofed by a third party that intercepted the public key.
The agent signs the challenge nonce with its Ed25519 private key, binding the device UUID into the payload to prevent cross-device replay:
signed_payload = Ed25519_sign(privateKey, "{uuid}:{challenge}")
The signed payload is sent to POST /device/{uuid}/key-exchange with:
signature— base64-encoded Ed25519 signaturex-agent-keyheader — device API key (proves the caller is the registered device)
The cloud verifies the signature using the public key it received in Phase 1. If the signature is invalid, provisioning fails and no credentials are issued.
Phase 3 — Provisioning Key Destruction
After key exchange succeeds, the agent deletes the PROVISIONING_KEY from its stored state. From this point:
- All subsequent cloud communication uses the device API key (v2 format).
- The UUID is locked — any attempt to change it after registration throws an error.
- The device is considered fully provisioned.
API Key Format
The agent generates API keys in v2 format:
v2_{kid}_{secret}
↑ ↑
8 hex 64 hex
kid— 8-character key ID, logged safely for debugging without exposing the secret.secret— 32 cryptographic random bytes (256 bits), used for authentication and HMAC proofs.
The secret portion is derived using crypto.randomBytes(32) — CSPRNG, never user-input-derived. A fingerprint (first 8 chars of SHA-256 of the full key) is used in logs to allow correlation without exposing the key.
Ed25519 Key Pair
The Ed25519 key pair is generated on the device using Node's crypto.generateKeyPairSync('ed25519'). The private key never leaves the device. The key file is stored at {DATA_DIR}/.pop-keys.json with chmod 0600 (owner read/write only).
Ed25519 was chosen for:
- 128-bit security level — equivalent to a 3072-bit RSA key
- Small key size — 32-byte keys, 64-byte signatures
- Fast operations — ~60µs signature generation
- No parameter choices — unlike ECDSA, there are no algorithm-level footguns
UUID Immutability
Once the agent is registered with the cloud, its UUID cannot change. Any code path that attempts to change the UUID after provisioningState === 'registered' throws an exception with the message: "UUID cannot be changed after cloud registration. Use factory reset to re-provision with a new UUID."
Credential Storage — Database Encryption
All credentials stored in the local SQLite database are encrypted at rest using AES-256-GCM with a device-local master key.
Algorithm
| Property | Value |
|---|---|
| Algorithm | AES-256-GCM (NIST recommended authenticated encryption) |
| Key size | 256 bits (32 bytes) |
| IV | 96 bits (12 bytes), randomly generated per encryption operation |
| Auth tag | 128 bits — verifies ciphertext integrity, detects tampering |
| Wire format | {iv_base64}:{auth_tag_base64}:{ciphertext_base64} |
GCM mode was chosen because it provides authenticated encryption — the auth tag makes it impossible to modify the ciphertext without the key, even if an attacker has physical access to the SQLite file.
A unique IV is generated for every encryption operation. This means two encryptions of the same plaintext produce different ciphertext, preventing pattern analysis even when the same credential is stored multiple times.
Master Key Management
The master key is a 256-bit random value stored in {DATA_DIR}/.master.key:
- Generated with
crypto.randomBytes(32)on first boot - File written with
chmod 0600(owner read/write only) - Parent directory created with
chmod 0700(owner access only) - Loaded from disk on subsequent boots
- Cached in memory after first load (no repeated disk reads)
If the master key file is deleted, all encrypted data in the database becomes unreadable. Treat the key file as a recovery secret for the device.
Key Rotation
The agent includes a rotateMasterKey() method that:
- Generates a new 256-bit random key
- Backs up the old key file (timestamped)
- Writes the new key
- Returns the old key so the caller can re-encrypt all data
Key rotation requires re-encrypting every sensitive field in the database. This is currently an operator-triggered procedure, not automated.
Encrypted Fields
The following fields in the device record are always encrypted before writing to SQLite:
| Field | Contains |
|---|---|
deviceApiKey | Device authentication key for cloud API |
provisioningApiKey | One-time provisioning token (deleted after provisioning) |
apiKey | Current API key for ongoing authentication |
mqttUsername | MQTT broker username |
mqttPassword | MQTT broker password |
mqttBrokerConfig | Full MQTT broker config object (JSON, may contain credentials) |
Decryption is transparent — the calling code receives plaintext. If decryption fails (wrong key, corrupted data), the error is caught and logged; the field returns the raw encrypted string to avoid crashing the agent over a single bad field.
Upgrade Path
The migrateToEncrypted() function is idempotent: it detects whether a value is already in iv:tag:ciphertext format before encrypting. This allows the encryption layer to be added to an existing database without a separate migration step.
Remote Shell Security
The remote shell feature allows the Iotistica Cloud to open an interactive terminal session on the device over MQTT. It is protected by multiple independent security controls in depth.
Fail-Closed Default
The shell is completely disabled if AGENT_SHELL_HMAC_KEY is not set. An agent deployed without the HMAC key will log a critical-severity warning and silently reject all shell commands. There is no way to accidentally expose an unauthenticated shell.
HMAC-SHA256 Command Signing
Every shell command sent from the cloud must be signed with HMAC-SHA256 using the shared AGENT_SHELL_HMAC_KEY. The agent verifies the signature before processing any command.
The canonical payload signed by the cloud is a deterministic JSON object:
{
"deviceUuid": "...",
"action": "start|stop|input|resize",
"sessionId": "...",
"data": "...",
"cols": null,
"rows": null,
"issued_at": 1719000000000,
"expires_at": null
}
The device UUID is included in the canonical payload. This binds the signature to a specific device — a valid command for device A cannot be replayed against device B.
JSON serialization is used (not a string delimiter) to prevent delimiter collision attacks where a field value containing the delimiter character could be mistaken for a field boundary.
The signature comparison uses timingSafeEqual() from Node's crypto module to prevent timing attacks that could leak whether a partial signature was correct.
Anti-Replay Controls
Captured valid commands cannot be replayed:
| Control | How it works |
|---|---|
| Command age | issued_at is in every command. Commands older than 30 seconds are rejected regardless of valid signature. |
| Command expiry | Optional expires_at field. If present and in the past, the command is rejected. |
| Device UUID binding | Signature includes the device UUID — a captured command cannot be replayed against a different device. |
Session ID Validation
Each shell session has a randomly generated session ID. Input commands (action: 'input') must carry the correct session ID. A mismatched session ID is rejected:
Input rejected - sessionId mismatch
This prevents a command from being routed to the wrong user's active shell session, either by mistake or by injection.
Shell Allowlist
The shell binary is restricted to a static allowlist:
/bin/bash, /bin/sh, /bin/zsh, /bin/dash
/usr/bin/bash, /usr/bin/sh
powershell.exe, pwsh.exe, cmd.exe
If AGENT_SHELL is set to any path not on this list, the agent throws an error and refuses to start the session. The binary is also checked for execute permission (X_OK) before spawning.
Privilege Dropping
When the agent process runs as root (common in Docker deployments), shell sessions are spawned as a non-root user:
- Target UID/GID defaults to
1000:1000, configurable viaAGENT_UID/AGENT_GID - The PTY inherits the dropped-privilege environment
- On systemd deployments (already running as a service user), this is a no-op
Minimal Environment
The shell session receives only a minimal set of environment variables:
HOME, TERM, PATH, USER, SHELL, LANG
Application secrets — database passwords, API keys, cloud tokens — are not passed to the child process. An operator typing env in the remote shell cannot read any credentials.
Only non-sensitive operational variables are exposed (DEVICE_API_PORT, IOTISTICA_API) so command-line tools like iotctl continue to work.
Session Limits
| Limit | Default | Purpose |
|---|---|---|
| Idle timeout | 5 minutes | Terminates inactive sessions |
| Max session duration | 1 hour | Prevents keepalive bypass attacks — even continuous activity cannot extend a session past this limit |
| Output buffer cap | 200 KB | Prevents memory exhaustion from commands like yes or cat /dev/urandom |
Network Firewall
The agent manages its own iptables firewall chain (IOTISTIC-FIREWALL) to restrict which networks can reach its exposed services.
Modes
| Mode | Behaviour |
|---|---|
off | Firewall chain exists but ends with RETURN — all traffic passes through |
on | Strict mode — all traffic not explicitly allowed is rejected |
auto | Behaves like on (currently always enables strict mode; future versions may detect host-network services) |
Configured via FIREWALL_ENABLED=true and FIREWALL_MODE=on|off|auto.
Rule Structure
The agent inserts a jump to IOTISTIC-FIREWALL at the top of the INPUT chain. Rules inside the chain are applied in order:
Base rules (always active)
| Rule | Purpose |
|---|---|
Allow loopback (-i lo) | Agent inter-process communication |
| Allow locally-originated traffic | Same-host connections |
| Allow established/related connections | Existing sessions continue after rules change |
| Allow ICMP | Ping, TTL exceeded, PMTUD |
| Allow mDNS/multicast | Local service discovery |
Device API protection
The Device API (default port 48484) is rejected for any non-local source:
-A IOTISTIC-FIREWALL -p tcp --dport 48484 -j REJECT
This runs after the loopback and local-origin rules, so the admin UI on the local machine continues to work. External hosts on the LAN or public internet cannot reach the API even if the port is accidentally exposed.
MQTT broker protection
When a local MQTT broker is configured, access is restricted to trusted network ranges:
| Network | Default range |
|---|---|
| LAN networks | 10.0.0.0/8, 192.168.0.0/16 |
| Docker bridge | 172.17.0.0/16 |
All other sources are rejected. Additional LAN or Docker networks can be added via allowedLanNetworks and allowedDockerNetworks in the firewall configuration.
Final rule
In on/auto mode, the chain ends with REJECT (not DROP) — the sender receives an ICMP port-unreachable response. This prevents connection timeouts that can delay application startup and makes misconfiguration diagnosable.
IPv4 and IPv6
All rules are applied to both iptables (IPv4) and ip6tables (IPv6). The chain is inserted at position 1 in the INPUT chain of both families.
Dynamic Updates
The firewall can be reconfigured without restarting the agent:
updateMode('on'|'off'|'auto')— changes the enforcement level and reapplies rules immediately.updateConfig({...})— changes any firewall parameter (ports, network ranges) and reapplies.
On agent shutdown, the firewall chain is flushed and the jump rule is removed — no rules are left behind.
Local API Authentication
The local Device API (port 48484) supports optional token-based authentication controlled by two environment variables:
| Variable | Description |
|---|---|
ENABLE_AUTH=true | Enable API key authentication |
API_KEY=<secret> | Required key; must be sent as X-Api-Key header or ?apiKey= query parameter |
When ENABLE_AUTH is not set, the API is unauthenticated — suitable for isolated deployments where the firewall is the access control boundary. For any internet-exposed deployment, set ENABLE_AUTH=true and a strong API_KEY.
Security Controls Summary
| Area | Control | Mechanism |
|---|---|---|
| Identity | Provisioning key binds device to tenant | One-time PROVISIONING_KEY header |
| Identity | Proof of possession | Ed25519 challenge-response |
| Identity | UUID immutability | Enforced in provisioning state machine |
| Identity | Idempotent registration | X-Idempotency-Key header |
| Storage | Credential encryption | AES-256-GCM, unique IV per record |
| Storage | Master key protection | chmod 0600, in-process memory cache |
| Shell | Fail-closed default | Disabled without HMAC key |
| Shell | Command authentication | HMAC-SHA256 on canonical JSON payload |
| Shell | Replay prevention | issued_at age check (30s), expires_at, device UUID binding |
| Shell | Session isolation | Session ID validation on every input |
| Shell | Binary restriction | Allowlisted shell paths only |
| Shell | Privilege separation | Drops to UID 1000 when running as root |
| Shell | Secret isolation | Minimal environment — no credentials in shell env |
| Shell | Resource limits | Idle timeout (5 min), max session (1 h), output cap (200 KB) |
| Network | API port isolation | iptables REJECT for external sources |
| Network | MQTT port restriction | Allowlisted LAN and Docker subnets only |
| Network | IPv6 coverage | Rules applied to both iptables and ip6tables |
Related Docs
- Cloud Sync — provisioning flow and cloud connection
- Settings — firewall and API authentication configuration
- Quick Start — first-time setup including provisioning