Pair handshake protocol
Reference for the manager-initiated SSH pairing flow that joins a FreePBX PBX
to a customsso-manager server. Written for auditors + future implementers.
Goal
Establish a long-lived, authenticated, IP-restricted SSH relationship between exactly one manager and one PBX such that:
- The manager can SSH into the PBX as root (port-locked + key-locked + IP-locked)
- The manager can mint SSO tokens that the PBX will accept as authenticated admin sessions
- Either side can revoke the relationship cleanly
- Neither side can be added to a relationship without the operator's explicit action on the manager
What's exchanged
| Direction | Material | Purpose |
|---|---|---|
| Operator → manager (one time) | PBX SSH host + port + root password + optional label | Bootstraps the whole flow. Manager wipes the password after handshake. |
| Manager → PBX (over SSH) | ed25519 public key, freshly generated per PBX | Installed into /root/.ssh/authorized_keys with from="<manager_ip>",no-pty,… restrictions |
| Manager → PBX (over SSH) | HMAC secret (32 random bytes) | Written to the agent's config; both sides use it to sign SSO landing tokens |
| Manager → PBX (over SSH) | manager UUID + manager_url | Recorded in the agent's config so it knows who owns it |
| PBX → manager (post-pair status probe) | PBX UUID + hostname + disk / RAM / notification counts | Manager stores it in pbxes row; refreshed every 60 s by the sidecar |
| (inferred from TCP layer) | manager_ip = source IP of the SSH session | TOFU — PBX records this as the allowed source IP for SSO landing redemption |
All manager-side secrets (per-PBX SSH private key + HMAC secret) live in the
pbxes table encrypted at rest with AES-256-GCM under the master key at
/etc/customsso-manager/master.key.
Flow (current, v0.5+)
The pair is entirely manager-driven — no pair token, no PBX-side wizard, no copy/paste. The operator provides SSH credentials once, and the manager does everything over that SSH session.
┌──────────┐ ┌──────────┐
│ ADMIN │ │ MANAGER │
│ browser │ │ sidecar │
└────┬─────┘ └────┬─────┘
│ │
│ 1. Add PBX form │
│ (ssh_host, ssh_port, ssh_root_pw, label) │
│ ────────────────────────────────────────────────────► │
│ │
│ │ 2. INSERT jobs row
│ │ channel='pair', status='pending'
│ │
│ │ 3. Sidecar picks up the job,
│ │ opens SSH session to
│ │ root@ssh_host:ssh_port
│ │ using the provided password
│ │
│ │ 4. Over SSH, sidecar runs
│ │ pair_provisioner steps:
│ │
│ │ a. Fetch OS metadata
│ │ b. Generate per-PBX ed25519
│ │ SSH keypair (manager-side)
│ │ c. Install SSH pubkey into
│ │ /root/.ssh/authorized_keys
│ │ with from=<sidecar_ip>,
│ │ no-pty,no-X11,no-agent-fwd,
│ │ no-port-fwd
│ │ d. Download + install the
│ │ customsso FreePBX module
│ │ (fwconsole ma downloadinstall)
│ │ e. Write agent config:
│ │ manager_uuid, manager_url,
│ │ hmac_secret
│ │ f. Deploy sso_entry.php at
│ │ /var/www/html/customsso/
│ │ g. Add Apache Alias for
│ │ /customsso-sso
│ │ h. Record every path touched
│ │ in the pair manifest
│ │ /etc/customsso/pbx.manifest.json
│ │ i. Wipe the root SSH password
│ │ (passwd -d root) — the SSH
│ │ key is now the only way in
│ │
│ │ 5. Verify: reconnect over SSH
│ │ using key-only auth, run the
│ │ status probe once
│ │
│ │ 6. INSERT pbxes row with
│ │ encrypted ssh_privkey +
│ │ hmac_secret; job → 'complete'
│ │
│ 7. Operator sees PBX in fleet view; │
│ status badge flips to 'ok' on the │
│ next 60 s status refresh │
│ │
Every step is streamed to a per-job transcript visible at
/pair/progress/{job} while it's running, then persisted for 7 days at
/pair/batch/{batch} and on the PBX's own Pair log tab under its detail
page.
Trust chain
- The operator's manual entry of the SSH root password establishes the manager's authority to configure this PBX. It's a bootstrap credential used exactly once and then wiped from the box.
- The manager-generated SSH keypair replaces the password as the sole auth channel going forward. The public half is IP-restricted on the PBX; the private half is AES-256-GCM encrypted at rest on the manager and only ever exists in cleartext in the sidecar's memory during a live SSH call.
- The TOFU source IP recorded at pair time becomes the allowed source IP for both SSH and SSO landing calls. Anyone who steals the SSH private key still can't use it from a different IP.
- The HMAC secret exchanged at pair time signs SSO tokens end-to-end — the PBX only accepts landing redemptions whose signature validates against its stored copy of the same secret.
Trust assumptions
- Root SSH access at pair time — the operator has verified the target IP points at their PBX, not an attacker's box.
- Bootstrap network integrity — the SSH session used at pair time is TCP-
authenticated. If the operator's manager can reach
10.0.0.5:22, it's the right box. - Manager host security — anyone with read access to the manager's master key file and DB can decrypt every per-PBX SSH private key. See SECURITY.md.
Manifest — the record of everything the manager touched
At pair time the sidecar builds /etc/customsso/pbx.manifest.json on the PBX
listing every file created + every config edit made. Sample:
{
"manager_uuid": "0f0…",
"manager_url": "https://sso.example.com",
"paired_at": "2026-07-12T18:03:44Z",
"files": [
"/var/www/html/customsso/sso_entry.php",
"/etc/httpd/conf.d/customsso-alias.conf",
"/etc/customsso/agent.conf",
"/root/.ssh/authorized_keys.customsso.d/manager.pub"
],
"authorized_keys_line": "from=\"10.0.0.10\",no-pty,no-X11,no-agent-forwarding,no-port-forwarding ssh-ed25519 AAAA…",
"freepbx_modules": ["customsso"]
}
/usr/local/bin/customsso-unpair iterates this file and undoes exactly what
pair time wrote — no orphan configs, no leftover keys.
Revoke flow
Either side can initiate. Both use SSH — that's the only channel between them.
Manager-initiated
PBX detail → Unpair:
- Manager SSHes into the PBX as root using the stored per-PBX SSH key
- Manager reads
/etc/customsso/pbx.manifest.json - Manager removes every file listed
- Manager strips its
from=line from/root/.ssh/authorized_keys - Manager uninstalls the customsso FreePBX module
- Manager DELETEs the
pbxesrow (cascade also drops orphan jobs)
PBX-initiated (admin at the PBX box)
Admin runs sudo /usr/local/bin/customsso-unpair on the PBX:
- Script reads the manifest and removes every file it lists
- Script strips the manager's line from
/root/.ssh/authorized_keys - Script uninstalls the customsso FreePBX module
- Manager's outbound SSH now fails (key auth refused)
- Manager UI shows the PBX as
offline; admin can delete the pbxes row
After revoke, re-pair via the normal flow generates fresh SSH keypair + HMAC secret material.
Failure modes + diagnostics
| Symptom | Likely cause |
|---|---|
pair job failed — auth_failed |
The root password provided didn't work. Wrong password, PubkeyAuthentication-only SSH config, or root login disabled. |
pair job failed — module_install_failed |
fwconsole ma downloadinstall customsso failed. Usually because the PBX can't reach the customsso module repo URL configured on the manager (Settings → Advanced → PBX module repo). |
pair job failed — apache_reload_failed |
The Apache Alias write took but the reload failed. Usually SELinux — check /var/log/audit/audit.log on the PBX for the denial. |
Post-pair status shows offline immediately |
Sidecar can reach the PBX at pair time but the stored ssh_host differs from what the sidecar uses on refresh. Check the PBX detail → Access panel. |
| SSO landing returns "invalid manager IP" | The manager_ip TOFU'd at pair time doesn't match the sidecar's current source IP. NAT change on the manager or a multi-homed box — Settings → Advanced → Public manager URL + re-push. |