From 8c7e3e7b9b0dce8301f7069436781ebc8937c3a8 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 2 Feb 2026 18:07:19 -1000 Subject: [PATCH] Harden Nostr validation and BLE announce tests (#1012) Co-authored-by: jack --- bitchat/Nostr/NostrProtocol.swift | 20 +++++ bitchat/Nostr/NostrRelayManager.swift | 3 +- bitchat/Protocols/BinaryProtocol.swift | 1 + bitchat/Services/BLE/BLEService.swift | 44 +++++----- .../Extensions/ChatViewModel+Nostr.swift | 65 ++++++++------ bitchatTests/BLEServiceCoreTests.swift | 34 ++++++++ .../ChatViewModelExtensionsTests.swift | 85 +++++++++++++++---- bitchatTests/ChatViewModelTests.swift | 8 +- bitchatTests/GeohashPresenceTests.swift | 60 ++++++------- bitchatTests/LocationNotesManagerTests.swift | 11 +-- bitchatTests/NostrProtocolTests.swift | 27 ++++++ .../Protocol/BinaryProtocolTests.swift | 28 ++++++ 12 files changed, 283 insertions(+), 103 deletions(-) diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 97c13fbf..3f7dc9dc 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -528,6 +528,26 @@ struct NostrEvent: Codable { signed.sig = signatureHex return signed } + + /// Validate that the event ID and Schnorr signature match the content and pubkey. + /// Returns false when the signature is missing, malformed, or does not verify. + func isValidSignature() -> Bool { + guard let sig = sig, + let sigData = Data(hexString: sig), + let pubData = Data(hexString: pubkey), + sigData.count == 64, + pubData.count == 32, + let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData), + let (expectedId, eventHash) = try? calculateEventId(), + expectedId == id + else { + return false + } + + var messageBytes = [UInt8](eventHash) + let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData) + return xonly.isValid(signature, for: &messageBytes) + } private func calculateEventId() throws -> (String, Data) { let serialized = [ diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 519b340b..f2256c21 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -764,7 +764,8 @@ private enum ParsedInbound { if array.count >= 3, let subId = array[1] as? String, let eventDict = array[2] as? [String: Any], - let event = try? NostrEvent(from: eventDict) { + let event = try? NostrEvent(from: eventDict), + event.isValidSignature() { self = .event(subId: subId, event: event) return } diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift index ec2137a6..568daf2a 100644 --- a/bitchat/Protocols/BinaryProtocol.swift +++ b/bitchat/Protocols/BinaryProtocol.swift @@ -343,6 +343,7 @@ struct BinaryProtocol { } guard payloadLength >= 0 else { return nil } + guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil } guard let senderID = readData(senderIDSize) else { return nil } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 39020657..2980e4b5 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2038,26 +2038,28 @@ extension BLEService { #if DEBUG // Test-only helper to inject packets into the receive pipeline extension BLEService { - func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID) { - // Ensure the synthetic peer is known and marked verified for public-message tests - let normalizedID = PeerID(hexData: packet.senderID) - collectionsQueue.sync(flags: .barrier) { - if peers[normalizedID] == nil { - peers[normalizedID] = PeerInfo( - peerID: normalizedID, - nickname: "TestPeer_\(fromPeerID.id.prefix(4))", - isConnected: true, - noisePublicKey: packet.senderID, - signingPublicKey: nil, - isVerifiedNickname: true, - lastSeen: Date() - ) - } else { - var p = peers[normalizedID]! - p.isConnected = true - p.isVerifiedNickname = true - p.lastSeen = Date() - peers[normalizedID] = p + func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) { + if preseedPeer { + // Ensure the synthetic peer is known and marked verified for public-message tests + let normalizedID = PeerID(hexData: packet.senderID) + collectionsQueue.sync(flags: .barrier) { + if peers[normalizedID] == nil { + peers[normalizedID] = PeerInfo( + peerID: normalizedID, + nickname: "TestPeer_\(fromPeerID.id.prefix(4))", + isConnected: true, + noisePublicKey: packet.senderID, + signingPublicKey: nil, + isVerifiedNickname: true, + lastSeen: Date() + ) + } else { + var p = peers[normalizedID]! + p.isConnected = true + p.isVerifiedNickname = true + p.lastSeen = Date() + peers[normalizedID] = p + } } } handleReceivedPacket(packet, from: fromPeerID) @@ -3839,7 +3841,7 @@ extension BLEService { let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey) if derivedFromKey != peerID { SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security) - + return } // Don't add ourselves as a peer diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index fc430064..fcd03438 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -56,6 +56,7 @@ extension ChatViewModel { } func subscribeNostrEvent(_ event: NostrEvent) { + guard event.isValidSignature() else { return } guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), !deduplicationService.hasProcessedNostrEvent(event.id) @@ -146,13 +147,12 @@ extension ChatViewModel { } func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { + guard giftWrap.isValidSignature() else { return } guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return } deduplicationService.recordNostrEvent(giftWrap.id) guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id), - content.hasPrefix("bitchat1:"), - let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), - let packet = BitchatPacket.from(packetData), + let packet = Self.decodeEmbeddedBitChatPacket(from: content), packet.type == MessageType.noiseEncrypted.rawValue, let noisePayload = NoisePayload.decode(packet.payload) else { @@ -254,6 +254,7 @@ extension ChatViewModel { } func handleNostrEvent(_ event: NostrEvent) { + guard event.isValidSignature() else { return } // Only handle ephemeral kind 20000 or presence kind 20001 with matching tag guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } @@ -367,6 +368,7 @@ extension ChatViewModel { } func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { + guard giftWrap.isValidSignature() else { return } if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return } @@ -380,9 +382,7 @@ extension ChatViewModel { SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session) - guard content.hasPrefix("bitchat1:"), - let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), - let packet = BitchatPacket.from(packetData), + guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), packet.type == MessageType.noiseEncrypted.rawValue, let payload = NoisePayload.decode(packet.payload) else { @@ -485,6 +485,7 @@ extension ChatViewModel { } func subscribeNostrEvent(_ event: NostrEvent, gh: String) { + guard event.isValidSignature() else { return } guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } @@ -602,6 +603,7 @@ extension ChatViewModel { } func processNostrMessage(_ giftWrap: NostrEvent) async { + guard giftWrap.isValidSignature() else { return } guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return } do { @@ -619,8 +621,7 @@ extension ChatViewModel { // Check if it's a BitChat packet embedded in the content (bitchat1:...) if content.hasPrefix("bitchat1:") { - guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), - let packet = BitchatPacket.from(packetData) else { + guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else { SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) return } @@ -631,24 +632,23 @@ extension ChatViewModel { // Stable target ID if we know Noise key; otherwise temporary Nostr-based peer let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey) - if packet.type == MessageType.noiseEncrypted.rawValue { - if let payload = NoisePayload.decode(packet.payload) { - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) - // Store Nostr mapping - await MainActor.run { - nostrKeyMapping[targetPeerID] = senderPubkey - - // Handle packet types - switch payload.type { - case .privateMessage: - handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp) - case .delivered: - handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .readReceipt: - handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .verifyChallenge, .verifyResponse: - break - } + if packet.type == MessageType.noiseEncrypted.rawValue, + let payload = NoisePayload.decode(packet.payload) { + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) + // Store Nostr mapping + await MainActor.run { + nostrKeyMapping[targetPeerID] = senderPubkey + + // Handle packet types + switch payload.type { + case .privateMessage: + handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp) + case .delivered: + handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) + case .readReceipt: + handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) + case .verifyChallenge, .verifyResponse: + break } } } @@ -801,6 +801,19 @@ extension ChatViewModel { messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) } + private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { + guard content.hasPrefix("bitchat1:") else { return nil } + let encoded = String(content.dropFirst("bitchat1:".count)) + let maxBytes = FileTransferLimits.maxFramedFileBytes + // Base64url length upper bound for maxBytes (padded length; unpadded is <= this). + let maxEncoded = ((maxBytes + 2) / 3) * 4 + guard encoded.count <= maxEncoded else { return nil } + guard let packetData = Self.base64URLDecode(encoded), + packetData.count <= maxBytes + else { return nil } + return BitchatPacket.from(packetData) + } + // MARK: - Geohash Nickname Resolution (for /block in geohash) func nostrPubkeyForDisplayName(_ name: String) -> String? { diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 503a260b..b81a762d 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -49,6 +49,40 @@ struct BLEServiceCoreTests { #expect(!didReceive) #expect(delegate.publicMessagesSnapshot().isEmpty) } + + @Test + func announceSenderMismatch_isRejected() async throws { + let ble = makeService() + + let signer = NoiseEncryptionService(keychain: MockKeychain()) + let announcement = AnnouncementPacket( + nickname: "Spoof", + noisePublicKey: signer.getStaticPublicKeyData(), + signingPublicKey: signer.getSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode(), "Failed to encode announcement") + + let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey) + let wrongFirst = derivedPeerID.bare.first == "0" ? "1" : "0" + let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst()) + let wrongPeerID = PeerID(str: wrongBare) + let packet = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: wrongPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 7 + ) + let signed = try #require(signer.signPacket(packet), "Failed to sign announce packet") + + ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false) + + _ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3) + #expect(ble.currentPeerSnapshots().isEmpty) + } } private func makeService() -> BLEService { diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index af2dc61d..2507e89b 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -265,7 +265,7 @@ struct ChatViewModelNostrExtensionTests { } @Test @MainActor - func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async { + func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async throws { let geohash = "u4pruydq" let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) @@ -278,17 +278,16 @@ struct ChatViewModelNostrExtensionTests { _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }) - var event = NostrEvent( - pubkey: "pub1", + let signer = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: signer.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, tags: [["g", geohash]], content: "Hello Geo" ) - event.id = "evt1" - event.sig = "sig" - - viewModel.handleNostrEvent(event) + let signed = try event.sign(with: signer.schnorrSigningKey()) + viewModel.handleNostrEvent(signed) let didAppend = await TestHelpers.waitUntil({ viewModel.publicMessagePipeline.flushIfNeeded() @@ -305,16 +304,15 @@ struct ChatViewModelNostrExtensionTests { viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash) - var event = NostrEvent( + let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, tags: [["g", geohash]], content: "Self echo" ) - event.id = "evt-self" - - viewModel.handleNostrEvent(event) + let signed = try event.sign(with: identity.schnorrSigningKey()) + viewModel.handleNostrEvent(signed) try? await Task.sleep(nanoseconds: 100_000_000) viewModel.publicMessagePipeline.flushIfNeeded() @@ -323,24 +321,24 @@ struct ChatViewModelNostrExtensionTests { } @Test @MainActor - func handleNostrEvent_skipsBlockedSender() async { + func handleNostrEvent_skipsBlockedSender() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" - let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001" + let blockedIdentity = try NostrIdentity.generate() + let blockedPubkey = blockedIdentity.publicKeyHex viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true) - var event = NostrEvent( + let event = NostrEvent( pubkey: blockedPubkey, createdAt: Date(), kind: .ephemeralEvent, tags: [["g", geohash]], content: "Blocked" ) - event.id = "evt-blocked" - - viewModel.handleNostrEvent(event) + let signed = try event.sign(with: blockedIdentity.schnorrSigningKey()) + viewModel.handleNostrEvent(signed) try? await Task.sleep(nanoseconds: 100_000_000) viewModel.publicMessagePipeline.flushIfNeeded() @@ -348,6 +346,52 @@ struct ChatViewModelNostrExtensionTests { #expect(!viewModel.messages.contains { $0.content == "Blocked" }) } + @Test @MainActor + func handleNostrEvent_rejectsInvalidSignature() async throws { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + let identity = try NostrIdentity.generate() + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [["g", geohash]], + content: "Valid" + ) + var signed = try event.sign(with: identity.schnorrSigningKey()) + signed.id = "deadbeef" + + viewModel.handleNostrEvent(signed) + + try? await Task.sleep(nanoseconds: 100_000_000) + viewModel.publicMessagePipeline.flushIfNeeded() + + #expect(!viewModel.messages.contains { $0.content == "Tampered" }) + } + + @Test @MainActor + func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws { + let (viewModel, _) = makeTestableViewModel() + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + + let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1) + let content = "bitchat1:" + base64URLEncode(oversized) + let giftWrap = try NostrProtocol.createPrivateMessage( + content: content, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + viewModel.subscribeGiftWrap(giftWrap, id: recipient) + + try? await Task.sleep(nanoseconds: 100_000_000) + #expect(viewModel.privateChats.isEmpty) + } + @Test @MainActor func switchLocationChannel_clearsNostrDedupCache() async { let (viewModel, _) = makeTestableViewModel() @@ -406,3 +450,10 @@ struct ChatViewModelGeoDMTests { #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) } } + +private func base64URLEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 8fef7eca..e48aa17f 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -195,10 +195,12 @@ struct ChatViewModelReceivingTests { messageID: "pub-001" ) - // Give time for async Task and pipeline processing - try? await Task.sleep(nanoseconds: 500_000_000) + let found = await TestHelpers.waitUntil({ + viewModel.publicMessagePipeline.flushIfNeeded() + return viewModel.messages.contains { $0.content == "Public hello from Bob" } + }, timeout: TestConstants.defaultTimeout) - #expect(viewModel.messages.contains { $0.content == "Public hello from Bob" }) + #expect(found) } } diff --git a/bitchatTests/GeohashPresenceTests.swift b/bitchatTests/GeohashPresenceTests.swift index 067fffa2..dcb5e3bc 100644 --- a/bitchatTests/GeohashPresenceTests.swift +++ b/bitchatTests/GeohashPresenceTests.swift @@ -131,7 +131,7 @@ struct NostrFilterPresenceTests { @MainActor struct ChatViewModelPresenceHandlingTests { - @Test func handleNostrEvent_presenceUpdatesParticipantTracker() async { + @Test func handleNostrEvent_presenceUpdatesParticipantTracker() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" @@ -139,18 +139,18 @@ struct ChatViewModelPresenceHandlingTests { viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) // Create a presence event (kind 20001) - var event = NostrEvent( - pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, createdAt: Date(), kind: .geohashPresence, tags: [["g", geohash]], content: "" ) - event.id = "presence_evt_1" - event.sig = "sig" + let signed = try event.sign(with: identity.schnorrSigningKey()) // Handle the event - viewModel.handleNostrEvent(event) + viewModel.handleNostrEvent(signed) // Allow async processing try? await Task.sleep(nanoseconds: 50_000_000) @@ -160,7 +160,7 @@ struct ChatViewModelPresenceHandlingTests { #expect(count >= 1) } - @Test func handleNostrEvent_presenceDoesNotAddToTimeline() async { + @Test func handleNostrEvent_presenceDoesNotAddToTimeline() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" @@ -169,17 +169,17 @@ struct ChatViewModelPresenceHandlingTests { let initialMessageCount = viewModel.messages.count // Create a presence event (kind 20001) - var event = NostrEvent( - pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, createdAt: Date(), kind: .geohashPresence, tags: [["g", geohash]], content: "" ) - event.id = "presence_evt_2" - event.sig = "sig" + let signed = try event.sign(with: identity.schnorrSigningKey()) - viewModel.handleNostrEvent(event) + viewModel.handleNostrEvent(signed) try? await Task.sleep(nanoseconds: 50_000_000) @@ -187,24 +187,24 @@ struct ChatViewModelPresenceHandlingTests { #expect(viewModel.messages.count == initialMessageCount) } - @Test func handleNostrEvent_chatMessageUpdatesParticipant() async { + @Test func handleNostrEvent_chatMessageUpdatesParticipant() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) // Create a chat event (kind 20000) - NOT presence - var event = NostrEvent( - pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, tags: [["g", geohash]], content: "Hello world" ) - event.id = "chat_evt_1" - event.sig = "sig" + let signed = try event.sign(with: identity.schnorrSigningKey()) - viewModel.handleNostrEvent(event) + viewModel.handleNostrEvent(signed) try? await Task.sleep(nanoseconds: 50_000_000) @@ -223,25 +223,25 @@ struct ChatViewModelPresenceHandlingTests { #expect(chatKind == 20000) } - @Test func subscribeNostrEvent_acceptsPresenceKind() async { + @Test func subscribeNostrEvent_acceptsPresenceKind() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) // Create presence event - var event = NostrEvent( - pubkey: "test1234test1234test1234test1234test1234test1234test1234test1234", + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, createdAt: Date(), kind: .geohashPresence, tags: [["g", geohash]], content: "" ) - event.id = "subscribe_presence_evt" - event.sig = "sig" + let signed = try event.sign(with: identity.schnorrSigningKey()) // subscribeNostrEvent should accept kind 20001 - viewModel.subscribeNostrEvent(event) + viewModel.subscribeNostrEvent(signed) try? await Task.sleep(nanoseconds: 50_000_000) @@ -250,7 +250,7 @@ struct ChatViewModelPresenceHandlingTests { #expect(count >= 1) } - @Test func subscribeNostrEvent_presenceForNonActiveGeohash() async { + @Test func subscribeNostrEvent_presenceForNonActiveGeohash() async throws { let (viewModel, _) = makeTestableViewModel() let activeGeohash = "u4pruydq" let otherGeohash = "87yw7" @@ -258,18 +258,18 @@ struct ChatViewModelPresenceHandlingTests { viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash))) // Create presence event for a DIFFERENT geohash - var event = NostrEvent( - pubkey: "other1234other1234other1234other1234other1234other1234other1234", + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, createdAt: Date(), kind: .geohashPresence, tags: [["g", otherGeohash]], content: "" ) - event.id = "other_geohash_presence" - event.sig = "sig" + let signed = try event.sign(with: identity.schnorrSigningKey()) // Use subscribeNostrEvent with geohash parameter - viewModel.subscribeNostrEvent(event, gh: otherGeohash) + viewModel.subscribeNostrEvent(signed, gh: otherGeohash) try? await Task.sleep(nanoseconds: 50_000_000) diff --git a/bitchatTests/LocationNotesManagerTests.swift b/bitchatTests/LocationNotesManagerTests.swift index ce6c3e80..6f593b4e 100644 --- a/bitchatTests/LocationNotesManagerTests.swift +++ b/bitchatTests/LocationNotesManagerTests.swift @@ -48,7 +48,7 @@ struct LocationNotesManagerTests { // XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays") // } - @Test func subscribeUsesGeoRelaysAndAppendsNotes() { + @Test func subscribeUsesGeoRelaysAndAppendsNotes() throws { var relaysCaptured: [String] = [] var storedHandler: ((NostrEvent) -> Void)? var storedEOSE: (() -> Void)? @@ -71,15 +71,16 @@ struct LocationNotesManagerTests { #expect(relaysCaptured == ["wss://relay.one"]) #expect(manager.state == .loading) - var event = NostrEvent( - pubkey: "pub", + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, createdAt: Date(), kind: .textNote, tags: [["g", "u4pruydq"]], content: "hi" ) - event.id = "event1" - storedHandler?(event) + let signed = try event.sign(with: identity.schnorrSigningKey()) + storedHandler?(signed) storedEOSE?() #expect(manager.state == .ready) diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index 265ebb55..68dae5f1 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -213,6 +213,33 @@ struct NostrProtocolTests { } } + @Test func nostrEventSignatureVerification_roundTrip() throws { + let identity = try NostrIdentity.generate() + var event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [], + content: "Signed event" + ) + let signed = try event.sign(with: identity.schnorrSigningKey()) + #expect(signed.isValidSignature()) + } + + @Test func nostrEventSignatureVerification_detectsTamper() throws { + let identity = try NostrIdentity.generate() + var event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [], + content: "Original" + ) + var signed = try event.sign(with: identity.schnorrSigningKey()) + signed.id = "deadbeef" + #expect(!signed.isValidSignature()) + } + // MARK: - Helpers private static func base64URLDecode(_ s: String) -> Data? { var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") diff --git a/bitchatTests/Protocol/BinaryProtocolTests.swift b/bitchatTests/Protocol/BinaryProtocolTests.swift index 72def4c8..eb4582eb 100644 --- a/bitchatTests/Protocol/BinaryProtocolTests.swift +++ b/bitchatTests/Protocol/BinaryProtocolTests.swift @@ -315,6 +315,34 @@ struct BinaryProtocolTests { let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet") #expect(decodedPacket.payload == smallPayload) } + + @Test("Reject payloads larger than the framed file cap") + func oversizedPayloadIsRejected() throws { + let targetSize = FileTransferLimits.maxFramedFileBytes + 1 + var oversized = Data() + oversized.reserveCapacity(targetSize) + let byteRun = Data((0...255).map { UInt8($0) }) + while oversized.count < targetSize { + let remaining = targetSize - oversized.count + if remaining >= byteRun.count { + oversized.append(byteRun) + } else { + oversized.append(byteRun.prefix(remaining)) + } + } + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: "0011223344556677") ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: oversized, + signature: nil, + ttl: 1, + version: 2 + ) + let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode oversized packet") + #expect(BinaryProtocol.decode(encoded) == nil) + } // MARK: - Message Padding Tests