Skip to content

Field encryption

Sensitive columns in the DB are encrypted with AES-256-GCM. Plaintext lives only briefly in worker RAM when needed for a check.

Which fields

Table Column For
hosts snmp_community SNMPv1/v2c community
hosts ssh_password SSH login password (rare — key preferred)
hosts ssh_private_key PEM content
host_services config_overrides.password any password override
notification_channels webhook.secret HMAC signature secret
ai_config api_key Cloud LLM API keys

Implementation: shared/encryption.py with encrypt_field() / decrypt_field().

Key

FIELD_ENCRYPTION_KEY is base64url, 32 bytes. Generated by setup script:

openssl rand -base64 32 | tr '+/' '-_' | tr -d '='

Lives in .env and secrets/field_encryption_key.

Lifecycle

sequenceDiagram
    participant U as User/UI
    participant API
    participant DB
    participant W as Worker

    U->>API: Create host with snmp_community="public"
    API->>API: encrypt_field("public") with FIELD_ENCRYPTION_KEY
    API->>DB: INSERT hosts (snmp_community = b64(nonce||ciphertext||tag))
    Note over DB: Plaintext nowhere stored

    W->>DB: SELECT host for check
    W->>W: decrypt_field(snmp_community)
    W->>W: SNMP walk with plaintext
    Note over W: Plaintext in RAM only, discarded immediately

API masking

HostOut (response schema in api/app/schemas) masks encrypted fields as "***". Even super admin doesn't see plaintext — they can only set, not read.

Legacy migration

Before v0.x there were plaintext fields. decrypt_field() has a fallback: if the value lacks the encrypted format (no magic byte), it's returned as plaintext. Old data still works, new data is encrypted.

On set, always encrypted — old plaintext entry becomes encrypted on first update.

Key loss

Lost key = data lost

Without FIELD_ENCRYPTION_KEY, encrypted fields stay unreadable forever. There's no recovery mechanism.

Consequence: backing up the key is as important as DB backup. Recommendation in Backup & restore.

Key rotation

Currently not UI-supported. Manual path:

  1. Generate new FIELD_ENCRYPTION_KEY
  2. Run a script that decrypts every encrypted column with old key and re-encrypts with new:
# scripts/rotate_field_key.py — abbreviated
from shared.encryption import decrypt_field, encrypt_field
from sqlalchemy.orm import Session

def rotate(db: Session, old_key: str, new_key: str):
    for host in db.query(Host).filter(Host.snmp_community.isnot(None)).all():
        plaintext = decrypt_field(host.snmp_community, key=old_key)
        host.snmp_community = encrypt_field(plaintext, key=new_key)
    db.commit()
  1. Replace .env with new key
  2. Restart stack

No write traffic during rotation. Be very careful with backups.

Encryption in backups

PostgreSQL dumps contain encrypted values (not plaintext, not the key). Restore into an instance with a different FIELD_ENCRYPTION_KEY and the values are unreadable — always restore with the same key.

So .env backup is mandatory along with DB backup, but not in the same container:

  • DB backup: volume / S3
  • .env: password manager / encrypted separate storage

Next