Files
bitchat/bitchat/Noise/NoiseSessionManager.swift
T

577 lines
23 KiB
Swift

//
// NoiseSessionManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import CryptoKit
import Foundation
import BitFoundation
struct NoiseHandshakeProcessingResult {
let response: Data?
let didEstablishAuthenticatedSession: Bool
}
struct NoiseHandshakeInitiation: Equatable, Sendable {
let payload: Data
let attemptID: UUID
}
final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
/// Opaque identity for each exact entry in `sessions`. The generation is
/// created and removed under the same barrier as the session itself, so a
/// caller can never authenticate data with one session and lease another.
private var sessionGenerations: [PeerID: UUID] = [:]
/// One-time handoff tokens prevent a prepared XX message 1 from leaving
/// after an inbound collision has already changed this peer's role.
private var ordinaryInitiationIDs: [PeerID: UUID] = [:]
private struct QuarantinedTransport {
let session: NoiseSession
let generation: UUID
/// Duplicate message 1 packets replace the incomplete responder but
/// never extend the original rollback window indefinitely.
let rollbackDeadline: DispatchTime
}
/// An unauthenticated inbound message 1 cannot keep old sending keys live,
/// but it also must not permanently destroy a victim session. The old
/// transport remains receive-only while the ordinary responder proves the
/// claimed static identity, then is discarded on success or restored on
/// bounded failure.
private var quarantinedTransports: [PeerID: QuarantinedTransport] = [:]
private var quarantinedResponderTimeouts: [PeerID: DispatchWorkItem] = [:]
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let ordinaryResponderHandshakeTimeout: TimeInterval
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
var onSessionRestored: ((PeerID, UUID) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)?
init(
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
keychain: KeychainManagerProtocol,
ordinaryResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
) {
self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout
self.sessionFactory = { peerID, role in
SecureNoiseSession(
peerID: peerID,
role: role,
keychain: keychain,
localStaticKey: localStaticKey
)
}
}
#if DEBUG
init(
localStaticKey _: Curve25519.KeyAgreement.PrivateKey,
keychain _: KeychainManagerProtocol,
ordinaryResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
) {
self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout
self.sessionFactory = sessionFactory
}
#endif
// MARK: - Session Management
func getSession(for peerID: PeerID) -> NoiseSession? {
return managerQueue.sync {
return sessions[peerID]
}
}
func removeSession(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) {
removeSessionLocked(for: peerID)
}
}
private func removeSessionLocked(for peerID: PeerID) {
if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing
}
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
quarantined.session.reset()
}
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
}
func removeAllSessions() {
managerQueue.sync(flags: .barrier) {
for (_, session) in sessions {
session.reset()
}
for (_, quarantined) in quarantinedTransports {
quarantined.session.reset()
}
for (_, timeout) in quarantinedResponderTimeouts {
timeout.cancel()
}
sessions.removeAll()
sessionGenerations.removeAll()
ordinaryInitiationIDs.removeAll()
quarantinedTransports.removeAll()
quarantinedResponderTimeouts.removeAll()
}
}
// MARK: - Handshake Helpers
func initiateHandshake(with peerID: PeerID) throws -> Data {
return try managerQueue.sync(flags: .barrier) {
// Check if we already have an established session
if let existingSession = sessions[peerID], existingSession.isEstablished() {
// Session already established, don't recreate
throw NoiseSessionError.alreadyEstablished
}
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
existingSession.reset()
}
// Create new initiator session
let session = sessionFactory(peerID, .initiator)
sessions[peerID] = session
sessionGenerations[peerID] = UUID()
do {
let handshakeData = try session.startHandshake()
return handshakeData
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
session.reset()
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
}
}
/// Prepares an ordinary reconnect before atomically retiring the current
/// transport. Authorization and handshake-start failure leave the working
/// session untouched. Once this returns, encryption observes only the new
/// handshaking session and must queue until it establishes.
func initiateReconnectHandshake(
with peerID: PeerID,
authorize: () throws -> Void
) throws -> NoiseHandshakeInitiation {
try managerQueue.sync(flags: .barrier) {
guard let established = sessions[peerID],
established.isEstablished() else {
throw NoiseSessionError.notEstablished
}
try authorize()
let next = sessionFactory(peerID, .initiator)
let payload: Data
do {
payload = try next.startHandshake()
} catch {
next.reset()
SecureLogger.error(
.handshakeFailed(
peerID: peerID.id,
error: error.localizedDescription
)
)
throw error
}
removeSessionLocked(for: peerID)
let attemptID = UUID()
sessions[peerID] = next
sessionGenerations[peerID] = UUID()
ordinaryInitiationIDs[peerID] = attemptID
return NoiseHandshakeInitiation(
payload: payload,
attemptID: attemptID
)
}
}
/// Claims a prepared message 1 exactly once. A crossed inbound initiation
/// that already made this peer a responder invalidates the token.
func claimHandshakeInitiation(
_ initiation: NoiseHandshakeInitiation,
for peerID: PeerID
) -> Data? {
managerQueue.sync(flags: .barrier) {
guard ordinaryInitiationIDs[peerID] == initiation.attemptID,
let session = sessions[peerID],
session.role == .initiator,
session.getState() == .handshaking else {
return nil
}
ordinaryInitiationIDs.removeValue(forKey: peerID)
return initiation.payload
}
}
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
try handleIncomingHandshakeWithResult(
from: peerID,
message: message
).response
}
/// Processes one exact handshake candidate and reports whether that
/// candidate completed authenticated establishment. The peer's retained
/// session may already be established while a replacement is only on
/// message one, so callers must not infer candidate completion from the
/// peer-level session table.
func handleIncomingHandshakeWithResult(
from peerID: PeerID,
message: Data
) throws -> NoiseHandshakeProcessingResult {
// Process everything within the synchronized block to prevent race conditions.
// Return establishment metadata and publish the callback only after the
// manager barrier is released, avoiding both a deadlock and a window in
// which `processHandshakeMessage` returns before authentication state.
let result: (
response: Data?,
establishedSession: (
remoteKey: Curve25519.KeyAgreement.PublicKey,
generation: UUID
)?
) = try managerQueue.sync(flags: .barrier) {
let session: NoiseSession
if let existing = sessions[peerID],
existing.isEstablished()
|| message.count
== NoiseSecurityConstants.xxInitialMessageSize {
if existing.isEstablished(),
let generation = sessionGenerations[peerID] {
// Message 1 cannot prove that the remote still owns its
// old keys. Remove them from every sending/generation API
// immediately, but retain the object solely for bounded
// rollback until the ordinary responder authenticates.
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
if let prior = quarantinedTransports.updateValue(
QuarantinedTransport(
session: existing,
generation: generation,
rollbackDeadline: .now()
+ ordinaryResponderHandshakeTimeout
),
forKey: peerID
) {
prior.session.reset()
}
} else {
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
existing.reset()
}
let replacement = sessionFactory(peerID, .responder)
sessions[peerID] = replacement
sessionGenerations[peerID] = UUID()
session = replacement
if quarantinedTransports[peerID] != nil {
scheduleQuarantinedResponderTimeoutLocked(
replacement,
for: peerID
)
}
} else if let existing = sessions[peerID] {
session = existing
} else {
let newSession = sessionFactory(peerID, .responder)
sessions[peerID] = newSession
sessionGenerations[peerID] = UUID()
session = newSession
}
// Process the handshake message within the synchronized block
do {
let response = try session.processHandshakeMessage(message)
// Check the exact session that processed this message. A
// preserved peer-level session can remain established while a
// replacement candidate is still unauthenticated.
var establishedSession: (
remoteKey: Curve25519.KeyAgreement.PublicKey,
generation: UUID
)?
if session.isEstablished() {
guard let remoteKey = session.getRemoteStaticPublicKey(),
authenticatedRemoteKey(remoteKey, matches: peerID) else {
throw NoiseSessionError.peerIdentityMismatch
}
if let quarantined = quarantinedTransports.removeValue(
forKey: peerID
) {
quarantinedResponderTimeouts.removeValue(
forKey: peerID
)?.cancel()
quarantined.session.reset()
}
ordinaryInitiationIDs.removeValue(forKey: peerID)
guard let generation = sessionGenerations[peerID] else {
throw NoiseEncryptionError.sessionNotEstablished
}
establishedSession = (remoteKey, generation)
}
return (response, establishedSession)
} catch {
if let storedSession = sessions[peerID],
storedSession === session {
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
}
ordinaryInitiationIDs.removeValue(forKey: peerID)
session.reset()
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
let restoredGeneration: UUID?
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
sessions[peerID] = quarantined.session
sessionGenerations[peerID] = quarantined.generation
restoredGeneration = quarantined.generation
} else {
restoredGeneration = nil
}
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
if let restoredGeneration {
self?.onSessionRestored?(peerID, restoredGeneration)
}
self?.onSessionFailed?(peerID, error)
}
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
}
if let established = result.establishedSession {
onSessionEstablished?(peerID, established.remoteKey, established.generation)
}
return NoiseHandshakeProcessingResult(
response: result.response,
didEstablishAuthenticatedSession: result.establishedSession != nil
)
}
private func scheduleQuarantinedResponderTimeoutLocked(
_ responder: NoiseSession,
for peerID: PeerID
) {
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak responder] in
guard let self,
let responder,
let current = self.sessions[peerID],
current === responder,
current.role == .responder,
current.getState() == .handshaking,
let quarantined = self.quarantinedTransports.removeValue(
forKey: peerID
) else {
return
}
_ = self.sessions.removeValue(forKey: peerID)
self.sessionGenerations.removeValue(forKey: peerID)
self.ordinaryInitiationIDs.removeValue(forKey: peerID)
self.quarantinedResponderTimeouts.removeValue(forKey: peerID)
responder.reset()
self.sessions[peerID] = quarantined.session
self.sessionGenerations[peerID] = quarantined.generation
SecureLogger.debug(
"Ordinary responder handshake with \(peerID) timed out; restored quarantined transport",
category: .session
)
DispatchQueue.global().async { [weak self] in
self?.onSessionRestored?(peerID, quarantined.generation)
}
}
quarantinedResponderTimeouts[peerID] = timeout
guard let deadline = quarantinedTransports[peerID]?.rollbackDeadline else {
quarantinedResponderTimeouts.removeValue(forKey: peerID)
return
}
managerQueue.asyncAfter(deadline: deadline, execute: timeout)
}
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
/// also accepted by internal callers when they exactly match the static
/// key. Non-wire identifiers remain available to protocol test harnesses;
/// BLE packet ingress always supplies a short hexadecimal ID.
private func authenticatedRemoteKey(
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
matches claimedPeerID: PeerID
) -> Bool {
let rawKey = remoteKey.rawRepresentation
if claimedPeerID.isShort {
return PeerID(publicKey: rawKey) == claimedPeerID
}
if let claimedNoiseKey = claimedPeerID.noiseKey {
return claimedNoiseKey == rawKey
}
return true
}
// MARK: - Encryption/Decryption
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
try managerQueue.sync {
guard let session = sessions[peerID] else {
throw NoiseSessionError.sessionNotFound
}
return try session.encrypt(plaintext)
}
}
/// Encrypts only if `expected` still names the current established entry.
/// A rekey between capability proof and media encryption therefore fails
/// closed instead of sending on an unproven reconnect session.
func encrypt(
_ plaintext: Data,
for peerID: PeerID,
expectedSessionGeneration expected: UUID
) throws -> Data {
try managerQueue.sync {
guard let session = sessions[peerID],
session.isEstablished(),
sessionGenerations[peerID] == expected else {
throw NoiseEncryptionError.sessionNotEstablished
}
return try session.encrypt(plaintext)
}
}
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext
}
func sessionGeneration(for peerID: PeerID) -> UUID? {
managerQueue.sync {
guard sessions[peerID]?.isEstablished() == true else { return nil }
return sessionGenerations[peerID]
}
}
/// Decrypts while holding the manager's read lease. Session promotion and
/// removal require its barrier, so the returned generation always names
/// the exact session object that authenticated these bytes.
func decryptWithSessionGeneration(
_ ciphertext: Data,
from peerID: PeerID
) throws -> (plaintext: Data, sessionGeneration: UUID) {
try managerQueue.sync {
if let session = sessions[peerID],
session.isEstablished(),
let generation = sessionGenerations[peerID] {
return (try session.decrypt(ciphertext), generation)
}
// Quarantine is receive-only: old keys cannot encrypt, advertise
// an established generation, or authorize outbound state, but
// legitimate in-flight ciphertext from the retained peer may
// still advance and later resume on rollback.
if let responder = sessions[peerID],
responder.role == .responder,
responder.getState() == .handshaking,
let quarantined = quarantinedTransports[peerID] {
return (
try quarantined.session.decrypt(ciphertext),
quarantined.generation
)
}
if sessions[peerID] == nil, quarantinedTransports[peerID] == nil {
throw NoiseSessionError.sessionNotFound
}
throw NoiseEncryptionError.sessionNotEstablished
}
}
func hasReceiveSession(for peerID: PeerID) -> Bool {
managerQueue.sync {
if sessions[peerID]?.isEstablished() == true {
return true
}
guard let responder = sessions[peerID],
responder.role == .responder,
responder.getState() == .handshaking else {
return false
}
return quarantinedTransports[peerID]?.session.isEstablished() == true
}
}
/// Runs a state commit under a read lease for the exact established
/// session. Rekey, reconnect, and removal all need the same barrier.
func withCurrentSessionGeneration<Result>(
for peerID: PeerID,
expected: UUID,
_ body: () -> Result
) -> Result? {
managerQueue.sync {
guard sessions[peerID]?.isEstablished() == true,
sessionGenerations[peerID] == expected else { return nil }
return body()
}
}
// MARK: - Key Management
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
return getSession(for: peerID)?.getRemoteStaticPublicKey()
}
// MARK: - Session Rekeying
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
return managerQueue.sync {
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
for (peerID, session) in sessions {
if let secureSession = session as? SecureNoiseSession,
secureSession.isEstablished(),
secureSession.needsRenegotiation() {
needingRekey.append((peerID: peerID, needsRekey: true))
}
}
return needingRekey
}
}
func initiateRekey(for peerID: PeerID) throws -> Data {
let initiation = try initiateReconnectHandshake(
with: peerID,
authorize: {}
)
guard let payload = claimHandshakeInitiation(initiation, for: peerID) else {
throw NoiseSessionError.invalidState
}
return payload
}
}