Skip to main content

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:

FieldDescription
uuidDevice UUID (randomly generated by the agent with crypto.randomUUID())
deviceNameHuman-readable device name
deviceTypeDevice class (e.g., standalone, gateway)
deviceApiKeyA freshly generated v2 API key (see below)
devicePublicKeyEd25519 public key in PEM format
macAddressPhysical interface MAC address (hardware fingerprint)
osVersionOperating system version
agentVersionAgent 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 signature
  • x-agent-key header — 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

PropertyValue
AlgorithmAES-256-GCM (NIST recommended authenticated encryption)
Key size256 bits (32 bytes)
IV96 bits (12 bytes), randomly generated per encryption operation
Auth tag128 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:

  1. Generates a new 256-bit random key
  2. Backs up the old key file (timestamped)
  3. Writes the new key
  4. 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:

FieldContains
deviceApiKeyDevice authentication key for cloud API
provisioningApiKeyOne-time provisioning token (deleted after provisioning)
apiKeyCurrent API key for ongoing authentication
mqttUsernameMQTT broker username
mqttPasswordMQTT broker password
mqttBrokerConfigFull 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:

ControlHow it works
Command ageissued_at is in every command. Commands older than 30 seconds are rejected regardless of valid signature.
Command expiryOptional expires_at field. If present and in the past, the command is rejected.
Device UUID bindingSignature 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 via AGENT_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

LimitDefaultPurpose
Idle timeout5 minutesTerminates inactive sessions
Max session duration1 hourPrevents keepalive bypass attacks — even continuous activity cannot extend a session past this limit
Output buffer cap200 KBPrevents 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

ModeBehaviour
offFirewall chain exists but ends with RETURN — all traffic passes through
onStrict mode — all traffic not explicitly allowed is rejected
autoBehaves 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)

RulePurpose
Allow loopback (-i lo)Agent inter-process communication
Allow locally-originated trafficSame-host connections
Allow established/related connectionsExisting sessions continue after rules change
Allow ICMPPing, TTL exceeded, PMTUD
Allow mDNS/multicastLocal 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:

NetworkDefault range
LAN networks10.0.0.0/8, 192.168.0.0/16
Docker bridge172.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:

VariableDescription
ENABLE_AUTH=trueEnable 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

AreaControlMechanism
IdentityProvisioning key binds device to tenantOne-time PROVISIONING_KEY header
IdentityProof of possessionEd25519 challenge-response
IdentityUUID immutabilityEnforced in provisioning state machine
IdentityIdempotent registrationX-Idempotency-Key header
StorageCredential encryptionAES-256-GCM, unique IV per record
StorageMaster key protectionchmod 0600, in-process memory cache
ShellFail-closed defaultDisabled without HMAC key
ShellCommand authenticationHMAC-SHA256 on canonical JSON payload
ShellReplay preventionissued_at age check (30s), expires_at, device UUID binding
ShellSession isolationSession ID validation on every input
ShellBinary restrictionAllowlisted shell paths only
ShellPrivilege separationDrops to UID 1000 when running as root
ShellSecret isolationMinimal environment — no credentials in shell env
ShellResource limitsIdle timeout (5 min), max session (1 h), output cap (200 KB)
NetworkAPI port isolationiptables REJECT for external sources
NetworkMQTT port restrictionAllowlisted LAN and Docker subnets only
NetworkIPv6 coverageRules applied to both iptables and ip6tables

  • Cloud Sync — provisioning flow and cloud connection
  • Settings — firewall and API authentication configuration
  • Quick Start — first-time setup including provisioning