Repository navigation
Implement connection migration and session resumption with zero-downtime client handoff, encrypted session state transfer, and consistent client identity across gateway instances #228
Description
Difficulty
10/10 — Expert. Estimated effort: 6–8 days for a senior engineer.
Context
Issue 11 (distributed room state) enables multiple gateway instances to share room membership and broadcast across instances. However, it does not solve connection migration: when a client's WebSocket connection is terminated (load balancer rebalance, instance scale-down, network glitch, TLS cert rotation) and the client reconnects to a different gateway instance, the new instance must instantly restore the client's full session state — room subscriptions, sequence numbers, ACK state (issue 16), geofence inside-set (issue 12), rate limit counters, and protocol version — without the client re-sending join_room for each room or losing its place in the message stream.
The README.md targets "mobile or web-based tracking platforms" (line 23) where clients frequently switch networks (WiFi → cellular, tower handoff) and reconnect. Kubernetes HPA scales gateway pods up/down, causing connection draining and rebalancing. Without session resumption, each reconnection causes: (1) client re-joins all rooms (N round-trips), (2) replay from last known seq (may have gaps if client didn't persist), (3) rate limit counters reset (bursts allowed), (4) geofence state lost (re-evaluation from scratch).
Problem statement
Design and implement a session resumption protocol that enables zero-downtime client handoff between gateway instances:
-
Encrypted session state blob: On each significant state change (room join/leave, sequence advance, ACK update, geofence state change, rate limit window shift), the gateway serializes the client's session state and stores it in Redis with TTL (default 1 hour). State includes:
clientId,protocolVersion,authIdentity(JWT claims or mTLS deviceId)rooms:[{ roomId, highestAckedSeq, highestReceivedSeq, geofenceInsideSet: [...] }]rateLimitState:{ messageWindow: [...], connectionWindow: [...] }metadata:{ ip, userAgent, connectedAt, lastActivityAt }- Encrypted with AEAD (AES-256-GCM) using a per-deployment key (
SESSION_ENCRYPTION_KEYenv var, 32 bytes base64). Key rotation supported via key ID prefix in blob.
-
Session resumption handshake: On WebSocket connection, client sends
?token=...&session_id=<blob>(orsession_idin JWTsidclaim). Server:- Decrypts blob, verifies integrity, checks TTL.
- Validates
clientIdmatches auth identity (JWTsubor mTLS deviceId). - Restores
RoomManagermemberships locally (callsjoin()for each room with restoredhighestAckedSeq). - Restores rate limiter windows.
- Responds with
{ type: "session_resumed", rooms: [...], currentSeqPerRoom: [...] }. - Client compares
currentSeqPerRoomwith its localhighestAckedSeq— if gap, sendsreconnectfor each room.
-
Proactive session push (optional optimization): Before draining a connection (issue 5 graceful shutdown, or instance scale-down), the gateway pushes the latest session state to Redis. Client receives
server_shutting_downwithsession_id— can immediately reconnect to another instance with full state. -
Session affinity via load balancer cookie: For clients that don't support
session_id(legacy), use a sticky session cookie (GW_AFFINITY=<instanceId>) so reconnections land on the same instance. Instance reads local session cache (in-memory Map) instead of Redis. -
Cross-instance session migration API (admin):
POST /admin/v1/clients/{clientId}/migrate— forces session state sync to Redis, then disconnects client withsession_idin close frame reason. Client reconnects (load balancer routes to new instance) and resumes. -
Security: Session blob MUST be encrypted. If decryption fails (key rotation, corruption, tampering), fall back to full re-authentication (treat as new connection). No sensitive data (JWT, coordinates) in plaintext in Redis.
-
Metrics:
session_resumption_total{result="success|decrypt_failed|expired|mismatch|new_session"},session_state_size_bytes.
Current behavior
- Issue 11: distributed room state syncs membership via Redis, but no per-client session blob.
server.js: on connect, generates newclientId(uuid), no session restoration.room-manager.js:join()creates new membership, nohighestAckedSeqrestoration.- Rate limiters: fresh windows on each connection.
- No
session_idconcept in protocol.
Required behavior
- New module
src/session-manager.jsexportingSessionManagerclass. SessionManagerconstructor:{ redis, encryptionKey, ttlMs: 3600000, keyId: "v1" }.sessionManager.save(clientId, state)— encrypts, stores in Redissession:{clientId}with TTL.sessionManager.load(sessionId)— decrypts, returns state or null.sessionManager.delete(clientId)— removes from Redis (on explicit logout).- Encryption:
crypto.subtle(Web Crypto API) ornode:cryptocreateCipheriv/createDecipherivwith AES-256-GCM. Key derived fromSESSION_ENCRYPTION_KEYvia HKDF if needed. server.jsconnection handler:- Extract
session_idfrom query or JWTsid. - If present:
state = await sessionManager.load(session_id). If valid: restore. - Else: check sticky cookie
GW_AFFINITY. If matches local instance: load from local cache. - Else: new session.
- Extract
- On state change (join, leave, broadcast ACK, geofence change, rate limit update): debounce
sessionManager.save()(max once per 500ms per client). - On graceful shutdown (issue 5):
sessionManager.save()for all clients before closing connections. validator.js(issue 13): addsession_resumedserver-to-client message type.- Admin API (issue 14):
POST /admin/v1/clients/{clientId}/migratetriggers save + disconnect.
Constraints
- Do not modify
auth.js,validator.js,rate-limiter.js,conn-rate-limiter.js,logger.js,errors.js,room-manager.js,geofence-engine.js,protocol-registry.js,distributed-room-manager.js,tls-manager.js,admin-server.js. - Do not modify existing test files. New test files required.
- Use only Node.js built-in
cryptofor encryption. No new npm dependencies. - Redis key TTL must be refreshed on each save (sliding window).
- Session blob size must be < 16KB (Redis limit for reasonable performance). Compress with
zlib.deflateSyncbefore encryption if needed. - Debounced save: use a
Map<clientId, NodeJS.Timeout>to coalesce rapid state changes. - The
SessionManagermust work in single-instance mode (no Redis) — falls back to in-memory Map with TTL cleanup. - Client identity binding:
state.authIdentitymust match current connection's auth (JWTsubor mTLS deviceId). If mismatch → reject session, force new auth. - Key rotation:
SESSION_ENCRYPTION_KEYcan be a JSON object{ "v1": "base64key1", "v2": "base64key2" }. Blob format:v1.<base64iv>.<base64ciphertext>.<base64tag>. On load, try each key until one works.
Acceptance criteria
-
SessionManagerencrypts/decrypts session blobs with AES-256-GCM - Client connects with valid
session_id→session_resumedreceived with correct room list and sequence numbers - Client connects with expired
session_id(TTL exceeded) → treated as new session - Client connects with corrupted
session_id→ treated as new session - Client connects with
session_idfor differentclientId(identity mismatch) → rejected, new session - Room join/leave triggers debounced session save (verify Redis updated within 1s)
- Graceful shutdown saves all sessions before closing connections
- Sticky cookie fallback: client with
GW_AFFINITY=instance-1reconnects to instance-1 → local cache restore (no Redis round-trip) - Admin migrate API:
POST /admin/v1/clients/{clientId}/migrate→ session saved, client disconnected withsession_idin close reason - Key rotation: add
v2key, new sessions usev2, oldv1sessions still load - Metrics:
session_resumption_totalcounters increment correctly - Session blob size < 16KB for 50 rooms with full state
-
npm run lintpasses - All existing tests pass
- New test file:
tests/session-manager.test.jswith unit tests for encryption, save/load, debouncing - New test file:
tests/session-resumption-integration.test.jswith full handshake scenarios (requires Redis)
Out of scope
- Client-side session persistence (localStorage, IndexedDB) — server manages state.
- Session sharing across different deployments (multi-region) — single Redis cluster assumed.
- Session revocation list (for logout everywhere) —
delete(clientId)is sufficient. - WebSocket session resumption (RFC 8441) — application-layer only.
Hints and references
- AES-256-GCM with Node.js
crypto:Decrypt: parse blob,const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); const tag = cipher.getAuthTag(); const blob = `v1.${iv.toString('base64')}.${ciphertext.toString('base64')}.${tag.toString('base64')}`;
createDecipheriv,setAuthTag,update+final. - HKDF for key derivation:
crypto.hkdfSync('sha256', masterKey, '', 'session-key', 32). - Debounce pattern:
const timers = new Map(); function debouncedSave(clientId, state) { const existing = timers.get(clientId); if (existing) clearTimeout(existing); timers.set(clientId, setTimeout(() => { timers.delete(clientId); sessionManager.save(clientId, state); }, 500)); }
- Session state structure:
{ clientId: "abc", protocolVersion: 3, authIdentity: { sub: "device-123", iss: "fleet-auth", ... }, rooms: [ { roomId: "fleet-alpha", highestAckedSeq: 42, highestReceivedSeq: 45, geofenceInsideSet: ["fence-1", "fence-3"] } ], rateLimitState: { messageWindow: [ts1, ts2, ...], connectionWindow: [ts1, ...] }, metadata: { ip: "10.0.0.1", userAgent: "FleetApp/2.3", connectedAt: 1234567890, lastActivityAt: 1234567900 } }
- Integration with issue 11:
DistributedRoomManageralready syncs membership to Redis.SessionManageradds the per-client sequence/ACK/geoface/rate-limit state. They can share the same Redis connection. - For sticky cookie:
res.cookie('GW_AFFINITY', instanceId, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 3600000 })on HTTP upgrade response (requireshttpserver access — see issue 9).
Activity
RajeshRk18 commentedon Aug 17, 2026
I have experience working on cryptography and managing sessions securely. I can take this!
grantfox-oss commentedon Aug 17, 2026
🦊 GrantFox — @RajeshRk18 has been assigned to this issue as part of the Third Campaign campaign!
Next steps:
Closes #228)Good luck! Track your progress on GrantFox.
feat: implement exactly-once delivery semantics (closes RiftCore00#226,
grantfox-oss commentedon Aug 21, 2026
🎉 This issue has been marked as completed on GrantFox as part of the Third Campaign campaign!
@RajeshRk18's PR #266 was approved and merged by @levibliz.
🏆 @RajeshRk18: You earned 35 FoxPoints for this contribution! Your current tier: Explorer (188 total points). Track your full progress on GrantFox.
👏 Great work, @RajeshRk18! Keep contributing to RiftCore00.