Bind capability state to Noise generations

This commit is contained in:
jack
2026-07-26 00:12:26 +02:00
committed by jack
parent 8b1b13842f
commit 5a8689ea22
9 changed files with 569 additions and 137 deletions
+106 -22
View File
@@ -18,6 +18,10 @@ struct NoiseHandshakeProcessingResult {
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] = [:]
/// A responder rehandshake must not evict a working transport session
/// before the candidate proves that its authenticated static key belongs
/// to the claimed wire ID. Candidates therefore live outside `sessions`
@@ -27,7 +31,7 @@ final class NoiseSessionManager {
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
@@ -64,6 +68,7 @@ final class NoiseSessionManager {
if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing
}
sessionGenerations.removeValue(forKey: peerID)
if let candidate = responderCandidates.removeValue(forKey: peerID) {
candidate.reset()
}
@@ -79,6 +84,7 @@ final class NoiseSessionManager {
candidate.reset()
}
sessions.removeAll()
sessionGenerations.removeAll()
responderCandidates.removeAll()
}
}
@@ -96,12 +102,14 @@ final class NoiseSessionManager {
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.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()
@@ -109,6 +117,7 @@ final class NoiseSessionManager {
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
session.reset()
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
@@ -132,8 +141,17 @@ final class NoiseSessionManager {
from peerID: PeerID,
message: Data
) throws -> NoiseHandshakeProcessingResult {
// Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) {
// 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
let isReplacementCandidate: Bool
@@ -164,9 +182,11 @@ final class NoiseSessionManager {
// No established transport state exists to preserve. A
// fresh initiation replaces the incomplete handshake.
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
existing.reset()
let replacement = sessionFactory(peerID, .responder)
sessions[peerID] = replacement
sessionGenerations[peerID] = UUID()
session = replacement
isReplacementCandidate = false
} else {
@@ -176,6 +196,7 @@ final class NoiseSessionManager {
} else {
let newSession = sessionFactory(peerID, .responder)
sessions[peerID] = newSession
sessionGenerations[peerID] = UUID()
session = newSession
isReplacementCandidate = false
}
@@ -187,8 +208,11 @@ final class NoiseSessionManager {
// Check the exact session that processed this message. A
// preserved peer-level session can remain established while a
// replacement candidate is still unauthenticated.
let didEstablishAuthenticatedSession = session.isEstablished()
if didEstablishAuthenticatedSession {
var establishedSession: (
remoteKey: Curve25519.KeyAgreement.PublicKey,
generation: UUID
)?
if session.isEstablished() {
guard let remoteKey = session.getRemoteStaticPublicKey(),
authenticatedRemoteKey(remoteKey, matches: peerID) else {
throw NoiseSessionError.peerIdentityMismatch
@@ -197,22 +221,18 @@ final class NoiseSessionManager {
if isReplacementCandidate {
_ = responderCandidates.removeValue(forKey: peerID)
let previous = sessions.updateValue(session, forKey: peerID)
sessionGenerations[peerID] = UUID()
if let previous, previous !== session {
previous.reset()
}
}
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
guard let generation = sessionGenerations[peerID] else {
throw NoiseEncryptionError.sessionNotEstablished
}
establishedSession = (remoteKey, generation)
}
return NoiseHandshakeProcessingResult(
response: response,
didEstablishAuthenticatedSession:
didEstablishAuthenticatedSession
)
return (response, establishedSession)
} catch {
// A failed candidate is discarded without touching the
// established session. Ordinary failed handshakes retain the
@@ -225,6 +245,7 @@ final class NoiseSessionManager {
} else if let storedSession = sessions[peerID],
storedSession === session {
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
}
session.reset()
@@ -237,6 +258,14 @@ final class NoiseSessionManager {
throw error
}
}
if let established = result.establishedSession {
onSessionEstablished?(peerID, established.remoteKey, established.generation)
}
return NoiseHandshakeProcessingResult(
response: result.response,
didEstablishAuthenticatedSession: result.establishedSession != nil
)
}
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
@@ -260,19 +289,74 @@ final class NoiseSessionManager {
// MARK: - Encryption/Decryption
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
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 replacement 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)
}
return try session.encrypt(plaintext)
}
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
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 {
guard let session = sessions[peerID] else {
throw NoiseSessionError.sessionNotFound
}
guard session.isEstablished(),
let generation = sessionGenerations[peerID] else {
throw NoiseEncryptionError.sessionNotEstablished
}
return (try session.decrypt(ciphertext), generation)
}
}
/// Runs a state commit under a read lease for the exact established
/// session. Rekey, replacement, 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()
}
return try session.decrypt(ciphertext)
}
// MARK: - Key Management