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. -
[ ] Service user
maid-enginehas no login shell. -
[ ] Operator group
maid-adminmembership reviewed quarterly. -
[ ]
maid-priv-helperis0750 root:maid-admin. -
[ ] Sudoers allowlist matches expected verbs (no wildcards).
systemd hardening¶
-
[ ] systemd unit hardening directives present and effective.
Network exposure¶
-
[ ] Telnet wrapped in TLS (stunnel) OR restricted to loopback + SSH jump host.
-
[ ] Observability internal port is loopback-only.
-
[ ] Host firewall denies
:9090from non-loopback.
Secrets and backups¶
-
[ ] Off-host backup destination provides encryption-at-rest (operator-supplied).
MAID does not encrypt backup archives.
backup.shuploads plaintext members —base/base.tar.gz, optionalcontent.tar.gz, andMANIFEST.json(+.sha256sidecar) — 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_REMOTEis eitherssh://user@host/pathorlocal_path:///abs/path(the only two transports). Resolve off-host-ness per scheme — and forssh://strip theuser@userinfo before resolving (leaving it in makesgetentfail 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_KEYrotated per your calendar (≤ 90 days).There is no
maid-admin secretsverb (the wrapper exposes onlystatus,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_KEYis the HMAC secret that signs player access/refresh JWTs — always, HS256 (get_player_auth_secretreuses the admin secret;auth/core.py) — and admin JWTs only whenMAID_ADMIN_ALGORITHM=HS256. Under the admin API defaultMAID_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 rotatingMAID_ADMIN_SECRET_KEYalways 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 ifMAID_ADMIN_SECRET_KEYis 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.
Automatable subset¶
After install, run:
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):
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.