diff --git a/bitchat/Noise/SecureNoiseSession.swift b/bitchat/Noise/SecureNoiseSession.swift index 8f95842c..e2f67f34 100644 --- a/bitchat/Noise/SecureNoiseSession.swift +++ b/bitchat/Noise/SecureNoiseSession.swift @@ -66,12 +66,24 @@ final class SecureNoiseSession: NoiseSession { if messageCount >= messageThreshold { return true } - + // Check if last activity was more than 30 minutes ago if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout { return true } - + + // A session that never goes idle (a long-lived conversation with + // steady traffic) keeps refreshing lastActivityTime forever, so the + // check above never fires -- only sessionStartTime tracks how close + // encrypt/decrypt are to the hard sessionExpired cutoff above. Firing + // at 90% of sessionTimeout leaves the rekey timer's polling interval + // (checked every 60s, see NoiseEncryptionService.rekeyCheckInterval) + // room to complete a full handshake well before the old session + // starts throwing sessionExpired on every call. + if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout * 0.9 { + return true + } + return false } diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index 763f5816..c777d63f 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -893,6 +893,44 @@ struct NoiseCoverageTests { } } + @Test("A continuously active session still schedules a rekey before it hard-expires") + func continuouslyActiveSessionStillNeedsRenegotiationBeforeExpiry() throws { + // A session with steady traffic never goes idle, so lastActivityTime + // stays fresh right up to the moment sessionStartTime crosses the + // hard sessionTimeout cutoff above and encrypt/decrypt start throwing + // sessionExpired on every call. needsRenegotiation() has to notice + // the session is old on its own -- it can't rely on the idle check, + // because there is no idle time to find. + let session = SecureNoiseSession( + peerID: alicePeerID, + role: .initiator, + keychain: keychain, + localStaticKey: aliceStaticKey + ) + let responder = SecureNoiseSession( + peerID: bobPeerID, + role: .responder, + keychain: keychain, + localStaticKey: bobStaticKey + ) + try establishSessions(initiator: session, responder: responder) + + // Just under the hard cutoff, but past the 90% rekey threshold. + session.setSessionStartTimeForTesting( + Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout * 0.95)) + ) + session.setLastActivityTimeForTesting(Date()) + session.setMessageCountForTesting(0) + + #expect(session.needsRenegotiation()) + + // The session is still within its hard cutoff, so it must keep + // working right up until a rekey actually replaces it -- the fix is + // to schedule the rekey earlier, not to expire the session sooner. + let ciphertext = try session.encrypt(Data("still working".utf8)) + #expect(try responder.decrypt(ciphertext) == Data("still working".utf8)) + } + @Test("Rate limiter handles global message caps and per-peer resets") func rateLimiterGlobalMessageCapAndReset() async throws { let globalLimiter = NoiseRateLimiter()