Files
bitchat/bitchat/Noise/NoiseSession.swift
T
593fd7d737 Harden public intake bounds against untrusted growth (#1451)
* Bound public rate-limit buckets against attacker-keyed growth.

Keep the NIP-13 PoW sender bypass, skip content-bucket minting on
sender reject, and evict idle/oldest entries at a hard cap.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Stop Cashu-looking text from skipping long-message guards.

Oversized public content always collapses and takes the plain
formatting path so remote tokens cannot force layout/regex DoS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cap teleported geohash participant markers.

Bound the set with FIFO eviction, clear it on channel switch, and
prune markers that leave the visible participant list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bound untrusted Nostr relay frames and event tags.

Reject oversized inbound messages before JSON parse, cap tag
arrays/values at decode, and stop logging raw tag contents.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fail soft when Noise handshake state is unexpectedly missing.

Replace the initiator startHandshake force unwrap with a guard
that throws invalidState instead of crashing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cap geohash nickname cache from remote Nostr events.

FIFO-evict at capacity, clear on channel switch, and prune
nicknames that leave the visible participant list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Avoid overlapping exclusive access in the rate limiter.

Make bucket helpers static so inout dictionary updates do not
conflict with a mutating call on self.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix rate-limiter tests for mutating allow under #expect.

Call allow outside the macro so Swift Testing does not capture an
immutable copy of the struct.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop unused WebSocket data helper; reset rate limiter on panic wipe.

dataWithinInboundLimit replaced the unbounded path, and panic clear
should not leave public intake buckets behind.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 12:25:46 +02:00

234 lines
8.2 KiB
Swift

//
// NoiseSession.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
import CryptoKit
import BitFoundation
class NoiseSession {
let peerID: PeerID
let role: NoiseRole
private let keychain: KeychainManagerProtocol
private var state: NoiseSessionState = .uninitialized
private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState?
private var receiveCipher: NoiseCipherState?
// Keys
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private var remoteStaticPublicKey: Curve25519.KeyAgreement.PublicKey?
// Handshake messages for retransmission
private var sentHandshakeMessages: [Data] = []
private var handshakeHash: Data?
// Thread safety
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
init(
peerID: PeerID,
role: NoiseRole,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.peerID = peerID
self.role = role
self.keychain = keychain
self.localStaticKey = localStaticKey
self.remoteStaticPublicKey = remoteStaticKey
}
// MARK: - Handshake
func startHandshake() throws -> Data {
return try sessionQueue.sync(flags: .barrier) {
guard case .uninitialized = state else {
throw NoiseSessionError.invalidState
}
// For XX pattern, we don't need remote static key upfront
handshakeState = NoiseHandshakeState(
role: role,
pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey,
remoteStaticKey: nil
)
state = .handshaking
// Only initiator writes the first message
if role == .initiator {
guard let handshake = handshakeState else {
throw NoiseSessionError.invalidState
}
let message = try handshake.writeMessage()
sentHandshakeMessages.append(message)
return message
} else {
// Responder doesn't send first message in XX pattern
return Data()
}
}
}
func processHandshakeMessage(_ message: Data) throws -> Data? {
return try sessionQueue.sync(flags: .barrier) {
SecureLogger.debug("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)")
// Initialize handshake state if needed (for responders)
if state == .uninitialized && role == .responder {
handshakeState = NoiseHandshakeState(
role: role,
pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey,
remoteStaticKey: nil
)
state = .handshaking
SecureLogger.debug("NoiseSession[\(peerID)]: Initialized handshake state for responder")
}
guard case .handshaking = state, let handshake = handshakeState else {
throw NoiseSessionError.invalidState
}
// Process incoming message
_ = try handshake.readMessage(message)
SecureLogger.debug("NoiseSession[\(peerID)]: Read handshake message, checking if complete")
// Check if handshake is complete
if handshake.isHandshakeComplete() {
// Get transport ciphers and handshake hash (hash captured before split clears state)
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
sendCipher = send
receiveCipher = receive
// Store remote static key
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding
handshakeHash = hash
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
return nil
} else {
// Generate response
let response = try handshake.writeMessage()
sentHandshakeMessages.append(response)
SecureLogger.debug("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)")
// Check if handshake is complete after writing
if handshake.isHandshakeComplete() {
// Get transport ciphers and handshake hash (hash captured before split clears state)
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
sendCipher = send
receiveCipher = receive
// Store remote static key
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding
handshakeHash = hash
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
}
return response
}
}
}
// MARK: - Transport
func encrypt(_ plaintext: Data) throws -> Data {
return try sessionQueue.sync(flags: .barrier) {
guard case .established = state, let cipher = sendCipher else {
throw NoiseSessionError.notEstablished
}
return try cipher.encrypt(plaintext: plaintext)
}
}
func decrypt(_ ciphertext: Data) throws -> Data {
return try sessionQueue.sync(flags: .barrier) {
guard case .established = state, let cipher = receiveCipher else {
throw NoiseSessionError.notEstablished
}
return try cipher.decrypt(ciphertext: ciphertext)
}
}
// MARK: - State Management
func getState() -> NoiseSessionState {
return sessionQueue.sync {
return state
}
}
func isEstablished() -> Bool {
return sessionQueue.sync {
if case .established = state {
return true
}
return false
}
}
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
return sessionQueue.sync {
return remoteStaticPublicKey
}
}
func reset() {
sessionQueue.sync(flags: .barrier) {
let wasEstablished = state == .established
state = .uninitialized
handshakeState = nil
// Clear sensitive cipher states
sendCipher?.clearSensitiveData()
receiveCipher?.clearSensitiveData()
sendCipher = nil
receiveCipher = nil
// Clear sent handshake messages
for i in 0..<sentHandshakeMessages.count {
var message = sentHandshakeMessages[i]
keychain.secureClear(&message)
}
sentHandshakeMessages.removeAll()
// Clear handshake hash
if var hash = handshakeHash {
keychain.secureClear(&hash)
}
handshakeHash = nil
if wasEstablished {
SecureLogger.info(.sessionExpired(peerID: peerID.id))
}
}
}
}