# Bidirectional Nonce Exchange (Layer 2 Authentication) ## Mutual Trust Validation Between Client and Server **Date**: 2026-03-26 **Version**: 1.1.0 **Status**: Supplement to PKI-PTT-ARCH-001 (Updated) --- ## Executive Summary The Layer 2 (Application Layer) nonce exchange is **bidirectional**: 1. **Client → Server**: Client proves it holds the device private key (supply chain authenticity) 2. **Server → Client**: Server proves it holds the System CA-issued private key (System membership) **Both proofs must succeed** for the connection to be authenticated. This is not a one-way server-to-client validation, but a **mutual authentication** where each party validates the other. --- ## Original Design: Client Key Proof The original nonce exchange logic, as implemented in your system, was designed for the **client to prove possession of the device private key**: ``` Server sends: CHALLENGE (nonce) Client receives: Challenge Client signs: nonce with device private key Client sends: CHALLENGE_RESPONSE (signature) Server validates: Signature against client's device public key Result: Server confirms client holds device private key ``` **Purpose**: Prevent repackaged or rogue client apps from connecting to the server. The server validates that the connecting device is authentic (holds the hardware-bound device key in Android Keystore). --- ## Extended Design: Bidirectional Validation The updated design extends the original client authentication with **reciprocal server authentication**: ``` Layer 2 Bidirectional Exchange: CLIENT PROOF (Original Design - Preserved): ├─ Client sends: Challenge + Device Public Key + Signature │ └─ Signature proves: Client holds device private key ├─ Server validates: Signature against device public key └─ Result: Server verifies client authenticity SERVER PROOF (Extended Design - New): ├─ Server sends: Response signature (nonce signed with System CA key) ├─ Client extracts: Public key from System CA-issued Server Certificate ├─ Client validates: Signature against extracted public key └─ Result: Client verifies server is System member ``` --- ## Nonce: socket.id Both phases share a single nonce: **`socket.id`** (the Socket.IO socket identifier assigned by the server at connection time). Both parties already know this value from the Socket.IO handshake — no separate nonce message is needed. Signatures over the nonce are computed over `SHA-256(socket.id)`: ``` challengeHash = SHA-256(socket.id encoded as UTF-8) signature = RSA-PKCS1-v1.5(challengeHash, private_key) ``` --- ## How the Bidirectional Exchange Works ### Operational Modes Two modes are controlled by `PTT_REQUIRE_PHASE1`: - **`PTT_REQUIRE_PHASE1=0` (default)**: Server emits `server_challenge` immediately on connection. Client independently sends `client_proof` later (e.g. during or after the `authentication` emit to `socketio-auth`). The two phases are concurrent rather than strictly sequential. The server does **not** gate the `server_challenge` on receipt of `client_proof`. - **`PTT_REQUIRE_PHASE1=1` (strict)**: Server waits for the `client_proof` event, validates it, then emits `server_challenge`. If `client_proof` is not received within 20 seconds, the connection is dropped. ### Phase 1: Client Proves Device Key Possession (`client_proof` event) **Client → Server: "I hold the device private key"** Socket.IO event: **`client_proof`** Payload: ```json { "deviceCert": "", "deviceCertChain": "", "sig": "" } ``` ``` ┌─────────────┐ ┌────────────┐ │ pttclient │ │ ptt-server │ │ │ │ │ │ ├─ Sign SHA-256(socket.id) with device privkey │ │ │ │ │ │ │ └─ Emit client_proof: │ │ │ { │ │ │ deviceCert: , │ │ │ deviceCertChain: , │ │ │ sig: │ │ │ } │ │ │ │ │ │ │ │ SEND client_proof │ │ │ │ ─────────────────────────────────────────────►│ │ │ │ │ │ │ │ VALIDATE │ │ │ │ ├─ Verify device cert │ │ │ │ chains to Platform CA│ │ │ ├─ Check cert CN = │ │ │ │ "device-" │ │ │ ├─ Confirm cert pubkey │ │ │ │ matches socket pubKey│ │ │ ├─ Verify sig over │ │ │ │ SHA-256(socket.id) │ │ │ └─ Verify DNI derivation│ │ │ │ │ │ │ ✓ Proof OK │ │ │ │ Client │ │ │ │ authentic │ │ └─────────────┘ └────────────┘ ``` **Validation Steps (Server)**: 1. Parse the Device Certificate PEM and validate chain to Platform Root CA (via optional Platform Issuing CA chain) 2. Check that the cert Subject CN equals `device-` where `` is `socket.data.dni` 3. Confirm the cert's public key DER bytes match `socket.data.publicKeyB64` (the SPKI presented at connect) 4. Verify the signature over `SHA-256(socket.id)` using the cert's public key (RSA-PKCS1-v1.5) 5. Reconfirm DNI derivation from the public key matches `socket.data.dni` 6. **Result**: Server confirms the client holds the device private key and is Platform-attested --- ### Phase 2: Server Proves System CA-Issued Key Possession (`server_challenge` event) **Server → Client: "I hold the System CA-issued private key"** Socket.IO event: **`server_challenge`** Payload: ```json { "cert": "", "certChain": "", "sig": "", "keyId": "" } ``` ``` ┌─────────────┐ ┌────────────┐ │ pttclient │ │ ptt-server │ │ │ │ │ │ │ │ ├─ Sign SHA-256(socket.id)│ │ │ │ │ with Server ID privkey │ │ │ │ └─ Emit server_challenge: │ │ │ │ { cert, certChain, │ │ │ │ sig, keyId } │ │ │◄──────── SEND server_challenge ──┤ │ │ │ │ │ │ VALIDATE │ │ │ │ ├─ Parse cert (Server Identity) │ │ │ ├─ Validate cert chain against │ │ │ │ bundled/imported System CA(s) │ │ │ ├─ Extract server public key from cert │ │ │ ├─ Verify sig over SHA-256(socket.id) │ │ │ │ using extracted public key │ │ │ └─ Set mServerVerified = true │ │ │ │ │ │ ✓ Proof OK │ │ │ Server is System member │ │ └─────────────┘ └────────────┘ ``` **Validation Steps (Client — `ServerTrustManager.verifyWithCert()`)**: 1. Parse the Server Identity Certificate PEM from the payload 2. Validate the certificate chain against bundled and runtime-imported System CA certificates 3. Extract the server's public key from the verified certificate 4. Verify the `sig` field over `SHA-256(socket.id)` using the extracted public key (RSA-PKCS1-v1.5) 5. **Result**: Client confirms the server holds the System CA-issued private key and is a System member --- ## Why Both Proofs Are Required ### Threat Scenarios #### Scenario 1: Rogue Client (Repackaged App) ``` Attack: Attacker repackages pttclient code, removes supply chain checks Phase 1: Client Proof Attacker creates fake app with fake device key ├─ Attacker signs challenge with FAKE device privkey ├─ Server receives challenge with fake device pubkey ├─ Server attempts to validate signature against fake pubkey │ → Signature may validate (it's mathematically valid) │ → But can validate against Device Certificate? NO └─ Result: Authentication FAILS if server validates against Device Certificate Alternative: If server doesn't check against Device Certificate ├─ Fake device pubkey passes signature validation ├─ Proceeds to Phase 2 validation (server proof) ├─ Remains secure because Phase 2 still validates server └─ But client authenticity is compromised ``` **Mitigation**: Server validates client's public key against Device Certificate chain: - Device Certificate issued by Platform Issuing CA - Device pubkey in cert must match presented pubkey - Prevents rogue clients from authenticating #### Scenario 2: Rogue Server (Outside System) ``` Attack: Attacker sets up fake ptt-server outside the System Phase 2: Server Proof Rogue server receives nonce from client ├─ Rogue server attempts to sign nonce with FAKE server privkey ├─ Rogue server sends signature to client ├─ Client attempts to validate signature against System CA-issued cert │ → Client has System CA certificate (bundled) │ → Rogue server has NO System CA-issued certificate │ → Client extracts public key from rogue server's TLS cert (not System CA) │ → Signature validation FAILS └─ Result: Authentication FAILS because server is not System member ``` **Mitigation**: Client validates server signature against System CA-issued certificate: - Server must have System CA-issued Server Identity Certificate - Client validates certificate chain (cert → System CA → Platform Issuing CA) - Prevents rogue servers from authenticating #### Scenario 3: Compromised Reverse Proxy (Layer 1 Breach) ``` Attack: AWS TLS certificate stolen or forged (Layer 1 compromised) Phase 1: Client Proof Attacker uses stolen AWS cert to impersonate proxy ├─ Layer 1: TLS validation MAY PASS (attacker has valid AWS cert) ├─ But can attacker proceed to Layer 2? │ → Attacker doesn't have Device Certificate │ → Attacker doesn't have device private key │ → Cannot sign challenge with device key ├─ Client proof validation FAILS └─ Result: Connection REJECTED at Phase 1 Even if Phase 1 somehow passed: Phase 2: Server Proof Attacker doesn't have System CA-issued server key ├─ Attacker cannot sign nonce with System CA key ├─ Client validates signature against System CA-issued cert ├─ Signature validation FAILS └─ Result: Connection REJECTED at Phase 2 Summary: Layer 1 compromise does NOT compromise Phases 1 & 2 validation Both layers provide independent security ``` **Mitigation**: Bidirectional validation at Layer 2: - Phase 1 (client proof) protects against rogue clients - Phase 2 (server proof) protects against rogue servers - Both must pass for connection to be trusted --- ## Implementation Reference ### Server-Side Validation (Phase 1: `client_proof` event handler) ```javascript // Pseudocode aligned with ptt-server index.js validatePhase1() function validatePhase1(socket, clientProof): nonce = socket.id // shared nonce — no exchange needed deviceCertPem = clientProof.deviceCert chainPem = clientProof.deviceCertChain sigB64 = clientProof.sig // Step 1: Parse and chain-validate Device Certificate deviceCert = X509Certificate(deviceCertPem) IF chainPem present: issuingCert = X509Certificate(chainPem[0]) VERIFY issuingCert signed by platformRootCaPem VERIFY deviceCert signed by issuingCert ELSE: VERIFY deviceCert signed by platformRootCaPem // Step 2: CN must equal "device-" IF deviceCert.subject.CN != "device-" + socket.data.dni: RETURN FAIL("device_cert_cn_mismatch") // Step 3: Cert public key must match presented SPKI IF deviceCert.publicKey.DER != socket.data.publicKeyB64_decoded.DER: RETURN FAIL("device_cert_pubkey_mismatch") // Step 4: Verify signature over SHA-256(socket.id) challengeHash = SHA-256(socket.id) IF NOT RSA_PKCS1_verify(challengeHash, base64decode(sigB64), deviceCert.publicKey): RETURN FAIL("challenge_signature_invalid") // Step 5: Confirm DNI derivation IF deriveDniFromBase64Spki(socket.data.publicKeyB64) != socket.data.dni: RETURN FAIL("dni_derivation_mismatch") RETURN SUCCESS ``` **Key Points**: - Device Certificate is included in the `client_proof` payload (server does not fetch it) - Certificate chain validation uses the bundled Platform Root CA - CN check uses the DNI (`device-`), not the legacy hex UUID - No separate pubkey field — public key is extracted from the Device Certificate --- ### Client-Side Validation (Phase 2: `server_challenge` event handler) ```java // Pseudocode aligned with pttclient SocketService + ServerTrustManager.verifyWithCert() ON "server_challenge" event (payload): cert = payload.cert // Server Identity Certificate PEM certChain = payload.certChain // optional intermediate PEM sig = payload.sig // Base64 RSA-PKCS1-v1.5 over SHA-256(socket.id) nonce = mSocket.id() // shared nonce — no exchange needed // Step 1: Validate cert chain against bundled + imported System CAs serverCert = X509Certificate(cert) VERIFY serverCert chains to a trusted System CA (checked against assets/certs/ + runtime-imported System CAs in ServerTrustManager) // Step 2: Extract server public key serverPubKey = serverCert.getPublicKey() // Step 3: Verify signature challengeHash = SHA-256(nonce) IF NOT RSA_PKCS1_verify(challengeHash, base64decode(sig), serverPubKey): disconnect() RETURN FAIL // Step 4: Mark server as verified mServerVerified = true ``` **Key Points**: - The Server Identity Certificate is included in the `server_challenge` payload (client does not pre-fetch it) - Cert validation uses `ServerTrustManager` which checks bundled and runtime-imported System CAs - Nonce is `mSocket.id()` — no separate nonce exchange required - On failure, the client calls `mSocket.disconnect()` --- ## Certificate Delivery During Layer 2 For Phase 2 (server authentication) to work, the client needs the server's **System CA-issued Server Identity Certificate** (separate from the Layer 1 TLS certificate). **How this works in practice**: ``` During Layer 1 TLS handshake: ├─ Server presents Layer 1 TLS certificate (AWS/Let's Encrypt/System CA) │ └─ Client validates chain to trusted CA root │ └─ TLS/Socket.IO channel established │ On connection (server_challenge emitted immediately or after client_proof): ├─ server_challenge payload includes { cert, certChain, sig, keyId } ├─ Client parses cert from the event payload — no pre-fetch or out-of-band step ├─ Client validates cert chain using ServerTrustManager (bundled System CAs) └─ Client verifies sig over SHA-256(socket.id) using cert's public key ``` **Practical Implementation**: - The server sends its System CA-issued Server Identity Certificate **inline** within the `server_challenge` event payload (`cert` field) - The optional `certChain` field carries intermediate certificates if the chain cannot be completed from the client's trust store alone - No pre-distribution, caching, or out-of-band certificate fetch is required --- ## Summary: Bidirectional Mutual Authentication | Direction | Prover | Proof | Validator | Trust | |-----------|--------|-------|-----------|-------| | **Client → Server** | Client | Device private key signature | Server | Device Certificate chain (Platform CA issued) | | **Server → Client** | Server | System CA-issued private key signature | Client | System CA certificate (Platform Issuing CA issued) | **Both Must Pass**: - Client authenticated: Holds device private key (supply chain verified) - Server authenticated: System member with System CA-issued identity (System verified) - Connection established with mutual trust **Neither Alone Is Sufficient**: - Client auth alone: Server has no guarantee client is authentic (could be rogue app) - Server auth alone: Client has no guarantee server is System member (could be rogue server) - Both together: Mutual trust established in both directions --- ## Integration with Two-Layer Model ### Layer 1 (HTTPS/TLS) - **Purpose**: Encrypted transport - **Authenticates**: Nothing (transport only) - **Ensures**: Confidentiality (encryption) ### Layer 2 (Application/Nonce) - **Purpose**: Mutual authentication - **Authenticates**: - **Client**: Device key proof (supply chain integrity) - **Server**: System CA key proof (System membership) - **Ensures**: Authentication (both directions) + Integrity (signatures prove authenticity) ### Why Both Are Needed - **Layer 1**: Provides encryption (prevent eavesdropping) - **Layer 2**: Provides authentication (prove both parties are who they claim) - **Together**: Confidentiality + Authentication = Secure communication --- ## Conclusion The Layer 2 nonce exchange is **fundamentally bidirectional**: 1. **Client proves device key possession** (original design preserved) - Demonstrates client authenticity - Prevents repackaged or rogue client apps 2. **Server proves System CA key possession** (extended design) - Demonstrates server is System member - Prevents rogue or external servers **Both validations must succeed** for the connection to be authenticated. This provides **mutual trust** where each party verifies the other before establishing the connection. --- *This supplement clarifies that Layer 2 authentication is bidirectional, not unidirectional, and explains why both client and server proofs are essential for mutual trust.* --- ## PQ Phase 1: ML-DSA-65 Client Authentication (`v=pq1`) PQ Phase 1 replaces the client-side RSA key pair with ML-DSA-65 (signatures) and ML-KEM-768 (key encapsulation). The server's Layer 2 identity (Phase 2, `server_challenge`) remains Platform PKI cert-based and is **not changed** in Phase 1. ### Connection Handshake Query Parameters | Mode | Parameters | |------|------------| | Classical (RSA) | `dni=<16d>&publicKey=&manufacturer=…` | | PQ Phase 1 | `dni=<16d>&dsaPub=&kemPub=&v=pq1&manufacturer=…` | ### DNI Derivation (PQ) ``` raw ML-DSA-65 verification key bytes (1952 bytes) → SHA-256(rawBytes) → first 8 bytes → big-endian unsigned integer → mod 10^15 → pad to 15 digits → append Luhn check digit = 16-digit DNI ``` Server: `dniMod.deriveDniFromRawBase64(dsaPub)` (new function in `device-numeric-identity.js`). Client: `DniCalculator.deriveFromRawKeyBytes(byte[])` (new method in `DniCalculator.java`). ### Authentication Event (`authentication` socket event) | Mode | Payload | |------|---------| | Classical | `{ dni, pubKey: , sig: }` | | PQ Phase 1 | `{ dni, dsaPub: , kemPub: , sig: , v: 'pq1' }` | Challenge signed: `SHA-256(dni + socket.id)` (UTF-8) — same challenge input as classical. Signature algorithm: ML-DSA-65 (FIPS 204) instead of RSA-PKCS1v1.5-SHA256. ### Talkspurt Signature (`floor_request` → `srcPubKey` field) PQ clients send the raw ML-DSA-65 verification key (1952 bytes) as `srcPubKey`. The server auto-detects by decoded byte length: - `srcPubKeyBytes.length === 1952` → `mlDsa65Verify(pubKey, UTF-8(talkspurtUUID), sig)` - other length → RSA-SHA256 SPKI DER path (classical) ### Platform PKI `client_proof` (Phase 1 cert chain) PQ clients that have not yet received a PQ-capable Platform Device Certificate skip the RSA pubkey-binding steps in `validatePhase1()`. Full PQ device cert support (ML-DSA in Platform Device Certificate) is a future phase. ### Keys NOT Changed in PQ Phase 1 - Server Identity Certificate (Phase 2, `server_challenge`): remains Platform PKI X.509 / RSA or EC. - Platform Root CA, Platform Issuing CA, System CA hierarchy: unchanged. - AES-256-GCM voice encryption: unchanged (already PQC-safe at 256-bit key strength).