From b72f4de09596c182e516133501e6a12849207607 Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Sat, 21 Mar 2026 12:13:36 -0500 Subject: [PATCH] Validate packet inputs and fail closed on malformed responses --- .../MeshCore/Protocol/PacketBuilder.swift | 21 +++++-- .../MeshCore/Protocol/PacketParser.swift | 6 ++ .../Sources/MeshCore/Protocol/Parsers.swift | 55 +++++++++++++++-- .../MeshCore/Session/MeshCoreSession.swift | 31 ++++++++-- .../Protocol/NewCommandsTests.swift | 53 ++++++++++++++++ .../Session/ConnectionStateTests.swift | 45 ++++++++++++++ .../Validation/NewResponseParsingTests.swift | 17 ++++++ .../Validation/ProtocolBugFixTests.swift | 16 +++++ .../Validation/RoundTripTests.swift | 61 +++++++++++++++++++ 9 files changed, 290 insertions(+), 15 deletions(-) diff --git a/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift b/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift index 29485a2bf..14117c90c 100644 --- a/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift +++ b/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift @@ -37,6 +37,15 @@ public enum PacketBuilder: Sendable { /// Size of a public key in bytes. static let publicKeySize = 32 + static let rawDataMaxPathBytes = 64 + static let rawDataMaxPayloadBytes = 184 + + private static func encodePublicKey(_ publicKey: Data) -> Data { + if publicKey.count >= publicKeySize { + return publicKey.prefix(publicKeySize) + } + return publicKey + Data(repeating: 0, count: publicKeySize - publicKey.count) + } // MARK: - Device Commands @@ -790,7 +799,7 @@ public enum PacketBuilder: Sendable { /// - Offset 1 (1 byte): Reserved `0x00` /// - Offset 2 (1 byte): Mode value (0, 1, or 2) public static func setPathHashMode(_ mode: UInt8) -> Data { - Data([CommandCode.setPathHashMode.rawValue, 0x00, mode]) + Data([CommandCode.setPathHashMode.rawValue, 0x00, min(mode, 2)]) } /// Builds a factoryReset command to wipe all settings and data from the device. @@ -864,10 +873,10 @@ public enum PacketBuilder: Sendable { /// - Offset 2+N (M bytes): Payload public static func sendRawData(path: Data, payload: Data) -> Data { var data = Data([CommandCode.sendRawData.rawValue]) - let clampedPath = path.prefix(255) + let clampedPath = path.prefix(rawDataMaxPathBytes) data.append(UInt8(clampedPath.count)) data.append(clampedPath) - data.append(payload) + data.append(payload.prefix(rawDataMaxPayloadBytes)) return data } @@ -881,7 +890,7 @@ public enum PacketBuilder: Sendable { /// - Offset 1 (32 bytes): Full public key public static func hasConnection(publicKey: Data) -> Data { var data = Data([CommandCode.hasConnection.rawValue]) - data.append(publicKey.prefix(publicKeySize)) + data.append(encodePublicKey(publicKey)) return data } @@ -895,7 +904,7 @@ public enum PacketBuilder: Sendable { /// - Offset 1 (32 bytes): Full public key public static func getContactByKey(publicKey: Data) -> Data { var data = Data([CommandCode.getContactByKey.rawValue]) - data.append(publicKey.prefix(publicKeySize)) + data.append(encodePublicKey(publicKey)) return data } @@ -910,7 +919,7 @@ public enum PacketBuilder: Sendable { /// - Offset 2 (32 bytes): Full public key public static func getAdvertPath(publicKey: Data) -> Data { var data = Data([CommandCode.getAdvertPath.rawValue, 0x00]) - data.append(publicKey.prefix(publicKeySize)) + data.append(encodePublicKey(publicKey)) return data } diff --git a/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift b/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift index c18b9ed4b..b6468fab0 100644 --- a/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift +++ b/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift @@ -102,6 +102,12 @@ extension PacketParser { reason: "Battery response too short: \(payload.count) < \(PacketSize.batteryMinimum)" ) } + if payload.count > PacketSize.batteryMinimum && payload.count < PacketSize.batteryExtended { + return .parseFailure( + data: payload, + reason: "Battery response has partial extended payload: \(payload.count) < \(PacketSize.batteryExtended)" + ) + } let level = Int(payload.readUInt16LE(at: 0)) var usedKB: Int? var totalKB: Int? diff --git a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift index 0c7b54ece..a30f91f02 100644 --- a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift +++ b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift @@ -150,6 +150,7 @@ public enum Parsers { let type = ContactType(rawValue: data[offset]) ?? .chat; offset += 1 let flags = ContactFlags(rawValue: data[offset]); offset += 1 let pathLen = data[offset]; offset += 1 + guard pathLen == 0xFF || decodePathLen(pathLen) != nil else { return nil } let actualPathLen = (pathLen == 0xFF) ? 0 : (decodePathLen(pathLen)?.byteLength ?? 0) // Read full 64-byte path field, but only use first actualPathLen bytes let pathBytes = Data(data[offset.. MeshEvent { + if data.count >= PacketSize.contact { + let pathLen = data[34] + if pathLen != 0xFF && decodePathLen(pathLen) == nil { + return .parseFailure( + data: data, + reason: "Contact response uses reserved path length encoding: 0x\(String(format: "%02X", pathLen))" + ) + } + } guard let contact = parseContactData(data) else { return .parseFailure( data: data, @@ -296,6 +306,13 @@ public enum Parsers { var model: String? var version: String? + if fwVer >= 3 && data.count < PacketSize.deviceInfoV3Full { + return .parseFailure( + data: data, + reason: "DeviceInfo v\(fwVer) response too short: \(data.count) < \(PacketSize.deviceInfoV3Full)" + ) + } + // v3+ format: fwBuild=12, model=40, version=20 bytes if fwVer >= 3 && data.count >= PacketSize.deviceInfoV3Full { maxContacts = Int(data[offset]) * 2 /// Stored as count/2 in firmware. @@ -320,14 +337,26 @@ public enum Parsers { // v9+: client_repeat byte after version string var clientRepeat = false - if fwVer >= 9 && data.count > offset { + if fwVer >= 9 { + guard data.count > offset else { + return .parseFailure( + data: data, + reason: "DeviceInfo v\(fwVer) missing client_repeat byte" + ) + } clientRepeat = data[offset] != 0 offset += 1 } // v10+: path_hash_mode byte after client_repeat var pathHashMode: UInt8 = 0 - if fwVer >= 10 && data.count > offset { + if fwVer >= 10 { + guard data.count > offset else { + return .parseFailure( + data: data, + reason: "DeviceInfo v\(fwVer) missing pathHashMode byte" + ) + } pathHashMode = data[offset] } @@ -391,7 +420,13 @@ public enum Parsers { let timestamp = Date(timeIntervalSince1970: TimeInterval(data.readUInt32LE(at: offset))); offset += 4 var signature: Data? - if txtType == 2 && data.count >= offset + 4 { + if txtType == 2 { + guard data.count >= offset + 4 else { + return .parseFailure( + data: data, + reason: "ContactMessage signature truncated: \(data.count) < \(offset + 4)" + ) + } signature = Data(data[offset..= 5 + byteLen else { + return .parseFailure( + data: data, + reason: "AdvertPathResponse path truncated: \(data.count) < \(5 + byteLen)" + ) + } let path = Data(data.dropFirst(5).prefix(byteLen)) return .advertPathResponse(MeshCore.AdvertPathResponse( diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 1523bc83d..6c0e28fac 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift @@ -720,6 +720,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. public func getContact(publicKey: Data) async throws -> MeshContact? { + try requireFullPublicKey(publicKey, operation: "getContact") let data = PacketBuilder.getContactByKey(publicKey: publicKey) return try await sendAndWait(data) { event in switch event { @@ -873,8 +874,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Throws: ``MeshCoreError/timeout`` if no response within the timeout period. /// ``MeshCoreError/invalidResponse`` if an unexpected response is received. public func requestStatus(from publicKey: Data) async throws -> StatusResponse { + try requireFullPublicKey(publicKey, operation: "requestStatus") // Serialize binary requests to prevent messageSent race conditions - try await binaryRequestSerializer.withSerialization { [self] in + return try await binaryRequestSerializer.withSerialization { [self] in try await performStatusRequest(from: publicKey) } } @@ -998,6 +1000,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Returns: Information about the sent message. /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. public func sendKeepAlive(to publicKey: Data, syncSince: UInt32) async throws -> MessageSentInfo { + try requireFullPublicKey(publicKey, operation: "sendKeepAlive") var syncSinceLE = syncSince.littleEndian let payload = withUnsafeBytes(of: &syncSinceLE) { Data($0) } let data = PacketBuilder.binaryRequest(to: publicKey, type: .keepAlive, payload: payload) @@ -1386,6 +1389,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter publicKey: The full 32-byte public key of the contact. /// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure. public func resetPath(publicKey: Data) async throws { + try requireFullPublicKey(publicKey, operation: "resetPath") try await sendSimpleCommand(PacketBuilder.resetPath(publicKey: publicKey)) } @@ -1394,6 +1398,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter publicKey: The full 32-byte public key of the contact to remove. /// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure. public func removeContact(publicKey: Data) async throws { + try requireFullPublicKey(publicKey, operation: "removeContact") try await sendSimpleCommand(PacketBuilder.removeContact(publicKey: publicKey)) } @@ -1405,6 +1410,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter publicKey: The full 32-byte public key of the contact to share. /// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure. public func shareContact(publicKey: Data) async throws { + try requireFullPublicKey(publicKey, operation: "shareContact") try await sendSimpleCommand(PacketBuilder.shareContact(publicKey: publicKey)) } @@ -1414,7 +1420,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Returns: A URI string encoding the contact information. /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. public func exportContact(publicKey: Data? = nil) async throws -> String { - try await sendAndWait(PacketBuilder.exportContact(publicKey: publicKey)) { event in + if let publicKey { + try requireFullPublicKey(publicKey, operation: "exportContact") + } + return try await sendAndWait(PacketBuilder.exportContact(publicKey: publicKey)) { event in if case .contactURI(let uri) = event { return uri } return nil } @@ -1819,6 +1828,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). /// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure. public func setPathHashMode(_ mode: UInt8) async throws { + guard mode <= 2 else { + throw MeshCoreError.invalidInput("Path hash mode must be 0, 1, or 2") + } try await sendSimpleCommand(PacketBuilder.setPathHashMode(mode)) } @@ -1870,8 +1882,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Throws: ``MeshCoreError/timeout`` if no response within timeout period. /// ``MeshCoreError/invalidResponse`` if unexpected response received. public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse { + try requireFullPublicKey(publicKey, operation: "requestTelemetry") // Serialize binary requests to prevent messageSent race conditions - try await binaryRequestSerializer.withSerialization { [self] in + return try await binaryRequestSerializer.withSerialization { [self] in try await performTelemetryRequest(from: publicKey) } } @@ -1990,7 +2003,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Returns: MMA response containing aggregated statistics. /// - Throws: ``MeshCoreError/timeout`` if no response within timeout period. public func requestMMA(from publicKey: Data, start: Date, end: Date) async throws -> MMAResponse { - try await binaryRequestSerializer.withSerialization { [self] in + try requireFullPublicKey(publicKey, operation: "requestMMA") + return try await binaryRequestSerializer.withSerialization { [self] in try await performMMARequest(from: publicKey, start: start, end: end) } } @@ -2073,7 +2087,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Returns: ACL response containing authorized public keys. /// - Throws: ``MeshCoreError/timeout`` if no response within timeout period. public func requestACL(from publicKey: Data) async throws -> ACLResponse { - try await binaryRequestSerializer.withSerialization { [self] in + try requireFullPublicKey(publicKey, operation: "requestACL") + return try await binaryRequestSerializer.withSerialization { [self] in try await performACLRequest(from: publicKey) } } @@ -2581,6 +2596,12 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { break } } + + private func requireFullPublicKey(_ publicKey: Data, operation: String) throws { + guard publicKey.count == PacketBuilder.publicKeySize else { + throw MeshCoreError.invalidInput("Full \(PacketBuilder.publicKeySize)-byte public key required for \(operation)") + } + } } // MARK: - Configuration Types diff --git a/MeshCore/Tests/MeshCoreTests/Protocol/NewCommandsTests.swift b/MeshCore/Tests/MeshCoreTests/Protocol/NewCommandsTests.swift index 9cd840bc4..97d3626e7 100644 --- a/MeshCore/Tests/MeshCoreTests/Protocol/NewCommandsTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Protocol/NewCommandsTests.swift @@ -25,6 +25,18 @@ struct NewCommandsTests { #expect(packet.count == 3, "command + pathLen + payload") } + @Test("sendRawData clamps to firmware limits") + func sendRawDataClampsToFirmwareLimits() { + let path = Data(repeating: 0x11, count: 80) + let payload = Data(repeating: 0x22, count: 220) + + let packet = PacketBuilder.sendRawData(path: path, payload: payload) + + #expect(packet[1] == 64, "Path length should clamp to firmware 64-byte limit") + #expect(Data(packet[2..<66]) == Data(repeating: 0x11, count: 64), "Path bytes should be truncated to 64 bytes") + #expect(Data(packet[66...]) == Data(repeating: 0x22, count: 184), "Payload should be truncated to firmware 184-byte limit") + } + @Test("hasConnection format") func hasConnectionFormat() { let pubkey = Data(repeating: 0xAA, count: 32) @@ -36,6 +48,17 @@ struct NewCommandsTests { #expect(Data(packet[1...]) == pubkey, "Public key") } + @Test("hasConnection pads short public key to protocol width") + func hasConnectionPadsShortPublicKey() { + let pubkey = Data(repeating: 0xAA, count: 31) + + let packet = PacketBuilder.hasConnection(publicKey: pubkey) + + #expect(packet.count == 33, "Packet should still contain a full 32-byte key field") + #expect(Data(packet[1..<32]) == Data(repeating: 0xAA, count: 31)) + #expect(packet[32] == 0x00, "Short public keys should be zero-padded at the builder layer") + } + @Test("getContactByKey format") func getContactByKeyFormat() { let pubkey = Data(repeating: 0xBB, count: 32) @@ -47,6 +70,17 @@ struct NewCommandsTests { #expect(Data(packet[1...]) == pubkey, "Public key") } + @Test("getContactByKey pads short public key to protocol width") + func getContactByKeyPadsShortPublicKey() { + let pubkey = Data(repeating: 0xBB, count: 31) + + let packet = PacketBuilder.getContactByKey(publicKey: pubkey) + + #expect(packet.count == 33, "Packet should still contain a full 32-byte key field") + #expect(Data(packet[1..<32]) == Data(repeating: 0xBB, count: 31)) + #expect(packet[32] == 0x00) + } + @Test("getAdvertPath format") func getAdvertPathFormat() { let pubkey = Data(repeating: 0xCC, count: 32) @@ -59,6 +93,17 @@ struct NewCommandsTests { #expect(Data(packet[2...]) == pubkey, "Public key") } + @Test("getAdvertPath pads short public key to protocol width") + func getAdvertPathPadsShortPublicKey() { + let pubkey = Data(repeating: 0xCC, count: 31) + + let packet = PacketBuilder.getAdvertPath(publicKey: pubkey) + + #expect(packet.count == 34, "Packet should still contain reserved byte plus a full 32-byte key field") + #expect(Data(packet[2..<33]) == Data(repeating: 0xCC, count: 31)) + #expect(packet[33] == 0x00) + } + @Test("getTuningParams format") func getTuningParamsFormat() { let packet = PacketBuilder.getTuningParams() @@ -82,4 +127,12 @@ struct NewCommandsTests { #expect(packet[1] == 0x00, "Reserved byte at offset 1 should be 0x00") #expect(packet[2] == mode, "Mode byte at offset 2 should be \(mode) (\(label))") } + + @Test("setPathHashMode clamps reserved values to max supported mode") + func setPathHashModeClampsReservedValues() { + let packet = PacketBuilder.setPathHashMode(3) + + #expect(packet.count == 3) + #expect(packet[2] == 2, "Reserved path hash modes should clamp to the max supported mode") + } } diff --git a/MeshCore/Tests/MeshCoreTests/Session/ConnectionStateTests.swift b/MeshCore/Tests/MeshCoreTests/Session/ConnectionStateTests.swift index 1113e13ef..e6cbe526b 100644 --- a/MeshCore/Tests/MeshCoreTests/Session/ConnectionStateTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Session/ConnectionStateTests.swift @@ -60,6 +60,51 @@ struct ConnectionStateTests { await session.stop() } + @Test("getContact rejects short public key before sending") + func getContactRejectsShortPublicKey() async { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "Test") + ) + + await #expect(throws: MeshCoreError.self) { + _ = try await session.getContact(publicKey: Data(repeating: 0xAA, count: 31)) + } + + #expect(await transport.sentData.isEmpty) + } + + @Test("requestStatus rejects short public key before sending") + func requestStatusRejectsShortPublicKey() async { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "Test") + ) + + await #expect(throws: MeshCoreError.self) { + _ = try await session.requestStatus(from: Data(repeating: 0xBB, count: 31)) + } + + #expect(await transport.sentData.isEmpty) + } + + @Test("setPathHashMode rejects reserved mode before sending") + func setPathHashModeRejectsReservedMode() async { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "Test") + ) + + await #expect(throws: MeshCoreError.self) { + try await session.setPathHashMode(3) + } + + #expect(await transport.sentData.isEmpty) + } + private func makeSelfInfoPacket(name: String = "TestNode") -> Data { var payload = Data() payload.append(0) diff --git a/MeshCore/Tests/MeshCoreTests/Validation/NewResponseParsingTests.swift b/MeshCore/Tests/MeshCoreTests/Validation/NewResponseParsingTests.swift index d68fc9e54..26652e37a 100644 --- a/MeshCore/Tests/MeshCoreTests/Validation/NewResponseParsingTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Validation/NewResponseParsingTests.swift @@ -54,6 +54,23 @@ struct NewResponseParsingTests { } } + @Test("advertPathResponse rejects reserved path length encoding") + func advertPathResponseRejectsReservedPathLengthEncoding() { + var payload = Data() + payload.appendLittleEndian(UInt32(1704067200)) + payload.append(0xC1) // mode 3 (reserved), hop count 1 + payload.append(0x11) + + let event = Parsers.AdvertPathResponse.parse(payload) + + guard case .parseFailure(_, let reason) = event else { + Issue.record("Expected parseFailure for reserved path length, got \(event)") + return + } + + #expect(reason.contains("reserved path length encoding")) + } + @Test("tuningParamsResponse parse") func tuningParamsResponseParse() { var payload = Data() diff --git a/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift b/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift index b93d77d8c..79cf1b453 100644 --- a/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift @@ -213,6 +213,22 @@ struct ProtocolBugFixTests { #expect(status.receiveErrors == 0, "receiveErrors should default to 0 when not present") } + @Test("battery response rejects partial extended payload") + func batteryResponseRejectsPartialExtendedPayload() { + var packet = Data([ResponseCode.battery.rawValue]) + packet.append(contentsOf: [0xE8, 0x03]) // 1000 mV + packet.append(contentsOf: [0x01, 0x02, 0x03]) // partial extended fields only + + let event = PacketParser.parse(packet) + + guard case .parseFailure(_, let reason) = event else { + Issue.record("Expected .parseFailure event, got \(event)") + return + } + + #expect(reason.contains("partial extended payload")) + } + @Test("statusResponse parseFromBinaryResponse 52 bytes parses rxAirtime") func statusResponseParseFromBinaryResponse52BytesParsesRxAirtime() { // 52 bytes: has rxAirtime but no receiveErrors diff --git a/MeshCore/Tests/MeshCoreTests/Validation/RoundTripTests.swift b/MeshCore/Tests/MeshCoreTests/Validation/RoundTripTests.swift index f6b873447..1ae6dacb6 100644 --- a/MeshCore/Tests/MeshCoreTests/Validation/RoundTripTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Validation/RoundTripTests.swift @@ -59,6 +59,24 @@ struct RoundTripTests { #expect(abs(contact.longitude - (-122.4194)) <= 0.0001) } + @Test("Contact rejects reserved path length encoding") + func contactRejectsReservedPathLengthEncoding() { + var data = Data(repeating: 0, count: 147) + data.replaceSubrange(0..<32, with: Data(repeating: 0xAA, count: 32)) + data[32] = 1 + data[33] = 0 + data[34] = 0xC1 // mode 3 (reserved), hop count 1 + + let event = Parsers.Contact.parse(data) + + guard case .parseFailure(_, let reason) = event else { + Issue.record("Expected .parseFailure event, got \(event)") + return + } + + #expect(reason.contains("reserved path length encoding")) + } + // MARK: - SelfInfo Round-Trip @Test("SelfInfo round trip") @@ -265,6 +283,27 @@ struct RoundTripTests { #expect(msg.text == "Hello World") } + @Test("ContactMessage rejects truncated signature payload") + func contactMessageRejectsTruncatedSignaturePayload() { + var data = Data() + data.append(UInt8(bitPattern: Int8(0))) + data.append(contentsOf: withUnsafeBytes(of: UInt16(0).littleEndian) { Data($0) }) + data.append(Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xAB])) + data.append(0x00) + data.append(0x02) // signed text + data.append(contentsOf: withUnsafeBytes(of: UInt32(1704067200).littleEndian) { Data($0) }) + data.append(Data([0xDE, 0xAD, 0xBE])) // only 3 signature bytes, no text payload + + let event = Parsers.ContactMessage.parse(data, version: .v3) + + guard case .parseFailure(_, let reason) = event else { + Issue.record("Expected .parseFailure event, got \(event)") + return + } + + #expect(reason.contains("signature truncated")) + } + @Test("ChannelMessage v3 round trip") func channelMessageV3RoundTrip() { var data = Data() @@ -680,6 +719,28 @@ struct RoundTripTests { #expect(caps.pathHashMode == 2, "pathHashMode should be 2 (3-byte hashes)") } + @Test("DeviceInfo v10 rejects truncated payload before pathHashMode") + func deviceInfoV10RejectsTruncatedPayloadBeforePathHashMode() { + var data = Data() + data.append(10) // fwVer + data.append(50) // maxContacts + data.append(8) // maxChannels + let blePin: UInt32 = 654321 + data.append(contentsOf: withUnsafeBytes(of: blePin.littleEndian) { Data($0) }) + data.append(Data(repeating: 0, count: 12 + 40 + 20)) + data.append(1) // client_repeat present + #expect(data.count == 80, "v10 payload missing pathHashMode byte should be truncated") + + let event = Parsers.DeviceInfo.parse(data) + + guard case .parseFailure(_, let reason) = event else { + Issue.record("Expected .parseFailure event, got \(event)") + return + } + + #expect(reason.contains("missing pathHashMode byte")) + } + @Test("DeviceInfo v9 defaults pathHashMode to 0") func deviceInfoV9DefaultsPathHashMode() { // v9 firmware doesn't include pathHashMode — it should default to 0