Skip to content

Security Checklist (Bare-Metal Deployment)

Every item below has a literal verification command. Run them in order on every host before declaring an install production-ready. The machine-verifiable subset is checked automatically by sudo "${MAID_SRC}/scripts/maid-doctor.sh" --phase install --instance <inst>, which returns 0 on clean, 1 on warnings, and 2 on failures.

scripts/maid-doctor.sh is repo-only — the installer does not stage scripts/ under /opt/maid/current (staging is deferred to M1.4). Run it from a source checkout: export MAID_SRC=/path/to/maid-checkout before the commands below. These checks are run by a full-sudo operator, so invoking the doctor from the checkout is safe. (The delegated passwordless-sudo case uses a root-owned staged copy instead — see ssh-bootstrap.md.)

The checklist assumes the canonical layout produced by scripts/install.sh:

  • service user: maid-engine (no shell)
  • operator group: maid-admin (members can run the priv-helper)
  • env file: /etc/maid/<instance>.env
  • runtime dir: /run/maid-engine/<instance>/
  • state dir: /var/lib/maid-engine/<instance>/

Replace <inst> with your systemd instance name throughout (e.g. default).

Filesystem and accounts

  • [ ] Env file permissions: 0640 root:maid-engine.

    stat -c '%a:%U:%G' /etc/maid/<inst>.env
    # Expected: 640:root:maid-engine
    
  • [ ] Service user maid-engine has no login shell.

    getent passwd maid-engine | cut -d: -f7
    # Expected: /usr/sbin/nologin (or /sbin/nologin)
    
  • [ ] Operator group maid-admin membership reviewed quarterly.

    getent group maid-admin
    # Compare against your OWNERS file; remove ex-team-members.
    
  • [ ] maid-priv-helper is 0750 root:maid-admin.

    stat -c '%a:%U:%G' /usr/local/sbin/maid-priv-helper
    # Expected: 750:root:maid-admin
    
  • [ ] Sudoers allowlist matches expected verbs (no wildcards).

    sudo -l -U <operator>
    # Expected output enumerates only the documented maid-admin verbs
    # (status, maintenance, announce, doctor, reload, etc.). No "ALL".
    

systemd hardening

  • [ ] systemd unit hardening directives present and effective.

    systemd-analyze security maid-engine@<inst>.service
    # Expected: overall exposure ≤ 3.0 ("MEDIUM" or better).
    # Specifically verify the following are not "✗":
    #   - ProtectSystem=strict
    #   - ProtectHome=true
    #   - PrivateTmp=true
    #   - NoNewPrivileges=true
    #   - RestrictSUIDSGID=true
    

Network exposure

  • [ ] Telnet wrapped in TLS (stunnel) OR restricted to loopback + SSH jump host.

    ss -tlnp | grep :4000
    # Acceptable EITHER:
    #   - bound to 127.0.0.1:4000 (jump-host model), OR
    #   - bound to public IP but fronted by stunnel/tlswrap on 4001 with
    #     the plain port firewalled off.
    
  • [ ] Observability internal port is loopback-only.

    ss -tlnp | grep :9090
    # Expected: 127.0.0.1:9090 (NOT 0.0.0.0:9090 or *:9090)
    
  • [ ] Host firewall denies :9090 from non-loopback.

    sudo nft list ruleset | grep -E 'dport (9090|maid-internal)'
    # Expected: an explicit drop/reject for non-127.0.0.0/8 on :9090.
    

Secrets and backups

  • [ ] Off-host backup destination provides encryption-at-rest (operator-supplied).

    MAID does not encrypt backup archives. backup.sh uploads plaintext members — base/base.tar.gz, optional content.tar.gz, and MANIFEST.json (+ .sha256 sidecar) — under <remote-root>/<BID>/ (no per-instance subdir). Confidentiality at rest is a property of the destination you configured, not of MAID, so verify it out of band (encrypted bucket/volume/filesystem on the backup host).

    grep -E '^MAID_DEPLOY_BACKUP_REMOTE=' /etc/maid/<inst>.env
    # Then list one backup on that transport. Expect plaintext members
    # (base/, content.tar.gz, MANIFEST.json). A ".age"/".gpg" file would
    # ONLY appear if YOU added storage-layer encryption — its ABSENCE is
    # normal, not a finding. The finding is an UNENCRYPTED destination.
    
  • [ ] WAL archive / backup destination is OFF-HOST.

    MAID_DEPLOY_BACKUP_REMOTE is either ssh://user@host/path or local_path:///abs/path (the only two transports). Resolve off-host-ness per scheme — and for ssh:// strip the user@ userinfo before resolving (leaving it in makes getent fail and would print a credential):

    REMOTE="$(awk -F= '/^MAID_DEPLOY_BACKUP_REMOTE=/{sub(/^[^=]*=/,"");print;exit}' \
      /etc/maid/<inst>.env)"
    case "$REMOTE" in
      ssh://*)
        # ssh://user@host/path → strip scheme, then path, then userinfo.
        HOST="${REMOTE#ssh://}"; HOST="${HOST%%/*}"; HOST="${HOST##*@}"
        echo "ssh backup host: $HOST"
        getent hosts "$HOST" | awk '{print $1}'
        # FINDING if this resolves to 127.0.0.0/8 or this host's own
        # primary IP — that is not off-host.
        ;;
      local_path://*)
        # local_path:///abs/path has NO remote host: "off-host" means the
        # path must sit on a REMOTE mount (nfs/cifs/sshfs), not local disk.
        P="${REMOTE#local_path://}"
        findmnt -no SOURCE,FSTYPE,TARGET -T "$P"
        # FINDING if FSTYPE is a local block filesystem (ext4/xfs/btrfs on
        # a /dev/* SOURCE) — same-host storage, not off-host. Expect a
        # network FSTYPE (nfs/nfs4/cifs/fuse.sshfs) with a <host>:/export
        # SOURCE.
        ;;
      *) echo "MAID_DEPLOY_BACKUP_REMOTE unset or unknown scheme: '$REMOTE'" ;;
    esac
    
  • [ ] MAID_ADMIN_SECRET_KEY rotated per your calendar (≤ 90 days).

    There is no maid-admin secrets verb (the wrapper exposes only status, doctor, reload, restore, rotate-credentials, instance). Track the rotation date in your external secrets record; the only local proxy is the env-file mtime:

    stat -c '%Y' /etc/maid/<inst>.env   # compare against your rotation calendar
    grep -E '^MAID_ADMIN_SECRET_KEY=' /etc/maid/<inst>.env >/dev/null \
      && echo "set" || echo "MISSING — engine refuses to start in prod"
    

    What this secret signs (algorithm-dependent). MAID_ADMIN_SECRET_KEY is the HMAC secret that signs player access/refresh JWTs — always, HS256 (get_player_auth_secret reuses the admin secret; auth/core.py) — and admin JWTs only when MAID_ADMIN_ALGORITHM=HS256. Under the admin API default MAID_ADMIN_ALGORITHM=RS256, admin JWTs are signed by an auto-generated RSA keypair (MAID_ADMIN_PRIVATE_KEY_PATH / MAID_ADMIN_PUBLIC_KEY_PATH), not by this secret. So rotating MAID_ADMIN_SECRET_KEY always invalidates existing player sessions (and admin sessions too under HS256), but under RS256 it does not change admin JWT signatures — to rotate admin JWT signing under RS256, replace the RSA key material and restart. CSRF tokens are random per session (secrets.token_urlsafe), not derived from this secret. Either way the engine refuses to start in production if MAID_ADMIN_SECRET_KEY is unset or < 32 chars.

  • [ ] Admin API keys are scoped per integration (no shared "master" key).

    # Query the LIVE inventory from the running engine. `maid api
    # list-keys` only reads the CLI's cwd-relative data/api_keys.json,
    # which is not the engine's authoritative store — use the REST API.
    # Each key reports its granted permissions as base-flag names
    # (READ_PUBLIC, READ_PLAYERS, WRITE_PLAYERS, READ_WORLD,
    # WRITE_WORLD, ADMIN); a key created with FULL_ACCESS expands to
    # all six.
    keys="$(curl -fsS http://127.0.0.1:8080/api/v1/admin/api-keys \
      -H "X-API-Key: ${MAID_ADMIN_API_KEY}")"
    
    # Show every key with its scope:
    echo "${keys}" \
      | jq -r '.keys[] | "\(.name)\t\(.is_active)\t\(.permissions | join(","))"'
    # Expected: one active row per integration, each with a distinct,
    # minimal scope. "service-account-shared" or "tmp" names are findings.
    
    # FAIL if any active key carries ADMIN (an over-broad "master"
    # scope that must be reserved for interactive operators, not
    # integrations):
    broad="$(echo "${keys}" \
      | jq -r '[.keys[] | select(.is_active and (.permissions | index("ADMIN"))) | .name]')"
    if [ "$(echo "${broad}" | jq 'length')" -ne 0 ]; then
      echo "FINDING: over-broad ADMIN-scoped API keys: ${broad}" >&2
    fi
    
  • [ ] No default placeholder secrets present.

    sudo "${MAID_SRC}/scripts/maid-doctor.sh" \
      --phase install --instance <inst>
    # Must exit 0. Any finding of class `secret.placeholder` exits non-zero.
    

Automatable subset

After install, run:

sudo "${MAID_SRC}/scripts/maid-doctor.sh" \
  --phase install --instance <inst>

Exit codes (per scripts/maid-doctor.sh --help):

  • 0 — all checks passed or skipped (clean)
  • 1 — at least one WARN finding (review and remediate)
  • 2 — at least one FAIL finding (something is broken)
  • 64 — usage error

The bash doctor performs the file-mode checks, the service-user check, the loopback-binding checks for the observability port, the MAID_DEPLOY_BACKUP_REMOTE resolution check, and the placeholder-secret scan. It does not verify quarterly human reviews (group membership, sudoers contents) — those remain manual and are explicitly out of scope for the auto-check.

For runtime checks against a running engine (tick lag, save queue age, AI breaker, archive lag), use the functional maid-admin wrapper (it shells out to maid-doctor.sh):

maid-admin doctor --phase runtime --instance <inst>

Per plan.md §3 M9, maid ops doctor --json is the planned canonical runtime doctor, but it is an M9 stub today (prints NOT YET IMPLEMENTED) — do not use it yet. The bash wrapper above is the install-time / unattended-CI doctor.