diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index e2d432a8..f68caa8d 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -118,6 +118,50 @@ struct NostrProtocol { return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at) } + #if DEBUG + static func createPrivateMessageWithInvalidSealSignatureForTesting( + content: String, + recipientPubkey: String, + senderIdentity: NostrIdentity + ) throws -> NostrEvent { + let rumor = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .dm, + tags: [], + content: content + ) + var seal = try createSeal( + rumor: rumor, + recipientPubkey: recipientPubkey, + senderKey: senderIdentity.schnorrSigningKey() + ) + seal.sig = String(repeating: "0", count: 128) + return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) + } + + static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting( + content: String, + recipientPubkey: String, + rumorIdentity: NostrIdentity, + sealSignerIdentity: NostrIdentity + ) throws -> NostrEvent { + let rumor = NostrEvent( + pubkey: rumorIdentity.publicKeyHex, + createdAt: Date(), + kind: .dm, + tags: [], + content: content + ) + let seal = try createSeal( + rumor: rumor, + recipientPubkey: recipientPubkey, + senderKey: sealSignerIdentity.schnorrSigningKey() + ) + return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) + } + #endif + /// Create a geohash-scoped ephemeral public message (kind 20000) static func createEphemeralGeohashEvent( content: String, diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index ed291838..53d794fa 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -281,6 +281,7 @@ final class NostrRelayManager: ObservableObject { task.cancel(with: .goingAway, reason: nil) } connections.removeAll() + markRelaySocketsClosed(resetState: false) // Sockets are gone, so per-relay subscription state is cleared — but // durable intent (subscriptionRequestState, messageHandlers, parked // EOSE callbacks) is kept so REQs replay when relays reconnect @@ -309,6 +310,7 @@ final class NostrRelayManager: ObservableObject { task.cancel(with: .goingAway, reason: nil) } connections.removeAll() + markRelaySocketsClosed(resetState: true) subscriptions.removeAll() pendingSubscriptions.removeAll() messageHandlers.removeAll() @@ -333,6 +335,24 @@ final class NostrRelayManager: ObservableObject { updateConnectionStatus() } + + private func markRelaySocketsClosed(resetState: Bool) { + let now = dependencies.now() + for index in relays.indices { + relays[index].isConnected = false + relays[index].nextReconnectTime = nil + if resetState { + relays[index].lastError = nil + relays[index].lastConnectedAt = nil + relays[index].lastDisconnectedAt = nil + relays[index].messagesSent = 0 + relays[index].messagesReceived = 0 + relays[index].reconnectAttempts = 0 + } else { + relays[index].lastDisconnectedAt = now + } + } + } /// Ensure connections exist to the given relay URLs (idempotent). func ensureConnections(to relayUrls: [String]) { diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index 4ea8020f..d237f5fc 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -120,6 +120,42 @@ struct NostrProtocolTests { } } + @Test func decryptRejectsInvalidSealSignature() throws { + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting( + content: "forged signature", + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + expectInvalidEvent { + _ = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: recipient + ) + } + } + + @Test func decryptRejectsSealRumorPubkeyMismatch() throws { + let claimedSender = try NostrIdentity.generate() + let sealSigner = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting( + content: "spoofed sender", + recipientPubkey: recipient.publicKeyHex, + rumorIdentity: claimedSender, + sealSignerIdentity: sealSigner + ) + + expectInvalidEvent { + _ = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: recipient + ) + } + } + func testAckRoundTripNIP44V2_Delivered() throws { // Identities let sender = try NostrIdentity.generate() @@ -260,4 +296,15 @@ struct NostrProtocolTests { if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) } return Data(base64Encoded: str) } + + private func expectInvalidEvent(_ operation: () throws -> Void) { + do { + try operation() + Issue.record("Expected NostrError.invalidEvent") + } catch NostrError.invalidEvent { + return + } catch { + Issue.record("Expected NostrError.invalidEvent, got \(error)") + } + } } diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index ce07f303..fbb46060 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -1371,6 +1371,25 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(eoseCount, 0) } + func test_resetForPanicWipe_marksConnectedRelaysDisconnected() async { + let relayURL = "wss://panic-connected.example" + let context = makeContext(permission: .denied) + + context.manager.ensureConnections(to: [relayURL]) + let connected = await waitUntil { + context.manager.isConnected && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(connected) + + context.manager.resetForPanicWipe() + + XCTAssertFalse(context.manager.isConnected) + XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.isConnected, false) + XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts, 0) + XCTAssertNil(context.manager.relays.first(where: { $0.url == relayURL })?.lastError) + } + func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async { let relayURL = "wss://jitter-bounds.example" // Pin the jitter source to the extremes and the midpoint of [0, 1).