diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 8425646a..032508b2 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -32,6 +32,8 @@ 04636BEB2E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */; }; 04636BED2E30CD4A00FBCFA8 /* 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 */; }; 04891CAA2E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.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 = ""; }; 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = ""; }; + 046D705C2E3C105D00C00594 /* InputValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputValidator.swift; sourceTree = ""; }; 04891CA82E22971E0064A111 /* LRUCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; 04B6BA412E2035530090FE39 /* NoiseProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocol.swift; sourceTree = ""; }; 04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSecurityConsiderations.swift; sourceTree = ""; }; @@ -361,6 +364,7 @@ 9A78348821A7D3374607D4E3 /* Utils */ = { isa = PBXGroup; children = ( + 046D705C2E3C105D00C00594 /* InputValidator.swift */, 04636BBE2E2FCA8A00FBCFA8 /* SecureLogger.swift */, 04891CA82E22971E0064A111 /* LRUCache.swift */, ED176FF3B274E35C2D827894 /* BatteryOptimizer.swift */, @@ -655,6 +659,7 @@ 31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */, 04F127FE2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */, 04F128062E37F10000FFBA8D /* PeerSession.swift in Sources */, + 046D705E2E3C105D00C00594 /* InputValidator.swift in Sources */, 04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */, 04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */, C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */, @@ -696,6 +701,7 @@ 7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */, 04F127FF2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */, 04F128082E37F10000FFBA8D /* PeerSession.swift in Sources */, + 046D705D2E3C105D00C00594 /* InputValidator.swift in Sources */, 04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */, 04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */, CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */, diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index 7777d7b4..97025787 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -149,6 +149,10 @@ class NoiseCipherState { self.useExtractedNonce = useExtractedNonce } + deinit { + clearSensitiveData() + } + func initializeKey(_ key: SymmetricKey) { self.key = key self.nonce = 0 @@ -357,6 +361,21 @@ class NoiseCipherState { 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.. [UInt8] { var result = [UInt8]() 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) 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 } diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 57a7ff39..eec0d147 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -355,10 +355,14 @@ struct DeliveryAck: Codable { guard let recipientIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } let recipientID = recipientIDData.hexEncodedString() + guard InputValidator.validatePeerID(recipientID) else { return nil } guard let hopCount = dataCopy.readUInt8(at: &offset), + InputValidator.validateHopCount(hopCount), 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, ackID: ackID, @@ -443,9 +447,12 @@ struct ReadReceipt: Codable { guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } let readerID = readerIDData.hexEncodedString() + guard InputValidator.validatePeerID(readerID) else { return nil } 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, receiptID: receiptID, @@ -540,13 +547,17 @@ struct HandshakeRequest: Codable { guard let requesterIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } let requesterID = requesterIDData.hexEncodedString() + guard InputValidator.validatePeerID(requesterID) else { return nil } guard let targetIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } let targetID = targetIDData.hexEncodedString() + guard InputValidator.validatePeerID(targetID) else { return nil } guard let pendingMessageCount = dataCopy.readUInt8(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, requesterID: requesterID, @@ -616,11 +627,16 @@ struct ProtocolAck: Codable { let senderIDData = dataCopy.readFixedBytes(at: &offset, count: 8), let receiverIDData = dataCopy.readFixedBytes(at: &offset, count: 8), let packetType = dataCopy.readUInt8(at: &offset), + InputValidator.validateMessageType(packetType), 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 receiverID = receiverIDData.hexEncodedString() + guard InputValidator.validatePeerID(senderID), + InputValidator.validatePeerID(receiverID) else { return nil } return ProtocolAck(originalPacketID: originalPacketID, ackID: ackID, @@ -706,12 +722,17 @@ struct ProtocolNack: Codable { let senderIDData = dataCopy.readFixedBytes(at: &offset, count: 8), let receiverIDData = dataCopy.readFixedBytes(at: &offset, count: 8), let packetType = dataCopy.readUInt8(at: &offset), + InputValidator.validateMessageType(packetType), let errorCode = dataCopy.readUInt8(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 receiverID = receiverIDData.hexEncodedString() + guard InputValidator.validatePeerID(senderID), + InputValidator.validatePeerID(receiverID) else { return nil } return ProtocolNack(originalPacketID: originalPacketID, nackID: nackID, @@ -839,23 +860,28 @@ struct NoiseIdentityAnnouncement: Codable { // Read peerID using safe method guard let peerIDBytes = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } let peerID = peerIDBytes.hexEncodedString() + guard InputValidator.validatePeerID(peerID) else { return nil } guard let publicKey = dataCopy.readData(at: &offset), + InputValidator.validatePublicKey(publicKey), let signingPublicKey = dataCopy.readData(at: &offset), + InputValidator.validatePublicKey(signingPublicKey), let rawNickname = dataCopy.readString(at: &offset), - let timestamp = dataCopy.readDate(at: &offset) else { return nil } - - // Trim whitespace from nickname - let nickname = rawNickname.trimmingCharacters(in: .whitespacesAndNewlines) + let nickname = InputValidator.validateNickname(rawNickname), + let timestamp = dataCopy.readDate(at: &offset), + InputValidator.validateTimestamp(timestamp) else { return nil } var previousPeerID: String? = nil if hasPreviousPeerID { // Read previousPeerID using safe method 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, publicKey: publicKey, diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index deb18f50..d77b06fe 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -2424,6 +2424,45 @@ class BluetoothMeshService: NSObject { DispatchQueue.main.async { [weak self] in 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] { diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index 7f716acc..5eee249c 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -88,10 +88,29 @@ class KeychainManager { private func isSandboxed() -> Bool { #if os(macOS) + // More robust sandbox detection using multiple methods + + // Method 1: Check environment variable (can be spoofed) 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 - return false + // iOS is always sandboxed + return true #endif } @@ -139,10 +158,9 @@ class KeychainManager { // Add a label for easier debugging query[kSecAttrLabel as String] = "bitchat-\(key)" - // For sandboxed apps, use the app group for sharing between app instances - if isSandboxed() { - query[kSecAttrAccessGroup as String] = appGroup - } + // Always use app group when available for consistent behavior + // This ensures keychain items are properly isolated to our app + query[kSecAttrAccessGroup as String] = appGroup // For sandboxed macOS apps, we need to ensure the item is NOT synchronized #if os(macOS) @@ -176,10 +194,8 @@ class KeychainManager { kSecMatchLimit as String: kSecMatchLimitOne ] - // For sandboxed apps, use the app group - if isSandboxed() { - query[kSecAttrAccessGroup as String] = appGroup - } + // Always use app group for consistent behavior + query[kSecAttrAccessGroup as String] = appGroup var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -201,10 +217,8 @@ class KeychainManager { kSecAttrService as String: service ] - // For sandboxed apps, use the app group - if isSandboxed() { - query[kSecAttrAccessGroup as String] = appGroup - } + // Always use app group for consistent behavior + query[kSecAttrAccessGroup as String] = appGroup let status = SecItemDelete(query as CFDictionary) return status == errSecSuccess || status == errSecItemNotFound @@ -253,10 +267,25 @@ class KeychainManager { var shouldDelete = false let account = item[kSecAttrAccount 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" - // This is the safest approach - we only touch items we know are ours - if service.lowercased().contains("bitchat") { + // More precise deletion criteria: + // 1. Check for our specific app group + // 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 } @@ -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 = [ - "chat.bitchat", + self.service, // Current service name "com.bitchat.passwords", "com.bitchat.deviceidentity", "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) 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 func verifyIdentityKeyExists() -> Bool { diff --git a/bitchat/Utils/InputValidator.swift b/bitchat/Utils/InputValidator.swift new file mode 100644 index 00000000..3b00e9ad --- /dev/null +++ b/bitchat/Utils/InputValidator.swift @@ -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 = [ + 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) + } +} \ No newline at end of file