From 83a808fce678423c0c912141b42f1a07e71dca48 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 22 Jul 2025 10:50:47 +0200 Subject: [PATCH 1/3] Implement Ed25519 signatures for identity announcements - Add Ed25519 signing key pair to NoiseEncryptionService - Update NoiseIdentityAnnouncement to include signingPublicKey - Replace HMAC signatures with proper Ed25519 signatures - Fix timestamp synchronization between signing and verification - Add signature verification in PeerIdentityBinding - Persist signing keys in keychain alongside Noise static keys This provides cryptographic non-repudiation for peer identity claims and strengthens the security of the identity rotation mechanism. --- bitchat/Protocols/BitchatProtocol.swift | 20 +++++-- bitchat/Services/BluetoothMeshService.swift | 24 +++++--- bitchat/Services/NoiseEncryptionService.swift | 58 +++++++++++++++---- 3 files changed, 77 insertions(+), 25 deletions(-) diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 677584d8..f9272489 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -347,16 +347,18 @@ struct ChannelMetadata: Codable { struct NoiseIdentityAnnouncement: Codable { let peerID: String // Current ephemeral peer ID let publicKey: Data // Noise static public key + let signingPublicKey: Data // Ed25519 signing public key let nickname: String // Current nickname let timestamp: Date // When this binding was created let previousPeerID: String? // Previous peer ID (for smooth transition) let signature: Data // Signature proving ownership - init(peerID: String, publicKey: Data, nickname: String, previousPeerID: String? = nil, signature: Data) { + init(peerID: String, publicKey: Data, signingPublicKey: Data, nickname: String, timestamp: Date, previousPeerID: String? = nil, signature: Data) { self.peerID = peerID self.publicKey = publicKey + self.signingPublicKey = signingPublicKey self.nickname = nickname - self.timestamp = Date() + self.timestamp = timestamp self.previousPeerID = previousPeerID self.signature = signature } @@ -366,7 +368,7 @@ struct NoiseIdentityAnnouncement: Codable { } static func decode(from data: Data) -> NoiseIdentityAnnouncement? { - try? JSONDecoder().decode(NoiseIdentityAnnouncement.self, from: data) + return try? JSONDecoder().decode(NoiseIdentityAnnouncement.self, from: data) } } @@ -375,14 +377,22 @@ struct PeerIdentityBinding { let currentPeerID: String // Current ephemeral ID let fingerprint: String // Permanent cryptographic identity let publicKey: Data // Noise static public key + let signingPublicKey: Data // Ed25519 signing public key let nickname: String // Last known nickname let bindingTimestamp: Date // When this binding was created let signature: Data // Cryptographic proof of binding // Verify the binding signature func verify() -> Bool { - // TODO: Implement signature verification - return true + let bindingData = currentPeerID.data(using: .utf8)! + publicKey + + String(Int64(bindingTimestamp.timeIntervalSince1970 * 1000)).data(using: .utf8)! + + do { + let signingKey = try Curve25519.Signing.PublicKey(rawRepresentation: signingPublicKey) + return signingKey.isValidSignature(signature, for: bindingData) + } catch { + return false + } } } diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 25dc39c0..2663a2bd 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -2303,11 +2303,12 @@ class BluetoothMeshService: NSObject { return } - // Verify the signature (currently always returns true for compatibility) - let bindingData = announcement.peerID.data(using: .utf8)! + announcement.publicKey + announcement.timestamp.timeIntervalSince1970.data - if !noiseService.verifySignature(announcement.signature, for: bindingData, publicKey: announcement.publicKey) { - // Log but don't reject - signature verification is temporarily disabled - SecurityLogger.log("Signature verification skipped for \(senderID)", category: SecurityLogger.noise, level: .debug) + // Verify the signature using the signing public key + let timestampData = String(Int64(announcement.timestamp.timeIntervalSince1970 * 1000)).data(using: .utf8)! + let bindingData = announcement.peerID.data(using: .utf8)! + announcement.publicKey + timestampData + if !noiseService.verifySignature(announcement.signature, for: bindingData, publicKey: announcement.signingPublicKey) { + SecurityLogger.log("Signature verification failed for \(senderID)", category: SecurityLogger.noise, level: .warning) + return // Reject announcements with invalid signatures } // Calculate fingerprint from public key @@ -2319,6 +2320,7 @@ class BluetoothMeshService: NSObject { currentPeerID: announcement.peerID, fingerprint: fingerprint, publicKey: announcement.publicKey, + signingPublicKey: announcement.signingPublicKey, nickname: announcement.nickname, bindingTimestamp: announcement.timestamp, signature: announcement.signature @@ -3800,23 +3802,27 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { lastIdentityAnnounceTimes["*broadcast*"] = now } - // Get our Noise static public key + // Get our Noise static public key and signing public key let staticKey = noiseService.getStaticPublicKeyData() + let signingKey = noiseService.getSigningPublicKeyData() // Get nickname from delegate let nickname = (delegate as? ChatViewModel)?.nickname ?? "Anonymous" - // Create the binding data to sign - let bindingData = myPeerID.data(using: .utf8)! + staticKey + now.timeIntervalSince1970.data + // Create the binding data to sign (peerID + publicKey + timestamp) + let timestampData = String(Int64(now.timeIntervalSince1970 * 1000)).data(using: .utf8)! + let bindingData = myPeerID.data(using: .utf8)! + staticKey + timestampData - // Sign the binding with our private key + // Sign the binding with our Ed25519 signing key let signature = noiseService.signData(bindingData) ?? Data() // Create the identity announcement let announcement = NoiseIdentityAnnouncement( peerID: myPeerID, publicKey: staticKey, + signingPublicKey: signingKey, nickname: nickname, + timestamp: now, previousPeerID: previousPeerID, signature: signature ) diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 3bfd4983..99994558 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -17,6 +17,10 @@ class NoiseEncryptionService { private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey + // Ed25519 signing key (persistent across sessions) + private let signingKey: Curve25519.Signing.PrivateKey + public let signingPublicKey: Curve25519.Signing.PublicKey + // Session manager private let sessionManager: NoiseSessionManager @@ -63,6 +67,27 @@ class NoiseEncryptionService { self.staticIdentityKey = loadedKey self.staticIdentityPublicKey = staticIdentityKey.publicKey + // Load or create signing key pair + let loadedSigningKey: Curve25519.Signing.PrivateKey + + // Try to load from keychain + if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"), + let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { + loadedSigningKey = key + } + // If no signing key exists, create new one + else { + loadedSigningKey = Curve25519.Signing.PrivateKey() + let keyData = loadedSigningKey.rawRepresentation + + // Save to keychain + _ = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey") + } + + // Now assign the signing keys + self.signingKey = loadedSigningKey + self.signingPublicKey = signingKey.publicKey + // Initialize session manager self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey) @@ -82,6 +107,11 @@ class NoiseEncryptionService { return staticIdentityPublicKey.rawRepresentation } + /// Get our signing public key for sharing + func getSigningPublicKeyData() -> Data { + return signingPublicKey.rawRepresentation + } + /// Get our identity fingerprint func getIdentityFingerprint() -> String { let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation) @@ -97,25 +127,31 @@ class NoiseEncryptionService { func clearPersistentIdentity() { // Clear from keychain _ = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey") + _ = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey") // Stop rekey timer stopRekeyTimer() } - /// Sign data with our static identity key + /// Sign data with our Ed25519 signing key func signData(_ data: Data) -> Data? { - // For now, use HMAC with the private key as a simple signature - // This is not cryptographically ideal but works for identity binding - let key = SymmetricKey(data: staticIdentityKey.rawRepresentation) - let signature = HMAC.authenticationCode(for: data, using: key) - return Data(signature) + do { + let signature = try signingKey.signature(for: data) + return signature + } catch { + SecurityLogger.logError(error, context: "Failed to sign data", category: SecurityLogger.noise) + return nil + } } - /// Verify signature with a peer's public key + /// Verify signature with a peer's Ed25519 public key func verifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { - // For verification, we can't use the same HMAC approach since we don't have the private key - // For now, we'll skip signature verification but maintain the protocol structure - // In production, this should use proper Ed25519 signatures - return true // Temporarily accept all signatures to fix the immediate issue + do { + let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey) + return signingPublicKey.isValidSignature(signature, for: data) + } catch { + SecurityLogger.logError(error, context: "Failed to verify signature", category: SecurityLogger.noise) + return false + } } // MARK: - Handshake Management From 0be35a53780711166d99290693e7eaf150b2126a Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 22 Jul 2025 11:01:24 +0200 Subject: [PATCH 2/3] Fix Noise session persistence during peer ID rotation - Add session migration when peer IDs rotate - Sessions now follow peers across ID changes via fingerprint - Add migratePeerSession to NoiseEncryptionService - Add migrateSession to NoiseSessionManager - Integrate migration in BluetoothMeshService updatePeerBinding This fixes the issue where established Noise sessions were lost when peer IDs rotated, causing "No Noise session" errors and requiring re-handshake. --- bitchat/Noise/NoiseSession.swift | 13 +++++++++++++ bitchat/Services/BluetoothMeshService.swift | 3 +++ bitchat/Services/NoiseEncryptionService.swift | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/bitchat/Noise/NoiseSession.swift b/bitchat/Noise/NoiseSession.swift index 973b6fa8..da873cb9 100644 --- a/bitchat/Noise/NoiseSession.swift +++ b/bitchat/Noise/NoiseSession.swift @@ -259,6 +259,19 @@ class NoiseSessionManager { } } + func migrateSession(from oldPeerID: String, to newPeerID: String) { + _ = managerQueue.sync(flags: .barrier) { + // Check if we have a session for the old peer ID + if let session = sessions[oldPeerID] { + // Move the session to the new peer ID + sessions[newPeerID] = session + sessions.removeValue(forKey: oldPeerID) + + SecurityLogger.log("Migrated Noise session from \(oldPeerID) to \(newPeerID)", category: SecurityLogger.noise, level: .info) + } + } + } + func getEstablishedSessions() -> [String: NoiseSession] { return managerQueue.sync { return sessions.filter { $0.value.isEstablished() } diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 2663a2bd..29a9f0a5 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -364,6 +364,9 @@ class BluetoothMeshService: NSObject { // Notify about the change if it's a rotation if let oldID = oldPeerID { + // Migrate Noise session to new peer ID + self.noiseService.migratePeerSession(from: oldID, to: newPeerID, fingerprint: fingerprint) + self.notifyPeerIDChange(oldPeerID: oldID, newPeerID: newPeerID, fingerprint: fingerprint) } } diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 99994558..79f7190a 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -279,6 +279,24 @@ class NoiseEncryptionService { } } + /// Migrate session when peer ID changes + func migratePeerSession(from oldPeerID: String, to newPeerID: String, fingerprint: String) { + // First update the fingerprint mappings + serviceQueue.sync(flags: .barrier) { + // Remove old mapping + if let oldFingerprint = peerFingerprints[oldPeerID], oldFingerprint == fingerprint { + peerFingerprints.removeValue(forKey: oldPeerID) + } + + // Add new mapping + peerFingerprints[newPeerID] = fingerprint + fingerprintToPeerID[fingerprint] = newPeerID + } + + // Migrate the session in session manager + sessionManager.migrateSession(from: oldPeerID, to: newPeerID) + } + // MARK: - Private Helpers private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { From 8fccbe69b93b9703156e4c8a2c282e62ca533f7e Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 22 Jul 2025 11:06:57 +0200 Subject: [PATCH 3/3] Fix session destruction race condition causing handshake delays Root cause: When receiving a handshake initiation (32 bytes) from a peer with whom we already had an established session, the code would destroy the existing session to "help" the other side. This created a cascade: - Peer A completes handshake with Peer B - Peer A sends message, realizes no session, initiates handshake - Peer B destroys its working session to "help" - Peer B now has no session, initiates handshake - Both peers keep destroying each other's sessions Fix: - Never destroy an established session when receiving new handshake attempts - Add early check in handshake initiation to skip if session exists - Clear handshake rate limit timers when session already established This eliminates the delays and repeated handshakes seen in the logs. --- bitchat/Noise/NoiseSession.swift | 15 ++++----------- bitchat/Services/BluetoothMeshService.swift | 8 ++++++++ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/bitchat/Noise/NoiseSession.swift b/bitchat/Noise/NoiseSession.swift index da873cb9..2f8b2eb7 100644 --- a/bitchat/Noise/NoiseSession.swift +++ b/bitchat/Noise/NoiseSession.swift @@ -319,18 +319,11 @@ class NoiseSessionManager { var existingSession: NoiseSession? = nil if let existing = sessions[peerID] { - // If we have an established session, we might need to help the other side complete theirs + // If we have an established session, reject new handshake attempts if existing.isEstablished() { - // If this is a handshake initiation (32 bytes), the other side doesn't have a session - // We should complete the handshake to help them establish their session - if message.count == 32 { - // Remove existing session and create new one - sessions.removeValue(forKey: peerID) - shouldCreateNew = true - } else { - // For other handshake messages, ignore if already established - throw NoiseSessionError.alreadyEstablished - } + // Don't destroy our working session just because the other side is confused + // They should detect the established session through successful message exchange + throw NoiseSessionError.alreadyEstablished } else { // If we're in the middle of a handshake and receive a new initiation, // reset and start fresh (the other side may have restarted) diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 29a9f0a5..56b4d641 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -3368,6 +3368,14 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { SecurityLogger.log("Initiating Noise handshake with \(peerID)", category: SecurityLogger.noise, level: .info) + // Check if we already have an established session + if noiseService.hasEstablishedSession(with: peerID) { + SecurityLogger.log("Already have established session with \(peerID)", category: SecurityLogger.noise, level: .debug) + // Clear any lingering handshake attempt time + handshakeAttemptTimes.removeValue(forKey: peerID) + return + } + // Check if we've recently tried to handshake with this peer if let lastAttempt = handshakeAttemptTimes[peerID], Date().timeIntervalSince(lastAttempt) < handshakeTimeout {