M-Bus Gateway
← Tilbage til blog
· sikkerhed· IoT· gateway· MQTT· TLS· firewall· SSH· OTA· secrets· penetrationstest

IoT security hardening — gateway og cloud backend

Security hardening af IoT-gateway og cloud backend: SSH-hærdning, firewall, MQTT TLS, certificate pinning, OTA signaturer, secrets management og penetrationstest.

Af M-Bus Gateway

Et IoT-gateway med 4G-forbindelse er et angrebspunkt. Her er det komplette security hardening setup for M-Bus Gateway.


Trusselsbillede

Angrebsvektorer for en wM-Bus gateway:

1. Fysisk adgang (gateway i kælder)
   → SD-kort fjernes → direkte adgang til data
   Modforanstaltning: Fuld disk-kryptering (dm-crypt/LUKS)

2. Netværksangreb via 4G SIM
   → SIM med offentlig IP → direkte adgang til gateway
   Modforanstaltning: Ingen åbne indgående porte (Cloudflare Tunnel)

3. MQTT man-in-the-middle
   → Opfang af varmemålerdata
   Modforanstaltning: Mutual TLS (mTLS) + certificate pinning

4. OTA-firmware kompromittering
   → Ondsindet firmware installeres
   Modforanstaltning: SHA256 + GPG signatur

5. Cloud backend angreb
   → API-endpoints kompromitteret
   Modforanstaltning: WAF, rate limiting, RBAC, audit log

SSH hardening på gateway (Pi)

# /etc/ssh/sshd_config (kun relevante ændringer)

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile /home/mbus/.ssh/authorized_keys

# Tillad kun specifikke brugere:
AllowUsers mbus

# Deaktivér X11 og TCP forwarding:
X11Forwarding no
AllowTcpForwarding no

# Timeout idle sessions:
ClientAliveInterval 300
ClientAliveCountMax 2

# Skift default port (security through obscurity — supplement ikke erstatning):
# Port 2222  ← konfigureres i gateway config, ikke her (SIM-afhængig)

# Genstart sshd:
# systemctl restart sshd

UFW firewall regler

# Gateway (Raspberry Pi) — minimal angrebsflade:

# Reset til default (deny incoming, allow outgoing):
ufw --force reset
ufw default deny incoming
ufw default allow outgoing

# Tillad SSH fra management IP (Hetzner server):
ufw allow from 178.105.90.8 to any port 22

# MQTT udgående (kun til Mosquitto server):
# Udgående tillades som default — ingen speciel regel nødvendig

# Aktivér:
ufw --force enable
ufw status verbose

# Hetzner server — porte:
# 8883 (MQTT TLS): Kun fra kendte gateway-IP'er (1NCE IP-pool)
# 8000 (FastAPI): Via Nginx proxy + Cloudflare (ikke direkte)
# 5432 (PostgreSQL): Kun intern Docker-netværk
# 6379 (Redis): Kun intern Docker-netværk

MQTT certificate pinning på gateway

# gateway/src/mqtt/client.py

import ssl
import hashlib
from pathlib import Path

def create_tls_context(
    ca_cert: Path,
    client_cert: Path,
    client_key: Path,
    expected_server_fingerprint: str,
) -> ssl.SSLContext:
    """
    Mutual TLS med certificate pinning.
    Afviser forbindelser til servere med ukendt certifikat.
    """
    ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
    ctx.load_verify_locations(ca_cert)
    ctx.load_cert_chain(client_cert, client_key)
    ctx.check_hostname = True
    ctx.verify_mode = ssl.CERT_REQUIRED

    # Certificate pinning: Verificér server-certifikat fingerprint
    def check_fingerprint(conn, cert, err_num, depth, ok):
        if depth == 0:  # Leaf certificate
            der = conn.getpeercert(binary_form=True)
            fingerprint = hashlib.sha256(der).hexdigest()
            if fingerprint != expected_server_fingerprint:
                return False
        return ok

    # NB: ssl.SSLContext understøtter ikke post_handshake callback direkte
    # Implementeres i on_connect callback via getpeercert()
    return ctx

OTA signatur verification

# gateway/src/ota/verifier.py

import hashlib
import gnupg
from pathlib import Path

GPG_KEYRING = Path("/etc/mbus-gateway/trusted_keys.gpg")

def verify_firmware(
    firmware_path: Path,
    signature_path: Path,
    expected_sha256: str,
) -> bool:
    """
    Trin 1: SHA256 checksum
    Trin 2: GPG signatur mod betroet nøgle
    Begge skal passere — ellers afvis firmware.
    """
    # Trin 1: SHA256
    sha256 = hashlib.sha256(firmware_path.read_bytes()).hexdigest()
    if sha256 != expected_sha256:
        log.error("firmware_sha256_mismatch",
                  expected=expected_sha256, actual=sha256)
        return False

    # Trin 2: GPG signatur
    gpg = gnupg.GPG(gnupghome=str(GPG_KEYRING.parent))
    with open(signature_path, "rb") as sig_file:
        verified = gpg.verify_file(sig_file, str(firmware_path))

    if not verified.valid:
        log.error("firmware_signature_invalid",
                  fingerprint=verified.fingerprint,
                  status=verified.status)
        return False

    log.info("firmware_verified",
             sha256=sha256[:16],
             signer=verified.username)
    return True

Secrets management

# Gateway: Secrets i /etc/mbus-gateway/ med strenge permissions
ls -la /etc/mbus-gateway/
# -rw------- mbus mbus  config.env
# -rw------- mbus mbus  client.crt
# -rw------- mbus mbus  client.key
# -r-------- mbus mbus  ca.crt
# -rw------- mbus mbus  aes_keys.json.enc  ← krypteret med gateway-specifik nøgle

# Kryptering af AES-nøgler:
openssl enc -aes-256-cbc -pbkdf2 \
  -in aes_keys.json \
  -out aes_keys.json.enc \
  -pass file:/etc/mbus-gateway/.master_key

# .master_key gemmes i RAM-tmpfs:
mount -t tmpfs -o size=1m tmpfs /run/mbus-secrets
# Populeres ved boot af init-script inden service starter
# server: Docker Secrets (aldrig environment variables til credentials)
# docker-compose.prod.yml:
# secrets:
#   jwt_secret:
#     external: true
# environment:
#   JWT_SECRET_KEY_FILE: /run/secrets/jwt_secret

def load_secret(name: str) -> str:
    """Læs Docker Secret fra /run/secrets/."""
    secret_file = Path(f"/run/secrets/{name}")
    if secret_file.exists():
        return secret_file.read_text().strip()
    # Fallback til environment (dev/test)
    return os.environ[name.upper()]

API security headers (Nginx)

# /etc/nginx/sites-enabled/mbus-gateway

server {
    listen 443 ssl http2;
    server_name api.mbus-gateway.dk;

    # HSTS — 1 år
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

    # Forhindre clickjacking
    add_header X-Frame-Options "DENY" always;

    # Forhindre MIME-sniffing
    add_header X-Content-Type-Options "nosniff" always;

    # CSP — til API (ingen HTML responses):
    add_header Content-Security-Policy "default-src 'none'" always;

    # Fjern server-version fra headers:
    server_tokens off;

    # Rate limiting (defineret i http{} blok):
    limit_req zone=api burst=20 nodelay;
    limit_req_status 429;
}

Penetrationstest checkliste

Kvartalvis security review:

Gateway:
  □ nmap scan fra internet (alle porte skal være lukkede)
  □ SSH password login forsøg (skal afvises)
  □ AES-nøgler krypterede på disk (verify)
  □ wmbusmeters kører som mbus-bruger (ikke root)

MQTT:
  □ Forsøg connect uden klientcertifikat (skal afvises)
  □ Forsøg subscribe til anden gateways topic (skal afvises)
  □ TLS version check (kun TLS 1.2+)

API:
  □ OWASP Top 10 gennemgang
  □ JWT ekspirering fungerer
  □ rate limiting ved 100+ req/min
  □ SQL injection forsøg (parametriserede queries beskytter)
  □ Tenant isolation: Kan lejer X tilgå lejer Ys data? (skal afvises)

Audit:
  □ Alle kritiske handlinger logges i audit_log
  □ Ingen credentials i git-historik (git-secrets scan)
  □ Dependencies: npm audit + safety check (Python)

Konklusion

IoT security kræver forsvar i dybden: ingen åbne indgående porte på gateway, mutual TLS til MQTT, GPG-signerede OTA-pakker, krypterede secrets på disk, Docker Secrets i cloud og strenge SSH-regler. Quarterly penetrationstest sikrer at nye angrebsvektorer opdages. MQTT ACL-pattern meters/%u/# isolerer gateways fra hinanden på broker-niveau.

Se IoT netværkssikkerhed guide eller MQTT broker guide.