Peer-to-Peer Encrypted TCP Messaging for React Native
@harbouli/fprot uses WebRTC exclusively as a temporary handshake channel to discover peers and exchange ephemeral X25519 keys. Once authenticated over a direct raw TCP socket, WebRTC is closed and 100% of application traffic travels over end-to-end encrypted TCP.
Stage 1 — Out-of-Band Signed Signal Envelope (SignalProtocol.ts):
{
"body": "{\"v\":1,\"conversationId\":\"room-1\",\"from\":\"Ed25519_Pub_A\",\"to\":\"Ed25519_Pub_B\",\"type\":\"offer\",\"challenge\":\"Ch_16CharBase64\",\"attempt\":\"At_16CharBase64\",\"sdp\":\"...\"}",
"signature": "Ed25519_Detached_Signature_Over_fprot.signaling.v1:body"
}
Complete Root Package Exports
Every class, cryptographic primitive, storage journal, signaling codec, stream framer, constant, and TypeScript type is exported directly from @harbouli/fprot:
import {
// Core Classes & Transports
ReliableConversation, P2PTcpPeer, WebSocketSignaling,
MessageStore, LineDecoder, EventBus, TcpSocket,
// Ed25519 Identity, Signatures & Encrypted At-Rest Storage
loadOrCreateIdentity, validatePublicKey, validateIdentity,
sign, verify, conversationStorageKey, createEncryptedStorage, validatePayload,
// Ephemeral X25519 Session Keys & JSON Frame Encryption
createSessionKeys, exportPublicKey, importPublicKey,
encryptJson, decryptJson, randomId,
// Cross-Platform Native Crypto Primitives & Base64URL
toBase64Url, fromBase64Url, randomBytes, sha256,
boxKeypair, boxSeal, boxOpen,
signKeypair, signDetached, verifyDetached,
secretboxKeygen, secretboxSeal, secretboxOpen,
// Signaling Protocol Codecs & Constants
encodeSignal, decodeSignal, encodeReliableSignal, decodeReliableSignal,
encodePeerSignal, decodePeerSignal, encodeSdpSignal, decodeSdpSignal,
MAX_MESSAGE_BYTES, MAX_MESSAGES, SIGNAL_VERSION, NONCE_BYTES,
} from '@harbouli/fprot';
Core Classes & Transports
Immortal conversation supervisor that spawns disposable P2PTcpPeer instances across generations, persists outgoing messages in MessageStore before wire transmission, enforces Stop-and-Wait ARQ delivery receipts (inFlight), runs encrypted 10s heartbeats, and auto-reconnects with jittered exponential backoff.
| Member | Signature | Behavior |
|---|---|---|
state | ConversationState | 'stopped' | 'offline' | 'reconnecting' | 'connecting' | 'connected' |
messages | ChatMessage[] | Deep-cloned array of all stored incoming and outgoing messages. |
start() | Promise<void> | Validates Ed25519 keys, loads MessageStore from disk, starts signaling, and triggers peer discovery. |
sendMessage(payload) | Promise<string> | Validates JSON purity (≤ 2,730 chars), commits to encrypted disk as 'pending', updates UI listeners, and flushes over TCP. |
setAvailable(bool) | void | Connects OS foreground/background and network state. Passing true resets retry count to 0 and discovers immediately. |
reconnect() | void | Drops current TCP peer and re-runs discovery with fresh getHostOptions() (use on Wi-Fi IP changes). |
stop() | void | Stops signaling, destroys active peer, clears timers, and enters 'stopped'. |
on(event, cb) | Unsubscribe | Subscribes to 'state', 'messages', or 'error' events. |
Single-session peer-to-peer transport. Gathers non-trickle WebRTC ICE candidates, exchanges ephemeral 32-byte X25519 public keys and the Host's TCP endpoint over an ordered WebRTC data channel (p2p-tcp-control), verifies a mutual encrypted TCP ready frame (seq: 1), and immediately closes WebRTC and the TCP listener.
| Member | Signature | Behavior |
|---|---|---|
createOffer(host) | Promise<string> | Starts TCP listener on host.listenHost/port, creates X25519 keys, gathers ICE candidates, and returns encoded offer. |
acceptOffer(offer) | Promise<string> | Applies remote offer SDP, creates X25519 keys, gathers ICE candidates, and returns encoded answer. |
acceptAnswer(answer) | Promise<void> | Applies Guest answer SDP on the Host; triggers control channel handshake and TCP connection. |
sendMessage(payload) | string | Encrypts payload with next monotonic txSequence and writes newline-delimited frame to TCP socket. |
close() | void | Closes TCP socket, server, and WebRTC, and zeroes ephemeral private key in memory (privateKey.fill(0)). |
Auto-reconnecting WebSocket transport for server/signaling.mjs performing 2-factor authentication (shared 32+ char token + Ed25519 signature over fprot.broker.v1:${nonce}).
Single-writer write-ahead persistent journal. Commits snapshots to KeyValueStorage before mutating in-memory rows. Provides load(), snapshot(), pending(), idempotent add(message), and acknowledge(id).
Newline-delimited (\n) TCP stream framer that enforces maxBytes before splitting to prevent memory exhaustion attacks.
Generic type-safe event emitter with on(event, listener): Unsubscribe, emit(event, value): void, and clear(): void.
Cross-platform TCP Server & Client bridge routing through built-in Swift/Kotlin FprotNative sockets on iOS/Android and node:net in Node/Jest tests.
Identity, Signature & Storage Utilities
Loads existing Ed25519 keypair from secureStorage.getItem('fprot.identity.v1'), verifies it with a live signature self-check, or creates and saves a new 32B/64B Ed25519 identity keypair.
Verifies canonical 32-byte base64url Ed25519 public keys and 64-byte private keys with a live sign/verify round-trip check over 'fprot.identity.check'.
Detached Ed25519 signing and boolean signature verification over UTF-8 strings.
Computes fprot.chat.<base64url_sha256> over JSON.stringify([id, local, remote]) to isolate storage records per conversation pair.
createEncryptedStorage wraps any storage backend with AEAD encryption (storing the 256-bit master key in secureStorage under fprot.storage-key.v1) and binds the record key name inside the authenticated plaintext. validatePayload enforces strict JSON purity (≤ 2,730 chars, rejecting undefined, NaN, Infinity, functions, and symbols).
Session & Native Cryptographic Utilities
Generates ephemeral 32-byte X25519 keypairs and converts binary Uint8Array buffers to/from unpadded Base64URL.
Encrypts and authenticates JSON values into { v: 1, nonce, ciphertext } frames and generates 16-character (96-bit entropy) Base64URL IDs.
Low-level RFC 4648 §5 Base64URL codec, CSPRNG byte generator, and SHA-256 hash primitive.
Direct access to the cross-platform X25519 ECDH + AES-256-GCM / ChaCha20-Poly1305 and Ed25519 native cryptographic primitives.
Reliable Ed25519-signed signaling codec (fprot.signaling.v1:) and low-level WebRTC SDP offer/answer codec.
| Constant | Value | Description |
|---|---|---|
MAX_MESSAGE_BYTES | 16384 | Base size constant (MAX_MESSAGE_BYTES / 6 = 2730 max serialized chars per chat message). |
MAX_MESSAGES | 2000 | Maximum messages stored per conversation in MessageStore. |
SIGNAL_VERSION | 1 | Version number for SignalEnvelope payloads. |
NONCE_BYTES | 12 | Nonce byte length for boxSeal and secretboxSeal. |
What is Signaling & How Connection Setup Works
How two mobile devices solve the P2P Bootstrap Paradox using Ed25519-signed envelopes, anti-replay challenge/attempt nonces, and an ephemeral WebRTC control channel that self-destructs once TCP connects.
1. The P2P Bootstrap Paradox
Two mobile phones cannot open a direct TCP socket without prior coordination because:
- Dynamic IP Addresses & Ephemeral Ports: Mobile IPs change on every Wi-Fi/Cellular switch, and the Host binds a temporary free OS port (
port: 0) per attempt. - Asynchronous Availability: Device A and Device B may launch the app at different times and need a lightweight rendezvous signal (
wake/request). - Pre-TCP Session Key Exchange: Both devices must exchange ephemeral 32-byte X25519 public keys before opening the TCP socket so the very first TCP frame (
seq: 1) is already encrypted.
2. Two-Stage Handshake & Challenge-Attempt State Machine
3. Why Repeated wake Signals Never Invalidate In-Flight Offers
In ReliableConversation.ts (line 237), when the Guest receives a wake signal from the Host, it checks if (!this.challenge) this.challenge = randomId(). By reusing an outstanding challenge rather than overwriting it, a duplicate wake packet never invalidates an offer that the Host is currently gathering ICE candidates for.
4. Reference Broker Hardening (server/signaling.mjs)
| Protection | Mechanism | Limit |
|---|---|---|
| 2-Factor Auth | crypto.timingSafeEqual shared token + Ed25519 signature over fprot.broker.v1:${nonce} | 10s auth timeout |
| Rate Limiting | Per-socket fixed-window counter disconnects flooding clients with code 1008 | 120 msgs / 60s |
| Frame Size Cap | maxPayload and perMessageDeflate: false prevent memory/zlib bombs | 132 KB max |
| Sender Pinning | Enforces JSON.parse(envelope.body).from === socket.publicKey | Strict match |
Reliability, Persistence & Fault Tolerance
How ReliableConversation and MessageStore guarantee zero message loss, strict FIFO ordering, and idempotent deduplication across network drops and app restarts.
1. Immortal Supervisor vs. Disposable Peers
Raw OS TCP sockets break whenever a mobile user switches between Wi-Fi and Cellular or backgrounds the app. fprot solves this by making P2PTcpPeer strictly single-use and disposable, while ReliableConversation acts as a persistent supervisor that spawns a fresh P2PTcpPeer with a new ++this.generation counter on every reconnect.
Every async callback checks this.current(peer, generation) so late events from a dying socket can never corrupt the new connection.
2. Write-Before-Memory Commit Pipeline (MessageStore)
3. Stop-and-Wait ARQ & Lost-ACK Idempotent Deduplication
- Single In-Flight Lock (
this.inFlight): Only the oldestpendingoutgoing message is transmitted at a time, guarded by a 10-secondackTimer. - Lost-ACK Recovery: If Device B receives message
m1, saves it to disk, and sends{ kind: "ack", id: m1 }, but the Wi-Fi drops before Device A receives the ACK, Device A will re-transmitm1on the next generation's TCP connection. - Idempotent Receiver: Device B's
MessageStore.add()detects thatincoming:m1already exists with identicalsentAtandpayload, returnsfalse(preventing duplicate UI entries), and re-transmits theackso Device A marksm1asdelivered!
4. Glare Immunity & Exponential Backoff with Jitter
While state === 'connected', ReliableConversation ignores all incoming signaling packets (if (this.state === 'connected') return;), relying solely on its encrypted TCP heartbeat (10s ping/pong, 35s idleTimeoutMs) to detect if the partner restarted.
Cryptographic Primitives & Wire Framing
Multi-layered defense combining pinned Ed25519 identities, ephemeral X25519 Diffie-Hellman session keys, 1-based monotonic sequence counters, and record-bound local storage encryption.
1. Three-Layer TCP Packet Encapsulation
// Layer 1: Raw newline-delimited TCP line (EncryptedFrame)
{"v":1,"nonce":"","ciphertext":""}\n
// Layer 2: Decrypted Session Payload (WirePayload in P2PTcpPeer.ts)
{
"v": 2,
"sender": "host", // Verified against expected opposite role to prevent reflection attacks
"seq": 2, // Must equal this.rxSequence + 1 (strict anti-replay & anti-drop)
"kind": "message",
"message": {
"id": "xY9zA2bC5dE8fG1h",
"sentAt": 1780000000000,
// Layer 3: ReliableConversation Packet (fprot.chat.v1)
"payload": {
"protocol": "fprot.chat.v1",
"conversationId": "main-chat-room",
"kind": "message", // "message" | "ack" | "ping" | "pong"
"id": "18c21a4f9b0d2e3c",
"payload": "Hello over direct encrypted TCP!",
"sentAt": 1780000000000
}
}
}
2. Security Threat Defense Matrix
| Attack Vector | Defense in @harbouli/fprot |
|---|---|
| Malicious Signaling Server (MITM) | All SDP offers/answers are signed with Ed25519 (fprot.signaling.v1:) and verified against the pinned remotePublicKey. |
| Cross-Protocol Signature Replay | Strict domain separation prefixes: fprot.broker.v1: vs fprot.signaling.v1: vs fprot.identity.check. |
| TCP Ciphertext Reflection | WirePayload.sender is authenticated inside the ciphertext and checked against this.role === 'host' ? 'guest' : 'host'. |
| Unauthorized Port Scanning | Host TCP server immediately destroys any socket connecting before WebRTC delivers remotePublicKey, and closes the listening port once seq: 1 is verified. |
| Storage Record Swapping | createEncryptedStorage encrypts { name, value } together and verifies plain.name === name on read. |
Custom Signaling Backend Integration
Integrate ReliableConversation with your existing Socket.io, Node.js, Go, FastAPI, or Supabase Realtime infrastructure by implementing the 3-method SignalTransport interface.
1. The SignalTransport Contract
import type { SignalTransport } from '@harbouli/fprot';
export class CustomSocketSignaling implements SignalTransport {
start(handlers: { onMessage: (msg: string) => void; onOnline: (online: boolean) => void }): void {
// Subscribe to incoming signals for myPublicKey and report connection status
}
send(message: string): boolean {
// Forward the signed JSON envelope string to the recipient specified in JSON.parse(JSON.parse(message).body).to
return true;
}
stop(): void {
// Close sockets and clean up listeners
}
}