Security model & deployment best practices
This document describes the security model of customsso (the FreePBX agent module) and customsso-manager (the standalone manager server), and the deployment practices that preserve it. Read this before pairing your first production PBX.
v1.0.0+ security hardening — what's new
The v1.0.0 release shipped the UCP admin-impersonation feature and closed a post-audit sweep for parity with the existing admin-SSO path:
Sso::launch(admin variant) tightened to admin role. Previously any logged-in manager user (includingreadonly) could mint an admin-SSO token and land asadminon the paired PBX — full privilege on that PBX. Now requiresSession::requireRole('admin'). Behavior change for existing operator/readonly users: they must be promoted toadminto open Native UI SSO. UCP-variantSso::launchUcpwas admin-gated from the start.- Audit-log every UCP mint rejection. New helper
Sso::auditUcpReject()writes an audit row (action=sso.ucp_blocked, reason, submitted target_user, remote IP, User-Agent) before every reject redirect (bad_target_user,user_not_found,user_login_disabled,pbx_not_found). Blocks silent enumeration of usernames via scripted POSTs. Symmetric withrejectXsrfon the admin path. - UCP-session IP-pinning.
\FreePBX::Ucp()->storeToken($token, $uid, $srcIp)binds the impersonated UCP session to the operator's browser IP — a hijacked token used from a different IP is refused by UCP. variantclaim inside HMAC-signed payload. SSO tokens now carryvariant='admin'orvariant='ucp'inside the signed payload. PBX-sidesso_entry.phpenforces variant/target-path match (avariant=ucptoken can't be redeemed against/admin/…and vice versa). Pre-v1.0.0 tokens (novariantclaim) default toadminfor backward-compat.- Effective
Allow UCP Login?check on both sides. The manager's UCP picker cache AND the PBX-side gate both useUserman::getCombinedModuleSettingByID($uid, 'ucp|Global', 'allowLogin')— anything the picker offers is guaranteed to redeem. Prevents "click succeeds but silently fails on landing" UX.
Deferred to v1.0.1 (H-series audit finding): signed logout_ucp teardown endpoint + JS beforeunload sendBeacon so asterisk.ucp_sessions rows don't linger after Switch-user / tab close.
v0.6.4+ security hardening — what's new
The v0.6.4 release closed a full internal audit (3 CRITICAL + 10 HIGH findings). The changes most operator-visible:
- SSH host-key pinning (fixes prior MITM exposure) — every paired PBX's host key is captured at pair time (or via TOFU on first connect for legacy rows) and stored in
pbxes.ssh_host_key. Subsequent SSH sessions from the manager (probes, jobs, terminal, key rotation, reclaim) refuse to connect if the offered host key doesn't match. On rebuild-the-PBX scenarios, use Re-pair (see PAIRING.md) — that regenerates the pinned host key. - Permissive
from=on authorized_keys, host-key pinning as the substitute — the previousfrom="<manager-ip>"restriction was dropped so DR-restore-to-a-different-IP works, and host-key pinning + the encrypted-at-rest SSH private key replace the network-layer defense. - Secrets never on argv — freshly-minted HMAC secrets during reclaim / rotation are piped over stdin, never argv, so they can't be grepped from
/proc/PID/cmdlineon the PBX. - Backup passphrase min raised to 20 chars — bundles contain the manager master key + every PBX's SSH + HMAC secret; short passphrases are GPU-crackable. See BACKUP.md.
- Restore tar hardening — bundle uploads reject symlinks / hardlinks / non-regular files pre-extraction, use
--no-same-owner --no-same-permissions --no-overwrite-dir. - Operator role no longer has remote-shell — free-form shell (single-PBX and bulk) requires admin. Operators keep the allowlisted fwconsole / Custom Commands catalog.
rerunJobalso gated by original-command's minimum role. - Trusted-device 2FA bypass now expires — 90-day TTL, auto-renewed on each use, vacuum sweeps expired rows.
- 2FA bypass on broken mail requires an operator-created marker file —
/etc/customsso-manager/allow-2fa-bypass, single-use, deleted on first use.install.shcreates it once on fresh install so the very first login works; recovery from a lockout istouchas root. - Password-reset email refuses when public URL isn't set — blocks Host-header spoof.
- Sidecar systemd hardening —
NoNewPrivileges,ProtectSystem=strict,ProtectHome,PrivateTmp,CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_BIND_SERVICE,SystemCallFilter=@system-service,RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6. - WebSocket terminal ticket TTL now enforced in SQL — old-but-unused tickets can no longer open a shell arbitrarily far in the future.
- PBX-side SSO nonce writes are atomic (
O_CREAT|O_EXCL), the nonce store has opportunistic garbage collection (sweeps entries >60s on each write), and the SSO 302 response carriesReferrer-Policy: no-referrerso the token doesn't leak into downstream page Referer chains. - install.sh downgrade guard (v0.6.5+) —
--updaterefuses to install an older tarball over a newer one unless--allow-downgradeis passed.
Deferred (tracked as open items — planned for v0.7):
- PBX-side HMAC secret split into a helper-daemon signing model so the secret is no longer readable by the asterisk group.
- Full CSRF-token gate on /pbx/{uuid}/sso (current Referer-check is defense-in-depth only).
- Two-phase atomic master-key rotation.
- PBX-side session-fixation defense (needs investigation of FreePBX 17's session bootstrap interactions).
Layered defenses, ranked
| Layer | What | Why it matters |
|---|---|---|
| 1. Per-PBX unique keys | Every paired PBX gets its own HMAC secret + ed25519 SSH keypair. None are shared across PBXes. | Compromise of one pairing never compromises others. A manager DB row leak forces a per-PBX revoke loop, not a fleet-wide reset. |
| 2. Encrypted-at-rest secrets on manager | All *_ct columns (hmac_secret_ct, ssh_privkey_ct) are AES-256-GCM ciphertext under a master key. |
A read-only DB leak alone (SQL injection, backup tape) yields no usable credentials. The master key lives on disk in a separate file owned by apache:apache mode 0600. |
| 3. Layered access controls on each PBX | (a) OS firewall (firewalld/nftables/iptables — exported from the agent for the admin to apply), (b) SSH host-key pinning (TOFU'd at pair time, enforced on every sidecar connect — a re-imaged PBX offering a different host key is refused), (c) encrypted-at-rest SSH private key on the manager (ssh_privkey_ct AES-256-GCM), (d) SSO landing endpoint (/customsso-sso) accepts only HMAC-verified signed tokens with a 30s TTL and one-shot nonce. If any one layer is misconfigured, three more catch it. Note (v0.6.4+): the previous from="<manager_ip>" restriction on authorized_keys was dropped so DR-restore-to-a-different-IP works — host-key pinning + encrypted private key + HMAC-signed SSO token replace that network-layer defense. See BACKUP.md for the DR context. |
Defense in depth without brittle IP pinning. |
| 4. One-time SSO tokens | 30-second TTL, HMAC-SHA256 signature with constant-time compare, nonce stored in customsso_nonces (UNIQUE constraint = one-time-use). |
Replay-protected even on the manager → user → PBX redirect path. |
| 5. SSH command allowlist for bulk operations | The manager's Bulk/fwconsole dispatcher only runs commands in the built-in CMD_MAP or the admin-defined custom-commands catalog. Placeholder tokens (@MODNAME@, @LINES@) are validated before substitution. |
Bounds the blast radius of a compromised operator-role login. Note: an admin-role user can define new custom commands with arbitrary shell — role docs treat admin as fleet-wide root by design. |
| 6. Manager-initiated pairing | Pairing happens over SSH from the manager, not from a form on the PBX side. The operator's browser action never leaves the manager. | No drive-by pairing — there is no PBX-side "accept this manager" wizard to phish. |
| 7. Append-only audit log on both sides | Every SSO redemption, every SSH job dispatch, every pairing event, every login. Manager-side has user_id attribution; PBX-side has source_ip attribution. Both also emit syslog lines suitable for fail2ban. | Forensic trail post-incident. |
| 8. fail2ban integration | Bundled jail config on the manager (/etc/fail2ban/jail.d/customsso-manager.conf) bans IPs after 5 failed logins in 10 minutes. Agent's syslog format is parseable for a similar jail. |
Rate-limits credential stuffing. |
| 9. Role-based access on the manager | superuser / admin / operator / readonly. Only superusers can manage users and rotate the master key. |
Least-privilege for shared admin teams. |
| 10. CSRF tokens on every state-changing POST | Every write route validates Session::requireCsrf(); the token is per-session and embedded in each form. |
Blocks cross-site request forgery against authenticated admin sessions. |
Hard requirements
These are not optional — pairing won't work or won't be secure without them:
- Enter the PBX root SSH password directly into the manager UI over HTTPS. Don't route it through Slack / email / anywhere with logs. The manager wipes it on the PBX immediately after pair completes; the window of vulnerability is measured in seconds.
- Set a public manager URL (
Settings → Advanced → Public manager URL) before pairing if your manager is behind NAT or has a public-vs-internal hostname split. The pair handshake records this URL at the PBX; changing it later requires a push (also on the same Settings tab). - Configure your OS firewall on each PBX to allow only the manager IP to reach ports 22, 80, and 443. The manager writes matching restrictions into the PBX's
authorized_keysbut a host firewall is your defense-in-depth. - Back up
/etc/customsso-manager/master.keyoffline. Without it the encrypted DB columns are unreadable. Store on a USB key, encrypted password manager, or a fireproof safe — somewhere physically separate from the manager host.
Recommended deployment practices
| Practice | Why |
|---|---|
| Run the manager on a dedicated host (small VM is fine) | Reduces blast radius. Compromise of the manager OS root compromises every paired PBX's SSH access. |
| Use a VPN-only manager (e.g., WireGuard) reachable only by admin laptops | Removes the manager from the public attack surface entirely. fail2ban becomes a defense-in-depth, not the front line. |
| Rotate the master key every 90 days via Settings → Advanced → Rotate master key | Local-only re-encryption (PBXes aren't contacted); zero downtime; old key kept 1 hour as crash recovery. |
| Re-pair each PBX every 12 months | Generates fresh SSH keypair + HMAC secret; invalidates any silent-leak window. |
| Use non-default SSH port on the manager (not 22) | Reduces script-kiddie noise. SSH key auth + fail2ban handle real attackers regardless. |
| Enable disk encryption (LUKS / Tang+Clevis / TPM) on the manager | Defends the encrypted-at-rest secrets against physical disk theft. Install.sh has an advisory check but doesn't force it (see "LUKS tradeoffs" below). |
| Run separate admin accounts per human operator | Per-user audit attribution works only if each operator has their own login. |
Restrict superuser to 1–2 people |
User creation + master-key rotation are the rare, audit-worthy operations. |
LUKS tradeoffs
LUKS gives the strongest defense against disk theft but requires interactive boot password entry. Three deployment options:
- Headed/console-accessible host: full LUKS with prompt. Pick this when you have IPMI/iLO/console access.
- Headless with Tang/Clevis: LUKS volume auto-unlocks when the host can reach a "Tang" key-server on the network. Stolen + booted off-network → no unlock. Best of both worlds; requires running a Tang server somewhere on the LAN.
- No disk encryption + strict access control: app-level AES-256-GCM still encrypts secret columns. Master key lives on the same disk so this gives little protection against physical theft — defense becomes "the disk doesn't leave the rack."
Key rotation procedures
Master key (manager-local, all PBXes)
Settings → Advanced → Rotate master key(superuser only)- Manager re-encrypts every
*_ctcolumn under the new key in a single DB transaction. A mid-rotation crash rolls back cleanly. - Old key saved to
master.key.old.<timestamp>for 1 hour crash recovery. - No PBX contact required.
Atomicity caveat (H10 deferred): the rotation is single-transaction best-effort — atomic two-phase rotation (concurrent old-key retention with a grace window for in-flight decrypts on other web workers) is not yet implemented. In practice this means: run rotation during a low-traffic window; a request that's mid-decrypt when rotation commits gets a decrypt failure and needs a page reload. Not a data-safety issue — the crash-recovery master.key.old.<ts> lets you roll back if anything looks wrong.
Per-PBX HMAC + SSH (full re-key)
Two paths — pick by whether you have SSH still working:
Path A — SSH still works (rotation as maintenance) 1. Manager → PBX detail → Rotate keys 2. Manager SSHes in with the current key, generates a fresh keypair + HMAC secret, installs them, updates the PBX-side manifest. Zero-touch operator experience.
Path B — SSH broken, needs root password (Re-pair)
1. Manager → PBX Status page → Re-pair button (visible only when status is offline)
— OR — Settings → Reclaim (visible only when unpaired PBXes exist from a failed auto-reclaim during restore)
2. One-field form: PBX root password. Everything else (host, label, hardware node, notes) pre-populated from the existing DB row.
3. Sidecar SSHes in with the password, generates fresh keys + HMAC, installs the new customsso module version (if newer), rewrites manifest — preserves the pbxes.uuid so notes and node assignment survive.
Path C — Fleet-wide reset 1. Backup + restore on a new manager instance (see BACKUP.md). Auto-reclaim during restore handles every PBX in one pass.
GPG signing key (publisher identity)
The GPG key (046E8CA0EE6A755B) is used only to sign release tarballs. Rotation:
- Generate new key on the build machine; export new pubkey
- Update every published module's
signatures/bundle with the new pubkey - Re-sign all current module versions, bump versions, publish
- Each PBX needs to import + trust the new pubkey before it'll trust new releases
Don't rotate the GPG key unless it's actually compromised — it's a heavy lift and breaks every existing trust chain.
Audit log discipline
- Read the audit log weekly — at minimum check for unexpected
login.fail,sso.issueoutside business hours,bulk.dispatchyou didn't initiate - Export to CSV monthly for offline retention (Audit → CSV button)
- Don't disable retention in production — the vacuum loop trims audit at 365 days by default; shorter is fine, longer needs more disk
What to do if you suspect compromise
If you suspect the manager is compromised:
systemctl stop httpd customsso-sidecaron the manager to halt all outbound activity- On each PBX: log in as root and run
sudo /usr/local/bin/customsso-unpair— this reads the manifest and removes the manager's SSH key from/root/.ssh/authorized_keysplus every file the pair placed - Manually verify
/root/.ssh/authorized_keyshas nocustomsso-managermarker lines left - Examine the manager's
audittable for the compromise window — look forbulk.dispatch,shell.enqueue,master_key.rotateyou didn't authorize - Rebuild the manager from scratch on a clean host; do not restore from backup (master.key may be in the backup)
- Re-pair fresh
If you suspect a single PBX is compromised:
- On that PBX:
sudo /usr/local/bin/customsso-unpair - On the manager: PBX detail → Unpair (double-revoke is fine)
- Investigate via FreePBX's own audit and the agent's
customsso_audittable - Re-pair from clean state
If the GPG signing key is compromised:
- Stop publishing immediately
- Revoke the key in your public keyserver (if you've published one)
- Generate a new key, re-sign every current module, bump versions, publish
- Every existing PBX needs to import the new pubkey + delete the old trust; this is a fleet-wide manual operation
Out of scope — explicitly NOT defended against
See THREAT_MODEL.md for the explicit list. Highlights:
- Compromised admin workstation: a keylogger or session hijack on the admin's laptop has all the access the admin has. Use a clean workstation, FIDO2 / hardware-token-protected SSH keys.
- Compromised manager host root: the master key sits on the manager. Root on the manager = read all secrets. Defense is host hardening, dedicated VM, audit, restricted access.
- Compromised FreePBX core on a paired PBX: if FreePBX itself is compromised, the agent module can't protect against actions taken in its name. We sign + verify our own module but not the rest of FreePBX.
- Side-channel attacks against the GPG key: assumed not applicable since the key is on an air-gapped or admin-only build machine.