Security fixes and improvements (#374)

- Fix force unwrapping in NostrIdentity bech32 functions that could crash on non-ASCII input
- Add comprehensive input validation for all protocol messages (peer IDs, nicknames, timestamps)
- Strengthen keychain security with better sandbox detection and consistent app group usage
- Implement secure memory clearing for cryptographic keys and shared secrets
- Fix panic mode not reconnecting to mesh by restarting services after emergency disconnect

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-07-31 23:42:08 +02:00
committed by GitHub
co-authored by jack
parent 5a44b32719
commit 8f32edaa64
8 changed files with 384 additions and 40 deletions
+6
View File
@@ -32,6 +32,8 @@
04636BEB2E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */; }; 04636BEB2E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */; };
04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */; }; 04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */; };
04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */; }; 04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */; };
046D705D2E3C105D00C00594 /* InputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046D705C2E3C105D00C00594 /* InputValidator.swift */; };
046D705E2E3C105D00C00594 /* InputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046D705C2E3C105D00C00594 /* InputValidator.swift */; };
04891CA92E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; }; 04891CA92E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; };
04891CAA2E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; }; 04891CAA2E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; };
04AD0B4E2E25B9580002A40A /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2446380E7A44E49A35B664 /* IdentityModels.swift */; }; 04AD0B4E2E25B9580002A40A /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2446380E7A44E49A35B664 /* IdentityModels.swift */; };
@@ -154,6 +156,7 @@
04636BCF2E30BE5100FBCFA8 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; 04636BCF2E30BE5100FBCFA8 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = "<group>"; }; 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = "<group>"; };
04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; }; 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
046D705C2E3C105D00C00594 /* InputValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputValidator.swift; sourceTree = "<group>"; };
04891CA82E22971E0064A111 /* LRUCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = "<group>"; }; 04891CA82E22971E0064A111 /* LRUCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = "<group>"; };
04B6BA412E2035530090FE39 /* NoiseProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocol.swift; sourceTree = "<group>"; }; 04B6BA412E2035530090FE39 /* NoiseProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocol.swift; sourceTree = "<group>"; };
04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSecurityConsiderations.swift; sourceTree = "<group>"; }; 04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSecurityConsiderations.swift; sourceTree = "<group>"; };
@@ -361,6 +364,7 @@
9A78348821A7D3374607D4E3 /* Utils */ = { 9A78348821A7D3374607D4E3 /* Utils */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
046D705C2E3C105D00C00594 /* InputValidator.swift */,
04636BBE2E2FCA8A00FBCFA8 /* SecureLogger.swift */, 04636BBE2E2FCA8A00FBCFA8 /* SecureLogger.swift */,
04891CA82E22971E0064A111 /* LRUCache.swift */, 04891CA82E22971E0064A111 /* LRUCache.swift */,
ED176FF3B274E35C2D827894 /* BatteryOptimizer.swift */, ED176FF3B274E35C2D827894 /* BatteryOptimizer.swift */,
@@ -655,6 +659,7 @@
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */, 31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */,
04F127FE2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */, 04F127FE2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */,
04F128062E37F10000FFBA8D /* PeerSession.swift in Sources */, 04F128062E37F10000FFBA8D /* PeerSession.swift in Sources */,
046D705E2E3C105D00C00594 /* InputValidator.swift in Sources */,
04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */, 04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */,
04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */, 04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */,
C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */, C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */,
@@ -696,6 +701,7 @@
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */, 7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */,
04F127FF2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */, 04F127FF2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */,
04F128082E37F10000FFBA8D /* PeerSession.swift in Sources */, 04F128082E37F10000FFBA8D /* PeerSession.swift in Sources */,
046D705D2E3C105D00C00594 /* InputValidator.swift in Sources */,
04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */, 04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */,
04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */, 04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */,
CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */, CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */,
+43 -6
View File
@@ -149,6 +149,10 @@ class NoiseCipherState {
self.useExtractedNonce = useExtractedNonce self.useExtractedNonce = useExtractedNonce
} }
deinit {
clearSensitiveData()
}
func initializeKey(_ key: SymmetricKey) { func initializeKey(_ key: SymmetricKey) {
self.key = key self.key = key
self.nonce = 0 self.nonce = 0
@@ -357,6 +361,21 @@ class NoiseCipherState {
throw error throw error
} }
} }
/// Securely clear sensitive cryptographic data from memory
func clearSensitiveData() {
// Clear the symmetric key
key = nil
// Reset nonce
nonce = 0
highestReceivedNonce = 0
// Clear replay window
for i in 0..<replayWindow.count {
replayWindow[i] = 0
}
}
} }
// MARK: - Symmetric State // MARK: - Symmetric State
@@ -557,7 +576,10 @@ class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral) let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
case .es: case .es:
// DH(ephemeral, static) - direction depends on role // DH(ephemeral, static) - direction depends on role
@@ -602,7 +624,10 @@ class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic) let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
} }
} }
@@ -687,14 +712,20 @@ class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
} else { } else {
guard let localStatic = localStaticPrivate, guard let localStatic = localStaticPrivate,
let remoteEphemeral = remoteEphemeralPublic else { let remoteEphemeral = remoteEphemeralPublic else {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
} }
case .se: case .se:
@@ -704,14 +735,20 @@ class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
} else { } else {
guard let localEphemeral = localEphemeralPrivate, guard let localEphemeral = localEphemeralPrivate,
let remoteStatic = remoteStaticPublic else { let remoteStatic = remoteStaticPublic else {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
} }
case .ss: case .ss:
+21 -2
View File
@@ -221,9 +221,24 @@ class NoiseSession {
let wasEstablished = state == .established let wasEstablished = state == .established
state = .uninitialized state = .uninitialized
handshakeState = nil handshakeState = nil
// Clear sensitive cipher states
sendCipher?.clearSensitiveData()
receiveCipher?.clearSensitiveData()
sendCipher = nil sendCipher = nil
receiveCipher = nil receiveCipher = nil
// Clear sent handshake messages
for i in 0..<sentHandshakeMessages.count {
var message = sentHandshakeMessages[i]
KeychainManager.secureClear(&message)
}
sentHandshakeMessages.removeAll() sentHandshakeMessages.removeAll()
// Clear handshake hash
if var hash = handshakeHash {
KeychainManager.secureClear(&hash)
}
handshakeHash = nil handshakeHash = nil
if wasEstablished { if wasEstablished {
@@ -270,8 +285,12 @@ class NoiseSessionManager {
func removeSession(for peerID: String) { func removeSession(for peerID: String) {
managerQueue.sync(flags: .barrier) { managerQueue.sync(flags: .barrier) {
if let session = sessions[peerID], session.isEstablished() { if let session = sessions[peerID] {
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID)) if session.isEstablished() {
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
}
// Clear sensitive data before removing
session.reset()
} }
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
} }
+16 -2
View File
@@ -165,6 +165,14 @@ enum Bech32 {
} }
let hrp = String(bech32String[..<separatorIndex]) let hrp = String(bech32String[..<separatorIndex])
// Validate HRP contains only ASCII characters
for char in hrp {
guard char.asciiValue != nil else {
throw Bech32Error.invalidCharacter
}
}
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...]) let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
// Convert characters to values // Convert characters to values
@@ -238,11 +246,17 @@ enum Bech32 {
private static func hrpExpand(_ hrp: String) -> [UInt8] { private static func hrpExpand(_ hrp: String) -> [UInt8] {
var result = [UInt8]() var result = [UInt8]()
for c in hrp { for c in hrp {
result.append(UInt8(c.asciiValue! >> 5)) guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue >> 5))
} }
result.append(0) result.append(0)
for c in hrp { for c in hrp {
result.append(UInt8(c.asciiValue! & 31)) guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue & 31))
} }
return result return result
} }
+37 -11
View File
@@ -355,10 +355,14 @@ struct DeliveryAck: Codable {
guard let recipientIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } guard let recipientIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let recipientID = recipientIDData.hexEncodedString() let recipientID = recipientIDData.hexEncodedString()
guard InputValidator.validatePeerID(recipientID) else { return nil }
guard let hopCount = dataCopy.readUInt8(at: &offset), guard let hopCount = dataCopy.readUInt8(at: &offset),
InputValidator.validateHopCount(hopCount),
let timestamp = dataCopy.readDate(at: &offset), let timestamp = dataCopy.readDate(at: &offset),
let recipientNickname = dataCopy.readString(at: &offset) else { return nil } InputValidator.validateTimestamp(timestamp),
let recipientNicknameRaw = dataCopy.readString(at: &offset),
let recipientNickname = InputValidator.validateNickname(recipientNicknameRaw) else { return nil }
return DeliveryAck(originalMessageID: originalMessageID, return DeliveryAck(originalMessageID: originalMessageID,
ackID: ackID, ackID: ackID,
@@ -443,9 +447,12 @@ struct ReadReceipt: Codable {
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = readerIDData.hexEncodedString() let readerID = readerIDData.hexEncodedString()
guard InputValidator.validatePeerID(readerID) else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset), guard let timestamp = dataCopy.readDate(at: &offset),
let readerNickname = dataCopy.readString(at: &offset) else { return nil } InputValidator.validateTimestamp(timestamp),
let readerNicknameRaw = dataCopy.readString(at: &offset),
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
return ReadReceipt(originalMessageID: originalMessageID, return ReadReceipt(originalMessageID: originalMessageID,
receiptID: receiptID, receiptID: receiptID,
@@ -540,13 +547,17 @@ struct HandshakeRequest: Codable {
guard let requesterIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } guard let requesterIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let requesterID = requesterIDData.hexEncodedString() let requesterID = requesterIDData.hexEncodedString()
guard InputValidator.validatePeerID(requesterID) else { return nil }
guard let targetIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } guard let targetIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let targetID = targetIDData.hexEncodedString() let targetID = targetIDData.hexEncodedString()
guard InputValidator.validatePeerID(targetID) else { return nil }
guard let pendingMessageCount = dataCopy.readUInt8(at: &offset), guard let pendingMessageCount = dataCopy.readUInt8(at: &offset),
let timestamp = dataCopy.readDate(at: &offset), let timestamp = dataCopy.readDate(at: &offset),
let requesterNickname = dataCopy.readString(at: &offset) else { return nil } InputValidator.validateTimestamp(timestamp),
let requesterNicknameRaw = dataCopy.readString(at: &offset),
let requesterNickname = InputValidator.validateNickname(requesterNicknameRaw) else { return nil }
return HandshakeRequest(requestID: requestID, return HandshakeRequest(requestID: requestID,
requesterID: requesterID, requesterID: requesterID,
@@ -616,11 +627,16 @@ struct ProtocolAck: Codable {
let senderIDData = dataCopy.readFixedBytes(at: &offset, count: 8), let senderIDData = dataCopy.readFixedBytes(at: &offset, count: 8),
let receiverIDData = dataCopy.readFixedBytes(at: &offset, count: 8), let receiverIDData = dataCopy.readFixedBytes(at: &offset, count: 8),
let packetType = dataCopy.readUInt8(at: &offset), let packetType = dataCopy.readUInt8(at: &offset),
InputValidator.validateMessageType(packetType),
let hopCount = dataCopy.readUInt8(at: &offset), let hopCount = dataCopy.readUInt8(at: &offset),
let timestamp = dataCopy.readDate(at: &offset) else { return nil } InputValidator.validateHopCount(hopCount),
let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp) else { return nil }
let senderID = senderIDData.hexEncodedString() let senderID = senderIDData.hexEncodedString()
let receiverID = receiverIDData.hexEncodedString() let receiverID = receiverIDData.hexEncodedString()
guard InputValidator.validatePeerID(senderID),
InputValidator.validatePeerID(receiverID) else { return nil }
return ProtocolAck(originalPacketID: originalPacketID, return ProtocolAck(originalPacketID: originalPacketID,
ackID: ackID, ackID: ackID,
@@ -706,12 +722,17 @@ struct ProtocolNack: Codable {
let senderIDData = dataCopy.readFixedBytes(at: &offset, count: 8), let senderIDData = dataCopy.readFixedBytes(at: &offset, count: 8),
let receiverIDData = dataCopy.readFixedBytes(at: &offset, count: 8), let receiverIDData = dataCopy.readFixedBytes(at: &offset, count: 8),
let packetType = dataCopy.readUInt8(at: &offset), let packetType = dataCopy.readUInt8(at: &offset),
InputValidator.validateMessageType(packetType),
let errorCode = dataCopy.readUInt8(at: &offset), let errorCode = dataCopy.readUInt8(at: &offset),
let timestamp = dataCopy.readDate(at: &offset), let timestamp = dataCopy.readDate(at: &offset),
let reason = dataCopy.readString(at: &offset) else { return nil } InputValidator.validateTimestamp(timestamp),
let reasonRaw = dataCopy.readString(at: &offset),
let reason = InputValidator.validateReasonString(reasonRaw) else { return nil }
let senderID = senderIDData.hexEncodedString() let senderID = senderIDData.hexEncodedString()
let receiverID = receiverIDData.hexEncodedString() let receiverID = receiverIDData.hexEncodedString()
guard InputValidator.validatePeerID(senderID),
InputValidator.validatePeerID(receiverID) else { return nil }
return ProtocolNack(originalPacketID: originalPacketID, return ProtocolNack(originalPacketID: originalPacketID,
nackID: nackID, nackID: nackID,
@@ -839,23 +860,28 @@ struct NoiseIdentityAnnouncement: Codable {
// Read peerID using safe method // Read peerID using safe method
guard let peerIDBytes = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } guard let peerIDBytes = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let peerID = peerIDBytes.hexEncodedString() let peerID = peerIDBytes.hexEncodedString()
guard InputValidator.validatePeerID(peerID) else { return nil }
guard let publicKey = dataCopy.readData(at: &offset), guard let publicKey = dataCopy.readData(at: &offset),
InputValidator.validatePublicKey(publicKey),
let signingPublicKey = dataCopy.readData(at: &offset), let signingPublicKey = dataCopy.readData(at: &offset),
InputValidator.validatePublicKey(signingPublicKey),
let rawNickname = dataCopy.readString(at: &offset), let rawNickname = dataCopy.readString(at: &offset),
let timestamp = dataCopy.readDate(at: &offset) else { return nil } let nickname = InputValidator.validateNickname(rawNickname),
let timestamp = dataCopy.readDate(at: &offset),
// Trim whitespace from nickname InputValidator.validateTimestamp(timestamp) else { return nil }
let nickname = rawNickname.trimmingCharacters(in: .whitespacesAndNewlines)
var previousPeerID: String? = nil var previousPeerID: String? = nil
if hasPreviousPeerID { if hasPreviousPeerID {
// Read previousPeerID using safe method // Read previousPeerID using safe method
guard let prevIDBytes = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } guard let prevIDBytes = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
previousPeerID = prevIDBytes.hexEncodedString() let prevID = prevIDBytes.hexEncodedString()
guard InputValidator.validatePeerID(prevID) else { return nil }
previousPeerID = prevID
} }
guard let signature = dataCopy.readData(at: &offset) else { return nil } guard let signature = dataCopy.readData(at: &offset),
InputValidator.validateSignature(signature) else { return nil }
return NoiseIdentityAnnouncement(peerID: peerID, return NoiseIdentityAnnouncement(peerID: peerID,
publicKey: publicKey, publicKey: publicKey,
@@ -2424,6 +2424,45 @@ class BluetoothMeshService: NSObject {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.delegate?.didUpdatePeerList([]) self?.delegate?.didUpdatePeerList([])
} }
// Restart services after a short delay to create new identity
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.restartAfterPanic()
}
}
private func restartAfterPanic() {
SecureLogger.log("Restarting mesh services after panic mode", category: SecureLogger.session, level: .info)
// Regenerate peer ID with new identity
myPeerID = generateNewPeerID()
// Reset identity tracking since we have a new identity
peerIDToFingerprint.removeAll()
fingerprintToPeerID.removeAll()
peerIdentityBindings.removeAll()
// Reset rotation tracking
rotationTimestamp = nil
rotationLocked = false
// Restart advertising if peripheral is powered on
if peripheralManager?.state == .poweredOn {
setupPeripheral()
startAdvertising()
// Send announce after restart with new identity
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.sendBroadcastAnnounce()
}
}
// Restart scanning if central is powered on
if centralManager?.state == .poweredOn {
startScanning()
}
SecureLogger.log("Mesh services restarted with new peer ID: \(myPeerID)", category: SecureLogger.session, level: .info)
} }
private func getAllConnectedPeerIDs() -> [String] { private func getAllConnectedPeerIDs() -> [String] {
+80 -19
View File
@@ -88,10 +88,29 @@ class KeychainManager {
private func isSandboxed() -> Bool { private func isSandboxed() -> Bool {
#if os(macOS) #if os(macOS)
// More robust sandbox detection using multiple methods
// Method 1: Check environment variable (can be spoofed)
let environment = ProcessInfo.processInfo.environment let environment = ProcessInfo.processInfo.environment
return environment["APP_SANDBOX_CONTAINER_ID"] != nil let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
// Method 2: Check if we can access a path outside sandbox
let homeDir = FileManager.default.homeDirectoryForCurrentUser
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
if canWriteOutsideSandbox {
try? FileManager.default.removeItem(at: testPath)
}
// Method 3: Check container path
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
let hasContainerPath = containerPath.contains("/Containers/")
// If any method indicates sandbox, we consider it sandboxed
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
#else #else
return false // iOS is always sandboxed
return true
#endif #endif
} }
@@ -139,10 +158,9 @@ class KeychainManager {
// Add a label for easier debugging // Add a label for easier debugging
query[kSecAttrLabel as String] = "bitchat-\(key)" query[kSecAttrLabel as String] = "bitchat-\(key)"
// For sandboxed apps, use the app group for sharing between app instances // Always use app group when available for consistent behavior
if isSandboxed() { // This ensures keychain items are properly isolated to our app
query[kSecAttrAccessGroup as String] = appGroup query[kSecAttrAccessGroup as String] = appGroup
}
// For sandboxed macOS apps, we need to ensure the item is NOT synchronized // For sandboxed macOS apps, we need to ensure the item is NOT synchronized
#if os(macOS) #if os(macOS)
@@ -176,10 +194,8 @@ class KeychainManager {
kSecMatchLimit as String: kSecMatchLimitOne kSecMatchLimit as String: kSecMatchLimitOne
] ]
// For sandboxed apps, use the app group // Always use app group for consistent behavior
if isSandboxed() { query[kSecAttrAccessGroup as String] = appGroup
query[kSecAttrAccessGroup as String] = appGroup
}
var result: AnyObject? var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result) let status = SecItemCopyMatching(query as CFDictionary, &result)
@@ -201,10 +217,8 @@ class KeychainManager {
kSecAttrService as String: service kSecAttrService as String: service
] ]
// For sandboxed apps, use the app group // Always use app group for consistent behavior
if isSandboxed() { query[kSecAttrAccessGroup as String] = appGroup
query[kSecAttrAccessGroup as String] = appGroup
}
let status = SecItemDelete(query as CFDictionary) let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound return status == errSecSuccess || status == errSecItemNotFound
@@ -253,10 +267,25 @@ class KeychainManager {
var shouldDelete = false var shouldDelete = false
let account = item[kSecAttrAccount as String] as? String ?? "" let account = item[kSecAttrAccount as String] as? String ?? ""
let service = item[kSecAttrService as String] as? String ?? "" let service = item[kSecAttrService as String] as? String ?? ""
let accessGroup = item[kSecAttrAccessGroup as String] as? String
// ONLY delete if service name contains "bitchat" // More precise deletion criteria:
// This is the safest approach - we only touch items we know are ours // 1. Check for our specific app group
if service.lowercased().contains("bitchat") { // 2. OR check for our exact service name
// 3. OR check for known legacy service names
if accessGroup == appGroup {
shouldDelete = true
} else if service == self.service {
shouldDelete = true
} else if [
"com.bitchat.passwords",
"com.bitchat.deviceidentity",
"com.bitchat.noise.identity",
"chat.bitchat.passwords",
"bitchat.keychain",
"bitchat",
"com.bitchat"
].contains(service) {
shouldDelete = true shouldDelete = true
} }
@@ -288,9 +317,10 @@ class KeychainManager {
} }
} }
// Also try to delete by known service names (in case we missed any) // Also try to delete by known service names and app group
// This catches any items that might have been missed above
let knownServices = [ let knownServices = [
"chat.bitchat", self.service, // Current service name
"com.bitchat.passwords", "com.bitchat.passwords",
"com.bitchat.deviceidentity", "com.bitchat.deviceidentity",
"com.bitchat.noise.identity", "com.bitchat.noise.identity",
@@ -312,11 +342,42 @@ class KeychainManager {
} }
} }
// Also delete by app group to ensure complete cleanup
let groupQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccessGroup as String: appGroup
]
let groupStatus = SecItemDelete(groupQuery as CFDictionary)
if groupStatus == errSecSuccess {
totalDeleted += 1
}
SecureLogger.log("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: SecureLogger.keychain, level: .warning) SecureLogger.log("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: SecureLogger.keychain, level: .warning)
return totalDeleted > 0 return totalDeleted > 0
} }
// MARK: - Security Utilities
/// Securely clear sensitive data from memory
static func secureClear(_ data: inout Data) {
_ = data.withUnsafeMutableBytes { bytes in
// Use volatile memset to prevent compiler optimization
memset_s(bytes.baseAddress, bytes.count, 0, bytes.count)
}
data = Data() // Clear the data object
}
/// Securely clear sensitive string from memory
static func secureClear(_ string: inout String) {
// Convert to mutable data and clear
if var data = string.data(using: .utf8) {
secureClear(&data)
}
string = "" // Clear the string object
}
// MARK: - Debug // MARK: - Debug
func verifyIdentityKeyExists() -> Bool { func verifyIdentityKeyExists() -> Bool {
+142
View File
@@ -0,0 +1,142 @@
import Foundation
/// Comprehensive input validation for BitChat protocol
/// Prevents injection attacks, buffer overflows, and malformed data
struct InputValidator {
// MARK: - Constants
struct Limits {
static let maxNicknameLength = 50
static let maxMessageLength = 10_000
static let maxReasonLength = 200
static let maxPeerIDLength = 64
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
}
// MARK: - Peer ID Validation
/// Validates a peer ID from any source
static func validatePeerID(_ peerID: String) -> Bool {
// Handle both hex-encoded (from network) and alphanumeric (internal) formats
if peerID.count == Limits.hexPeerIDLength {
// Network format: 16 hex characters
return peerID.allSatisfy { $0.isHexDigit }
} else {
// Internal format: alphanumeric + dash/underscore
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return peerID.count > 0 &&
peerID.count <= Limits.maxPeerIDLength &&
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
}
}
// MARK: - String Content Validation
/// Validates and sanitizes user-provided strings (nicknames, messages)
static func validateUserString(_ string: String, maxLength: Int, allowNewlines: Bool = false) -> String? {
// Check empty
guard !string.isEmpty else { return nil }
// Trim whitespace
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
// Check length
guard trimmed.count <= maxLength else { return nil }
// Remove control characters except allowed ones
var allowedControlChars = CharacterSet()
if allowNewlines {
allowedControlChars.insert(charactersIn: "\n\r")
}
let controlChars = CharacterSet.controlCharacters.subtracting(allowedControlChars)
let cleaned = trimmed.components(separatedBy: controlChars).joined()
// Ensure valid UTF-8 (should already be, but double-check)
guard cleaned.data(using: .utf8) != nil else { return nil }
// Prevent zero-width characters and other invisible unicode
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
let visible = cleaned.components(separatedBy: invisibleChars).joined()
return visible.isEmpty ? nil : visible
}
/// Validates nickname
static func validateNickname(_ nickname: String) -> String? {
return validateUserString(nickname, maxLength: Limits.maxNicknameLength, allowNewlines: false)
}
/// Validates message content
static func validateMessageContent(_ content: String) -> String? {
return validateUserString(content, maxLength: Limits.maxMessageLength, allowNewlines: true)
}
/// Validates error/reason strings
static func validateReasonString(_ reason: String) -> String? {
return validateUserString(reason, maxLength: Limits.maxReasonLength, allowNewlines: false)
}
// MARK: - Protocol Field Validation
/// Validates message type is within valid range
static func validateMessageType(_ type: UInt8) -> Bool {
// Check against known message types
let validTypes: Set<UInt8> = [
0x01, 0x03, 0x04, 0x05, 0x06, 0x07, 0x0A, 0x0B, 0x0C,
0x10, 0x11, 0x12, 0x13,
0x20, 0x21, 0x22, 0x23, 0x24, 0x25,
0x30, 0x31
]
return validTypes.contains(type)
}
/// Validates hop count is reasonable
static func validateHopCount(_ hopCount: UInt8) -> Bool {
return hopCount <= 10 // Prevent excessive forwarding
}
/// Validates timestamp is reasonable (not too far in past or future)
static func validateTimestamp(_ timestamp: Date) -> Bool {
let now = Date()
let oneHourAgo = now.addingTimeInterval(-3600)
let oneHourFromNow = now.addingTimeInterval(3600)
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
}
/// Validates data size for different contexts
static func validateDataSize(_ data: Data, maxSize: Int) -> Bool {
return data.count > 0 && data.count <= maxSize
}
// MARK: - Binary Data Validation
/// Validates UUID format
static func validateUUID(_ uuid: String) -> Bool {
// Remove dashes and validate hex
let cleaned = uuid.replacingOccurrences(of: "-", with: "")
return cleaned.count == 32 && cleaned.allSatisfy { $0.isHexDigit }
}
/// Validates public key data
static func validatePublicKey(_ keyData: Data) -> Bool {
// Curve25519 public keys are 32 bytes
return keyData.count == 32
}
/// Validates signature data
static func validateSignature(_ signature: Data) -> Bool {
// Ed25519 signatures are 64 bytes
return signature.count == 64
}
}
// MARK: - Character Extensions
private extension Character {
var isHexDigit: Bool {
return "0123456789abcdefABCDEF".contains(self)
}
}