# Connection Lifecycle This document traces every step of a pttclient connecting to a ptt-server, from TCP handshake through the first voice transmission. --- > **Note on Device Identity:** The primary connection identifier is the **Device Numeric Identity (DNI)** — a 16-digit decimal number derived from the device's ML-DSA-65 public key, converted to a 15-digit decimal + Luhn check digit. `DeviceUUID` (the older hex identifier) is retained in Device Certificate subject CNs (`device-`) for continuity but is not sent on the PQ connection URL. --- ## PAS Attestation — User-Initiated (Not Part of Connection Lifecycle) PAS Attestation is implemented in `PasAttestation.java` and is available for future use, but it is **not triggered automatically** as part of the connection lifecycle. The automatic first-run call (`PasAttestation.runOnce()`) has been removed from `MainActivity`. Attestation is intended to become a user-initiated action within the app (e.g. triggered from a settings or provisioning screen). When eventually invoked, the flow is: ``` pttclient PAS │ │ │ 1. Read Bootstrap JWT from assets │ │ (assets/keys/bootstrap_cert.jwt) │ │ │ │ 2. Generate PKCS#10 CSR │ │ subject CN=device- │ │ signed with device private key │ │ │ │ POST /v1/device-certificates │ │ Authorization: Bearer │ │ Body: { csr, deviceUuid, buildVersion, │ │ buildFingerprint } │ │ ──────────────────────────────────────────►│ │ │ Verify Bootstrap JWT sig │ │ (Platform Issuing CA key) │ │ Verify CSR self-signature │ │ Sign with Platform Issuing CA │ │ │ 201 { certificate, certificateChain } │ │ ◄──────────────────────────────────────────│ │ │ │ Store Device Certificate │ │ in SharedPreferences via │ │ DeviceCertificateManager │ ``` The resulting Device Certificate is used in Phase 3a (`client_proof`) to prove supply chain authenticity. Phase 3a is only enforced when `PTT_REQUIRE_PHASE1=1` on the server (disabled by default). `PAS_BASE_URL` must also be set to a reachable PAS server in `certs.properties` for attestation to succeed. --- ## Phase 1: Layer 1 — TLS Establishment The client connects to the active server endpoint (direct ptt-server or reverse proxy). ``` pttclient Reverse Proxy / ptt-server │ │ │ TCP SYN │ │ ─────────────────────────────► │ │ │ │ TLS ClientHello │ │ ─────────────────────────────► │ │ │ Present TLS certificate │ │ (public CA cert for proxy, │ │ System CA cert for direct) │ TLS ServerHello + Certificate │ │ ◄─────────────────────────────── │ │ │ │ Client validates: │ │ ├─ cert signed by trusted CA │ │ │ (system store or bundled) │ │ ├─ cert not expired │ │ └─ hostname verifier is │ │ permissive (always passes); │ │ server identity proven at │ │ Layer 2 instead │ │ │ │ TLS Finished (encrypted channel)│ │ ◄────────────────────────────── ►│ ``` **Note:** Layer 1 validates only that the TLS certificate is well-formed. It does NOT establish System membership. A reverse proxy's public CA cert is fully acceptable here. --- ## Phase 2: Socket.IO Connect + DNI Validation **PQ mode (`v=pq1`) — current:** ``` pttclient ptt-server │ │ │ Socket.IO connect │ │ GET /socket.io/? │ │ dni=<16-digit decimal DNI> │ │ dsaPub= │ │ kemPub= │ │ v=pq1 │ │ manufacturer= │ │ model= │ │ ver= │ │ x-api-key: │ │ ──────────────────────────────────────►│ │ │ │ Validate DNI derivation: │ dniMod.deriveDniFromRawBase64(dsaPub) │ must equal the presented dni │ (disconnect if mismatch) │ │ │ If PTT_REQUIRE_BOOTSTRAP_JWT=1: │ Verify x-api-key JWT signature │ against Platform Issuing CA │ (disconnect if invalid) ``` The server stores `socket.data.dni` before any further processing. No backend call is made at this stage. --- ## Phase 3: Layer 2 — Server Proof (server_challenge) The server proves it holds a System CA-issued identity key. If `PTT_REQUIRE_PHASE1=0` (default, backward-compatible mode), this is emitted immediately on connect. If `PTT_REQUIRE_PHASE1=1`, the server waits for `client_proof` first (see Phase 3a). ``` ptt-server pttclient │ │ │ Sign nonce: SHA-256(socket.id) │ │ with server identity private key │ │ (System CA-issued) │ │ │ │ emit server_challenge { │ │ cert: , │ │ certChain: , │ │ sig: , │ │ keyId: <16-char hex> │ │ } │ │ ──────────────────────────────────────►│ │ │ │ Client validates: │ 1. Parse server identity cert │ 2. Verify cert chains to trusted System CA │ (bundled or imported in assets/certs/) │ 3. Extract server public key from cert │ 4. Verify sig over SHA-256(socket.id) │ using extracted public key │ 5. Store verified server info │ (disconnect if any step fails) ``` --- ## Phase 3a: Layer 2 — Client Proof (client_proof) — When PTT_REQUIRE_PHASE1=1 The client proves it holds the hardware-bound device private key and a Platform CA-issued Device Certificate. ``` pttclient ptt-server │ │ │ emit client_proof { │ │ deviceCert: , │ │ deviceCertChain: , │ │ sig: │ │ } │ │ ──────────────────────────────────────►│ │ │ │ Server validates: │ 1. Parse Device Certificate │ 2. Verify cert chain to Platform Root CA │ (PTT_PLATFORM_ROOT_CA_PEM) │ 3. Verify cert CN = "device-" + deviceUUID │ 4. Verify cert public key matches dsaPub │ from the connect query │ 5. Verify sig over SHA-256(socket.id) │ using cert public key │ 6. Verify DNI == deriveDniFromRawBase64(dsaPub) │ (disconnect if any step fails) │ │ │ Phase 3 (server_challenge) is now emitted ``` --- ## Phase 4: socketio-auth Authentication ``` pttclient ptt-server │ │ │ emit authentication { │ │ dni: <16-digit DNI>, │ │ pubKey: , │ │ sig: │ │ } │ │ (legacy clients may use "uuid" key │ │ as alias for "dni") │ │ ──────────────────────────────────────►│ │ │ │ 1. Re-derive DNI from pubKey, │ confirm matches claimed dni │ 2. Verify sig over SHA-256(dni + socketId) │ (local, no backend call) │ │ │ emit register_ok { dni } │ │ emit update_talkgroup { │ │ talkgroup_id: , │ │ talkgroup_label: } │ │ ◄──────────────────────────────────── │ │ │ │ Server joins socket to dev: room │ │ (enables device-addressed routing) │ ``` After authentication the device is in room `dev:`. No `jwt_credential` is issued in current DNI-mode deployments. The `update_talkgroup` payload uses the device's DNI as both id and label. --- ## Phase 5: Affiliation After authentication the client selects its active talkgroup. ``` pttclient ptt-server │ │ │ emit affiliate(targetTGID, │ │ sourceTGLabel, │ │ sourceTGPublicKey) │ │ [preferred 3-arg shape; legacy │ │ 4-arg with deprecated sourceTGID │ │ second arg also accepted] │ │ ──────────────────────────────────────►│ │ │ │ Group type resolved locally │ via groupTier module (no │ backend call for affiliate) │ │ │ socket.join("grp:" + targetTGID) │ socket.join("dev:" + socket.data.dni) │ │ │ emit update_target { │ │ targetTGID, targetTGType, │ │ targetTGLabel, targetTGPublicKey, │ │ targetTxEnabled, targetTGLocked │ │ } │ │ ◄──────────────────────────────────── │ ``` `targetTGPublicKey` is always `""` for group/broadcast targets (group key sharing uses the `key_share_offer` / `key_share_delivery` mechanism). For individual calls established via `ptt_call_setup`, the peer's public key is returned in the handshake callback. --- ## Phase 6: Squawk (Keepalive) Every minute (triggered by `ACTION_TIME_TICK`), the client emits `squawk`. The server calls `registerTalkgroup()`, which re-emits `update_talkgroup` with the device's current DNI — no backend lookup is performed. This keeps the client's local state consistent after reconnects. --- ## Disconnection On disconnect, the server fires the `socketio-auth` disconnect callback. The `dev:` and `grp:` Socket.IO room memberships are automatically cleaned up by the Socket.IO adapter. The disconnect handler also calls `syncClearGroupRedisMemberships(socket)` to remove group membership entries from Redis (`group::members` SADDs). The KEYOFFER and DM inboxes in Redis are not cleared on disconnect — they persist to TTL so offline recipients can fetch pending messages on reconnect. Other devices in the same room are notified by the Socket.IO adapter's `leave-room` event, which emits `room_event` for numeric group IDs and `grp:` prefixed room names.