diff --git a/Sources/IRCKit/Extensions/DateFormatter.swift b/Sources/IRCKit/Extensions/DateFormatter.swift index f55aa66..9107d98 100644 --- a/Sources/IRCKit/Extensions/DateFormatter.swift +++ b/Sources/IRCKit/Extensions/DateFormatter.swift @@ -34,3 +34,14 @@ extension DateFormatter { return formatter }() } + +extension Date { + /// Parses an IRC server timestamp, expressed as whole seconds since the Unix epoch (e.g. the + /// `setat` field of RPL_TOPICWHOTIME and the creation time of RPL_CREATIONTIME). + static func fromUnixTimestamp(_ string: String) -> Date? { + guard let seconds = TimeInterval(string) else { + return nil + } + return Date(timeIntervalSince1970: seconds) + } +} diff --git a/Sources/IRCKit/Extensions/String.swift b/Sources/IRCKit/Extensions/String.swift index 6862bc7..f5fee98 100644 --- a/Sources/IRCKit/Extensions/String.swift +++ b/Sources/IRCKit/Extensions/String.swift @@ -35,24 +35,6 @@ extension Array where Element == String { } } -extension Dictionary where Key == String, Value == String { - func keyValueString(joinedBy separator: String) -> String { - return self.map({ (kv: (String, String)) -> String in - let (key, value) = kv - return "\(key)=\(value)" - }).joined(separator: separator) - } -} - -extension Dictionary where Key == String, Value == String? { - func keyValueString(joinedBy separator: String) -> String { - return self.map({ (kv: (String, String?)) -> String in - let (key, value) = kv - return value != nil ? "\(key)=\(value!)" : key - }).joined(separator: separator) - } -} - extension Array where Element == UInt8 { func xor(with key: [UInt8]) -> String? { if self.isEmpty { @@ -77,6 +59,44 @@ extension String { return tokens.keyValuePairs() } + /// Removes the characters forbidden inside an IRC parameter (NUL, CR, LF) so caller-supplied + /// text cannot terminate the line and inject additional commands. Filtering operates on unicode + /// scalars because Swift treats a CR+LF pair as a single grapheme cluster. + func ircParameterSanitized() -> String { + return String(String.UnicodeScalarView(self.unicodeScalars.filter({ + $0 != "\0" && $0 != "\r" && $0 != "\n" + }))) + } + + /// Folds a nickname or channel name to a canonical lowercase form for case-insensitive + /// comparison, honouring the server's CASEMAPPING (RFC 2812 §2.2). `rfc1459` additionally treats + /// `[]\~` as the uppercase forms of `{}|^`; `rfc1459-strict` does the same except for `~`; + /// `ascii` folds only A–Z. Defaults to `rfc1459` when the server has not advertised a mapping. + func ircCaseFolded(mapping: String?) -> String { + let mapping = mapping?.lowercased() ?? "rfc1459" + let foldsBrackets = mapping == "rfc1459" || mapping == "rfc1459-strict" + let foldsTilde = mapping == "rfc1459" + + var folded = String.UnicodeScalarView() + for scalar in self.unicodeScalars { + switch scalar.value { + case 0x41...0x5A: // A–Z + folded.append(UnicodeScalar(scalar.value + 32) ?? scalar) + case 0x5B where foldsBrackets: // [ + folded.append("{") + case 0x5D where foldsBrackets: // ] + folded.append("}") + case 0x5C where foldsBrackets: // \ + folded.append("|") + case 0x7E where foldsTilde: // ~ + folded.append("^") + default: + folded.append(scalar) + } + } + return String(folded) + } + static func random(length: Int = 20) -> String { let base = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" var randomString: String = "" @@ -92,4 +112,48 @@ extension String { return randomString } + /// Escapes this string for use as an IRCv3 message-tag value, per the message-tags spec: + /// `\` → `\\`, `;` → `\:`, space → `\s`, CR → `\r`, LF → `\n`. + func ircTagValueEscaped() -> String { + var escaped = "" + escaped.reserveCapacity(self.count) + for character in self { + switch character { + case "\\": escaped += "\\\\" + case ";": escaped += "\\:" + case " ": escaped += "\\s" + case "\r": escaped += "\\r" + case "\n": escaped += "\\n" + default: escaped.append(character) + } + } + return escaped + } + + /// Reverses IRCv3 message-tag value escaping. Unrecognised escape sequences resolve to the + /// escaped character itself, and a trailing lone backslash is dropped, both per the spec. + func ircTagValueUnescaped() -> String { + var unescaped = "" + unescaped.reserveCapacity(self.count) + var iterator = self.makeIterator() + while let character = iterator.next() { + guard character == "\\" else { + unescaped.append(character) + continue + } + guard let escaped = iterator.next() else { + break + } + switch escaped { + case ":": unescaped.append(";") + case "s": unescaped.append(" ") + case "\\": unescaped.append("\\") + case "r": unescaped.append("\r") + case "n": unescaped.append("\n") + default: unescaped.append(escaped) + } + } + return unescaped + } + } diff --git a/Sources/IRCKit/IRCChannel.swift b/Sources/IRCKit/IRCChannel.swift index 9f6b8cc..6f87fd7 100644 --- a/Sources/IRCKit/IRCChannel.swift +++ b/Sources/IRCKit/IRCChannel.swift @@ -75,7 +75,7 @@ public class IRCChannel: Equatable, @unchecked Sendable { func set(nickname: String, member: IRCUser) { let existingMemberIndex = self.members.firstIndex(where: { - $0.nickname == nickname + self.client.isSameName($0.nickname, nickname) }) if let existingMemberIndex = existingMemberIndex { self.members[existingMemberIndex] = member @@ -86,7 +86,7 @@ public class IRCChannel: Equatable, @unchecked Sendable { func set(member: IRCUser) { let existingMemberIndex = self.members.firstIndex(where: { - $0.nickname == member.nickname + self.client.isSameName($0.nickname, member.nickname) }) if let existingMemberIndex = existingMemberIndex { self.members[existingMemberIndex] = member @@ -97,13 +97,13 @@ public class IRCChannel: Equatable, @unchecked Sendable { func remove(member: IRCUser) { self.members.removeAll(where: { - member.nickname == $0.nickname + self.client.isSameName(member.nickname, $0.nickname) }) } func remove(named nickname: String) { self.members.removeAll(where: { - $0.nickname == nickname + self.client.isSameName($0.nickname, nickname) }) } @@ -112,15 +112,14 @@ public class IRCChannel: Equatable, @unchecked Sendable { } public func member(named nickname: String) -> IRCUser? { - let nickname = nickname.lowercased() return members.first(where: { - $0.nickname.lowercased() == nickname + self.client.isSameName($0.nickname, nickname) }) } public func member(fromSender sender: IRCSender) -> IRCUser? { return members.first(where: { - $0.nickname == sender.nickname + self.client.isSameName($0.nickname, sender.nickname) }) } diff --git a/Sources/IRCKit/IRCClient/Authentication/Authentication.swift b/Sources/IRCKit/IRCClient/Authentication/Authentication.swift index 642fa57..a8bfa1f 100644 --- a/Sources/IRCKit/IRCClient/Authentication/Authentication.swift +++ b/Sources/IRCKit/IRCClient/Authentication/Authentication.swift @@ -65,7 +65,7 @@ extension IRCClient { } func handleAuthenticationCompleted(message: IRCMessage) { - self.send(command: .CAP, parameters: ["END"]) + self.endCapabilityNegotiation() } func handleAccountChangeServerEvent(message: IRCMessage) { @@ -73,10 +73,13 @@ extension IRCClient { return } + guard let account = message.parameters[safe: 0] else { + return + } + for channel in self.channels { if let member = channel.member(fromSender: sender) { - - member.account = message.parameters[0] != "*" ? message.parameters[0] : nil + member.account = account != "*" ? account : nil channel.set(member: member) } } diff --git a/Sources/IRCKit/IRCClient/Authentication/SASLExternal.swift b/Sources/IRCKit/IRCClient/Authentication/SASLExternal.swift index 1eefbf1..9d92219 100644 --- a/Sources/IRCKit/IRCClient/Authentication/SASLExternal.swift +++ b/Sources/IRCKit/IRCClient/Authentication/SASLExternal.swift @@ -34,7 +34,10 @@ class ExternalSASLHandler: SASLHandler { } func handleResponse(message: IRCMessage) { - if message.parameters[0] == "+" { + guard let response = message.parameters[safe: 0] else { + return + } + if response == "+" { client.sendAuthenticate(message: "+") } else { client.abortSaslAuthentication() diff --git a/Sources/IRCKit/IRCClient/Authentication/SASLPlainText.swift b/Sources/IRCKit/IRCClient/Authentication/SASLPlainText.swift index c1e8a61..5b51e39 100644 --- a/Sources/IRCKit/IRCClient/Authentication/SASLPlainText.swift +++ b/Sources/IRCKit/IRCClient/Authentication/SASLPlainText.swift @@ -34,7 +34,10 @@ class PlainTextSASLHandler: SASLHandler { } func handleResponse(message: IRCMessage) { - if message.parameters[0] == "+" { + guard let response = message.parameters[safe: 0] else { + return + } + if response == "+" { guard let password = client.configuration.authenticationPassword else { client.abortSaslAuthentication() return diff --git a/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift b/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift index 3d1f87e..06bca16 100644 --- a/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift +++ b/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift @@ -1,18 +1,18 @@ /* Copyright 2020 The Fuel Rats Mischief - + Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - + 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - + 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - + 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. - + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, @@ -25,11 +25,22 @@ import Foundation import CryptoSwift +/// SCRAM-SHA-256 SASL authentication, per RFC 5802 (SCRAM) and RFC 7677 (SHA-256 profile). +/// +/// The exchange is: client-first (`n,,n=user,r=nonce`) → server-first (`r=,s=,i=`) → +/// client-final (`c=,r=,p=proof`) → server-final (`v=signature`). The client derives its proof from +/// the salted password and verifies the server's signature before completing. class Sha256SASLHandler: SASLHandler { static let mechanism = "SCRAM-SHA-256" var client: IRCClient - var nonce: String? - var serverSignature: [UInt8]? + + /// Overrides the randomly generated client nonce. This is a test-only seam for reproducing the + /// fixed RFC 7677 vector; it is nil in normal operation. + var clientNonceOverride: String? + + private var clientNonce: String? + private var clientFirstMessageBare: String? + private var expectedServerSignature: [UInt8]? required init(client: IRCClient) { self.client = client @@ -37,123 +48,180 @@ class Sha256SASLHandler: SASLHandler { } func handleResponse(message: IRCMessage) { - if message.parameters[0] == "+" { - self.sendAuthenticationChallenge() + guard let response = message.parameters[safe: 0] else { + return + } + if response == "+" { + self.sendClientFirstMessage() } else { - parseSASLScramResponse(message: message) + self.handleServerMessage(response) } } - func sendAuthenticationChallenge() { - let username = self.client.configuration.authenticationUsername ?? self.client.configuration.username - let nonce = String.random(length: 32) - self.nonce = nonce + private func sendClientFirstMessage() { + let username = Sha256SASLHandler.encodeUsername( + self.client.configuration.authenticationUsername ?? self.client.configuration.username + ) + let clientNonce = self.clientNonceOverride ?? String.random(length: 32) + self.clientNonce = clientNonce - let challenge = "n,,n=\(username),r=\(nonce)" - guard let encodedChallenge = challenge.data(using: .utf8)?.base64EncodedString() else { + let clientFirstMessageBare = "n=\(username),r=\(clientNonce)" + self.clientFirstMessageBare = clientFirstMessageBare + + // gs2-header "n,," — no channel binding, no authzid. + let clientFirstMessage = "n,,\(clientFirstMessageBare)" + guard let encoded = clientFirstMessage.data(using: .utf8)?.base64EncodedString() else { self.client.abortSaslAuthentication() return } - - self.client.sendAuthenticate(message: encodedChallenge) + self.client.sendAuthenticate(message: encoded) } - func parseSASLScramResponse(message: IRCMessage) { + private func handleServerMessage(_ response: String) { guard - let scramData = Data(base64Encoded: message.parameters[0]), - let scramMessage = String(data: scramData, encoding: .utf8) + let data = Data(base64Encoded: response), + let message = String(data: data, encoding: .utf8) else { + self.client.abortSaslAuthentication() return } - let scramParams = scramMessage.keyValuePairs(separatedBy: ",") + let attributes = Sha256SASLHandler.parseAttributes(message) - guard scramParams["e"] == nil else { + guard attributes["e"] == nil else { self.client.abortSaslAuthentication() return } - if let verification = scramParams["v"] as? String { - scramSha256Verify(verification: verification) + if let verifier = attributes["v"] { + self.verifyServerSignature(verifier) return } guard - let nonce = scramParams["r"] as? String, - let salt = scramParams["s"] as? String, - let iterationCount = Int(scramParams["i"] as? String ?? "") + let fullNonce = attributes["r"], + let encodedSalt = attributes["s"], + let salt = Data(base64Encoded: encodedSalt), + let iterationString = attributes["i"], + let iterations = Int(iterationString), iterations > 0, + let clientNonce = self.clientNonce, + fullNonce.hasPrefix(clientNonce) else { + self.client.abortSaslAuthentication() return } - scramSha256Authenticate(salt: salt, nonce: nonce, iterationCount: iterationCount, message: scramMessage) + self.sendClientFinalMessage( + serverFirstMessage: message, + fullNonce: fullNonce, + salt: Array(salt), + iterations: iterations + ) } - /* Thank you to github.com/moortens for documenting how this works in their "yoil" IRC Library - because the people who made the specification sure didn't bother to. */ - func scramSha256Authenticate(salt: String, nonce: String, iterationCount: Int, message: String) { + private func sendClientFinalMessage( + serverFirstMessage: String, + fullNonce: String, + salt: [UInt8], + iterations: Int + ) { guard let password = self.client.configuration.authenticationPassword, - let saltedPassword = pbkdf2(password: password, salt: salt, iteration: iterationCount), + let clientFirstMessageBare = self.clientFirstMessageBare, + let saltedPassword = Sha256SASLHandler.pbkdf2(password: password, salt: salt, iterations: iterations), let clientKey = try? HMAC(key: saltedPassword, variant: .sha2(.sha256)) .authenticate(Array("Client Key".utf8)), let serverKey = try? HMAC(key: saltedPassword, variant: .sha2(.sha256)) - .authenticate(Array("Server Key".utf8)), - let base64Message = message.data(using: .utf8)?.base64EncodedString() + .authenticate(Array("Server Key".utf8)) else { self.client.abortSaslAuthentication() return } let storedKey = Digest.sha256(clientKey) - let username = self.client.configuration.authenticationUsername ?? self.client.configuration.username - let authMessage = [ - "n": username, - "r": nonce, - base64Message: nil, - "c": "biws" - ].keyValueString(joinedBy: ",") + + // The gs2-header "n,," base64-encoded is the constant "biws". + let channelBinding = Data("n,,".utf8).base64EncodedString() + let clientFinalMessageWithoutProof = "c=\(channelBinding),r=\(fullNonce)" + let authMessage = "\(clientFirstMessageBare),\(serverFirstMessage),\(clientFinalMessageWithoutProof)" guard let clientSignature = try? HMAC(key: storedKey, variant: .sha2(.sha256)) .authenticate(Array(authMessage.utf8)), let serverSignature = try? HMAC(key: serverKey, variant: .sha2(.sha256)) .authenticate(Array(authMessage.utf8)), - let clientXor = clientKey.xor(with: clientSignature) + let clientProof = clientKey.xor(with: clientSignature) else { self.client.abortSaslAuthentication() return } - self.serverSignature = serverSignature - - let final = [ - "c": "biws", - "r": nonce, - "p": clientXor - ].keyValueString(joinedBy: ",") + self.expectedServerSignature = serverSignature - guard let base64Final = final.data(using: .utf8)?.base64EncodedString() else { + let clientFinalMessage = "\(clientFinalMessageWithoutProof),p=\(clientProof)" + guard let encoded = clientFinalMessage.data(using: .utf8)?.base64EncodedString() else { self.client.abortSaslAuthentication() return } - - self.client.sendAuthenticate(message: base64Final) + self.client.sendAuthenticate(message: encoded) } - func scramSha256Verify(verification: String) { - if verification.bytes == self.serverSignature { + private func verifyServerSignature(_ verifier: String) { + guard + let expected = self.expectedServerSignature, + let decoded = Data(base64Encoded: verifier) + else { + self.client.abortSaslAuthentication() + return + } + + if Sha256SASLHandler.constantTimeEquals(Array(decoded), expected) { self.client.sendAuthenticate(message: "+") } else { self.client.abortSaslAuthentication() } } -} -private func pbkdf2(password: String, salt: String, iteration: Int) -> [UInt8]? { - return try? PKCS5.PBKDF2( - password: Array(password.utf8), - salt: Array(salt.utf8), - iterations: iteration, variant: .sha2(.sha256) - ).calculate() + /// Parses a SCRAM message into its single-letter attributes. Values may themselves contain `=` + /// (e.g. base64 salt padding), so only the first `=` separates key from value. + private static func parseAttributes(_ message: String) -> [String: String] { + var attributes: [String: String] = [:] + for token in message.split(separator: ",") { + let key = String(token.prefix(1)) + let afterKey = token.dropFirst() + guard afterKey.first == "=" else { + continue + } + attributes[key] = String(afterKey.dropFirst()) + } + return attributes + } + + /// Escapes a SASL username for the `n=` attribute, per RFC 5802: `=` → `=3D`, `,` → `=2C`. + /// The `=` substitution runs first so it does not corrupt the escapes it introduces. + private static func encodeUsername(_ username: String) -> String { + return username + .replacingOccurrences(of: "=", with: "=3D") + .replacingOccurrences(of: ",", with: "=2C") + } + + private static func pbkdf2(password: String, salt: [UInt8], iterations: Int) -> [UInt8]? { + return try? PKCS5.PBKDF2( + password: Array(password.utf8), + salt: salt, + iterations: iterations, + variant: .sha2(.sha256) + ).calculate() + } + + private static func constantTimeEquals(_ lhs: [UInt8], _ rhs: [UInt8]) -> Bool { + guard lhs.count == rhs.count else { + return false + } + var difference: UInt8 = 0 + for (left, right) in zip(lhs, rhs) { + difference |= left ^ right + } + return difference == 0 + } } diff --git a/Sources/IRCKit/IRCClient/Handlers/CAP.swift b/Sources/IRCKit/IRCClient/Handlers/CAP.swift index feb4bb4..e8d81ee 100644 --- a/Sources/IRCKit/IRCClient/Handlers/CAP.swift +++ b/Sources/IRCKit/IRCClient/Handlers/CAP.swift @@ -26,7 +26,9 @@ import Foundation extension IRCClient { func handleIRCv3CapabilityReply(message: IRCMessage) { - let capProtocolCommand = message.parameters[1] + guard let capProtocolCommand = message.parameters[safe: 1] else { + return + } switch capProtocolCommand { case "LS": capNegotiation(message: message) @@ -34,6 +36,10 @@ extension IRCClient { capNegotiationComplete(message: message) case "NAK": capNegotiationFailed(message: message) + case "NEW": + capabilitiesAdded(message: message) + case "DEL": + capabilitiesRemoved(message: message) default: break } @@ -46,7 +52,9 @@ extension IRCClient { // final line omits the `*`. Accumulate the tokens and only negotiate once the // complete list has arrived. let isMultilineContinuation = message.parameters.count > 3 && message.parameters[2] == "*" - let capString = isMultilineContinuation ? message.parameters[3] : message.parameters[2] + guard let capString = message.parameters[safe: isMultilineContinuation ? 3 : 2] else { + return + } if self.capabilityLSBuffer.isEmpty { self.capabilityLSBuffer = capString @@ -63,7 +71,7 @@ extension IRCClient { let caps = IRCv3CapabilityInfo.from(string: fullCapString) if let strictTransportInfo = caps.keyValuePairs(cap: .strictTransportSecurity) { - if let port = Int.parse(strictTransportInfo["port"]!), port != self.configuration.serverPort { + if let port = Int.parse(strictTransportInfo["port"] ?? nil), port != self.configuration.serverPort { self.configuration.serverPort = port self.configuration.prefersInsecureConnection = false self.connection.disconnect() @@ -77,10 +85,25 @@ extension IRCClient { } func capNegotiationComplete(message: IRCMessage) { - let acceptedCapabilities = IRCv3Capability.list(fromString: message.parameters[2]) - self.serverInfo.enabledIRCv3Capabilities = acceptedCapabilities + guard let capList = message.parameters[safe: 2] else { + if self.capabilityNegotiationComplete == false { + self.endCapabilityNegotiation() + } + return + } - let caps = IRCv3CapabilityInfo.from(string: message.parameters[2]) + let acceptedCapabilities = IRCv3Capability.list(fromString: capList) + for capability in acceptedCapabilities where self.hasIRCv3Capability(capability) == false { + self.serverInfo.enabledIRCv3Capabilities.append(capability) + } + + // An ACK received after the initial handshake (e.g. in response to a CAP NEW) only updates + // the enabled set; it must not restart SASL or re-send CAP END. + guard self.capabilityNegotiationComplete == false else { + return + } + + let caps = IRCv3CapabilityInfo.from(string: capList) if let saslCap = caps[.sasl] as? [String] { let mechanisms = saslCap.compactMap({ (serverMechanism: String) -> SASLHandler.Type? in return IRCClient.supportedHandlers.first(where: { (clientHandler: SASLHandler.Type) -> Bool in @@ -99,10 +122,43 @@ extension IRCClient { return } } - self.send(command: .CAP, parameters: ["END"]) + self.endCapabilityNegotiation() } func capNegotiationFailed(message: IRCMessage) { + // A NAK after the handshake (a rejected CAP NEW request) requires no action. + guard self.capabilityNegotiationComplete == false else { + return + } + self.endCapabilityNegotiation() + } + + /// Handles `CAP NEW` (cap-notify): records the newly-available capabilities and requests any the + /// client understands and has not already enabled. + func capabilitiesAdded(message: IRCMessage) { + guard let capString = message.parameters[safe: 2] else { + return + } + let caps = IRCv3CapabilityInfo.from(string: capString) + for capability in caps.keys where self.serverInfo.supportedIRCv3Capabilities.contains(capability) == false { + self.serverInfo.supportedIRCv3Capabilities.append(capability) + } + let capabilitiesToRequest = Array(caps.keys).filter({ self.hasIRCv3Capability($0) == false }) + self.requestIRCv3Capabilities(capabilities: capabilitiesToRequest) + } + + /// Handles `CAP DEL` (cap-notify): the server has withdrawn the given capabilities. + func capabilitiesRemoved(message: IRCMessage) { + guard let capString = message.parameters[safe: 2] else { + return + } + let removed = IRCv3Capability.list(fromString: capString) + self.serverInfo.enabledIRCv3Capabilities.removeAll(where: { removed.contains($0) }) + self.serverInfo.supportedIRCv3Capabilities.removeAll(where: { removed.contains($0) }) + } + + func endCapabilityNegotiation() { + self.capabilityNegotiationComplete = true self.send(command: .CAP, parameters: ["END"]) } diff --git a/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift b/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift index 7ef2784..a8bdeab 100644 --- a/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift +++ b/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift @@ -53,17 +53,20 @@ extension IRCClient { account: nil ) + guard let channelName = message.parameters[safe: 0] else { + return + } + if self.hasIRCv3Capability(.extendedJoin) { - let account = message.parameters[1] - if account != "*" { + if let account = message.parameters[safe: 1], account != "*" { user.account = account } - user.realName = message.parameters[2] + user.realName = message.parameters[safe: 2] } if sender.isCurrentUser(client: self) { - let channel = IRCChannel(channelName: message.parameters[0], onClient: self) + let channel = IRCChannel(channelName: channelName, onClient: self) channel.add(member: user) self.addChannel(channel: channel) @@ -84,7 +87,7 @@ extension IRCClient { raw: message )).post() } else { - guard let channel = self.getChannel(named: message.parameters[0]) else { + guard let channel = self.getChannel(named: channelName) else { return } channel.set(member: user) @@ -105,7 +108,11 @@ extension IRCClient { return } - guard let channel = self.getChannel(named: message.parameters[0]) else { + guard let channelName = message.parameters[safe: 0] else { + return + } + + guard let channel = self.getChannel(named: channelName) else { return } guard let user = channel.member(fromSender: sender) else { @@ -114,14 +121,14 @@ extension IRCClient { channel.remove(member: user) if sender.isCurrentUser(client: self) { - self.removeChannel(named: message.parameters[0]) + self.removeChannel(named: channelName) } IRCUserLeftChannelNotification().encode(payload: IRCChannelEvent( id: message.label, user: user, channel: channel, - message: message.parameters[safe: 0], + message: message.parameters[safe: 1], event: .Part, raw: message )).post() @@ -132,18 +139,22 @@ extension IRCClient { return } - guard let channel = self.getChannel(named: message.parameters[0]) else { + guard let channelName = message.parameters[safe: 0], let kickTarget = message.parameters[safe: 1] else { + return + } + + guard let channel = self.getChannel(named: channelName) else { return } - guard let kickUser = channel.member(named: message.parameters[1]) else { + guard let kickUser = channel.member(named: kickTarget) else { return } channel.remove(member: kickUser) if sender.isCurrentUser(client: self) { - self.removeChannel(named: message.parameters[0]) + self.removeChannel(named: channelName) } IRCChannelKickNotification().encode(payload: IRCChannelKickNotification.IRCChannelKick( @@ -160,14 +171,18 @@ extension IRCClient { return } - guard let channel = self.getChannel(named: message.parameters[0]) else { + // INVITE is ` `. The channel is often one we are not a member of + // (a plain invite), so fall back to a transient channel when it is not already tracked. + guard let invitedNick = message.parameters[safe: 0], let channelName = message.parameters[safe: 1] else { return } + let channel = self.getChannel(named: channelName) ?? IRCChannel(channelName: channelName, onClient: self) + IRCChannelInviteNotification().encode(payload: IRCChannelInviteNotification.IRCChannelInvite( sender: sender, channel: channel, - invitedNick: message.parameters[1], + invitedNick: invitedNick, raw: message )).post() } @@ -177,30 +192,37 @@ extension IRCClient { return } - guard let channel = self.getChannel(named: message.parameters[0]) else { + guard let channelName = message.parameters[safe: 0], let topicContents = message.parameters[safe: 1] else { + return + } + + guard let channel = self.getChannel(named: channelName) else { return } - channel.topic = IRCChannel.Topic(contents: message.parameters[1], author: sender.nickname, date: message.time) + channel.topic = IRCChannel.Topic(contents: topicContents, author: sender.nickname, date: message.time) let user = channel.member(fromSender: sender) IRCChannelTopicChangeNotification().encode(payload: IRCChannelTopicChangeNotification.IRCChannelTopicChange( user: user, channel: channel, - contents: message.parameters[1], + contents: topicContents, raw: message )).post() } func handleChannelModeChangeEvent(message: IRCMessage) { - guard let channel = self.getChannel(named: message.parameters[0]) else { + guard let channelName = message.parameters[safe: 0], let modes = message.parameters[safe: 1] else { + return + } + + guard let channel = self.getChannel(named: channelName) else { return } - let modes = message.parameters[1] var modeArgs = message.parameters[2...] var revoking = false - let user = channel.member(fromSender: message.sender!) + let user = message.sender.flatMap({ channel.member(fromSender: $0) }) for modeChar in Array(modes) { if modeChar == "+" { diff --git a/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift b/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift index dcb0cde..e616e6e 100644 --- a/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift +++ b/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift @@ -104,21 +104,29 @@ extension IRCClient { } func handleTopicInformation(message: IRCMessage) { - let channelName = message.parameters[1] + guard let channelName = message.parameters[safe: 1] else { + return + } guard let channel = self.getChannel(named: channelName) else { return } switch message.command { case .RPL_TOPIC: - channel.topic = IRCChannel.Topic(contents: message.parameters[2], author: nil, date: nil) + if let contents = message.parameters[safe: 2] { + channel.topic = IRCChannel.Topic(contents: contents, author: nil, date: nil) + } case .RPL_TOPICWHOTIME: - guard let date = DateFormatter.iso8601Full.date(from: message.parameters[3]) else { + guard + let author = message.parameters[safe: 2], + let dateString = message.parameters[safe: 3], + let date = Date.fromUnixTimestamp(dateString) + else { return } - channel.topic?.author = message.parameters[2] + channel.topic?.author = author channel.topic?.date = date case .RPL_NOTOPIC: @@ -130,7 +138,9 @@ extension IRCClient { } func handleChannelModeInformation(message: IRCMessage) { - let channelName = message.parameters[1] + guard let channelName = message.parameters[safe: 1] else { + return + } guard let channel = self.getChannel(named: channelName) else { return } @@ -139,12 +149,17 @@ extension IRCClient { } func handleChannelCreatedInformation(message: IRCMessage) { - let channelName = message.parameters[1] + guard let channelName = message.parameters[safe: 1] else { + return + } guard let channel = self.getChannel(named: channelName) else { return } - guard let date = DateFormatter.iso8601Full.date(from: message.parameters[2]) else { + guard + let createdString = message.parameters[safe: 2], + let date = Date.fromUnixTimestamp(createdString) + else { return } diff --git a/Sources/IRCKit/IRCClient/Handlers/Notice.swift b/Sources/IRCKit/IRCClient/Handlers/Notice.swift index e615b8a..43b7cce 100644 --- a/Sources/IRCKit/IRCClient/Handlers/Notice.swift +++ b/Sources/IRCKit/IRCClient/Handlers/Notice.swift @@ -34,11 +34,15 @@ public struct IRCServerNotice: Sendable { extension IRCClient { func handleNoticeEvent(message: IRCMessage) { - if message.sender?.nickname == self.currentNick { + if let senderNick = message.sender?.nickname, self.isSameName(senderNick, self.currentNick) { return } - if let channel = self.getChannel(named: message.parameters[0]) { + guard let target = message.parameters[safe: 0] else { + return + } + + if let channel = self.getChannel(named: target) { self.handleChannelNoticeEvent(message: message, channel: channel) } else { self.handleNonChannelNoticeEvent(message: message) @@ -51,7 +55,9 @@ extension IRCClient { return } - var messageContents = message.parameters[1] + guard var messageContents = message.parameters[safe: 1] else { + return + } if sender.isServer { IRCChannelServerNoticeNotification().encode(payload: IRCServerNotice( client: message.client, @@ -99,7 +105,9 @@ extension IRCClient { return } - var messageContents = message.parameters[1] + guard var messageContents = message.parameters[safe: 1] else { + return + } if sender.isServer { IRCPrivateServerNoticeNotification().encode(payload: IRCServerNotice( @@ -112,7 +120,9 @@ extension IRCClient { return } - let user = IRCUser(fromPrivateMessage: message, onClient: self) + guard let user = IRCUser(fromPrivateMessage: message, onClient: self) else { + return + } let destination = IRCChannel(privateMessage: user, onClient: self) if message.isCTCPReply { diff --git a/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift b/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift index 7d96299..0237377 100644 --- a/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift +++ b/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift @@ -43,12 +43,16 @@ public struct IRCPrivateMessage: IRCNotification, Sendable { extension IRCClient { func handlePrivmsgEvent(message: IRCMessage) { - if message.sender?.nickname == self.currentNick { + if let senderNick = message.sender?.nickname, self.isSameName(senderNick, self.currentNick) { self.handleEchoPrivmsgEvent(message: message) return } - if let channel = self.getChannel(named: message.parameters[0]) { + guard let target = message.parameters[safe: 0] else { + return + } + + if let channel = self.getChannel(named: target) { self.handleChannelPrivmsgEvent(message: message, channel: channel) } else { self.handleNonChannelPrivmsgEvent(message: message) @@ -60,11 +64,15 @@ extension IRCClient { guard let sender = message.sender else { return } - var user = IRCUser(fromPrivateMessage: message, onClient: self) - let destination = self.getChannel(named: message.parameters[0]) + guard let target = message.parameters[safe: 0], let messageContents = message.parameters[safe: 1] else { + return + } + guard var user = IRCUser(fromPrivateMessage: message, onClient: self) else { + return + } + let destination = self.getChannel(named: target) ?? IRCChannel(privateMessage: user, onClient: self) user = destination.member(fromSender: sender) ?? user - let messageContents = message.parameters[1] IRCEchoMessageNotification().encode(payload: IRCPrivateMessage( id: message.label, @@ -81,7 +89,9 @@ extension IRCClient { return } - var messageContents = message.parameters[1] + guard var messageContents = message.parameters[safe: 1] else { + return + } if message.isCTCPRequest { messageContents.remove(at: messageContents.startIndex) @@ -124,11 +134,13 @@ extension IRCClient { } func handleNonChannelPrivmsgEvent(message: IRCMessage) { - guard message.sender != nil else { + guard var messageContents = message.parameters[safe: 1] else { return } - - var messageContents = message.parameters[1] + guard let user = IRCUser(fromPrivateMessage: message, onClient: self) else { + return + } + let destination = IRCChannel(privateMessage: user, onClient: self) if message.isCTCPRequest { messageContents.remove(at: messageContents.startIndex) @@ -138,8 +150,6 @@ extension IRCClient { messageContents = String(messageContents.suffix( from: messageContents.index(messageContents.startIndex, offsetBy: 7)) ) - let user = IRCUser(fromPrivateMessage: message, onClient: self) - let destination = IRCChannel(privateMessage: user, onClient: self) IRCPrivateActionMessageNotification().encode(payload: IRCPrivateMessage( id: message.label, @@ -150,9 +160,6 @@ extension IRCClient { raw: message )).post() } else { - let user = IRCUser(fromPrivateMessage: message, onClient: self) - let destination = IRCChannel(privateMessage: user, onClient: self) - IRCPrivateCTCPRequestNotification().encode(payload: IRCPrivateMessage( id: message.label, client: self, @@ -163,9 +170,6 @@ extension IRCClient { )).post() } } else { - let user = IRCUser(fromPrivateMessage: message, onClient: self) - let destination = IRCChannel(privateMessage: user, onClient: self) - IRCPrivateMessageNotification().encode(payload: IRCPrivateMessage( id: message.label, client: self, diff --git a/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift b/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift index abeb643..e482c40 100644 --- a/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift +++ b/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift @@ -46,9 +46,11 @@ extension IRCClient { return } - let newNick = message.parameters[0] + guard let newNick = message.parameters[safe: 0] else { + return + } - if sender.nickname == self.currentNick { + if self.isSameName(sender.nickname, self.currentNick) { var newSender = sender newSender.nickname = newNick self.currentNick = newNick @@ -87,7 +89,9 @@ extension IRCClient { return } - let realName = message.parameters[0] + guard let realName = message.parameters[safe: 0] else { + return + } for channel in self.channels { if let member = channel.member(fromSender: sender) { @@ -101,13 +105,13 @@ extension IRCClient { return } - let newUser = message.parameters[0] - let newHost = message.parameters[1] + guard let newUser = message.parameters[safe: 0], let newHost = message.parameters[safe: 1] else { + return + } - if sender.nickname == self.currentNick { - var newSender = sender - newSender.username = newUser - newSender.hostmask = newHost + if self.isSameName(sender.nickname, self.currentNick) { + self.currentSender?.username = newUser + self.currentSender?.hostmask = newHost } for channel in self.channels { diff --git a/Sources/IRCKit/IRCClient/IRCClient.swift b/Sources/IRCKit/IRCClient/IRCClient.swift index 66a6f87..fcc55b3 100644 --- a/Sources/IRCKit/IRCClient/IRCClient.swift +++ b/Sources/IRCKit/IRCClient/IRCClient.swift @@ -71,6 +71,10 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { // server sends when the capability list exceeds one IRC message. var capabilityLSBuffer: String = "" + // True once the initial capability handshake has finished (CAP END sent). Used to distinguish a + // mid-session CAP ACK/NAK (from a cap-notify CAP NEW) from the registration handshake. + var capabilityNegotiationComplete = false + public var monitor: Set = [] { didSet { if self.connection.connected && self.serverInfo.supportsMonitor { @@ -129,13 +133,23 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { return } + guard message.parameters.count >= message.command.minimumParameterCount else { + let command = message.command.rawValue + let minimum = message.command.minimumParameterCount + logger.trace("Ignoring \(command): expected at least \(minimum) parameters, got \(message.parameters.count)") + return + } + switch message.command { case .ERROR: logger.warning("Disconnecting due to error") self.connection.disconnect() case .PING: - self.send(command: .PONG, parameters: [":\(message.parameters[0])"]) + guard let token = message.parameters[safe: 0] else { + break + } + self.send(command: .PONG, parameters: [token]) case .RPL_WELCOME: if @@ -211,7 +225,7 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { handleChannelKickEvent(message: message) case .INVITE: - handleChannelKickEvent(message: message) + handleChannelInviteEvent(message: message) case .MODE: handleChannelModeChangeEvent(message: message) @@ -225,24 +239,43 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { case .RPL_HOSTISHIDDEN: handleHostHidden(message: message) + case .RPL_MONONLINE: + handleMonitorOnlineEvent(message: message) + + case .RPL_MONOFFLINE: + handleMonitorOfflineEvent(message: message) + default: break } } + /// Folds a nickname or channel name using the server's CASEMAPPING, for case-insensitive + /// comparison of the two. + func caseFold(_ name: String) -> String { + return name.ircCaseFolded(mapping: self.serverInfo.caseMapping) + } + + /// Whether two nicknames or channel names refer to the same entity under the server's CASEMAPPING. + func isSameName(_ lhs: String, _ rhs: String) -> Bool { + return self.caseFold(lhs) == self.caseFold(rhs) + } + func getChannel(named channelName: String) -> IRCChannel? { - return self.channels.first(where: { $0.name == channelName }) + let target = self.caseFold(channelName) + return self.channels.first(where: { self.caseFold($0.name) == target }) } func addChannel(channel: IRCChannel) { - guard self.channels.first(where: { $0.name == channel.name }) == nil else { + guard self.getChannel(named: channel.name) == nil else { return } self.channels.append(channel) } func removeChannel(named channelName: String) { - self.channels.removeAll(where: { $0.name == channelName }) + let target = self.caseFold(channelName) + self.channels.removeAll(where: { self.caseFold($0.name) == target }) } public func send(command: IRCCommand, parameters: String...) { @@ -255,15 +288,16 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { public func send(_ command: String, parameters: [String], tags: [String: String?] = [:]) { var tags = tags - var params = parameters + // Strip NUL/CR/LF so caller-supplied text cannot inject additional commands into the stream. + var params = parameters.map({ $0.ircParameterSanitized() }) if self.hasIRCv3Capability(.labeledResponses) && tags["label"] == nil { tags["label"] = String.random(length: 10) } - /* In IRC if a command has more than one argument, - the last argument can contain spaces if it is prefixed with : */ - let lastParam = params.last ?? "" - if params.count > 1 && lastParam.components(separatedBy: .whitespaces).count > 1 { + /* The last argument is the "trailing" parameter and must be prefixed with ':' when it is + empty, contains a space, or itself begins with ':', otherwise the server would misparse it + (for example a one-word message ":)" would be read as an empty trailing parameter). */ + if let lastParam = params.last, lastParam.isEmpty || lastParam.contains(" ") || lastParam.hasPrefix(":") { params[params.count - 1] = ":" + lastParam } @@ -272,7 +306,7 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { if tags.count > 0 && self.hasIRCv3Capability(.messageTags) { let tagString = tags.map({ (key, value) -> String in if let value = value { - return "\(key)=\(value)" + return "\(key)=\(value.ircTagValueEscaped())" } return key }).joined(separator: ";") diff --git a/Sources/IRCKit/IRCClient/IRCClientSendMethods.swift b/Sources/IRCKit/IRCClient/IRCClientSendMethods.swift index 7a93b44..1dacb59 100644 --- a/Sources/IRCKit/IRCClient/IRCClientSendMethods.swift +++ b/Sources/IRCKit/IRCClient/IRCClientSendMethods.swift @@ -26,6 +26,7 @@ import Foundation extension IRCClient { func sendRegistration() { + self.capabilityNegotiationComplete = false if let password = self.configuration.serverPassword { self.send(command: .PASS, parameters: [password]) } diff --git a/Sources/IRCKit/IRCClient/IRCServerInfo.swift b/Sources/IRCKit/IRCClient/IRCServerInfo.swift index 186c005..616e49d 100644 --- a/Sources/IRCKit/IRCClient/IRCServerInfo.swift +++ b/Sources/IRCKit/IRCClient/IRCServerInfo.swift @@ -62,6 +62,9 @@ public struct IRCServerInfo: @unchecked Sendable { } internal mutating func setServerInfo(parameters: [String]) { + guard parameters.count >= 5 else { + return + } self.serverName = parameters[1] self.serverVersion = parameters[2] @@ -72,6 +75,9 @@ public struct IRCServerInfo: @unchecked Sendable { } internal mutating func setSupported(parameters: [String]) { + guard parameters.count >= 2 else { + return + } var supportEntries = parameters supportEntries.removeFirst() supportEntries.removeLast() diff --git a/Sources/IRCKit/IRCClientConfiguration.swift b/Sources/IRCKit/IRCClientConfiguration.swift index d473eca..f930895 100644 --- a/Sources/IRCKit/IRCClientConfiguration.swift +++ b/Sources/IRCKit/IRCClientConfiguration.swift @@ -61,6 +61,11 @@ public struct IRCClientConfiguration: Codable, Sendable { public var prefersInsecureConnection: Bool = false public var chiperSuite: String? public var clientCertificatePath: String? + + /// When true, TLS certificate verification is disabled entirely — not only the chain of trust + /// but hostname validation as well, which leaves the connection open to man-in-the-middle + /// attacks. Despite the name this is not limited to self-signed certificates; enable it only for + /// controlled testing against a known server, never in production. public var allowsServerSelfSignedCertificate: Bool = false public var channels: [String] = [] diff --git a/Sources/IRCKit/IRCConnection/IRCConnection.swift b/Sources/IRCKit/IRCConnection/IRCConnection.swift index b57d9c8..c41ae9f 100644 --- a/Sources/IRCKit/IRCConnection/IRCConnection.swift +++ b/Sources/IRCKit/IRCConnection/IRCConnection.swift @@ -69,7 +69,7 @@ public class IRCConnection: ChannelInboundHandler, @unchecked Sendable { var sslConfiguration = TLSConfiguration.makeClientConfiguration() sslConfiguration.cipherSuites = configuration.chiperSuite ?? TLSConfiguration.clientDefault.cipherSuites - sslConfiguration.minimumTLSVersion = .tlsv11 + sslConfiguration.minimumTLSVersion = .tlsv12 sslConfiguration.maximumTLSVersion = nil sslConfiguration.certificateVerification = verification sslConfiguration.certificateChain = certificateChain diff --git a/Sources/IRCKit/IRCMessage.swift b/Sources/IRCKit/IRCMessage.swift index cfd74e7..57ce03b 100644 --- a/Sources/IRCKit/IRCMessage.swift +++ b/Sources/IRCKit/IRCMessage.swift @@ -119,8 +119,11 @@ public struct IRCMessage: Sendable { static func parseMessageTags(tagsString: String, client: IRCClient) -> [String: String] { let tagsString = tagsString.suffix(from: tagsString.index(tagsString.startIndex, offsetBy: 1)) return tagsString.split(separator: ";").reduce(into: [String: String](), { tags, tag in - let tagComponents = tag.split(separator: "=") - tags[String(tagComponents[0])] = tagComponents.count > 1 ? String(tagComponents[1]) : nil + let tagComponents = tag.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard let key = tagComponents.first, key.isEmpty == false else { + return + } + tags[String(key)] = tagComponents.count > 1 ? String(tagComponents[1]).ircTagValueUnescaped() : nil }) } } diff --git a/Sources/IRCKit/IRCReply.swift b/Sources/IRCKit/IRCReply.swift index 5379afd..125989a 100644 --- a/Sources/IRCKit/IRCReply.swift +++ b/Sources/IRCKit/IRCReply.swift @@ -189,3 +189,26 @@ public enum IRCReply: String, Sendable { case ERR_SASLALREADY = "907" case RPL_SASLMECHS = "908" } + +extension IRCReply { + /// The minimum number of parameters a message for this command must carry before its handler + /// is allowed to run. Messages with fewer parameters are dropped at dispatch, so a malformed or + /// hostile server cannot drive a handler into an out-of-bounds trap. This is a coarse first line + /// of defence; handlers remain individually defensive via `[safe:]` for parameters they access + /// conditionally (e.g. extended-join fields or optional trailing text). + var minimumParameterCount: Int { + switch self { + case .PING, .NICK, .PART, .SETNAME, .ACCOUNT, .AUTHENTICATE: + return 1 + + case .CAP, .RPL_ISUPPORT, .PRIVMSG, .NOTICE, .KICK, .INVITE, .MODE, .TOPIC, .CHGHOST: + return 2 + + case .RPL_MYINFO: + return 5 + + default: + return 0 + } + } +} diff --git a/Sources/IRCKit/IRCSender.swift b/Sources/IRCKit/IRCSender.swift index 3d55b5c..2ff8402 100644 --- a/Sources/IRCKit/IRCSender.swift +++ b/Sources/IRCKit/IRCSender.swift @@ -61,7 +61,7 @@ public struct IRCSender: CustomStringConvertible, Sendable { } public func isCurrentUser(client: IRCClient) -> Bool { - return self.nickname == client.currentNick + return client.isSameName(self.nickname, client.currentNick) } static func hostmaskComponents(from senderString: String) -> (String, String, String)? { diff --git a/Sources/IRCKit/IRCUser.swift b/Sources/IRCKit/IRCUser.swift index 40d4196..e8c57b2 100644 --- a/Sources/IRCKit/IRCUser.swift +++ b/Sources/IRCKit/IRCUser.swift @@ -69,13 +69,18 @@ public class IRCUser: @unchecked Sendable { self.channelUserModes = userModes } - init (fromPrivateMessage message: IRCMessage, onClient client: IRCClient) { + init? (fromPrivateMessage message: IRCMessage, onClient client: IRCClient) { + guard + let sender = message.sender, + let username = sender.username, + let hostmask = sender.hostmask + else { + return nil + } self.client = client - let sender = message.sender! - self.nickname = sender.nickname - self.username = sender.username! - self.hostmask = sender.hostmask! + self.username = username + self.hostmask = hostmask self.realName = nil self.account = message.account self.isAway = false @@ -113,8 +118,16 @@ public enum IRCChannelUserMode: Character, Sendable { } static func map(fromString prefixString: String) -> [IRCChannelUserMode: Character] { - let prefixLettersStartIndex = prefixString.index(prefixString.firstIndex(of: "(")!, offsetBy: 1) - let prefixLettersEndIndex = prefixString.firstIndex(of: ")")! + guard + let openParenIndex = prefixString.firstIndex(of: "("), + let prefixLettersEndIndex = prefixString.firstIndex(of: ")") + else { + return [:] + } + let prefixLettersStartIndex = prefixString.index(openParenIndex, offsetBy: 1) + guard prefixLettersStartIndex <= prefixLettersEndIndex else { + return [:] + } let prefixSymbolsStartIndex = prefixString.index(prefixLettersEndIndex, offsetBy: 1) let prefixLetters = prefixString[prefixLettersStartIndex.. IRCClient { + var configuration = IRCTestSupport.configuration() + configuration.floodControlMaximumMessages = 0 + let client = IRCClient(configuration: configuration) + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * LS :\(offered)") + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * ACK :\(acked)") + return client + } + + private func capEndCount(_ client: IRCClient) -> Int { + return client.connection.sendQueue.filter({ $0 == "CAP END" }).count + } + + func testInitialHandshakeSendsOneCapEnd() { + let client = negotiatedClient(offered: "message-tags server-time", acked: "message-tags server-time") + XCTAssertEqual(capEndCount(client), 1) + XCTAssertTrue(client.capabilityNegotiationComplete) + XCTAssertTrue(client.serverInfo.enabledIRCv3Capabilities.contains(.messageTags)) + XCTAssertTrue(client.serverInfo.enabledIRCv3Capabilities.contains(.serverSentTimestamps)) + } + + func testCapDelDisablesCapability() { + let client = negotiatedClient(offered: "message-tags server-time", acked: "message-tags server-time") + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * DEL :server-time") + + XCTAssertFalse(client.serverInfo.enabledIRCv3Capabilities.contains(.serverSentTimestamps)) + XCTAssertFalse(client.serverInfo.supportedIRCv3Capabilities.contains(.serverSentTimestamps)) + XCTAssertTrue(client.serverInfo.enabledIRCv3Capabilities.contains(.messageTags)) + } + + func testCapNewRequestsNewlyOfferedCapability() { + let client = negotiatedClient(offered: "message-tags", acked: "message-tags") + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * NEW :echo-message") + + XCTAssertTrue(client.serverInfo.supportedIRCv3Capabilities.contains(.messageConfirmation)) + XCTAssertTrue(client.connection.sendQueue.contains(where: { + $0.hasPrefix("CAP REQ") && $0.contains("echo-message") + })) + } + + func testMidSessionAckEnablesWithoutAnotherCapEnd() { + let client = negotiatedClient(offered: "message-tags", acked: "message-tags") + let endsBefore = capEndCount(client) + + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * NEW :echo-message") + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * ACK :echo-message") + + XCTAssertTrue(client.serverInfo.enabledIRCv3Capabilities.contains(.messageConfirmation)) + XCTAssertEqual(capEndCount(client), endsBefore) + } +} diff --git a/Tests/IRCKitTests/CaseMappingTests.swift b/Tests/IRCKitTests/CaseMappingTests.swift new file mode 100644 index 0000000..63e0b8f --- /dev/null +++ b/Tests/IRCKitTests/CaseMappingTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import IRCKit + +/// Tests for CASEMAPPING-aware folding of nicknames and channel names (RFC 2812 §2.2) and its use in +/// channel/member lookups. +final class CaseMappingTests: XCTestCase { + func testAsciiFoldingOnlyLowercasesLetters() { + XCTAssertEqual("Nick[]\\~".ircCaseFolded(mapping: "ascii"), "nick[]\\~") + } + + func testRfc1459FoldsBracketsAndTilde() { + XCTAssertEqual("Nick[]\\~".ircCaseFolded(mapping: "rfc1459"), "nick{}|^") + } + + func testRfc1459StrictFoldsBracketsButNotTilde() { + XCTAssertEqual("Nick[]\\~".ircCaseFolded(mapping: "rfc1459-strict"), "nick{}|~") + } + + func testDefaultsToRfc1459WhenMappingUnspecified() { + XCTAssertEqual("A[".ircCaseFolded(mapping: nil), "a{") + } + + func testChannelLookupIsCaseInsensitive() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN #Channel") + // A later reference using different case must resolve to the same channel. + XCTAssertNotNil(client.getChannel(named: "#channel")) + XCTAssertNotNil(client.getChannel(named: "#CHANNEL")) + } + + func testChannelLookupHonoursRfc1459BracketEquivalence() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN #foo[bar]") + // Under rfc1459 (the default), '[' == '{' and ']' == '}'. + XCTAssertNotNil(client.getChannel(named: "#foo{bar}")) + } + + func testMemberLookupIsCaseInsensitive() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN #chan") + client.didReceiveDataFromConnection(data: ":Alice!a@host JOIN #chan") + + let channel = client.getChannel(named: "#chan") + XCTAssertNotNil(channel?.member(named: "alice")) + XCTAssertNotNil(channel?.member(named: "ALICE")) + } + + func testPartRemovesMemberRegardlessOfCase() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN #chan") + client.didReceiveDataFromConnection(data: ":Alice!a@host JOIN #chan") + // Server refers to the same user with different case on PART. + client.didReceiveDataFromConnection(data: ":ALICE!a@host PART #chan") + + XCTAssertNil(client.getChannel(named: "#chan")?.member(named: "alice")) + } +} diff --git a/Tests/IRCKitTests/ChannelInfoTests.swift b/Tests/IRCKitTests/ChannelInfoTests.swift new file mode 100644 index 0000000..0642eb7 --- /dev/null +++ b/Tests/IRCKitTests/ChannelInfoTests.swift @@ -0,0 +1,45 @@ +import Foundation +import XCTest +@testable import IRCKit + +/// Tests for channel metadata numerics whose timestamps are Unix epoch seconds (not ISO8601). +final class ChannelInfoTests: XCTestCase { + private let epochSeconds: TimeInterval = 1_609_459_200 // 2021-01-01T00:00:00Z + + private func clientJoinedTo(_ channelName: String) -> IRCClient { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN \(channelName)") + return client + } + + private func channel(_ client: IRCClient, _ name: String) -> IRCChannel? { + return client.channels.first(where: { $0.name == name }) + } + + func testTopicWhoTimeParsesUnixTimestamp() { + let client = clientJoinedTo("#chan") + client.didReceiveDataFromConnection(data: ":irc.example.org 332 tester #chan :The topic") + client.didReceiveDataFromConnection(data: ":irc.example.org 333 tester #chan setterNick 1609459200") + + let topic = channel(client, "#chan")?.topic + XCTAssertEqual(topic?.author, "setterNick") + XCTAssertEqual(topic?.date, Date(timeIntervalSince1970: epochSeconds)) + } + + func testCreationTimeParsesUnixTimestamp() { + let client = clientJoinedTo("#chan") + client.didReceiveDataFromConnection(data: ":irc.example.org 329 tester #chan 1609459200") + + XCTAssertEqual(channel(client, "#chan")?.createdAt, Date(timeIntervalSince1970: epochSeconds)) + } + + func testNonNumericTimestampIsIgnored() { + let client = clientJoinedTo("#chan") + client.didReceiveDataFromConnection(data: ":irc.example.org 332 tester #chan :The topic") + client.didReceiveDataFromConnection(data: ":irc.example.org 333 tester #chan setterNick notanumber") + + // The topic survives, but the unparseable set-time is dropped rather than crashing. + XCTAssertEqual(channel(client, "#chan")?.topic?.contents, "The topic") + XCTAssertNil(channel(client, "#chan")?.topic?.date) + } +} diff --git a/Tests/IRCKitTests/EventDispatchTests.swift b/Tests/IRCKitTests/EventDispatchTests.swift new file mode 100644 index 0000000..45255bf --- /dev/null +++ b/Tests/IRCKitTests/EventDispatchTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import IRCKit + +/// Tests that inbound commands reach the correct handler and produce the expected notification. +/// Guards two previously-broken advertised features: invite-notify (INVITE was routed to the KICK +/// handler with swapped parameters) and MONITOR (730/731 replies were never dispatched). +final class EventDispatchTests: XCTestCase { + /// Collects payloads delivered on the notification (main) queue so the test thread can read them + /// after `wait(for:)`. Mutated only on the main queue; read only after the wait completes. + private final class Collector: @unchecked Sendable { + private(set) var values: [Value] = [] + func append(_ value: Value) { + values.append(value) + } + } + + private func makeClient() -> IRCClient { + let configuration = IRCClientConfiguration( + serverName: "test", + serverAddress: "irc.example.org", + serverPort: 6697, + nickname: "tester", + username: "tester", + realName: "Integration Tester" + ) + return IRCClient(configuration: configuration) + } + + func testInviteIsDispatchedWithCorrectFields() { + let client = makeClient() + let received = expectation(description: "invite notification") + let collector = Collector() + let token = NotificationCenter.default.addObserver( + descriptor: IRCChannelInviteNotification(), + queue: .main + ) { payload in + collector.append(payload) + received.fulfill() + } + + // ` INVITE ` — the channel is not one we are in. + client.didReceiveDataFromConnection(data: ":inviter!u@host INVITE tester #secret") + + wait(for: [received], timeout: 2.0) + withExtendedLifetime(token) {} + let invite = collector.values.first + XCTAssertEqual(invite?.invitedNick, "tester") + XCTAssertEqual(invite?.channel.name, "#secret") + XCTAssertEqual(invite?.sender.nickname, "inviter") + } + + func testMonitorOnlineReplyIsDispatchedPerTarget() { + let client = makeClient() + let received = expectation(description: "online notifications") + received.expectedFulfillmentCount = 2 + let collector = Collector() + let token = NotificationCenter.default.addObserver( + descriptor: IRCUserOnlineNotification(), + queue: .main + ) { target in + collector.append(target) + received.fulfill() + } + + client.didReceiveDataFromConnection(data: ":irc.example.org 730 tester :Alice!a@host,Bob!b@host") + + wait(for: [received], timeout: 2.0) + withExtendedLifetime(token) {} + XCTAssertEqual(collector.values.sorted(), ["Alice!a@host", "Bob!b@host"]) + } + + func testMonitorOfflineReplyIsDispatched() { + let client = makeClient() + let received = expectation(description: "offline notification") + let collector = Collector() + let token = NotificationCenter.default.addObserver( + descriptor: IRCUserOfflineNotification(), + queue: .main + ) { target in + collector.append(target) + received.fulfill() + } + + client.didReceiveDataFromConnection(data: ":irc.example.org 731 tester :Alice") + + wait(for: [received], timeout: 2.0) + withExtendedLifetime(token) {} + XCTAssertEqual(collector.values, ["Alice"]) + } +} diff --git a/Tests/IRCKitTests/IRCMessageParsingTests.swift b/Tests/IRCKitTests/IRCMessageParsingTests.swift new file mode 100644 index 0000000..cfe60c4 --- /dev/null +++ b/Tests/IRCKitTests/IRCMessageParsingTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import IRCKit + +/// Tests for the hand-rolled IRC wire parser in `IRCMessage`. +final class IRCMessageParsingTests: XCTestCase { + func testParsesPrefixCommandAndParameters() { + let message = IRCMessage(line: ":nick!user@host PRIVMSG #channel :hello world", client: IRCTestSupport.client()) + XCTAssertEqual(message?.command, .PRIVMSG) + XCTAssertEqual(message?.sender?.nickname, "nick") + XCTAssertEqual(message?.sender?.username, "user") + XCTAssertEqual(message?.sender?.hostmask, "host") + XCTAssertEqual(message?.sender?.isServer, false) + XCTAssertEqual(message?.parameters, ["#channel", "hello world"]) + } + + func testParsesServerPrefix() { + let message = IRCMessage(line: ":irc.example.org 001 tester :Welcome to the network", client: IRCTestSupport.client()) + XCTAssertEqual(message?.command, .RPL_WELCOME) + XCTAssertEqual(message?.sender?.isServer, true) + XCTAssertEqual(message?.sender?.nickname, "irc.example.org") + XCTAssertNil(message?.sender?.username) + XCTAssertEqual(message?.parameters, ["tester", "Welcome to the network"]) + } + + func testParsesMessageWithoutPrefix() { + let message = IRCMessage(line: "PING :LAG1234567", client: IRCTestSupport.client()) + XCTAssertEqual(message?.command, .PING) + XCTAssertNil(message?.sender) + XCTAssertEqual(message?.parameters, ["LAG1234567"]) + } + + func testTrailingParameterPreservesSpacesAndColons() { + let message = IRCMessage(line: ":n!u@h PRIVMSG #c :see http://x/y :end", client: IRCTestSupport.client()) + XCTAssertEqual(message?.parameters, ["#c", "see http://x/y :end"]) + } + + func testParsesMultipleMiddleParameters() { + let message = IRCMessage( + line: ":irc.example.org 353 tester = #channel :alice bob carol", + client: IRCTestSupport.client() + ) + XCTAssertEqual(message?.command, .RPL_NAMEREPLY) + XCTAssertEqual(message?.parameters, ["tester", "=", "#channel", "alice bob carol"]) + } + + func testParsesTagsPrefixAndCommandTogether() { + let message = IRCMessage(line: "@id=42;account=alice :n!u@h PRIVMSG #c :hi", client: IRCTestSupport.client()) + XCTAssertEqual(message?.messageTags["id"], "42") + XCTAssertEqual(message?.account, "alice") + XCTAssertEqual(message?.command, .PRIVMSG) + XCTAssertEqual(message?.sender?.nickname, "n") + XCTAssertEqual(message?.parameters, ["#c", "hi"]) + } + + func testMessageIdAndLabelFromTags() { + let message = IRCMessage(line: "@msgid=abc123;label=req-1 :n!u@h PRIVMSG #c :hi", client: IRCTestSupport.client()) + XCTAssertEqual(message?.messageId, "abc123") + XCTAssertEqual(message?.label, "req-1") + XCTAssertEqual(message?.id, "req-1") + } + + func testLabelDefaultsToRandomWhenAbsent() { + let message = IRCMessage(line: ":n!u@h PRIVMSG #c :hi", client: IRCTestSupport.client()) + XCTAssertEqual(message?.label.isEmpty, false) + XCTAssertNil(message?.messageId) + } + + func testDetectsCTCPActionMessage() { + let message = IRCMessage(line: ":n!u@h PRIVMSG #c :\u{01}ACTION waves\u{01}", client: IRCTestSupport.client()) + XCTAssertEqual(message?.isCTCPRequest, true) + XCTAssertEqual(message?.isActionMessage, true) + } + + func testPlainMessageIsNotCTCP() { + let message = IRCMessage(line: ":n!u@h PRIVMSG #c :just text", client: IRCTestSupport.client()) + XCTAssertEqual(message?.isCTCPRequest, false) + XCTAssertEqual(message?.isActionMessage, false) + } + + func testUnknownCommandReturnsNil() { + XCTAssertNil(IRCMessage(line: ":n!u@h FLOOOP #c :hi", client: IRCTestSupport.client())) + } + + func testServerTimeTagIsParsedRatherThanNow() { + let message = IRCMessage( + line: "@time=2021-01-15T12:30:45.000Z :n!u@h PRIVMSG #c :hi", + client: IRCTestSupport.client() + ) + let expected = ISO8601DateFormatter().date(from: "2021-01-15T12:30:45Z") + XCTAssertEqual(message?.time.timeIntervalSince1970, expected?.timeIntervalSince1970) + } +} diff --git a/Tests/IRCKitTests/IRCTestSupport.swift b/Tests/IRCKitTests/IRCTestSupport.swift new file mode 100644 index 0000000..996a8ab --- /dev/null +++ b/Tests/IRCKitTests/IRCTestSupport.swift @@ -0,0 +1,20 @@ +@testable import IRCKit + +/// Shared factories for building an offline `IRCClient` in tests. `autoConnect` defaults to false, so +/// no network connection is opened. +enum IRCTestSupport { + static func configuration() -> IRCClientConfiguration { + IRCClientConfiguration( + serverName: "test", + serverAddress: "irc.example.org", + serverPort: 6697, + nickname: "tester", + username: "tester", + realName: "Integration Tester" + ) + } + + static func client() -> IRCClient { + IRCClient(configuration: configuration()) + } +} diff --git a/Tests/IRCKitTests/ISupportTests.swift b/Tests/IRCKitTests/ISupportTests.swift new file mode 100644 index 0000000..d85187a --- /dev/null +++ b/Tests/IRCKitTests/ISupportTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import IRCKit + +/// Tests for RPL_ISUPPORT (005) parsing in `IRCServerInfo.setSupported`. +final class ISupportTests: XCTestCase { + func testParsesCommonTokens() { + let client = IRCTestSupport.client() + let line = ":irc.example.org 005 tester PREFIX=(ov)@+ CHANTYPES=# NETWORK=TestNet " + + "CASEMAPPING=rfc1459 WHOX AWAYLEN=200 NICKLEN=30 MONITOR=100 :are supported by this server" + client.didReceiveDataFromConnection(data: line) + + let info = client.serverInfo + XCTAssertEqual(info.networkName, "TestNet") + XCTAssertEqual(info.caseMapping, "rfc1459") + XCTAssertTrue(info.supportsExtendedWhoQuery) + XCTAssertTrue(info.supportsMonitor) + XCTAssertEqual(info.maximumMonitorTargets, 100) + XCTAssertEqual(info.maximumAwayMessageLength, 200) + XCTAssertEqual(info.maximumNicknameLength, 30) + XCTAssertEqual(info.prefixMapping[.operator], "@") + XCTAssertEqual(info.prefixMapping[.voice], "+") + } + + func testValuelessTokenIsRecognised() { + let client = IRCTestSupport.client() + XCTAssertFalse(client.serverInfo.supportsExtendedWhoQuery) + client.didReceiveDataFromConnection(data: ":irc.example.org 005 tester WHOX :are supported by this server") + XCTAssertTrue(client.serverInfo.supportsExtendedWhoQuery) + } + + func testPrefixMappingReplacesDefault() { + let client = IRCTestSupport.client() + // Default mapping includes owner/admin/halfop; a server offering only o/v should narrow it. + client.didReceiveDataFromConnection(data: ":irc.example.org 005 tester PREFIX=(ov)@+ :are supported") + let mapping = client.serverInfo.prefixMapping + XCTAssertEqual(mapping[.operator], "@") + XCTAssertEqual(mapping[.voice], "+") + XCTAssertNil(mapping[.owner]) + } + + func testMalformedPrefixYieldsEmptyMapping() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":irc.example.org 005 tester PREFIX=noparens :are supported") + XCTAssertTrue(client.serverInfo.prefixMapping.isEmpty) + } + + func testInvalidNumericValuesAreIgnored() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":irc.example.org 005 tester AWAYLEN=notanumber :are supported") + XCTAssertNil(client.serverInfo.maximumAwayMessageLength) + } +} diff --git a/Tests/IRCKitTests/MalformedMessageTests.swift b/Tests/IRCKitTests/MalformedMessageTests.swift new file mode 100644 index 0000000..454b4fc --- /dev/null +++ b/Tests/IRCKitTests/MalformedMessageTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import IRCKit + +/// Regression tests for parameter-bounds hardening: a malformed or hostile server must not be able +/// to crash the client by sending messages with missing parameters. A Swift out-of-bounds access is +/// a fatal trap, so these tests "pass" by the process surviving the dispatch of each malformed line. +final class MalformedMessageTests: XCTestCase { + private func makeClient() -> IRCClient { + let configuration = IRCClientConfiguration( + serverName: "test", + serverAddress: "irc.example.org", + serverPort: 6697, + nickname: "tester", + username: "tester", + realName: "Integration Tester" + ) + return IRCClient(configuration: configuration) + } + + /// Every branch of the command dispatcher, fed a message that is missing the parameters its + /// handler previously indexed unconditionally. None of these may trap. + func testMalformedMessagesDoNotCrash() { + let client = makeClient() + + let malformedLines = [ + // Registration / server info + ":irc.example.org 004 tester", // RPL_MYINFO, missing 4 params + ":irc.example.org 005", // RPL_ISUPPORT, no params + ":irc.example.org 005 tester PREFIX=badvalue :ok", // PREFIX without parentheses + ":irc.example.org 005 tester PREFIX=)( :ok", // PREFIX parens reversed + // Capability negotiation + ":irc.example.org CAP", // missing subcommand + ":irc.example.org CAP * LS", // LS with no capability list + ":irc.example.org CAP * ACK", // ACK with no capability list + // Channel lifecycle + ":n!u@h JOIN", // JOIN with no channel + ":n!u@h PART", // PART with no channel + ":n!u@h KICK #channel", // KICK with no target + ":n!u@h INVITE tester", // INVITE with no channel + ":n!u@h TOPIC #channel", // TOPIC with no text + "MODE #channel", // MODE with no modes and no prefix + ":n!u@h MODE #channel", // MODE with no mode string + // Messaging + ":n!u@h PRIVMSG", // PRIVMSG with no target/text + ":n!u@h PRIVMSG #channel", // PRIVMSG with no text + ":n!u@h NOTICE", // NOTICE with no target/text + ":n!u@h NOTICE tester", // NOTICE with no text + ":irc.example.org PRIVMSG tester :from a server", // server source lacks user!host + ":irc.example.org NOTICE tester :from a server", // server source lacks user!host + // User events + ":n!u@h NICK", // NICK with no new nick + ":n!u@h SETNAME", // SETNAME with no realname + ":n!u@h CHGHOST newuser", // CHGHOST with no host + ":n!u@h ACCOUNT", // ACCOUNT with no account + // Ping / topic info numerics + "PING", // PING with no token + ":irc.example.org 332 tester", // RPL_TOPIC missing channel/text + ":irc.example.org 333 tester #channel", // RPL_TOPICWHOTIME missing fields + ":irc.example.org 329 tester #channel", // RPL_CREATIONTIME missing timestamp + ":irc.example.org 353 tester", // RPL_NAMEREPLY wrong arity + ":irc.example.org 352 tester", // RPL_WHOREPLY wrong arity + // Authentication + "AUTHENTICATE", // AUTHENTICATE with no payload + // Total garbage + "", + ":", + "@tag", + "@=;= :n!u@h" + ] + + for line in malformedLines { + client.didReceiveDataFromConnection(data: line) + } + + // If we reached here the dispatcher survived every malformed line; sanity-check the client + // is still usable by processing a well-formed self-JOIN. + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN #welformed") + XCTAssertTrue(client.channels.contains(where: { $0.name == "#welformed" })) + } + + /// A private message whose source is a server (no `user@host`) must be dropped, not crash, now + /// that `IRCUser(fromPrivateMessage:)` is failable. + func testServerSourcedPrivateMessageIsIgnored() { + let client = makeClient() + client.didReceiveDataFromConnection(data: ":services. PRIVMSG tester :hello from services") + // No channel is created for an unusable sender. + XCTAssertTrue(client.channels.isEmpty) + } + + /// A duration-only STS capability (valid: no `port=`) must not trap on a forced unwrap. + func testStrictTransportSecurityWithoutPortDoesNotCrash() { + let client = makeClient() + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * LS :sts=duration=2592000") + } +} diff --git a/Tests/IRCKitTests/MessageTagTests.swift b/Tests/IRCKitTests/MessageTagTests.swift new file mode 100644 index 0000000..e827186 --- /dev/null +++ b/Tests/IRCKitTests/MessageTagTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import IRCKit + +/// Tests for IRCv3 message-tag value escaping/unescaping (message-tags spec) and its integration +/// into inbound message parsing. +final class MessageTagTests: XCTestCase { + private func makeClient() -> IRCClient { + let configuration = IRCClientConfiguration( + serverName: "test", + serverAddress: "irc.example.org", + serverPort: 6697, + nickname: "tester", + username: "tester", + realName: "Integration Tester" + ) + return IRCClient(configuration: configuration) + } + + func testUnescapeKnownSequences() { + XCTAssertEqual("a\\:b".ircTagValueUnescaped(), "a;b") + XCTAssertEqual("a\\sb".ircTagValueUnescaped(), "a b") + XCTAssertEqual("a\\\\b".ircTagValueUnescaped(), "a\\b") + XCTAssertEqual("a\\rb".ircTagValueUnescaped(), "a\rb") + XCTAssertEqual("a\\nb".ircTagValueUnescaped(), "a\nb") + } + + func testUnescapeEdgeCases() { + // Unrecognised escapes resolve to the escaped character itself. + XCTAssertEqual("a\\xb".ircTagValueUnescaped(), "axb") + // A trailing lone backslash is dropped. + XCTAssertEqual("abc\\".ircTagValueUnescaped(), "abc") + // A value with no escapes is unchanged. + XCTAssertEqual("plain".ircTagValueUnescaped(), "plain") + } + + func testEscapeKnownSequences() { + XCTAssertEqual("a;b".ircTagValueEscaped(), "a\\:b") + XCTAssertEqual("a b".ircTagValueEscaped(), "a\\sb") + XCTAssertEqual("a\\b".ircTagValueEscaped(), "a\\\\b") + XCTAssertEqual("a\rb".ircTagValueEscaped(), "a\\rb") + XCTAssertEqual("a\nb".ircTagValueEscaped(), "a\\nb") + } + + func testEscapeUnescapeRoundTrip() { + let originals = [ + "simple", + "has spaces and ; semicolons", + "back\\slash and \r\n newlines", + "\\:\\s\\\\", + "" + ] + for original in originals { + XCTAssertEqual(original.ircTagValueEscaped().ircTagValueUnescaped(), original) + } + } + + func testEscapingHappensBeforeSeparatorMeaning() { + // A raw semicolon and space must survive a round trip without being read as tag/word + // separators once escaped. + let value = "key1=val1;key2=val2 trailing" + XCTAssertEqual(value.ircTagValueEscaped().ircTagValueUnescaped(), value) + XCTAssertFalse(value.ircTagValueEscaped().contains(";")) + XCTAssertFalse(value.ircTagValueEscaped().contains(" ")) + } + + func testInboundParsingUnescapesTagValues() { + let client = makeClient() + let message = IRCMessage( + line: "@account=a\\sb;+example/tag=x\\:y :nick!user@host PRIVMSG #channel :hi", + client: client + ) + XCTAssertEqual(message?.messageTags["account"], "a b") + XCTAssertEqual(message?.messageTags["+example/tag"], "x;y") + } +} diff --git a/Tests/IRCKitTests/SASLTests.swift b/Tests/IRCKitTests/SASLTests.swift new file mode 100644 index 0000000..288de23 --- /dev/null +++ b/Tests/IRCKitTests/SASLTests.swift @@ -0,0 +1,70 @@ +import Foundation +import XCTest +@testable import IRCKit + +/// Tests for SASL mechanism selection and the PLAIN authentication payload. +final class SASLTests: XCTestCase { + /// Setting the flood-control ceiling to zero forces every outbound line into + /// `IRCConnection.sendQueue` (rather than being written to a socket that isn't connected), so a + /// test can inspect exactly what the client would have sent. + private func queueingClient(configure: (inout IRCClientConfiguration) -> Void = { _ in }) -> IRCClient { + var configuration = IRCTestSupport.configuration() + configuration.floodControlMaximumMessages = 0 + configure(&configuration) + return IRCClient(configuration: configuration) + } + + func testExternalIsPreferredWhenClientCertificateConfigured() { + let client = queueingClient { $0.clientCertificatePath = "/path/to/cert.pem" } + client.serverInfo.supportedSASLMechanisms = [PlainTextSASLHandler.self, ExternalSASLHandler.self] + + XCTAssertTrue(client.saslNegotiation()) + XCTAssertTrue(client.activeAuthenticationHandler is ExternalSASLHandler) + } + + func testScramIsPreferredOverPlainWhenBothOffered() { + let client = queueingClient { $0.authenticationPassword = "s3cr3t" } + client.serverInfo.supportedSASLMechanisms = [PlainTextSASLHandler.self, Sha256SASLHandler.self] + + XCTAssertTrue(client.saslNegotiation()) + XCTAssertTrue(client.activeAuthenticationHandler is Sha256SASLHandler) + } + + func testPlainIsSelectedWhenOnlyMechanismOffered() { + let client = queueingClient { $0.authenticationPassword = "s3cr3t" } + client.serverInfo.supportedSASLMechanisms = [PlainTextSASLHandler.self] + + XCTAssertTrue(client.saslNegotiation()) + XCTAssertTrue(client.activeAuthenticationHandler is PlainTextSASLHandler) + } + + func testNegotiationFailsWithoutCredentials() { + let client = queueingClient() + client.serverInfo.supportedSASLMechanisms = [PlainTextSASLHandler.self] + + XCTAssertFalse(client.saslNegotiation()) + XCTAssertNil(client.activeAuthenticationHandler) + } + + func testPlainProducesCorrectAuthenticatePayload() { + let client = queueingClient { + $0.authenticationUsername = "alice" + $0.authenticationPassword = "s3cr3t" + } + client.serverInfo.supportedSASLMechanisms = [PlainTextSASLHandler.self] + + XCTAssertTrue(client.saslNegotiation()) + // The server acknowledges the mechanism with an empty challenge; the client responds with the + // base64-encoded `authzid\0authcid\0passwd` credential. + client.didReceiveDataFromConnection(data: "AUTHENTICATE +") + + let payloadLine = client.connection.sendQueue.last(where: { + $0.hasPrefix("AUTHENTICATE ") && $0 != "AUTHENTICATE PLAIN" + }) + let base64 = payloadLine.map { String($0.dropFirst("AUTHENTICATE ".count)) } + let decoded = base64 + .flatMap { Data(base64Encoded: $0) } + .flatMap { String(data: $0, encoding: .utf8) } + XCTAssertEqual(decoded, "alice\u{0}alice\u{0}s3cr3t") + } +} diff --git a/Tests/IRCKitTests/SCRAMTests.swift b/Tests/IRCKitTests/SCRAMTests.swift new file mode 100644 index 0000000..a83edee --- /dev/null +++ b/Tests/IRCKitTests/SCRAMTests.swift @@ -0,0 +1,116 @@ +import Foundation +import XCTest +@testable import IRCKit + +/// SCRAM-SHA-256 tests driven by the canonical RFC 7677 test vector (username `user`, +/// password `pencil`, client nonce `rOprNGfwEbeRWgbNEkqO`). +final class SCRAMTests: XCTestCase { + private let clientNonce = "rOprNGfwEbeRWgbNEkqO" + private let serverFirstMessage = + "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096" + private let expectedClientFinalMessage = + "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," + + "p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=" + private let serverFinalMessage = "v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=" + + /// A client whose outbound lines accumulate in `IRCConnection.sendQueue` (flood ceiling zero), + /// with the RFC vector's credentials. + private func makeClient() -> IRCClient { + var configuration = IRCTestSupport.configuration() + configuration.floodControlMaximumMessages = 0 + configuration.authenticationUsername = "user" + configuration.authenticationPassword = "pencil" + return IRCClient(configuration: configuration) + } + + private func makeHandler(_ client: IRCClient) -> Sha256SASLHandler { + let handler = Sha256SASLHandler(client: client) + handler.clientNonceOverride = clientNonce + return handler + } + + private func authenticate(_ payload: String, client: IRCClient) -> IRCMessage { + return IRCMessage(line: "AUTHENTICATE \(payload)", client: client)! + } + + private func base64(_ text: String) -> String { + return Data(text.utf8).base64EncodedString() + } + + private func lastSentLine(_ client: IRCClient) -> String? { + return client.connection.sendQueue.last + } + + private func lastDecodedAuthenticate(_ client: IRCClient) -> String? { + guard let line = lastSentLine(client), line.hasPrefix("AUTHENTICATE ") else { + return nil + } + return Data(base64Encoded: String(line.dropFirst("AUTHENTICATE ".count))) + .flatMap { String(data: $0, encoding: .utf8) } + } + + func testClientFirstMessageMatchesVector() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + + XCTAssertEqual(lastDecodedAuthenticate(client), "n,,n=user,r=\(clientNonce)") + } + + func testClientFinalMessageMatchesVector() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + handler.handleResponse(message: authenticate(base64(serverFirstMessage), client: client)) + + XCTAssertEqual(lastDecodedAuthenticate(client), expectedClientFinalMessage) + } + + func testValidServerSignatureCompletesAuthentication() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + handler.handleResponse(message: authenticate(base64(serverFirstMessage), client: client)) + handler.handleResponse(message: authenticate(base64(serverFinalMessage), client: client)) + + // The client acknowledges a valid server signature with an empty response. + XCTAssertEqual(lastSentLine(client), "AUTHENTICATE +") + } + + func testInvalidServerSignatureAborts() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + handler.handleResponse(message: authenticate(base64(serverFirstMessage), client: client)) + // Flip a byte of the server signature. + let tampered = "v=7rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=" + handler.handleResponse(message: authenticate(base64(tampered), client: client)) + + XCTAssertEqual(lastSentLine(client), "AUTHENTICATE *") + } + + func testServerNonceNotPrefixedByClientNonceAborts() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + let forgedFirst = "r=totallyDifferentNonce,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096" + handler.handleResponse(message: authenticate(base64(forgedFirst), client: client)) + + XCTAssertEqual(lastSentLine(client), "AUTHENTICATE *") + } + + func testServerErrorAttributeAborts() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + handler.handleResponse(message: authenticate(base64("e=invalid-proof"), client: client)) + + XCTAssertEqual(lastSentLine(client), "AUTHENTICATE *") + } +} diff --git a/Tests/IRCKitTests/SendMethodTests.swift b/Tests/IRCKitTests/SendMethodTests.swift new file mode 100644 index 0000000..a75b66f --- /dev/null +++ b/Tests/IRCKitTests/SendMethodTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import IRCKit + +/// Tests for outbound line construction in `IRCClient.send`: trailing-parameter quoting and +/// sanitisation of forbidden characters. +final class SendMethodTests: XCTestCase { + /// A client whose outbound lines accumulate in `IRCConnection.sendQueue` rather than being + /// written to a socket that isn't connected. + private func queueingClient() -> IRCClient { + var configuration = IRCTestSupport.configuration() + configuration.floodControlMaximumMessages = 0 + return IRCClient(configuration: configuration) + } + + private func lastLine(_ client: IRCClient) -> String? { + return client.connection.sendQueue.last + } + + func testTrailingParameterWithSpaceIsColonPrefixed() { + let client = queueingClient() + client.send("PRIVMSG", parameters: ["#channel", "hello world"]) + XCTAssertEqual(lastLine(client), "PRIVMSG #channel :hello world") + } + + func testTrailingParameterStartingWithColonIsColonPrefixed() { + let client = queueingClient() + // A one-word message that begins with ':' must still be quoted, or it reads as empty. + client.send("PRIVMSG", parameters: ["#channel", ":)"]) + XCTAssertEqual(lastLine(client), "PRIVMSG #channel ::)") + } + + func testEmptyTrailingParameterIsColonPrefixed() { + let client = queueingClient() + client.send("PART", parameters: ["#channel", ""]) + XCTAssertEqual(lastLine(client), "PART #channel :") + } + + func testSingleWordTrailingParameterIsNotColonPrefixed() { + let client = queueingClient() + client.send("PRIVMSG", parameters: ["#channel", "hello"]) + XCTAssertEqual(lastLine(client), "PRIVMSG #channel hello") + } + + func testSingleParameterIsNotColonPrefixed() { + let client = queueingClient() + client.send("JOIN", parameters: ["#channel"]) + XCTAssertEqual(lastLine(client), "JOIN #channel") + } + + func testForbiddenCharactersAreStrippedToPreventInjection() { + let client = queueingClient() + client.send("PRIVMSG", parameters: ["#channel", "hi there\r\nJOIN #evil"]) + let line = lastLine(client) + XCTAssertEqual(line, "PRIVMSG #channel :hi thereJOIN #evil") + XCTAssertEqual(line?.contains("\r"), false) + XCTAssertEqual(line?.contains("\n"), false) + } + + func testPongEchoesTokenWithoutDoubleColon() { + let client = queueingClient() + client.didReceiveDataFromConnection(data: ":irc.example.org PING :LAG1234567") + XCTAssertEqual(lastLine(client), "PONG LAG1234567") + } + + func testPongQuotesTokenContainingSpace() { + let client = queueingClient() + client.didReceiveDataFromConnection(data: ":irc.example.org PING :tok en") + XCTAssertEqual(lastLine(client), "PONG :tok en") + } +} diff --git a/Tests/IRCKitTests/irckitTests.swift b/Tests/IRCKitTests/irckitTests.swift index d172142..1f6220d 100644 --- a/Tests/IRCKitTests/irckitTests.swift +++ b/Tests/IRCKitTests/irckitTests.swift @@ -8,7 +8,7 @@ final class irckitTests: XCTestCase { // results. } - static var allTests = [ - ("testExample", testExample), + static let allTests = [ + ("testExample", testExample) ] }