Merge branch 'main' into feat/courier

This commit is contained in:
jack
2026-06-17 10:03:07 +02:00
committed by GitHub
23 changed files with 1040 additions and 534 deletions
+10 -4
View File
@@ -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
+32
View File
@@ -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
+47
View File
@@ -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).