mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 07:25:19 +00:00
Merge branch 'main' into feat/courier
This commit is contained in:
@@ -14,23 +14,29 @@ import BitFoundation
|
||||
struct BLEServiceCoreTests {
|
||||
|
||||
@Test
|
||||
func duplicatePacket_isDeduped() async {
|
||||
func duplicatePacket_isDeduped() async throws {
|
||||
let ble = makeService()
|
||||
let delegate = PublicCaptureDelegate()
|
||||
ble.delegate = delegate
|
||||
|
||||
// Public messages must carry a valid signature from the claimed sender;
|
||||
// sign the packet and preseed the sender's signing key so the receiver
|
||||
// can verify it (production `sendMessage` signs public broadcasts too).
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let sender = PeerID(str: "1122334455667788")
|
||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
let unsigned = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
let packet = try #require(signer.signPacket(unsigned), "Failed to sign public message")
|
||||
let signingKey = signer.getSigningPublicKeyData()
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||
let receivedFirst = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(receivedFirst)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||
let receivedDuplicate = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
|
||||
@@ -1042,6 +1042,38 @@ struct ChatViewModelPanicTests {
|
||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_resetsLiveGeohashAndNostrState() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruy"
|
||||
let channel = GeohashChannel(level: .city, geohash: geohash)
|
||||
let identity = try NostrIdentity.generate()
|
||||
let pubkey = String(repeating: "ab", count: 32)
|
||||
let peerID = PeerID(nostr: pubkey)
|
||||
|
||||
viewModel.activeChannel = .location(channel)
|
||||
viewModel.setGeoChatSubscriptionID("geo-\(geohash)")
|
||||
viewModel.setGeoDmSubscriptionID("geo-dm-\(geohash)")
|
||||
viewModel.addGeoSamplingSub("geo-sample-\(geohash)", forGeohash: geohash)
|
||||
viewModel.cachedGeohashIdentity = (geohash, identity)
|
||||
viewModel.registerNostrKeyMapping(pubkey, for: peerID)
|
||||
viewModel.currentGeohash = geohash
|
||||
viewModel.geoNicknames = [pubkey: "alice"]
|
||||
viewModel.teleportedGeo = [pubkey]
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
#expect(viewModel.activeChannel == .mesh)
|
||||
#expect(viewModel.geoSubscriptionID == nil)
|
||||
#expect(viewModel.geoDmSubscriptionID == nil)
|
||||
#expect(viewModel.geoSamplingSubs.isEmpty)
|
||||
#expect(viewModel.cachedGeohashIdentity == nil)
|
||||
#expect(viewModel.nostrKeyMapping.isEmpty)
|
||||
#expect(viewModel.currentGeohash == nil)
|
||||
#expect(viewModel.geoNicknames.isEmpty)
|
||||
#expect(viewModel.teleportedGeo.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Service Lifecycle Tests
|
||||
|
||||
@@ -21,9 +21,16 @@ struct FragmentationTests {
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
||||
// Construct a big SIGNED public packet (3KB) from a remote sender. Public
|
||||
// messages must carry a valid signature, so the reassembled packet is
|
||||
// signed and the sender's signing key is preseeded into the registry.
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let signingKey = signer.getSigningPublicKeyData()
|
||||
let remoteShortID = PeerID(str: "1122334455667788")
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
||||
let original = try #require(
|
||||
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)),
|
||||
"Failed to sign public packet"
|
||||
)
|
||||
|
||||
// Use a small fragment size to ensure multiple pieces
|
||||
let fragments = fragmentPacket(original, fragmentSize: 400)
|
||||
@@ -36,7 +43,7 @@ struct FragmentationTests {
|
||||
if i > 0 {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
||||
}
|
||||
|
||||
// Wait for delegate callback with proper timeout
|
||||
@@ -52,8 +59,13 @@ struct FragmentationTests {
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let signingKey = signer.getSigningPublicKeyData()
|
||||
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
||||
let original = try #require(
|
||||
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)),
|
||||
"Failed to sign public packet"
|
||||
)
|
||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||
|
||||
// Duplicate one fragment
|
||||
@@ -66,7 +78,7 @@ struct FragmentationTests {
|
||||
if i > 0 {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
||||
}
|
||||
|
||||
// Wait for delegate callback with proper timeout
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,10 @@ struct BLEAnnounceHandlerTests {
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false)
|
||||
#expect(recorder.persistedIdentities.count == 1)
|
||||
// Identity persistence MUST NOT occur for unverified announces:
|
||||
// persisting would let an attacker who replays a victim's noisePublicKey
|
||||
// overwrite the victim's stored signing key/nickname (identity poisoning).
|
||||
#expect(recorder.persistedIdentities.isEmpty)
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.announceBacks == 1)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ struct BLEPublicMessageHandlerTests {
|
||||
var localNickname = "Me"
|
||||
var peers: [PeerID: BLEPeerInfo] = [:]
|
||||
var signedName: String?
|
||||
var verifyPacketSignatureResult = false
|
||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||
var selfBroadcastMessageID: String?
|
||||
|
||||
var peersSnapshotReads = 0
|
||||
var verifyPacketSignatureQueries: [PeerID] = []
|
||||
var signedNameQueries: [PeerID] = []
|
||||
var trackedPackets: [BitchatPacket] = []
|
||||
var selfBroadcastTakes: [BitchatPacket] = []
|
||||
@@ -35,6 +37,10 @@ struct BLEPublicMessageHandlerTests {
|
||||
recorder.peersSnapshotReads += 1
|
||||
return recorder.peers
|
||||
},
|
||||
verifyPacketSignature: { packet, _ in
|
||||
recorder.verifyPacketSignatureQueries.append(PeerID(hexData: packet.senderID))
|
||||
return recorder.verifyPacketSignatureResult
|
||||
},
|
||||
signedSenderDisplayName: { _, peerID in
|
||||
recorder.signedNameQueries.append(peerID)
|
||||
return recorder.signedName
|
||||
@@ -59,13 +65,17 @@ struct BLEPublicMessageHandlerTests {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
// A valid packet signature is required even for a registry-verified peer:
|
||||
// senderID is spoofable, so registry membership alone is not authentication.
|
||||
recorder.signedName = "SignedAlice"
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.peersSnapshotReads == 1)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
// Signature is verified, then the registry's collision-resolved name is preferred.
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.selfBroadcastTakes.isEmpty)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
@@ -133,6 +143,63 @@ struct BLEPublicMessageHandlerTests {
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func registryVerifiedPeerDeliveredBeforeIdentityCachePersists() {
|
||||
// A freshly verified announce updates the peer registry synchronously,
|
||||
// but identity-cache persistence is async. A message arriving in that
|
||||
// window has a valid signature and a registry signing key, yet the
|
||||
// persisted-identity lookup (signedName) would still return nil. It must
|
||||
// be verified against the registry key and delivered, not dropped.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: Data(repeating: 0xAB, count: 32)
|
||||
)]
|
||||
recorder.verifyPacketSignatureResult = true
|
||||
recorder.signedName = nil
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "first msg", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
|
||||
// Verified via the registry key, so no fallback to the persisted lookup.
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.nickname == "Alice")
|
||||
#expect(recorder.deliveries.first?.content == "first msg")
|
||||
}
|
||||
|
||||
@Test
|
||||
func registryPeerWithInvalidSignatureFallsBackAndDrops() {
|
||||
// Spoofed senderID: the peer is in the registry with a signing key, but
|
||||
// the packet signature does not verify against it. The handler must fall
|
||||
// back to the persisted lookup and, finding nothing, drop the message.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: Data(repeating: 0xAB, count: 32)
|
||||
)]
|
||||
recorder.verifyPacketSignatureResult = false
|
||||
recorder.signedName = nil
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "spoofed", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signedSenderFallbackDeliversWithSignedName() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
@@ -154,6 +221,7 @@ struct BLEPublicMessageHandlerTests {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.signedName = "SignedAlice"
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now))
|
||||
|
||||
@@ -187,6 +255,7 @@ struct BLEPublicMessageHandlerTests {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.signedName = "SignedAlice"
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(
|
||||
sender: remotePeerID,
|
||||
@@ -213,14 +282,15 @@ struct BLEPublicMessageHandlerTests {
|
||||
_ peerID: PeerID,
|
||||
nickname: String,
|
||||
isVerified: Bool,
|
||||
isConnected: Bool = true
|
||||
isConnected: Bool = true,
|
||||
signingPublicKey: Data? = nil
|
||||
) -> BLEPeerInfo {
|
||||
BLEPeerInfo(
|
||||
peerID: peerID,
|
||||
nickname: nickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: isVerified,
|
||||
lastSeen: Date(timeIntervalSince1970: 999)
|
||||
)
|
||||
|
||||
@@ -14,7 +14,10 @@ struct BLEReceivePipelineTests {
|
||||
let context = BLEReceivePipeline.context(for: packet, localPeerID: local)
|
||||
|
||||
#expect(context.senderID == sender)
|
||||
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)")
|
||||
// The message ID includes a payload digest so distinct packets sharing a
|
||||
// sender/timestamp(ms)/type are not collapsed as duplicates.
|
||||
let digest = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)-\(digest)")
|
||||
#expect(context.messageType == .message)
|
||||
#expect(context.shouldDeduplicate)
|
||||
#expect(context.logsHandlingDetails)
|
||||
|
||||
@@ -1328,6 +1328,68 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
||||
}
|
||||
|
||||
func test_resetForPanicWipe_dropsSessionRelayStateWithoutFiringCallbacks() async throws {
|
||||
let relayURL = "wss://panic-reset.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
let event = try makeSignedEvent(content: "queued before panic")
|
||||
var handledEvents = 0
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "panic-sub",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in handledEvents += 1 },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
|
||||
XCTAssertEqual(context.manager.debugMessageHandlerCount, 1)
|
||||
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
|
||||
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.manager.resetForPanicWipe()
|
||||
|
||||
XCTAssertEqual(context.manager.debugMessageHandlerCount, 0)
|
||||
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 0)
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
||||
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 0)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||
XCTAssertEqual(handledEvents, 0)
|
||||
XCTAssertEqual(eoseCount, 0)
|
||||
|
||||
// Stale Tor wait and fallback callbacks from the pre-wipe generation
|
||||
// must not resurrect connections or settle callbacks after reset.
|
||||
context.torWaiter.resolve(true)
|
||||
context.scheduler.runNext()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user