# PTT Server Architecture The `ptt-server` is a Node.js process using Socket.IO for real-time bi-directional communication and Express for HTTP endpoints. --- ## Startup Sequence 1. Load TLS cert/key from environment (`PTT_TLS_CERT` / `PTT_TLS_KEY`) or `pki/` directory. 2. Load server identity key and certificate (`PTT_SVRAUTH_PRIVKEY_PEM` / `PTT_SVRAUTH_CERT_PEM`). 3. Derive `PTT_SVRAUTH_KEY_ID = SHA-256(serverIdentityPublicKeyDER)[0:16]`. 4. (Stub) `pttDeviceService.setSigningIdentity()` is currently a no-op; the server identity key loaded in step 2 is used directly. 5. Load Platform Root CA (for Phase 1 device cert verification). 6. Load Platform Issuing CA (for Bootstrap JWT validation if enabled). 7. Create HTTPS (or HTTP) server and attach Socket.IO. 8. Register HTTP routes (`/api/*`, `/system-root-ca.crt`, `/qr`, etc.). 9. Configure `socketio-auth` middleware (`authenticate` / `postAuthenticate` / `disconnect` callback options — the client still emits the Socket.IO event **`authentication`**). 10. Load broadcast transmit allow-list (`PTT_BROADCAST_AUTH_PATH` or default `broadcast-auth.json`); reload on `SIGHUP`. 11. Start listening. ### Connection and event logging Operators and developers rely on logs for production triage. **`index.js` and related modules should follow these rules:** 1. **Lifecycle** — Log socket connect and disconnect with `socket.id`, authenticated `socket.data.dni` (when set), and disconnect reason when available. 2. **Inbound events** — Log the **event name** per client message; for `voice_data`, log routing metadata and **payload length**, not Base64 or binary content. Never log session key material or full JWT payloads. 3. **Auth failures** — Log a clear reason code or short message for `unauthorized` / failed `authentication` without echoing secrets. 4. **Consistency** — Prefer a small set of prefixes (e.g. `[io-connect]`, `[io-event]`) so log aggregation filters remain stable across releases. Client-side expectations for the same problem space are specified in `PTTCLIENT-REWRITE-SPEC-001_1.md` **FR-CONN-010** / **NFR-011**. ### Socket.IO connection query parameters **Connection query parameters:** `dni`, `dsaPub` (base64 raw ML-DSA-65 verification key, 1952 bytes), `kemPub` (base64 raw ML-KEM-768 public key), `v=pq1`, `manufacturer`, `model`, `ver`. Server derives DNI via `deriveDniFromRawBase64(dsaPub)`. The server stores `socket.data.dni` before `socketio-auth` runs. The **`authentication`** event payload must use the same DNI and key; after success the server joins `dev:` and emits `register_ok`. --- ## Socket.IO Events ### Client → Server Events | Event | Args | Description | |---|---|---| | `client_proof` | `{ deviceCert, deviceCertChain, sig }` | Phase 1 device identity proof (when `PTT_REQUIRE_PHASE1=1`) | | `authentication` | `{ dni, pubKey, sig }` (or legacy `uuid` alias for `dni`) | **Wire event** required by `socketio-auth`; DNI is 16-digit decimal, must match connect query. (Server registers handler via middleware option `authenticate`, not this string.) | | `group_join` | `{ id: }` | Join Socket.IO room `grp:`; receive `group_info` / `group_join_error` | | `affiliate` | Preferred: `targetTGID, sourceTGLabel, sourceTGPublicKey` (or `targetTGID, sourceTGLabel`). Legacy 4-arg `(targetTGID, _deprecatedSource, sourceTGLabel, sourceTGPublicKey)` and older 3-arg shapes still accepted; **caller identity is always `socket.data.dni`**, not the deprecated second argument. | | `affiliatescan` | `scannedTGID` | Add TG to scan list | | `deaffiliatescan` | `scannedTGID` | Remove TG from scan list | | `floor_request` | v2 JSON: `srcDni`, `tgid`, `talkspurt`, `talkspurtUUID`, `talkspurtSig`, `srcPubKey`, `ts`, optional `key` (private: `enc\|sig`; group: `GRP:v1:`). Socket.IO ack: `{ ok, reason? }` (`TARGET_OFFLINE`, `TARGET_BUSY`, `RATE_LIMIT`, …). Rate limit: 10 per socket per 60 s. | SCS-PTT-FLOOR-CONTROL-001: reserve floor and (for private) deliver session key to callee | | `voice_data` | v1: positional args, or v2: single JSON object (`tgid`: **number** group id or **string** peer DNI; `srcDni`; AAD `tg:` suffix matches that route). v2 is **media only**; v2 frames must match the sender’s active `floor_request` context (no `talkspurtUUID` / `talkspurtSig` / `srcPubKey` on the envelope). | Voice frame (encoded + encrypted) | | `text_data` | `targetTGID, text_data` | Text message | | `squawk` | `data` | Keepalive / talkgroup re-registration. Server acks `{ ok: true, ts }`. | | `send_tg_data` | `tgid` | Request talkgroup info for display | | `resume_session` | `{ token }` | Present a short-lived session resumption token (issued as `session_token` after auth). Server re-authenticates without full Layer 2 if valid; emits `authenticated`. | | `key_share_offer` | `{ recipientDni, kemCiphertext, aesIv, aesCiphertext, sig, senderDni, tgid }` | Group key offer. If recipient is online, forwarded immediately as `key_share_delivery`. If offline, stored in Redis KEYOFFER inbox (TTL = `PTT_KEY_INBOX_TTL_DAYS`). | | `key_inbox_fetch` | _(no args)_ | Pull all pending key offers and DMs from Redis inbox. Server responds with `key_inbox_batch`. | | `key_offer_ack` | `{ offerId }` | Acknowledge receipt of a key offer; deletes it from Redis inbox. | | `dm` | Device↔Device: `{ to, from, msgid, ts, msgtype, payload, iv, key, srcPubKey, v }` — E2E encrypted. If recipient online: forwarded immediately. If offline: queued in Redis `DM_INBOX:` LIST (TTL = `PTT_DM_QUEUE_TTL_SEC`, max depth = `PTT_DM_QUEUE_MAX`). Device→Email: `{ to: , from, msgid, ts, msgtype: 'email_outbound', message, inReplyTo? }` — plaintext, no crypto fields; `to` contains `@`; server relays via SES (rate-limited to `PTT_EMAIL_RATE_LIMIT_PER_MIN`). Requires `PTT_EMAIL_DOMAIN` set. Ack: `{ ok, code, relay }` where `relay` is `'live'`, `'queued'`, or `'email'`. | | `dm_ack` | `{ to, from, msgid }` | DM delivery receipt; relayed to original sender as `dm_ack`. | ### Server → Client Events | Event | Payload | Description | |---|---|---| | `server_challenge` | `{ cert, certChain?, sig, keyId }` | Layer 2 server identity proof | | `register_ok` | `{ dni }` | Emitted after successful `authentication` / socketio-auth; client is in `dev:` | | `group_info` | `{ id, label?, type?, dns_fallback?, ... }` | Group metadata (tiers, DNS TXT when resolved) | | `group_join_error` | `{ code }` | e.g. `INVALID_ID`, `RESERVED_RANGE` | | `jwt_credential` | `{ token, expiresAt, source }` | Optional JWT (legacy); not required for DNI routing | | `update_talkgroup` | `{ talkgroup_id, talkgroup_label, targetTGID?, ... }` | `talkgroup_id` may be the device's DNI string | | `update_target` | `{ targetTGID, targetTGType, targetTGLabel, targetTGPublicKey, targetTxEnabled, targetTGLocked }` | Active transmit target; `targetTGType` is `G`, `B`, or `P` (private / DNI) | | `update_directory` | `{ id, label }` | Talkgroup directory entry | | `floor_key_delivery` | `{ senderDni, talkspurtUUID, key, ts, talkspurt }` (private session key to callee); client must ack `{ ok: true/false }` | Sent to private call target during `floor_request`; callee installs AES session before media | | `voice_data` | v1 positional args or v2 JSON | Forwarded voice frame | | `text_data` | `{ from, to, message }` | Forwarded text message | | `room_event` | `tgid, "join"\|"leave", socketId, roomSize` | Room membership change | | `ptt_call_setup` | `targetTGID, callerDni, sourceTGLabel, sourcePubKey, callerPubKeyHash, callerJWT, callback` | Private call setup request; **callerDni** is the authenticated caller’s 16-digit DNI from the server (replaces deprecated client `sourceTGID`) | | `ptt_interrupt` | `{}` | Server-initiated PTT interrupt (max frames exceeded) | | `target_unavailable` | `tgid, reason` | Target cannot be reached (`tgid` may be group id or DNI string) | | `unauthorized` | `{ message }` | Authentication failed | | `authenticated` | _(no payload)_ | Emitted after successful `resume_session` token validation | | `session_token` | `{ token, expiresAt }` | Short-lived session resumption token issued after successful auth (60 s TTL). Present via `resume_session` to re-auth without full Layer 2. | | `udp_session_token` | `{ ust, udpRouteId, skeKey?, skeIv? }` | UDP Session Token issued after auth. Used to authenticate UDP voice frames. `skeKey`/`skeIv` carry the UDP SKE encryption key unless `PTT_UDP_SKE_DISABLED=1`. | | `ready` | `{ ts }` | Server readiness signal emitted after auth sequence completes | | `key_share_delivery` | `{ offerId, senderDni, kemCiphertext, aesIv, aesCiphertext, sig, tgid }` | Live group key offer forwarded to an online recipient | | `key_inbox_batch` | `{ offers: [...], dms: [...] }` | Batch of pending key offers and DMs delivered in response to `key_inbox_fetch` or on auth if inbox is non-empty | | `dm` | Device↔Device: `{ to, from, msgid, ts, msgtype, payload, iv, key, srcPubKey }` — E2E encrypted body. Email-inbound: `{ to, from, msgid, ts, msgtype: 'email_inbound', message, emailFrom, emailMsgId }` — plaintext body, no crypto fields; emitted when an email arrives for the device via the gateway. | Secure device message or email-inbound relay | | `dm_ack` | `{ to, from, msgid }` | DM delivery receipt relayed to original sender | --- ## Redis Redis is a **required runtime dependency** for the current feature set. Configure `REDIS_HOST` / `REDIS_PORT` for all deployments. `index.js` uses Redis for four distinct purposes: | Purpose | Key pattern | TTL | |---|---|---| | **KEYOFFER inbox** — store-and-forward group key offers for offline recipients | `KEYOFFER::` | `PTT_KEY_INBOX_TTL_DAYS` (default 30 days) | | **DM inbox** — store-and-forward device and email-inbound messages for offline recipients | `DM_INBOX:` (Redis LIST; RPUSH write, LPOP drain) | `PTT_DM_QUEUE_TTL_SEC` (default 7 days; refreshed on each push) | | **Group membership tracking** — per-node SADD for multi-node routing decisions | `group::members:` | Cleared on disconnect / node shutdown | | **Socket.IO Redis adapter** — multi-node socket room routing | internal `@socket.io/redis-adapter` keys | Managed by adapter | The Socket.IO adapter can be disabled with `PTT_USE_REDIS_ADAPTER=0` for single-process deployments, but the KEYOFFER inbox, DM inbox, and group membership tracking continue to use Redis regardless. ### Broadcast transmit allow-list JSON object keyed by string group id, values are arrays of DNIs allowed to transmit on **broadcast** groups. Default path: `broadcast-auth.json` next to `index.js`, or set `PTT_BROADCAST_AUTH_PATH`. Unlisted DNIs are denied broadcast TX (silently where applicable). Reload with `SIGHUP`. ### Group DNS (optional) When `PTT_DNS_DOMAIN` is set (e.g. `ptt.example.com`), eligible tiers resolve `tg-.` TXT records for `label` / `type` metadata, with a short TTL cache. See `service/dns-group-resolver.js`. --- ## HTTP Endpoints | Method + Path | Description | |---|---| | `POST /internal/email-inbound` | Email gateway inbound: receives email DM from Lambda; `Authorization: Bearer ` required; body `{ to, from, message, emailMsgId, subject }`; delivers to online device or queues to `DM_INBOX`. Returns 401 on auth failure, 503 if `PTT_EMAIL_DOMAIN` unset. | | `GET /system-root-ca.crt` | Returns System CA certificate PEM (for QR onboarding) | | `GET /qr` | Returns HTML page with QR code encoding `{ v:1, url, label }` | | `GET /active-rooms` | Lists active talkgroup rooms with device counts | | `GET /url` | Simple `{ ok: true }` health-style response (legacy probe; no Redis) | | `GET /update-check` | Checks git for newer app version (if `GITREPO_API_URL` set) | | `GET /my` | Authenticated device info (cookie-based) | | `GET /api/*` | Backend API proxy routes | | `GET /` | Static files from `public/` | | `GET /admin` | Admin UI (socket.io admin panel) | --- ## Authentication Internals ### DNI Derivation Check (on every connect) The server verifies that the presented `dni` query parameter matches the DNI derived from the presented `publicKey`. DNI derivation is **not** a simple hex truncation of SHA-256 — it uses the `device-numeric-identity` module: ```javascript // From service/device-numeric-identity.js: function deriveDniFromBase64Spki(publicKeyBase64) { const der = Buffer.from(publicKeyBase64.replace(/\s/g, ''), 'base64'); const hash = crypto.createHash('sha256').update(der).digest(); const bigInt = BigInt('0x' + hash.subarray(0, 8).toString('hex')); const raw15 = (bigInt % BigInt('1000000000000000')).toString().padStart(15, '0'); return raw15 + String(calculateLuhn15(raw15)); // 16-digit decimal with Luhn check digit } // Must equal socket.handshake.query.dni or connection is rejected ``` This produces a **16-digit decimal** DNI, not a 16-hex-character UUID. The DNI is also validated via `isValidDni()` (Luhn check on the 16 digits). ### Local Signature Verification (authenticate event) ```javascript const challenge = crypto.createHash('sha256') .update(dni + socketNonce, 'utf8') .digest(); const ok = crypto.createVerify('SHA256') .update(challenge) .verify(pubKeyObj, Buffer.from(sig, 'base64')); ``` Note: The challenge is `SHA-256(dni + socketId)` — a concatenation, not `SHA-256(socketId)` alone. The `server_challenge` nonce is `SHA-256(socketId)` only. The `uuid` field in the `authentication` payload is accepted as a legacy alias for `dni`. ### JWT Fallback (legacy) JWT-based offline auth may still exist in older clients; DNI deployments rely on live `authenticate` and do not require Redis-backed JWT caches on the server.