Skip to content

Security: YavLabs/iAIOM

Security

docs/security.md

iAIOM — Security Architecture

Threat model summary

Threat Mitigation
Rogue agent publishes false metrics HMAC-SHA256 payload signing; bridge rejects unsigned messages
Agent impersonation Agent key stored as bcrypt hash; credentials checked by Mosquitto auth plugin on every connect
Agent reads another agent's data Per-agent MQTT ACL files restrict topic access to own namespace
MitM between agent and external proxy TLS handled by the external reverse proxy — operator's responsibility to configure correctly
Compromised agent key in DB Keys stored as bcrypt hashes — raw key is shown exactly once
API session hijacking JWT short-lived (15 min); refresh token in HttpOnly cookie, Redis-backed with revocation
Brute-force admin login Rate-limited to 5 attempts per 15 minutes per IP
API abuse / DDoS slowapi rate limiting: 5 req/min on auth, 120 req/min general
XSS in browser CSP: script-src 'self'; X-Content-Type-Options nosniff
Clickjacking X-Frame-Options DENY
SQL injection SQLAlchemy ORM only — no raw string query construction
Secrets in source code All secrets via environment variables; .env is gitignored
Unauthorised API access RBAC enforced server-side on every route via FastAPI dependency
Privilege escalation via CORS Explicit allow_origins list — no wildcards; credentials only for hub origin
Internal service exposure Two-network Docker setup; only Nginx on the public network

Transport security

TLS boundary

TLS termination is the responsibility of the external reverse proxy (e.g. Traefik, Nginx, Cloudflare Tunnel) sitting in front of the Docker stack. The Docker stack operates entirely in plain HTTP and plain WebSocket internally — there is no TLS configuration inside Docker.

[Agent / Browser]
      │  wss:// / https://  (TLS terminated here)
      ▼
[External Reverse Proxy — Traefik / Nginx / etc.]
      │  ws:// / http://  (plain, forwarded to port 5555)
      ▼
[Docker: nginx:80 exposed as host port 5555]

The external proxy must:

  • Enforce TLS 1.2+ minimum
  • Set X-Forwarded-Proto: https so the API can build correct redirect URLs
  • Forward WebSocket upgrade headers for both /ws/ (browser) and /mqtt (agents)

Agent → Mosquitto (MQTT-over-WebSocket)

Agents connect to wss://hub.example.com/mqtt. TLS is handled by the external proxy. Inside Docker, traffic flows:

Agent (wss://)
  → External proxy (TLS terminates, forwards as ws://)
  → Nginx :80 /mqtt  (WebSocket proxy)
  → Mosquitto :9001 (WebSocket listener, plain)
  → Mosquitto auth plugin checks username (agent_id) + password (agent_key) against DB
  → ACL checked on every publish/subscribe

Browser → Hub (HTTPS + WSS)

  • TLS terminated at the external proxy — the Docker stack sees plain HTTP/WS
  • The API sets security headers including HSTS so browsers enforce HTTPS on subsequent visits
  • WebSocket upgrade at /ws/ only accepted from authenticated sessions

Payload signing

Every MQTT message includes an HMAC-SHA256 signature over the payload field, regardless of transport. This provides message-level integrity independent of TLS:

# Agent — signing (payload_sign.py)
import hmac, hashlib, json

def sign_payload(payload: dict, secret_key: str) -> str:
    serialised = json.dumps(payload, sort_keys=True, separators=(',', ':'))
    return hmac.new(secret_key.encode(), serialised.encode(), hashlib.sha256).hexdigest()
# Hub — verification (mqtt_bridge.py)
def verify_payload(payload: dict, signature: str, secret_key: str) -> bool:
    expected = sign_payload(payload, secret_key)
    return hmac.compare_digest(expected, signature)

Messages with missing or invalid HMAC are dropped silently (no error response to prevent oracle attacks) and logged internally.


Authentication

Google OIDC (primary)

  • Google ID tokens validated server-side with google-auth library
  • Only Google Workspace accounts may be permitted (configurable allowed_domains list)
  • PKCE flow for the OAuth redirect

Admin local login

  • Username + bcrypt-hashed password
  • Rate-limited: 5 failed attempts per 15 minutes per IP (Redis-backed counter)
  • Login form is hidden behind a "Sign in as administrator" link — not prominent in the UI

JWT tokens

Token Expiry Storage Revocation
Access token 15 minutes In-memory (JavaScript variable) Expires naturally
Refresh token 7 days HttpOnly cookie (SameSite=Strict) Redis entry deleted on logout or rotation

Token rotation: every refresh issues a new refresh token and invalidates the old one (rotation detected via Redis).

On logout: both the access token (client-side clear) and refresh token (Redis deletion) are invalidated.


RBAC

Roles are enforced server-side on every FastAPI route via dependency injection. Frontend role checks are cosmetic only — the API always enforces the real restriction.

See docs/rbac.md for the full permission matrix and implementation patterns.


API security headers

Applied by middleware/security_headers.py on every response:

Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()

HSTS is set here even though TLS is external — the external proxy forwards the header to the browser, which then enforces HTTPS on future requests.


Network isolation

internet → external proxy (TLS) → :5555 → [nginx:80] → [internal network]
                                                              ├── api :8000
                                                              ├── worker
                                                              ├── beat
                                                              ├── mosquitto :1883 (internal MQTT)
                                                              ├── mosquitto :9001 (MQTT-over-WS, via nginx)
                                                              ├── postgres :5432
                                                              └── redis :6379

Postgres, Redis, and Mosquitto have no host port bindings. They are only reachable from within the internal Docker network.


Secret management

All secrets are environment variables. The .env file is gitignored. .env.example documents every variable with no real values.

Secrets:

  • JWT_SECRET_KEY — 256-bit random, generated at first deploy
  • ADMIN_PASSWORD_HASH — bcrypt hash of the admin password
  • MQTT_BRIDGE_PASSWORD — internal bridge credentials
  • AUDIT_IP_HMAC_KEY — HMAC key for IP address pseudonymisation
  • GOOGLE_CLIENT_SECRET — Google OAuth client secret
  • Database passwords, Redis URL

Secrets are never logged, never included in error responses, never committed to source control.


Agent key lifecycle

  1. Generation: Hub generates a 32-byte cryptographically random key (URL-safe base64). Displayed once to the operator.
  2. Storage: Bcrypt hash stored in agents.agent_key_hash. Plaintext is never stored.
  3. Use: Agent sends plaintext key as MQTT password on every connect. Mosquitto auth plugin hashes it and compares to the stored hash.
  4. Revocation:
    • Set agents.status = 'revoked'
    • Delete ACL file from infra/mosquitto/acl/
    • Reload Mosquitto (SIGHUP)
    • Audit log entry created

Key rotation procedures

JWT secret key rotation

  1. Set JWT_SECRET_KEY to a new value in the environment
  2. Restart the API service
  3. All existing access tokens are immediately invalidated (signed with the old key)
  4. All users are prompted to log in again
  5. Refresh tokens in Redis are also invalidated as they will fail validation

MQTT bridge password rotation

  1. Update MQTT_BRIDGE_PASSWORD in the environment
  2. Update the Mosquitto password file: mosquitto_passwd -b /etc/mosquitto/passwd hub_bridge <new_password>
  3. Reload Mosquitto: kill -HUP <mosquitto_pid> or docker compose exec mosquitto kill -HUP 1
  4. Restart the API service with the new password

There aren't any published security advisories