Compare commits

..
Author SHA1 Message Date
jackandGitHub e455a23fe9 Revert "SPM Test target + Github Action to build and test (#585)"
This reverts commit 179663663b.
2025-09-13 10:19:45 +02:00
920dc31795 Refactor: Testable Keychain and Identity Manager (#584)
* Make static functions instance functions to be testable

* Injectable KeychainManager + Mock + updated tests

* Remove `pendingActions` from identity manager (dead code)

* Remove `getHandshakeState` from identity manager (dead code)

* Remove `getAllSocialIdentities` from identity manager (dead code)

* Remove `getCryptographicIdentity` from identity manager (dead code)

* Remove `resolveIdentity` from identity manager (dead code)

* Identity Manager: minor clean up

* Put Identity Manager behind a protocol

* Remove Keychain and Identity Manager singletons

* Tests: include MockKeychain/MockIdentityManager in project; init identityManager in CommandProcessorTests

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-12 14:37:34 +02:00
bb3d99bdca Fix repeated favorite notifications; route system messages to mesh; simplify favorites (#588)
* Favorites: mesh-only system message; stop reconnect resends; gate system on state change

* Favorites: remove npub resend tracking and nickname-based key migration; rely on Noise key as identity

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-12 14:36:32 +02:00
jackandGitHub b01cac4649 Delete Frameworks/README.md 2025-09-12 13:16:08 +02:00
30 changed files with 488 additions and 757 deletions
-12
View File
@@ -1,12 +0,0 @@
Place Tor.xcframework here
Instructions
- Obtain a prebuilt Tor Apple xcframework (iCepa/Onion Browser lineage) or build your own minimal client-only Tor.
- Rename it (if needed) to `Tor.xcframework` and drop it in this `Frameworks/` directory.
- Regenerate the Xcode project if you use XcodeGen (`project.yml` already references `Frameworks/Tor.xcframework`).
- Build the app; `TorManager` will automatically bootstrap Tor and route all networking through it.
Notes
- For iOS, the framework will be embedded and code-signed automatically.
- For macOS, it will be linked and embedded as well (you may prefer a system tor for smaller bundles).
+13 -32
View File
@@ -41,14 +41,6 @@
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; }; 0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; }; 0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
0481A3912E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; }; 0481A3912E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
0481A3932E73730100FC845E /* RequestSyncPacket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3922E73730100FC845E /* RequestSyncPacket.swift */; };
0481A3942E73730100FC845E /* RequestSyncPacket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3922E73730100FC845E /* RequestSyncPacket.swift */; };
0481A3992E73730F00FC845E /* GossipSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3952E73730F00FC845E /* GossipSyncManager.swift */; };
0481A39A2E73730F00FC845E /* PacketIdUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3962E73730F00FC845E /* PacketIdUtil.swift */; };
0481A39B2E73730F00FC845E /* SeenPacketsBloomFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3972E73730F00FC845E /* SeenPacketsBloomFilter.swift */; };
0481A39C2E73730F00FC845E /* GossipSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3952E73730F00FC845E /* GossipSyncManager.swift */; };
0481A39D2E73730F00FC845E /* PacketIdUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3962E73730F00FC845E /* PacketIdUtil.swift */; };
0481A39E2E73730F00FC845E /* SeenPacketsBloomFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3972E73730F00FC845E /* SeenPacketsBloomFilter.swift */; };
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
@@ -181,6 +173,10 @@
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; };
A1B2C3D4E5F60123456789BB /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; };
A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; };
A1B2C3D4E5F60123456789BD /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -241,10 +237,6 @@
0481A35A2E6D9BEF00FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 0481A35A2E6D9BEF00FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessorTests.swift; sourceTree = "<group>"; }; 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessorTests.swift; sourceTree = "<group>"; };
0481A3922E73730100FC845E /* RequestSyncPacket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestSyncPacket.swift; sourceTree = "<group>"; };
0481A3952E73730F00FC845E /* GossipSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GossipSyncManager.swift; sourceTree = "<group>"; };
0481A3962E73730F00FC845E /* PacketIdUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketIdUtil.swift; sourceTree = "<group>"; };
0481A3972E73730F00FC845E /* SeenPacketsBloomFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeenPacketsBloomFilter.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; }; 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; }; 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; }; 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
@@ -319,6 +311,8 @@
FC75901A0F0073B5BB8356E7 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; }; FC75901A0F0073B5BB8356E7 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; };
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; }; FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; }; FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60123456789AA /* MockKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychain.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdentityManager.swift; sourceTree = "<group>"; };
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; }; FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -384,20 +378,9 @@
path = Frameworks; path = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
0481A3982E73730F00FC845E /* Sync */ = {
isa = PBXGroup;
children = (
0481A3952E73730F00FC845E /* GossipSyncManager.swift */,
0481A3962E73730F00FC845E /* PacketIdUtil.swift */,
0481A3972E73730F00FC845E /* SeenPacketsBloomFilter.swift */,
);
path = Sync;
sourceTree = "<group>";
};
0575DCBD15C7C719ADDCB67E /* Models */ = { 0575DCBD15C7C719ADDCB67E /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0481A3922E73730100FC845E /* RequestSyncPacket.swift */,
11186E29A064E8D210880E1B /* BitchatPeer.swift */, 11186E29A064E8D210880E1B /* BitchatPeer.swift */,
); );
path = Models; path = Models;
@@ -437,7 +420,6 @@
0575DCBD15C7C719ADDCB67E /* Models */, 0575DCBD15C7C719ADDCB67E /* Models */,
637EDFDD042BDB5F2569A501 /* Noise */, 637EDFDD042BDB5F2569A501 /* Noise */,
E78C7F4B6769C0A72F5DE544 /* Nostr */, E78C7F4B6769C0A72F5DE544 /* Nostr */,
0481A3982E73730F00FC845E /* Sync */,
ADD53BCDA233C02E53458926 /* Protocols */, ADD53BCDA233C02E53458926 /* Protocols */,
D98A3186D7E4C72E35BDF7FE /* Services */, D98A3186D7E4C72E35BDF7FE /* Services */,
9A78348821A7D3374607D4E3 /* Utils */, 9A78348821A7D3374607D4E3 /* Utils */,
@@ -488,6 +470,8 @@
children = ( children = (
C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */, C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */,
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */, FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */,
A1B2C3D4E5F60123456789AA /* MockKeychain.swift */,
A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */,
); );
path = Mocks; path = Mocks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -853,9 +837,6 @@
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */, 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */,
D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */, D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */,
B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */, B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */,
0481A3992E73730F00FC845E /* GossipSyncManager.swift in Sources */,
0481A39A2E73730F00FC845E /* PacketIdUtil.swift in Sources */,
0481A39B2E73730F00FC845E /* SeenPacketsBloomFilter.swift in Sources */,
92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */, 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */,
5C93B4FDD0C448C3EDDBF8AE /* FavoritesPersistenceService.swift in Sources */, 5C93B4FDD0C448C3EDDBF8AE /* FavoritesPersistenceService.swift in Sources */,
047502B42E55FED60083520F /* MeshPeerList.swift in Sources */, 047502B42E55FED60083520F /* MeshPeerList.swift in Sources */,
@@ -881,7 +862,6 @@
6A85FC357ACD85DBD9020845 /* NostrRelayManager.swift in Sources */, 6A85FC357ACD85DBD9020845 /* NostrRelayManager.swift in Sources */,
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */, 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */, 049BD3AF2E51ED60001A566B /* Transport.swift in Sources */,
0481A3942E73730100FC845E /* RequestSyncPacket.swift in Sources */,
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */, E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */,
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */, 049BD3A02E51DBF4001A566B /* Packets.swift in Sources */,
047502892E5416250083520F /* Geohash.swift in Sources */, 047502892E5416250083520F /* Geohash.swift in Sources */,
@@ -922,9 +902,6 @@
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */, 6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */,
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */, 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */,
7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */, 7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */,
0481A39C2E73730F00FC845E /* GossipSyncManager.swift in Sources */,
0481A39D2E73730F00FC845E /* PacketIdUtil.swift in Sources */,
0481A39E2E73730F00FC845E /* SeenPacketsBloomFilter.swift in Sources */,
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */, 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */,
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */, ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */,
047502B62E55FED60083520F /* MeshPeerList.swift in Sources */, 047502B62E55FED60083520F /* MeshPeerList.swift in Sources */,
@@ -950,7 +927,6 @@
BCD0EBACD82AF5E55C2CB2B9 /* NostrRelayManager.swift in Sources */, BCD0EBACD82AF5E55C2CB2B9 /* NostrRelayManager.swift in Sources */,
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */, 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */, 049BD3AE2E51ED60001A566B /* Transport.swift in Sources */,
0481A3932E73730100FC845E /* RequestSyncPacket.swift in Sources */,
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */, 68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */,
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */, 049BD3A22E51DBF4001A566B /* Packets.swift in Sources */,
047502872E5416250083520F /* Geohash.swift in Sources */, 047502872E5416250083520F /* Geohash.swift in Sources */,
@@ -981,6 +957,8 @@
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */, 6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
A1B2C3D4E5F60123456789BB /* MockKeychain.swift in Sources */,
A1B2C3D4E5F60123456789BD /* MockIdentityManager.swift in Sources */,
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */, 765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */, 968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
ED83C7AC1E6BEF15389C0132 /* PrivateChatE2ETests.swift in Sources */, ED83C7AC1E6BEF15389C0132 /* PrivateChatE2ETests.swift in Sources */,
@@ -1005,6 +983,9 @@
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */, 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */,
A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */,
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */, BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */, EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */, 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */,
+9 -1
View File
@@ -11,7 +11,7 @@ import UserNotifications
@main @main
struct BitchatApp: App { struct BitchatApp: App {
@StateObject private var chatViewModel = ChatViewModel() @StateObject private var chatViewModel: ChatViewModel
#if os(iOS) #if os(iOS)
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@@ -23,6 +23,14 @@ struct BitchatApp: App {
#endif #endif
init() { init() {
let keychain = KeychainManager()
_chatViewModel = StateObject(
wrappedValue: ChatViewModel(
keychain: keychain,
identityManager: SecureIdentityStateManager(keychain)
)
)
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Warm up georelay directory and refresh if stale (once/day) // Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded() GeoRelayDirectory.shared.prefetchIfNeeded()
@@ -93,13 +93,51 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
protocol SecureIdentityStateManagerProtocol {
// MARK: Secure Loading/Saving
func forceSave()
// MARK: Social Identity Management
func getSocialIdentity(for fingerprint: String) -> SocialIdentity?
// MARK: Cryptographic Identities
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
func updateSocialIdentity(_ identity: SocialIdentity)
// MARK: Favorites Management
func getFavorites() -> Set<String>
func setFavorite(_ fingerprint: String, isFavorite: Bool)
func isFavorite(fingerprint: String) -> Bool
// MARK: Blocked Users Management
func isBlocked(fingerprint: String) -> Bool
func setBlocked(_ fingerprint: String, isBlocked: Bool)
// MARK: Geohash (Nostr) Blocking
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool)
func getBlockedNostrPubkeys() -> Set<String>
// MARK: Ephemeral Session Management
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
func updateHandshakeState(peerID: String, state: HandshakeState)
// MARK: Cleanup
func clearAllIdentityData()
func removeEphemeralSession(peerID: String)
// MARK: Verification
func setVerified(fingerprint: String, verified: Bool)
func isVerified(fingerprint: String) -> Bool
func getVerifiedFingerprints() -> Set<String>
}
/// Singleton manager for secure identity state persistence and retrieval. /// Singleton manager for secure identity state persistence and retrieval.
/// Provides thread-safe access to identity mappings with encryption at rest. /// Provides thread-safe access to identity mappings with encryption at rest.
/// All identity data is stored encrypted in the device Keychain for security. /// All identity data is stored encrypted in the device Keychain for security.
final class SecureIdentityStateManager { final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
static let shared = SecureIdentityStateManager() private let keychain: KeychainManagerProtocol
private let keychain = KeychainManager.shared
private let cacheKey = "bitchat.identityCache.v2" private let cacheKey = "bitchat.identityCache.v2"
private let encryptionKeyName = "identityCacheEncryptionKey" private let encryptionKeyName = "identityCacheEncryptionKey"
@@ -108,9 +146,6 @@ final class SecureIdentityStateManager {
private var cryptographicIdentities: [String: CryptographicIdentity] = [:] private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
private var cache: IdentityCache = IdentityCache() private var cache: IdentityCache = IdentityCache()
// Pending actions before handshake
private var pendingActions: [String: PendingActions] = [:]
// Thread safety // Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
@@ -122,7 +157,9 @@ final class SecureIdentityStateManager {
// Encryption key // Encryption key
private let encryptionKey: SymmetricKey private let encryptionKey: SymmetricKey
private init() { init(_ keychain: KeychainManagerProtocol) {
self.keychain = keychain
// Generate or retrieve encryption key from keychain // Generate or retrieve encryption key from keychain
let loadedKey: SymmetricKey let loadedKey: SymmetricKey
@@ -146,9 +183,13 @@ final class SecureIdentityStateManager {
loadIdentityCache() loadIdentityCache()
} }
deinit {
forceSave()
}
// MARK: - Secure Loading/Saving // MARK: - Secure Loading/Saving
func loadIdentityCache() { private func loadIdentityCache() {
guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else { guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else {
// No existing cache, start fresh // No existing cache, start fresh
return return
@@ -164,12 +205,7 @@ final class SecureIdentityStateManager {
} }
} }
deinit { private func saveIdentityCache() {
// Force save any pending changes
forceSave()
}
func saveIdentityCache() {
// Mark that we need to save // Mark that we need to save
pendingSave = true pendingSave = true
@@ -201,25 +237,7 @@ final class SecureIdentityStateManager {
// Force immediate save (for app termination) // Force immediate save (for app termination)
func forceSave() { func forceSave() {
saveTimer?.invalidate() saveTimer?.invalidate()
if pendingSave { performSave()
performSave()
}
}
// MARK: - Identity Resolution
func resolveIdentity(peerID: String, claimedNickname: String) -> IdentityHint {
queue.sync {
// Check if we have candidates based on nickname
if let fingerprints = cache.nicknameIndex[claimedNickname] {
if fingerprints.count == 1 {
return .likelyKnown(fingerprint: fingerprints.first!)
} else {
return .ambiguous(candidates: fingerprints)
}
}
return .unknown
}
} }
// MARK: - Social Identity Management // MARK: - Social Identity Management
@@ -301,11 +319,6 @@ final class SecureIdentityStateManager {
} }
} }
/// Retrieve cryptographic identity by fingerprint
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
queue.sync { cryptographicIdentities[fingerprint] }
}
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID /// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] { func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
queue.sync { queue.sync {
@@ -315,12 +328,6 @@ final class SecureIdentityStateManager {
} }
} }
func getAllSocialIdentities() -> [SocialIdentity] {
queue.sync {
return Array(cache.socialIdentities.values)
}
}
func updateSocialIdentity(_ identity: SocialIdentity) { func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.cache.socialIdentities[identity.fingerprint] = identity self.cache.socialIdentities[identity.fingerprint] = identity
@@ -469,53 +476,6 @@ final class SecureIdentityStateManager {
} }
} }
func getHandshakeState(peerID: String) -> HandshakeState? {
queue.sync {
return ephemeralSessions[peerID]?.handshakeState
}
}
// MARK: - Pending Actions
func setPendingAction(peerID: String, action: PendingActions) {
queue.async(flags: .barrier) {
self.pendingActions[peerID] = action
}
}
func applyPendingActions(peerID: String, fingerprint: String) {
queue.async(flags: .barrier) {
guard let actions = self.pendingActions[peerID] else { return }
// Get or create social identity
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: "Unknown",
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
// Apply pending actions
if let toggleFavorite = actions.toggleFavorite {
identity.isFavorite = toggleFavorite
}
if let trustLevel = actions.setTrustLevel {
identity.trustLevel = trustLevel
}
if let petname = actions.setPetname {
identity.localPetname = petname
}
// Save updated identity
self.cache.socialIdentities[fingerprint] = identity
self.pendingActions.removeValue(forKey: peerID)
self.saveIdentityCache()
}
}
// MARK: - Cleanup // MARK: - Cleanup
func clearAllIdentityData() { func clearAllIdentityData() {
@@ -525,7 +485,6 @@ final class SecureIdentityStateManager {
self.cache = IdentityCache() self.cache = IdentityCache()
self.ephemeralSessions.removeAll() self.ephemeralSessions.removeAll()
self.cryptographicIdentities.removeAll() self.cryptographicIdentities.removeAll()
self.pendingActions.removeAll()
// Delete from keychain // Delete from keychain
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
@@ -536,7 +495,6 @@ final class SecureIdentityStateManager {
func removeEphemeralSession(peerID: String) { func removeEphemeralSession(peerID: String) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID) self.ephemeralSessions.removeValue(forKey: peerID)
self.pendingActions.removeValue(forKey: peerID)
} }
} }
-62
View File
@@ -1,62 +0,0 @@
import Foundation
// REQUEST_SYNC payload TLV (type, length16, value)
// - 0x01: mBytes (uint16)
// - 0x02: k (uint8)
// - 0x03: bloom filter bits (opaque byte array of length mBytes)
struct RequestSyncPacket {
let mBytes: Int
let k: Int
let bits: Data
func encode() -> Data {
var out = Data()
func putTLV(_ t: UInt8, _ v: Data) {
out.append(t)
let len = UInt16(v.count)
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(v)
}
// mBytes
let mbBE = UInt16(mBytes).bigEndian
let mbData = withUnsafeBytes(of: mbBE) { Data($0) }
putTLV(0x01, mbData)
// k
putTLV(0x02, Data([UInt8(k & 0xFF)]))
// bits
putTLV(0x03, bits)
return out
}
static func decode(from data: Data) -> RequestSyncPacket? {
var off = 0
var mBytes: Int? = nil
var k: Int? = nil
var bits: Data? = nil
while off + 3 <= data.count {
let t = Int(data[off]); off += 1
guard off + 2 <= data.count else { return nil }
let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2
guard off + len <= data.count else { return nil }
let v = data.subdata(in: off..<(off+len)); off += len
switch t {
case 0x01:
if v.count == 2 {
let mb = (Int(v[0]) << 8) | Int(v[1])
mBytes = mb
}
case 0x02:
if v.count == 1 { k = Int(v[0]) }
case 0x03:
bits = v
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let mb = mBytes, let kk = k, let bb = bits, mb == bb.count else { return nil }
return RequestSyncPacket(mBytes: mb, k: kk, bits: bb)
}
}
+15 -7
View File
@@ -490,6 +490,7 @@ final class NoiseSymmetricState {
final class NoiseHandshakeState { final class NoiseHandshakeState {
private let role: NoiseRole private let role: NoiseRole
private let pattern: NoisePattern private let pattern: NoisePattern
private let keychain: KeychainManagerProtocol
private var symmetricState: NoiseSymmetricState private var symmetricState: NoiseSymmetricState
// Keys // Keys
@@ -505,9 +506,16 @@ final class NoiseHandshakeState {
private var messagePatterns: [[NoiseMessagePattern]] = [] private var messagePatterns: [[NoiseMessagePattern]] = []
private var currentPattern = 0 private var currentPattern = 0
init(role: NoiseRole, pattern: NoisePattern, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) { init(
role: NoiseRole,
pattern: NoisePattern,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.role = role self.role = role
self.pattern = pattern self.pattern = pattern
self.keychain = keychain
// Initialize static keys // Initialize static keys
if let localKey = localStaticKey { if let localKey = localStaticKey {
@@ -578,7 +586,7 @@ final class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
case .es: case .es:
// DH(ephemeral, static) - direction depends on role // DH(ephemeral, static) - direction depends on role
@@ -626,7 +634,7 @@ final class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} }
} }
@@ -714,7 +722,7 @@ final class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} else { } else {
guard let localStatic = localStaticPrivate, guard let localStatic = localStaticPrivate,
let remoteEphemeral = remoteEphemeralPublic else { let remoteEphemeral = remoteEphemeralPublic else {
@@ -724,7 +732,7 @@ final class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} }
case .se: case .se:
@@ -737,7 +745,7 @@ final class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} else { } else {
guard let localEphemeral = localEphemeralPrivate, guard let localEphemeral = localEphemeralPrivate,
let remoteStatic = remoteStaticPublic else { let remoteStatic = remoteStaticPublic else {
@@ -747,7 +755,7 @@ final class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} }
case .ss: case .ss:
+19 -4
View File
@@ -36,6 +36,7 @@ enum NoiseSessionState: Equatable {
class NoiseSession { class NoiseSession {
let peerID: String let peerID: String
let role: NoiseRole let role: NoiseRole
private let keychain: KeychainManagerProtocol
private var state: NoiseSessionState = .uninitialized private var state: NoiseSessionState = .uninitialized
private var handshakeState: NoiseHandshakeState? private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState? private var sendCipher: NoiseCipherState?
@@ -52,9 +53,16 @@ class NoiseSession {
// Thread safety // Thread safety
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent) private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
init(peerID: String, role: NoiseRole, localStaticKey: Curve25519.KeyAgreement.PrivateKey, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) { init(
peerID: String,
role: NoiseRole,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.peerID = peerID self.peerID = peerID
self.role = role self.role = role
self.keychain = keychain
self.localStaticKey = localStaticKey self.localStaticKey = localStaticKey
self.remoteStaticPublicKey = remoteStaticKey self.remoteStaticPublicKey = remoteStaticKey
} }
@@ -71,6 +79,7 @@ class NoiseSession {
handshakeState = NoiseHandshakeState( handshakeState = NoiseHandshakeState(
role: role, role: role,
pattern: .XX, pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey, localStaticKey: localStaticKey,
remoteStaticKey: nil remoteStaticKey: nil
) )
@@ -98,6 +107,7 @@ class NoiseSession {
handshakeState = NoiseHandshakeState( handshakeState = NoiseHandshakeState(
role: role, role: role,
pattern: .XX, pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey, localStaticKey: localStaticKey,
remoteStaticKey: nil remoteStaticKey: nil
) )
@@ -230,13 +240,13 @@ class NoiseSession {
// Clear sent handshake messages // Clear sent handshake messages
for i in 0..<sentHandshakeMessages.count { for i in 0..<sentHandshakeMessages.count {
var message = sentHandshakeMessages[i] var message = sentHandshakeMessages[i]
KeychainManager.secureClear(&message) keychain.secureClear(&message)
} }
sentHandshakeMessages.removeAll() sentHandshakeMessages.removeAll()
// Clear handshake hash // Clear handshake hash
if var hash = handshakeHash { if var hash = handshakeHash {
KeychainManager.secureClear(&hash) keychain.secureClear(&hash)
} }
handshakeHash = nil handshakeHash = nil
@@ -252,14 +262,16 @@ class NoiseSession {
final class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [String: NoiseSession] = [:] private var sessions: [String: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks // Callbacks
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)? var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionFailed: ((String, Error) -> Void)? var onSessionFailed: ((String, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey) { init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey self.localStaticKey = localStaticKey
self.keychain = keychain
} }
// MARK: - Session Management // MARK: - Session Management
@@ -269,6 +281,7 @@ final class NoiseSessionManager {
let session = SecureNoiseSession( let session = SecureNoiseSession(
peerID: peerID, peerID: peerID,
role: role, role: role,
keychain: keychain,
localStaticKey: localStaticKey localStaticKey: localStaticKey
) )
sessions[peerID] = session sessions[peerID] = session
@@ -320,6 +333,7 @@ final class NoiseSessionManager {
let session = SecureNoiseSession( let session = SecureNoiseSession(
peerID: peerID, peerID: peerID,
role: .initiator, role: .initiator,
keychain: keychain,
localStaticKey: localStaticKey localStaticKey: localStaticKey
) )
sessions[peerID] = session sessions[peerID] = session
@@ -370,6 +384,7 @@ final class NoiseSessionManager {
let newSession = SecureNoiseSession( let newSession = SecureNoiseSession(
peerID: peerID, peerID: peerID,
role: .responder, role: .responder,
keychain: keychain,
localStaticKey: localStaticKey localStaticKey: localStaticKey
) )
sessions[peerID] = newSession sessions[peerID] = newSession
-2
View File
@@ -125,7 +125,6 @@ enum MessageType: UInt8 {
case announce = 0x01 // "I'm here" with nickname case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving" case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // Bloom filter-based sync request (local-only)
// Noise encryption // Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload) case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
@@ -139,7 +138,6 @@ enum MessageType: UInt8 {
case .announce: return "announce" case .announce: return "announce"
case .message: return "message" case .message: return "message"
case .leave: return "leave" case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment" case .fragment: return "fragment"
+11 -75
View File
@@ -88,7 +88,8 @@ final class BLEService: NSObject {
var myPeerID: String = "" var myPeerID: String = ""
var myNickname: String = "anon" var myNickname: String = "anon"
private let noiseService = NoiseEncryptionService() private let noiseService: NoiseEncryptionService
private let identityManager: SecureIdentityStateManagerProtocol
private var myPeerIDData: Data = Data() private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy // MARK: - Advertising Privacy
@@ -137,9 +138,6 @@ final class BLEService: NSObject {
private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:] private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
// Debounce for 'reconnected' logs // Debounce for 'reconnected' logs
private var lastReconnectLogAt: [String: Date] = [:] private var lastReconnectLogAt: [String: Date] = [:]
// MARK: - Gossip Sync
private var gossipSyncManager: GossipSyncManager?
// MARK: - Maintenance Timer // MARK: - Maintenance Timer
@@ -328,7 +326,9 @@ final class BLEService: NSObject {
} }
} }
override init() { init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
noiseService = NoiseEncryptionService(keychain: keychain)
self.identityManager = identityManager
super.init() super.init()
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars) // Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars)
@@ -398,15 +398,9 @@ final class BLEService: NSObject {
} }
timer.resume() timer.resume()
maintenanceTimer = timer maintenanceTimer = timer
// Publish initial empty state // Publish initial empty state
requestPeerDataPublish() requestPeerDataPublish()
// Initialize gossip sync manager
let sync = GossipSyncManager(myPeerID: myPeerID)
sync.delegate = self
sync.start()
self.gossipSyncManager = sync
} }
func setNickname(_ nickname: String) { func setNickname(_ nickname: String) {
@@ -781,8 +775,6 @@ final class BLEService: NSObject {
self.messageDeduplicator.markProcessed(dedupID) self.messageDeduplicator.markProcessed(dedupID)
// Call synchronously since we're already on background queue // Call synchronously since we're already on background queue
self.broadcastPacket(signedPacket) self.broadcastPacket(signedPacket)
// Track our own broadcast for sync
self.gossipSyncManager?.onPublicPacketSeen(signedPacket)
} }
} }
} }
@@ -1107,13 +1099,10 @@ final class BLEService: NSObject {
} }
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically // For broadcast (no directed peer) and non-fragment, choose a subset deterministically
// Special-case control/presence messages: do NOT subset to maximize immediate coverage // Special-case announces: do NOT subset to maximize reach for presence
var selectedPeripheralIDs = Set(allowedPeripheralIDs) var selectedPeripheralIDs = Set(allowedPeripheralIDs)
var selectedCentralIDs = Set(allowedCentralIDs) var selectedCentralIDs = Set(allowedCentralIDs)
if directedOnlyPeer == nil if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue {
&& packet.type != MessageType.fragment.rawValue
&& packet.type != MessageType.announce.rawValue
&& packet.type != MessageType.requestSync.rawValue {
let kp = subsetSizeForFanout(allowedPeripheralIDs.count) let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
let kc = subsetSizeForFanout(allowedCentralIDs.count) let kc = subsetSizeForFanout(allowedCentralIDs.count)
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID) selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
@@ -1144,12 +1133,6 @@ final class BLEService: NSObject {
} }
} }
// Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: String) {
guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
}
// MARK: - Directed store-and-forward // MARK: - Directed store-and-forward
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) { private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(for: packet)
@@ -1400,9 +1383,6 @@ final class BLEService: NSObject {
case .message: case .message:
handleMessage(packet, from: senderID) handleMessage(packet, from: senderID)
case .requestSync:
handleRequestSync(packet, from: senderID)
case .noiseHandshake: case .noiseHandshake:
handleNoiseHandshake(packet, from: senderID) handleNoiseHandshake(packet, from: senderID)
@@ -1571,7 +1551,7 @@ final class BLEService: NSObject {
// Derive fingerprint from Noise public key // Derive fingerprint from Noise public key
let hash = SHA256.hash(data: announcement.noisePublicKey) let hash = SHA256.hash(data: announcement.noisePublicKey)
let fingerprint = hash.map { String(format: "%02x", $0) }.joined() let fingerprint = hash.map { String(format: "%02x", $0) }.joined()
SecureIdentityStateManager.shared.upsertCryptographicIdentity( identityManager.upsertCryptographicIdentity(
fingerprint: fingerprint, fingerprint: fingerprint,
noisePublicKey: announcement.noisePublicKey, noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey, signingPublicKey: announcement.signingPublicKey,
@@ -1603,17 +1583,12 @@ final class BLEService: NSObject {
// Only notify of connection for new or reconnected peers when it is a direct announce // Only notify of connection for new or reconnected peers when it is a direct announce
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) { if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
self.delegate?.didConnectToPeer(peerID) self.delegate?.didConnectToPeer(peerID)
// Schedule initial unicast sync to this peer
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 5.0)
} }
self.requestPeerDataPublish() self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
// Track for sync (include our own and others' announces)
gossipSyncManager?.onPublicPacketSeen(packet)
// Send announce back for bidirectional discovery (only once per peer) // Send announce back for bidirectional discovery (only once per peer)
let announceBackID = "announce-back-\(peerID)" let announceBackID = "announce-back-\(peerID)"
let shouldSendBack = !messageDeduplicator.contains(announceBackID) let shouldSendBack = !messageDeduplicator.contains(announceBackID)
@@ -1635,15 +1610,6 @@ final class BLEService: NSObject {
} }
} }
} }
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
private func handleRequestSync(_ packet: BitchatPacket, from peerID: String) {
guard let req = RequestSyncPacket.decode(from: packet.payload) else {
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID)", category: .session)
return
}
gossipSyncManager?.handleRequestSync(fromPeerID: peerID, request: req)
}
// Mention parsing moved to ChatViewModel // Mention parsing moved to ChatViewModel
@@ -1667,13 +1633,13 @@ final class BLEService: NSObject {
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix // Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
// Find candidate identities by peerID prefix (16 hex) // Find candidate identities by peerID prefix (16 hex)
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(peerID) let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
for candidate in candidates { for candidate in candidates {
if let signingKey = candidate.signingPublicKey, if let signingKey = candidate.signingPublicKey,
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
accepted = true accepted = true
// Prefer persisted social petname or claimed nickname // Prefer persisted social petname or claimed nickname
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: candidate.fingerprint) { if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) {
senderNickname = social.localPetname ?? social.claimedNickname senderNickname = social.localPetname ?? social.claimedNickname
} else { } else {
senderNickname = "anon" + String(peerID.prefix(4)) senderNickname = "anon" + String(peerID.prefix(4))
@@ -1684,11 +1650,6 @@ final class BLEService: NSObject {
} }
} }
// Track broadcast messages (recipientID == nil indicates broadcast)
if packet.recipientID == nil && packet.type == MessageType.message.rawValue {
gossipSyncManager?.onPublicPacketSeen(packet)
}
guard accepted else { guard accepted else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: .security) SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: .security)
return return
@@ -1900,8 +1861,6 @@ final class BLEService: NSObject {
self?.broadcastPacket(signedPacket) self?.broadcastPacket(signedPacket)
} }
} }
// Ensure our own announce is included in sync state
gossipSyncManager?.onPublicPacketSeen(signedPacket)
} }
func sendDeliveryAck(for messageID: String, to peerID: String) { func sendDeliveryAck(for messageID: String, to peerID: String) {
@@ -2290,29 +2249,6 @@ final class BLEService: NSObject {
} }
} }
// MARK: - GossipSyncManager Delegate
extension BLEService: GossipSyncManager.Delegate {
func sendPacket(_ packet: BitchatPacket) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
}
func sendPacket(to peerID: String, packet: BitchatPacket) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
sendPacketDirected(packet, to: peerID)
} else {
messageQueue.async { [weak self] in self?.sendPacketDirected(packet, to: peerID) }
}
}
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
return noiseService.signPacket(packet) ?? packet
}
}
// MARK: - CBCentralManagerDelegate // MARK: - CBCentralManagerDelegate
extension BLEService: CBCentralManagerDelegate { extension BLEService: CBCentralManagerDelegate {
+14 -12
View File
@@ -20,10 +20,12 @@ enum CommandResult {
final class CommandProcessor { final class CommandProcessor {
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
weak var meshService: Transport? weak var meshService: Transport?
private let identityManager: SecureIdentityStateManagerProtocol
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil) { init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.meshService = meshService self.meshService = meshService
self.identityManager = identityManager
} }
/// Process a command string /// Process a command string
@@ -189,7 +191,7 @@ final class CommandProcessor {
} }
// Geohash blocked names (prefer visible display names; fallback to #suffix) // Geohash blocked names (prefer visible display names; fallback to #suffix)
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys()) let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
var geoNames: [String] = [] var geoNames: [String] = []
if let vm = chatViewModel { if let vm = chatViewModel {
let visible = vm.visibleGeohashPeople() let visible = vm.visibleGeohashPeople()
@@ -213,14 +215,14 @@ final class CommandProcessor {
if let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) { let fingerprint = meshService?.getFingerprint(for: peerID) {
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { if identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked") return .success(message: "\(nickname) is already blocked")
} }
// Block the user (mesh/noise identity) // Block the user (mesh/noise identity)
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { if var identity = identityManager.getSocialIdentity(for: fingerprint) {
identity.isBlocked = true identity.isBlocked = true
identity.isFavorite = false identity.isFavorite = false
SecureIdentityStateManager.shared.updateSocialIdentity(identity) identityManager.updateSocialIdentity(identity)
} else { } else {
let blockedIdentity = SocialIdentity( let blockedIdentity = SocialIdentity(
fingerprint: fingerprint, fingerprint: fingerprint,
@@ -231,16 +233,16 @@ final class CommandProcessor {
isBlocked: true, isBlocked: true,
notes: nil notes: nil
) )
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity) identityManager.updateSocialIdentity(blockedIdentity)
} }
return .success(message: "blocked \(nickname). you will no longer receive messages from them") return .success(message: "blocked \(nickname). you will no longer receive messages from them")
} }
// Mesh lookup failed; try geohash (Nostr) participant by display name // Mesh lookup failed; try geohash (Nostr) participant by display name
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) { if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) { if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is already blocked") return .success(message: "\(nickname) is already blocked")
} }
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: true) identityManager.setNostrBlocked(pub, isBlocked: true)
return .success(message: "blocked \(nickname) in geohash chats") return .success(message: "blocked \(nickname) in geohash chats")
} }
@@ -257,18 +259,18 @@ final class CommandProcessor {
if let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) { let fingerprint = meshService?.getFingerprint(for: peerID) {
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { if !identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked") return .success(message: "\(nickname) is not blocked")
} }
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false) identityManager.setBlocked(fingerprint, isBlocked: false)
return .success(message: "unblocked \(nickname)") return .success(message: "unblocked \(nickname)")
} }
// Try geohash unblock // Try geohash unblock
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) { if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) { if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is not blocked") return .success(message: "\(nickname) is not blocked")
} }
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: false) identityManager.setNostrBlocked(pub, isBlocked: false)
return .success(message: "unblocked \(nickname) in geohash chats") return .success(message: "unblocked \(nickname) in geohash chats")
} }
return .error(message: "cannot unblock \(nickname): not found") return .error(message: "cannot unblock \(nickname): not found")
@@ -13,12 +13,16 @@ final class FavoritesPersistenceService: ObservableObject {
let theyFavoritedUs: Bool let theyFavoritedUs: Bool
let favoritedAt: Date let favoritedAt: Date
let lastUpdated: Date let lastUpdated: Date
// Track what we last sent as OUR npub to this peer, to avoid resending unless it changes
// Note: we do not track which npub we last sent to them; sending happens only on favorite toggle
var isMutual: Bool { var isMutual: Bool {
isFavorite && theyFavoritedUs isFavorite && theyFavoritedUs
} }
} }
// We intentionally do not track when we last sent our npub; sending happens only on favorite toggle.
private static let storageKey = "chat.bitchat.favorites" private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites" private static let keychainService = "chat.bitchat.favorites"
+14 -7
View File
@@ -9,16 +9,23 @@
import Foundation import Foundation
import Security import Security
final class KeychainManager { protocol KeychainManagerProtocol {
static let shared = KeychainManager() func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data)
func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool
}
final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items // Use consistent service name for all keychain items
private let service = "chat.bitchat" private let service = "chat.bitchat"
private let appGroup = "group.chat.bitchat" private let appGroup = "group.chat.bitchat"
private init() {}
private func isSandboxed() -> Bool { private func isSandboxed() -> Bool {
#if os(macOS) #if os(macOS)
// More robust sandbox detection using multiple methods // More robust sandbox detection using multiple methods
@@ -310,7 +317,7 @@ final class KeychainManager {
// MARK: - Security Utilities // MARK: - Security Utilities
/// Securely clear sensitive data from memory /// Securely clear sensitive data from memory
static func secureClear(_ data: inout Data) { func secureClear(_ data: inout Data) {
_ = data.withUnsafeMutableBytes { bytes in _ = data.withUnsafeMutableBytes { bytes in
// Use volatile memset to prevent compiler optimization // Use volatile memset to prevent compiler optimization
memset_s(bytes.baseAddress, bytes.count, 0, bytes.count) memset_s(bytes.baseAddress, bytes.count, 0, bytes.count)
@@ -319,7 +326,7 @@ final class KeychainManager {
} }
/// Securely clear sensitive string from memory /// Securely clear sensitive string from memory
static func secureClear(_ string: inout String) { func secureClear(_ string: inout String) {
// Convert to mutable data and clear // Convert to mutable data and clear
if var data = string.data(using: .utf8) { if var data = string.data(using: .utf8) {
secureClear(&data) secureClear(&data)
+1
View File
@@ -74,6 +74,7 @@ final class MessageRouter {
} }
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
// Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peerID) { if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else { } else {
+11 -8
View File
@@ -155,6 +155,7 @@ final class NoiseEncryptionService {
// Security components // Security components
private let rateLimiter = NoiseRateLimiter() private let rateLimiter = NoiseRateLimiter()
private let keychain: KeychainManagerProtocol
// Session maintenance // Session maintenance
private var rekeyTimer: Timer? private var rekeyTimer: Timer?
@@ -181,12 +182,14 @@ final class NoiseEncryptionService {
} }
} }
init() { init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
// Load or create static identity key (ONLY from keychain) // Load or create static identity key (ONLY from keychain)
let loadedKey: Curve25519.KeyAgreement.PrivateKey let loadedKey: Curve25519.KeyAgreement.PrivateKey
// Try to load from keychain // Try to load from keychain
if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"), if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) { let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
loadedKey = key loadedKey = key
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true) SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
@@ -197,7 +200,7 @@ final class NoiseEncryptionService {
let keyData = loadedKey.rawRepresentation let keyData = loadedKey.rawRepresentation
// Save to keychain // Save to keychain
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey") let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved) SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
} }
@@ -209,7 +212,7 @@ final class NoiseEncryptionService {
let loadedSigningKey: Curve25519.Signing.PrivateKey let loadedSigningKey: Curve25519.Signing.PrivateKey
// Try to load from keychain // Try to load from keychain
if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"), if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
loadedSigningKey = key loadedSigningKey = key
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true) SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
@@ -220,7 +223,7 @@ final class NoiseEncryptionService {
let keyData = loadedSigningKey.rawRepresentation let keyData = loadedSigningKey.rawRepresentation
// Save to keychain // Save to keychain
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey") let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved) SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
} }
@@ -229,7 +232,7 @@ final class NoiseEncryptionService {
self.signingPublicKey = signingKey.publicKey self.signingPublicKey = signingKey.publicKey
// Initialize session manager // Initialize session manager
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey) self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
// Set up session callbacks // Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
@@ -266,8 +269,8 @@ final class NoiseEncryptionService {
/// Clear persistent identity (for panic mode) /// Clear persistent identity (for panic mode)
func clearPersistentIdentity() { func clearPersistentIdentity() {
// Clear from keychain // Clear from keychain
let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey") let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey") let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning) SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
SecureLogger.warning("Panic mode activated - identity cleared", category: .security) SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
// Stop rekey timer // Stop rekey timer
+15 -4
View File
@@ -21,10 +21,15 @@ final class NostrTransport: Transport {
private var readQueue: [QueuedRead] = [] private var readQueue: [QueuedRead] = []
private var isSendingReadAcks = false private var isSendingReadAcks = false
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
private let keychain: KeychainManagerProtocol
var myPeerID: String { senderPeerID } var myPeerID: String { senderPeerID }
var myNickname: String { "" } var myNickname: String { "" }
func setNickname(_ nickname: String) { /* not used for Nostr */ } func setNickname(_ nickname: String) { /* not used for Nostr */ }
init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
}
func startServices() { /* no-op */ } func startServices() { /* no-op */ }
func stopServices() { /* no-op */ } func stopServices() { /* no-op */ }
@@ -38,11 +43,17 @@ final class NostrTransport: Transport {
func getFingerprint(for peerID: String) -> String? { nil } func getFingerprint(for peerID: String) -> String? { nil }
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none } func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: String) { /* no-op */ } func triggerHandshake(with peerID: String) { /* no-op */ }
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
private static var cachedNoiseService: NoiseEncryptionService = { private static var cachedNoiseService: NoiseEncryptionService?
NoiseEncryptionService() func getNoiseService() -> NoiseEncryptionService {
}() if let noiseService = Self.cachedNoiseService {
func getNoiseService() -> NoiseEncryptionService { Self.cachedNoiseService } return noiseService
}
let noiseService = NoiseEncryptionService(keychain: keychain)
Self.cachedNoiseService = noiseService
return noiseService
}
// Public broadcast not supported over Nostr here // Public broadcast not supported over Nostr here
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ } func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
+7 -26
View File
@@ -27,14 +27,16 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private var peerIndex: [String: BitchatPeer] = [:] private var peerIndex: [String: BitchatPeer] = [:]
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
private let meshService: Transport private let meshService: Transport
private let identityManager: SecureIdentityStateManagerProtocol
weak var messageRouter: MessageRouter? weak var messageRouter: MessageRouter?
private let favoritesService = FavoritesPersistenceService.shared private let favoritesService = FavoritesPersistenceService.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization // MARK: - Initialization
init(meshService: Transport) { init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
self.meshService = meshService self.meshService = meshService
self.identityManager = identityManager
// Subscribe to changes from both services // Subscribe to changes from both services
setupSubscriptions() setupSubscriptions()
@@ -174,7 +176,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Determine reachability based on lastSeen and identity trust // Determine reachability based on lastSeen and identity trust
let now = Date() let now = Date()
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint() let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false let isVerified = fingerprint.map { identityManager.isVerified(fingerprint: $0) } ?? false
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
// A peer is reachable if we recently saw them AND we are attached to the mesh // A peer is reachable if we recently saw them AND we are attached to the mesh
@@ -195,27 +197,6 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
let favoriteStatus = favorites[noiseKey] { let favoriteStatus = favorites[noiseKey] {
peer.favoriteStatus = favoriteStatus peer.favoriteStatus = favoriteStatus
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
} else {
// Check by nickname for reconnected peers
let favoriteByNickname = favorites.values.first {
$0.peerNickname == peerInfo.nickname
}
if let favorite = favoriteByNickname,
let noiseKey = peerInfo.noisePublicKey {
SecureLogger.debug("🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key", category: .session)
// Update the favorite's key in persistence
favoritesService.updateNoisePublicKey(
from: favorite.peerNoisePublicKey,
to: noiseKey,
peerNickname: peerInfo.nickname
)
// Get updated favorite
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
}
} }
return peer return peer
@@ -268,7 +249,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
guard let fingerprint = getFingerprint(for: peerID) else { return false } guard let fingerprint = getFingerprint(for: peerID) else { return false }
// Check SecureIdentityStateManager for block status // Check SecureIdentityStateManager for block status
if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { if let identity = identityManager.getSocialIdentity(for: fingerprint) {
return identity.isBlocked return identity.isBlocked
} }
@@ -345,7 +326,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
guard let fingerprint = getFingerprint(for: peerID) else { return } guard let fingerprint = getFingerprint(for: peerID) else { return }
// Get or create social identity // Get or create social identity
var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) var identity = identityManager.getSocialIdentity(for: fingerprint)
?? SocialIdentity( ?? SocialIdentity(
fingerprint: fingerprint, fingerprint: fingerprint,
localPetname: nil, localPetname: nil,
@@ -368,7 +349,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
} }
SecureIdentityStateManager.shared.updateSocialIdentity(identity) identityManager.updateSocialIdentity(identity)
} }
/// Get fingerprint for peer ID /// Get fingerprint for peer ID
-168
View File
@@ -1,168 +0,0 @@
import Foundation
// Gossip-based sync manager using rotating Bloom filters
final class GossipSyncManager {
protocol Delegate: AnyObject {
func sendPacket(_ packet: BitchatPacket)
func sendPacket(to peerID: String, packet: BitchatPacket)
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
}
struct Config {
var seenCapacity: Int = 100 // recent broadcast messages kept
var bloomMaxBytes: Int = 256 // up to 256 bytes
var bloomTargetFpr: Double = 0.01 // 1%
}
private let myPeerID: String
private let config: Config
weak var delegate: Delegate?
// Bloom filter
private let bloom: SeenPacketsBloomFilter
// Storage: broadcast messages (ordered by insert), and latest announce per sender
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
private var messageOrder: [String] = []
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
// Timer
private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
init(myPeerID: String, config: Config = Config()) {
self.myPeerID = myPeerID
self.config = config
self.bloom = SeenPacketsBloomFilter(maxBytes: config.bloomMaxBytes, targetFpr: config.bloomTargetFpr)
}
func start() {
stop()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
timer.resume()
periodicTimer = timer
}
func stop() {
periodicTimer?.cancel(); periodicTimer = nil
}
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) {
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
self?.sendRequestSync(to: peerID)
}
}
func onPublicPacketSeen(_ packet: BitchatPacket) {
let mt = MessageType(rawValue: packet.type)
let isBroadcastMessage = (mt == .message && packet.recipientID == nil)
let isAnnounce = (mt == .announce)
guard isBroadcastMessage || isAnnounce else { return }
let idBytes = PacketIdUtil.computeId(packet)
bloom.add(idBytes)
let idHex = idBytes.hexEncodedString()
if isBroadcastMessage {
if messages[idHex] == nil {
messages[idHex] = packet
messageOrder.append(idHex)
// Enforce capacity
let cap = max(1, config.seenCapacity)
while messageOrder.count > cap {
let victim = messageOrder.removeFirst()
messages.removeValue(forKey: victim)
}
}
} else if isAnnounce {
let sender = packet.senderID.hexEncodedString()
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
}
}
private func sendRequestSync() {
let snap = bloom.snapshotActive()
let payload = RequestSyncPacket(mBytes: snap.mBytes, k: snap.k, bits: snap.bits).encode()
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: nil, // broadcast
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0 // local-only
)
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
delegate?.sendPacket(signed)
}
private func sendRequestSync(to peerID: String) {
let snap = bloom.snapshotActive()
let payload = RequestSyncPacket(mBytes: snap.mBytes, k: snap.k, bits: snap.bits).encode()
var recipient = Data()
var temp = peerID
while temp.count >= 2 && recipient.count < 8 {
let hexByte = String(temp.prefix(2))
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
temp = String(temp.dropFirst(2))
}
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: recipient,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0 // local-only
)
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
delegate?.sendPacket(to: peerID, packet: signed)
}
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
// Build membership checker from provided parameters
let mBits = request.mBytes * 8
let k = request.k
func mightContain(_ id: Data) -> Bool {
// Same hashing as local bloom; compute indices, check MSB-first bits in request.bits
var h1: UInt64 = 1469598103934665603
var h2: UInt64 = 0x27d4eb2f165667c5
for b in id { h1 = (h1 ^ UInt64(b)) &* 1099511628211; h2 = (h2 ^ UInt64(b)) &* 0x100000001B3 }
for i in 0..<k {
let combined = h1 &+ (UInt64(i) &* h2)
let idx = Int((combined & 0x7fff_ffff_ffff_ffff) % UInt64(mBits))
let byteIndex = idx / 8
let bitIndex = idx % 8
let byte = request.bits[byteIndex]
let bit = ((Int(byte) >> (7 - bitIndex)) & 1) == 1
if !bit { return false }
}
return true
}
// 1) Announcements: send latest per peer if requester lacks them
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: fromPeerID, packet: toSend)
}
}
// 2) Broadcast messages: send all missing
let toSendMsgs = messageOrder.compactMap { messages[$0] }
for pkt in toSendMsgs {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: fromPeerID, packet: toSend)
}
}
}
}
-22
View File
@@ -1,22 +0,0 @@
import Foundation
import CryptoKit
// Deterministic packet ID used for sync Bloom membership
// ID = first 16 bytes of SHA-256 over: [type | senderID | timestamp | payload]
enum PacketIdUtil {
static func computeId(_ packet: BitchatPacket) -> Data {
var hasher = SHA256()
hasher.update(data: Data([packet.type]))
hasher.update(data: packet.senderID)
var tsBE = packet.timestamp.bigEndian
withUnsafeBytes(of: &tsBE) { raw in hasher.update(data: Data(raw)) }
hasher.update(data: packet.payload)
let digest = hasher.finalize()
return Data(digest.prefix(16))
}
static func computeIdHex(_ packet: BitchatPacket) -> String {
return computeId(packet).hexEncodedString()
}
}
-116
View File
@@ -1,116 +0,0 @@
import Foundation
// Rotating Bloom filter for recently seen packet IDs (16-byte IDs)
final class SeenPacketsBloomFilter {
struct Snapshot { let mBytes: Int; let k: Int; let bits: Data }
private struct Filter { var mBits: Int; var k: Int; var bits: [UInt8]; var count: Int }
private let maxBytes: Int
private let targetFpr: Double
private let seed: UInt64 = 0x27d4eb2f165667c5
private let mBits: Int
private let kOptimal: Int
private let capacityOptimal: Int
private var active: Filter
private var standby: Filter
private var usingStandby: Bool = false
private let lock = NSLock()
init(maxBytes: Int = 256, targetFpr: Double = 0.01) {
self.maxBytes = max(1, maxBytes)
self.targetFpr = targetFpr
self.mBits = max(8, self.maxBytes * 8)
let (k, n) = SeenPacketsBloomFilter.deriveParams(mBits: self.mBits, fpr: targetFpr)
self.kOptimal = max(1, k)
self.capacityOptimal = max(1, n)
self.active = Filter(mBits: self.mBits, k: self.kOptimal, bits: [UInt8](repeating: 0, count: self.maxBytes), count: 0)
self.standby = Filter(mBits: self.mBits, k: self.kOptimal, bits: [UInt8](repeating: 0, count: self.maxBytes), count: 0)
}
private static func deriveParams(mBits: Int, fpr: Double) -> (Int, Int) {
// n -(m (ln 2)^2) / ln p ; k (m/n) ln 2
let ln2 = log(2.0)
let n = max(1, Int(Double(-mBits) * ln2 * ln2 / log(fpr)))
let k = max(1, Int(ceil((Double(mBits) / Double(n)) * ln2)))
return (k, n)
}
private func indicesFor(id: Data, mBits: Int, k: Int) -> [Int] {
var h1: UInt64 = 1469598103934665603 // FNV-1a 64-bit offset
var h2: UInt64 = seed
for b in id { // treat as unsigned bytes
h1 = (h1 ^ UInt64(b)) &* 1099511628211
h2 = (h2 ^ UInt64(b)) &* 0x100000001B3
}
var result = [Int]()
result.reserveCapacity(k)
for i in 0..<k {
let combined = h1 &+ (UInt64(i) &* h2)
let idx = Int((combined & 0x7fff_ffff_ffff_ffff) % UInt64(mBits))
result.append(idx)
}
return result
}
func add(_ id: Data) {
lock.lock(); defer { lock.unlock() }
let startStandbyAt = capacityOptimal / 2
if !usingStandby && active.count >= startStandbyAt {
standby = Filter(mBits: mBits, k: kOptimal, bits: [UInt8](repeating: 0, count: maxBytes), count: 0)
usingStandby = true
}
insert(into: &active, id: id)
if usingStandby { insert(into: &standby, id: id) }
if active.count >= capacityOptimal {
active = standby
standby = Filter(mBits: mBits, k: kOptimal, bits: [UInt8](repeating: 0, count: maxBytes), count: 0)
usingStandby = false
}
}
private func insert(into filter: inout Filter, id: Data) {
let idxs = indicesFor(id: id, mBits: filter.mBits, k: filter.k)
for i in idxs {
let byteIndex = i / 8
let bitIndex = i % 8
filter.bits[byteIndex] = UInt8(Int(filter.bits[byteIndex]) | (1 << (7 - bitIndex)))
}
filter.count &+= 1
}
func mightContain(_ id: Data) -> Bool {
lock.lock(); defer { lock.unlock() }
let a = active
let idx = indicesFor(id: id, mBits: a.mBits, k: a.k)
var inActive = true
for i in idx {
let byteIndex = i / 8
let bitIndex = i % 8
let set = ((Int(a.bits[byteIndex]) >> (7 - bitIndex)) & 1) == 1
if !set { inActive = false; break }
}
if inActive { return true }
if usingStandby {
let s = standby
let idx2 = indicesFor(id: id, mBits: s.mBits, k: s.k)
for i in idx2 {
let byteIndex = i / 8
let bitIndex = i % 8
let set = ((Int(s.bits[byteIndex]) >> (7 - bitIndex)) & 1) == 1
if !set { return false }
}
return true
}
return false
}
func snapshotActive() -> Snapshot {
lock.lock(); defer { lock.unlock() }
let a = active
return Snapshot(mBytes: a.bits.count, k: a.k, bits: Data(a.bits))
}
}
+89 -62
View File
@@ -344,13 +344,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Services and Storage // MARK: - Services and Storage
var meshService: Transport = BLEService() let meshService: Transport
let identityManager: SecureIdentityStateManagerProtocol
private var nostrRelayManager: NostrRelayManager? private var nostrRelayManager: NostrRelayManager?
// PeerManager replaced by UnifiedPeerService // PeerManager replaced by UnifiedPeerService
private var processedNostrEvents = Set<String>() // Simple deduplication private var processedNostrEvents = Set<String>() // Simple deduplication
private var processedNostrEventOrder: [String] = [] private var processedNostrEventOrder: [String] = []
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let keychain: KeychainManagerProtocol
private let nicknameKey = "bitchat.nickname" private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection) // Location channel state (macOS supports manual geohash selection)
@Published private var activeChannel: ChannelID = .mesh @Published private var activeChannel: ChannelID = .mesh
@@ -420,6 +423,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
private var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time private var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
// MARK: - Message Delivery Tracking // MARK: - Message Delivery Tracking
// Delivery tracking // Delivery tracking
@@ -484,7 +488,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Initialization // MARK: - Initialization
@MainActor @MainActor
init() { init(
keychain: KeychainManagerProtocol,
identityManager: SecureIdentityStateManagerProtocol
) {
self.keychain = keychain
self.identityManager = identityManager
self.meshService = BLEService(keychain: keychain, identityManager: identityManager)
// Load persisted read receipts // Load persisted read receipts
if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"), if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"),
let receipts = try? JSONDecoder().decode([String].self, from: data) { let receipts = try? JSONDecoder().decode([String].self, from: data) {
@@ -495,10 +506,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Initialize services // Initialize services
self.commandProcessor = CommandProcessor() self.commandProcessor = CommandProcessor(identityManager: identityManager)
self.privateChatManager = PrivateChatManager(meshService: meshService) self.privateChatManager = PrivateChatManager(meshService: meshService)
self.unifiedPeerService = UnifiedPeerService(meshService: meshService) self.unifiedPeerService = UnifiedPeerService(meshService: meshService, identityManager: identityManager)
let nostrTransport = NostrTransport() let nostrTransport = NostrTransport(keychain: keychain)
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport) self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
// Route receipts from PrivateChatManager through MessageRouter // Route receipts from PrivateChatManager through MessageRouter
self.privateChatManager.messageRouter = self.messageRouter self.privateChatManager.messageRouter = self.messageRouter
@@ -976,7 +987,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let messageId = pm.messageID let messageId = pm.messageID
// Send delivery ACK immediately (once per message ID) // Send delivery ACK immediately (once per message ID)
if !self.sentGeoDeliveryAcks.contains(messageId) { if !self.sentGeoDeliveryAcks.contains(messageId) {
let nt = NostrTransport() let nt = NostrTransport(keychain: keychain)
nt.senderPeerID = self.meshService.myPeerID nt.senderPeerID = self.meshService.myPeerID
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id) nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
self.sentGeoDeliveryAcks.insert(messageId) self.sentGeoDeliveryAcks.insert(messageId)
@@ -1004,7 +1015,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
deliveryStatus: .delivered(to: self.nickname, at: Date()) deliveryStatus: .delivered(to: self.nickname, at: Date())
) )
// Respect geohash blocks // Respect geohash blocks
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { if identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
return return
} }
if self.privateChats[convKey] == nil { self.privateChats[convKey] = [] } if self.privateChats[convKey] == nil { self.privateChats[convKey] = [] }
@@ -1014,7 +1025,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if isViewing { if isViewing {
// pared back: omit pre-send READ log // pared back: omit pre-send READ log
if !wasReadBefore { if !wasReadBefore {
let nt = NostrTransport() let nt = NostrTransport(keychain: keychain)
nt.senderPeerID = self.meshService.myPeerID nt.senderPeerID = self.meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
self.sentReadReceipts.insert(messageId) self.sentReadReceipts.insert(messageId)
@@ -1237,7 +1248,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Update identity state manager with handshake completion // Update identity state manager with handshake completion
SecureIdentityStateManager.shared.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprintStr)) identityManager.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprintStr))
// Update encryption status now that we have the fingerprint // Update encryption status now that we have the fingerprint
updateEncryptionStatus(for: peerID) updateEncryptionStatus(for: peerID)
@@ -1246,9 +1257,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let peerNicknames = meshService.getPeerNicknames() let peerNicknames = meshService.getPeerNicknames()
if let nickname = peerNicknames[peerID], nickname != "Unknown" && nickname != "anon\(peerID.prefix(4))" { if let nickname = peerNicknames[peerID], nickname != "Unknown" && nickname != "anon\(peerID.prefix(4))" {
// Update or create social identity with the claimed nickname // Update or create social identity with the claimed nickname
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprintStr) { if var identity = identityManager.getSocialIdentity(for: fingerprintStr) {
identity.claimedNickname = nickname identity.claimedNickname = nickname
SecureIdentityStateManager.shared.updateSocialIdentity(identity) identityManager.updateSocialIdentity(identity)
} else { } else {
let newIdentity = SocialIdentity( let newIdentity = SocialIdentity(
fingerprint: fingerprintStr, fingerprint: fingerprintStr,
@@ -1259,7 +1270,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isBlocked: false, isBlocked: false,
notes: nil notes: nil
) )
SecureIdentityStateManager.shared.updateSocialIdentity(newIdentity) identityManager.updateSocialIdentity(newIdentity)
} }
} }
@@ -1620,7 +1631,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
self.geoNicknames[event.pubkey.lowercased()] = nick self.geoNicknames[event.pubkey.lowercased()] = nick
} }
// If this pubkey is blocked, skip mapping, participants, and timeline // If this pubkey is blocked, skip mapping, participants, and timeline
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return return
} }
// Store mapping for geohash DM initiation // Store mapping for geohash DM initiation
@@ -1698,7 +1709,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", category: .session) SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", category: .session)
// Send delivery ACK immediately (even if duplicate), once per messageID // Send delivery ACK immediately (even if duplicate), once per messageID
if !self.sentGeoDeliveryAcks.contains(messageId) { if !self.sentGeoDeliveryAcks.contains(messageId) {
let nostrTransport = NostrTransport() let nostrTransport = NostrTransport(keychain: keychain)
nostrTransport.senderPeerID = self.meshService.myPeerID nostrTransport.senderPeerID = self.meshService.myPeerID
// pared back: omit pre-send log // pared back: omit pre-send log
nostrTransport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id) nostrTransport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
@@ -1733,7 +1744,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if isViewing { if isViewing {
// pared back: omit pre-send READ log // pared back: omit pre-send READ log
if !wasReadBefore { if !wasReadBefore {
let nostrTransport = NostrTransport() let nostrTransport = NostrTransport(keychain: keychain)
nostrTransport.senderPeerID = self.meshService.myPeerID nostrTransport.senderPeerID = self.meshService.myPeerID
nostrTransport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) nostrTransport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
self.sentReadReceipts.insert(messageId) self.sentReadReceipts.insert(messageId)
@@ -1818,7 +1829,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Prune expired entries // Prune expired entries
map = map.filter { $0.value >= cutoff } map = map.filter { $0.value >= cutoff }
// Remove blocked Nostr pubkeys // Remove blocked Nostr pubkeys
map = map.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
geoParticipants[gh] = map geoParticipants[gh] = map
// Build display list // Build display list
let people = map let people = map
@@ -1851,7 +1862,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let map = (geoParticipants[gh] ?? [:]) let map = (geoParticipants[gh] ?? [:])
.filter { $0.value >= cutoff } .filter { $0.value >= cutoff }
.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } .filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
let people = map let people = map
.map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) } .map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) }
.sorted { $0.lastSeen > $1.lastSeen } .sorted { $0.lastSeen > $1.lastSeen }
@@ -1868,12 +1879,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Geohash block helpers // Geohash block helpers
@MainActor @MainActor
func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool { func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool {
return SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) return identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
} }
@MainActor @MainActor
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
let hex = pubkeyHexLowercased.lowercased() let hex = pubkeyHexLowercased.lowercased()
SecureIdentityStateManager.shared.setNostrBlocked(hex, isBlocked: true) identityManager.setNostrBlocked(hex, isBlocked: true)
// Remove from participants for all geohashes // Remove from participants for all geohashes
for (gh, var map) in geoParticipants { for (gh, var map) in geoParticipants {
@@ -1921,7 +1932,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
@MainActor @MainActor
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
SecureIdentityStateManager.shared.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
addSystemMessage("unblocked \(displayName) in geohash chats") addSystemMessage("unblocked \(displayName) in geohash chats")
} }
@@ -1970,7 +1981,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return } guard !content.isEmpty else { return }
// Respect geohash blocks // Respect geohash blocks
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
// Skip self identity for this geohash // Skip self identity for this geohash
if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
// Only trigger when there were zero participants in this geohash recently // Only trigger when there were zero participants in this geohash recently
@@ -2122,7 +2133,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return return
} }
// Respect geohash blocks // Respect geohash blocks
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: recipientHex) { if identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(reason: "user is blocked") privateChats[peerID]?[msgIdx].deliveryStatus = .failed(reason: "user is blocked")
} }
@@ -2140,7 +2151,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return return
} }
SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session) SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session)
let nostrTransport = NostrTransport() let nostrTransport = NostrTransport(keychain: keychain)
nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID) nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
@@ -2798,15 +2809,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
meshService.stopServices() meshService.stopServices()
// Force save any pending identity changes (verifications, favorites, etc) // Force save any pending identity changes (verifications, favorites, etc)
SecureIdentityStateManager.shared.forceSave() identityManager.forceSave()
// Verify identity key is still there // Verify identity key is still there
_ = KeychainManager.shared.verifyIdentityKeyExists() _ = keychain.verifyIdentityKeyExists()
// No need to force synchronize here // No need to force synchronize here
// Verify identity key after save // Verify identity key after save
_ = KeychainManager.shared.verifyIdentityKeyExists() _ = keychain.verifyIdentityKeyExists()
} }
@objc private func appWillTerminate() { @objc private func appWillTerminate() {
@@ -2857,7 +2868,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
for message in messages where message.senderPeerID == peerID && !message.isRelay { for message in messages where message.senderPeerID == peerID && !message.isRelay {
if !sentReadReceipts.contains(message.id) { if !sentReadReceipts.contains(message.id) {
SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))", category: .session) SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))", category: .session)
let nostrTransport = NostrTransport() let nostrTransport = NostrTransport(keychain: keychain)
nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id) nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)
sentReadReceipts.insert(message.id) sentReadReceipts.insert(message.id)
@@ -3003,7 +3014,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
privateChatManager.unreadMessages.removeAll() privateChatManager.unreadMessages.removeAll()
// Delete all keychain data (including Noise and Nostr keys) // Delete all keychain data (including Noise and Nostr keys)
_ = KeychainManager.shared.deleteAllKeychainData() _ = keychain.deleteAllKeychainData()
// Clear UserDefaults identity data // Clear UserDefaults identity data
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey") userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
@@ -3019,14 +3030,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Clear favorites and peer mappings // Clear favorites and peer mappings
// Clear through SecureIdentityStateManager instead of directly // Clear through SecureIdentityStateManager instead of directly
SecureIdentityStateManager.shared.clearAllIdentityData() identityManager.clearAllIdentityData()
peerIDToPublicKeyFingerprint.removeAll() peerIDToPublicKeyFingerprint.removeAll()
// Clear persistent favorites from keychain // Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites() FavoritesPersistenceService.shared.clearAllFavorites()
// Clear identity data from secure storage // Clear identity data from secure storage
SecureIdentityStateManager.shared.clearAllIdentityData() identityManager.clearAllIdentityData()
// Clear autocomplete state // Clear autocomplete state
autocompleteSuggestions.removeAll() autocompleteSuggestions.removeAll()
@@ -4252,7 +4263,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Try to resolve through fingerprint and social identity // Try to resolve through fingerprint and social identity
if let fingerprint = getFingerprint(for: peerID) { if let fingerprint = getFingerprint(for: peerID) {
if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { if let identity = identityManager.getSocialIdentity(for: fingerprint) {
// Prefer local petname if set // Prefer local petname if set
if let petname = identity.localPetname { if let petname = identity.localPetname {
return petname return petname
@@ -4284,7 +4295,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
guard let fingerprint = getFingerprint(for: peerID) else { return } guard let fingerprint = getFingerprint(for: peerID) else { return }
// Update secure storage with verified status // Update secure storage with verified status
SecureIdentityStateManager.shared.setVerified(fingerprint: fingerprint, verified: true) identityManager.setVerified(fingerprint: fingerprint, verified: true)
// Update local set for UI // Update local set for UI
verifiedFingerprints.insert(fingerprint) verifiedFingerprints.insert(fingerprint)
@@ -4296,8 +4307,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
func unverifyFingerprint(for peerID: String) { func unverifyFingerprint(for peerID: String) {
guard let fingerprint = getFingerprint(for: peerID) else { return } guard let fingerprint = getFingerprint(for: peerID) else { return }
SecureIdentityStateManager.shared.setVerified(fingerprint: fingerprint, verified: false) identityManager.setVerified(fingerprint: fingerprint, verified: false)
SecureIdentityStateManager.shared.forceSave() identityManager.forceSave()
verifiedFingerprints.remove(fingerprint) verifiedFingerprints.remove(fingerprint)
updateEncryptionStatus(for: peerID) updateEncryptionStatus(for: peerID)
} }
@@ -4305,7 +4316,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
func loadVerifiedFingerprints() { func loadVerifiedFingerprints() {
// Load verified fingerprints directly from secure storage // Load verified fingerprints directly from secure storage
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints() verifiedFingerprints = identityManager.getVerifiedFingerprints()
// Log snapshot for debugging persistence // Log snapshot for debugging persistence
let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ") let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ")
SecureLogger.info("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: .security) SecureLogger.info("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: .security)
@@ -4505,8 +4516,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let fp = getFingerprint(for: peerID) { if let fp = getFingerprint(for: peerID) {
let short = fp.prefix(8) let short = fp.prefix(8)
SecureLogger.info("🔐 Marking verified fingerprint: \(short)", category: .security) SecureLogger.info("🔐 Marking verified fingerprint: \(short)", category: .security)
SecureIdentityStateManager.shared.setVerified(fingerprint: fp, verified: true) identityManager.setVerified(fingerprint: fp, verified: true)
SecureIdentityStateManager.shared.forceSave() identityManager.forceSave()
verifiedFingerprints.insert(fp) verifiedFingerprints.insert(fp)
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)
NotificationService.shared.sendLocalNotification( NotificationService.shared.sendLocalNotification(
@@ -4597,18 +4608,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isConnected = true isConnected = true
// Register ephemeral session with identity manager // Register ephemeral session with identity manager
SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID) identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
// Check if we favorite this peer and resend notification on reconnect // Intentionally do not resend favorites on reconnect.
// This ensures Nostr key mapping is maintained across reconnections // We only send our npub when a favorite is toggled on, or if our npub changes.
if let peer = unifiedPeerService.getPeer(by: peerID),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey),
favoriteStatus.isFavorite {
// Resend favorite notification with our Nostr key after a short delay
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncMediumSleepNs) // 0.5 seconds
meshService.sendFavoriteNotification(to: peerID, isFavorite: true)
SecureLogger.debug("📤 Resent favorite notification to reconnected peer \(peerID)", category: .session)
}
// Force UI refresh // Force UI refresh
objectWillChange.send() objectWillChange.send()
@@ -4630,7 +4633,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session) SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
// Remove ephemeral session from identity manager // Remove ephemeral session from identity manager
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID) identityManager.removeEphemeralSession(peerID: peerID)
// If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite) // If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)
var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID] var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID]
@@ -4737,7 +4740,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Register ephemeral sessions for all connected peers // Register ephemeral sessions for all connected peers
for peerID in peers { for peerID in peers {
SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID) self.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
} }
// Schedule UI refresh to ensure offline favorites are shown // Schedule UI refresh to ensure offline favorites are shown
@@ -4889,7 +4892,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
func isFavorite(fingerprint: String) -> Bool { func isFavorite(fingerprint: String) -> Bool {
return SecureIdentityStateManager.shared.isFavorite(fingerprint: fingerprint) return identityManager.isFavorite(fingerprint: fingerprint)
} }
// MARK: - Delivery Tracking // MARK: - Delivery Tracking
@@ -4957,6 +4960,26 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
messages.append(systemMessage) messages.append(systemMessage)
} }
/// Add a system message to the mesh timeline only (never geohash).
/// If mesh is currently active, also append to the visible `messages`.
@MainActor
private func addMeshOnlySystemMessage(_ content: String) {
let systemMessage = BitchatMessage(
sender: "system",
content: content,
timestamp: Date(),
isRelay: false
)
// Persist to mesh timeline
meshTimeline.append(systemMessage)
trimMeshTimelineIfNeeded()
// Only show inline if mesh is the active channel
if case .mesh = activeChannel {
messages.append(systemMessage)
}
objectWillChange.send()
}
/// Public helper to add a system message to the public chat timeline. /// Public helper to add a system message to the public chat timeline.
/// Also persists the message into the active channel's backing store so it survives timeline rebinds. /// Also persists the message into the active channel's backing store so it survives timeline rebinds.
@MainActor @MainActor
@@ -5199,7 +5222,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString()) messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
// Fallback: no Noise mapping yet send directly to sender's Nostr pubkey // Fallback: no Noise mapping yet send directly to sender's Nostr pubkey
let nt = NostrTransport() let nt = NostrTransport(keychain: keychain)
nt.senderPeerID = meshService.myPeerID nt.senderPeerID = meshService.myPeerID
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id) nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))", category: .session) SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))", category: .session)
@@ -5221,7 +5244,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString()) messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
sentReadReceipts.insert(messageId) sentReadReceipts.insert(messageId)
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
let nt = NostrTransport() let nt = NostrTransport(keychain: keychain)
nt.senderPeerID = meshService.myPeerID nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
sentReadReceipts.insert(messageId) sentReadReceipts.insert(messageId)
@@ -5384,23 +5407,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session) SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
return return
} }
// Determine prior state to avoid duplicate system messages on repeated notifications
// Update the favorite relationship let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false
// Update the favorite relationship (idempotent storage)
FavoritesPersistenceService.shared.updatePeerFavoritedUs( FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: finalNoiseKey, peerNoisePublicKey: finalNoiseKey,
favorited: isFavorite, favorited: isFavorite,
peerNickname: senderNickname, peerNickname: senderNickname,
peerNostrPublicKey: nostrPubkey peerNostrPublicKey: nostrPubkey
) )
// If they favorited us and provided their Nostr key, ensure it's stored // If they favorited us and provided their Nostr key, ensure it's stored (log only)
if isFavorite && nostrPubkey != nil { if isFavorite && nostrPubkey != nil {
SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", category: .session) SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", category: .session)
} }
// Show system message // Only show a system message when the state changes, and only in mesh
let action = isFavorite ? "favorited" : "unfavorited" if prior != isFavorite {
addSystemMessage("\(senderNickname) \(action) you") let action = isFavorite ? "favorited" : "unfavorited"
addMeshOnlySystemMessage("\(senderNickname) \(action) you")
}
} }
@MainActor @MainActor
@@ -5596,7 +5623,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Check geohash (Nostr) blocks using mapping to full pubkey // Check geohash (Nostr) blocks using mapping to full pubkey
if peerID.hasPrefix("nostr") { if peerID.hasPrefix("nostr") {
if let full = nostrKeyMapping[peerID]?.lowercased() { if let full = nostrKeyMapping[peerID]?.lowercased() {
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: full) { return true } if identityManager.isNostrBlocked(pubkeyHexLowercased: full) { return true }
} }
} }
return false return false
+3 -3
View File
@@ -1247,15 +1247,15 @@ struct ContentView: View {
!fav.peerNickname.isEmpty { return fav.peerNickname } !fav.peerNickname.isEmpty { return fav.peerNickname }
// Fallback: resolve from persisted social identity via fingerprint mapping // Fallback: resolve from persisted social identity via fingerprint mapping
if headerPeerID.count == 16 { if headerPeerID.count == 16 {
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let id = candidates.first, if let id = candidates.first,
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) { let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet } if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname } if !social.claimedNickname.isEmpty { return social.claimedNickname }
} }
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) { } else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
let fp = keyData.sha256Fingerprint() let fp = keyData.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet } if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname } if !social.claimedNickname.isEmpty { return social.claimedNickname }
} }
+1 -1
View File
@@ -53,7 +53,7 @@ struct FingerprintView: View {
if peerID.count == 64, let data = Data(hexString: peerID) { if peerID.count == 64, let data = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname } if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
let fp = data.sha256Fingerprint() let fp = data.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet } if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname } if !social.claimedNickname.isEmpty { return social.claimedNickname }
} }
+16 -4
View File
@@ -2,10 +2,23 @@ import XCTest
@testable import bitchat @testable import bitchat
final class CommandProcessorTests: XCTestCase { final class CommandProcessorTests: XCTestCase {
var identityManager: MockIdentityManager!
override func setUp() {
super.setUp()
// Provide a minimal identity manager for commands that query identity/block lists
identityManager = MockIdentityManager(MockKeychain())
}
override func tearDown() {
identityManager = nil
super.tearDown()
}
@MainActor @MainActor
func test_slap_notFoundGrammar() { func test_slap_notFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap @system") let result = processor.process("/slap @system")
switch result { switch result {
case .error(let message): case .error(let message):
@@ -17,7 +30,7 @@ final class CommandProcessorTests: XCTestCase {
@MainActor @MainActor
func test_hug_notFoundGrammar() { func test_hug_notFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/hug @system") let result = processor.process("/hug @system")
switch result { switch result {
case .error(let message): case .error(let message):
@@ -29,7 +42,7 @@ final class CommandProcessorTests: XCTestCase {
@MainActor @MainActor
func test_slap_usageMessage() { func test_slap_usageMessage() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap") let result = processor.process("/slap")
switch result { switch result {
case .error(let message): case .error(let message):
@@ -39,4 +52,3 @@ final class CommandProcessorTests: XCTestCase {
} }
} }
} }
@@ -16,6 +16,8 @@ final class PrivateChatE2ETests: XCTestCase {
var bob: MockBluetoothMeshService! var bob: MockBluetoothMeshService!
var charlie: MockBluetoothMeshService! var charlie: MockBluetoothMeshService!
private var mockKeychain: MockKeychain!
override func setUp() { override func setUp() {
super.setUp() super.setUp()
MockBLEService.resetTestBus() MockBLEService.resetTestBus()
@@ -24,6 +26,7 @@ final class PrivateChatE2ETests: XCTestCase {
alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1) alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1)
bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2) bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2)
charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3) charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3)
mockKeychain = MockKeychain()
// Delivery tracking is now handled internally by BLEService // Delivery tracking is now handled internally by BLEService
} }
@@ -32,6 +35,7 @@ final class PrivateChatE2ETests: XCTestCase {
alice = nil alice = nil
bob = nil bob = nil
charlie = nil charlie = nil
mockKeychain = nil
super.tearDown() super.tearDown()
} }
@@ -116,8 +120,8 @@ final class PrivateChatE2ETests: XCTestCase {
let aliceKey = Curve25519.KeyAgreement.PrivateKey() let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey()
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish encrypted session // Establish encrypted session
do { do {
@@ -10,6 +10,21 @@ import XCTest
@testable import bitchat @testable import bitchat
final class FragmentationTests: XCTestCase { final class FragmentationTests: XCTestCase {
private var mockKeychain: MockKeychain!
private var mockIdentityManager: MockIdentityManager!
override func setUp() {
super.setUp()
mockKeychain = MockKeychain()
mockIdentityManager = MockIdentityManager(mockKeychain)
}
override func tearDown() {
mockKeychain = nil
mockIdentityManager = nil
super.tearDown()
}
private final class CaptureDelegate: BitchatDelegate { private final class CaptureDelegate: BitchatDelegate {
var publicMessages: [(peerID: String, nickname: String, content: String)] = [] var publicMessages: [(peerID: String, nickname: String, content: String)] = []
@@ -75,7 +90,7 @@ final class FragmentationTests: XCTestCase {
} }
func test_reassembly_from_fragments_delivers_public_message() { func test_reassembly_from_fragments_delivers_public_message() {
let ble = BLEService() let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
@@ -106,7 +121,7 @@ final class FragmentationTests: XCTestCase {
} }
func test_duplicate_fragment_does_not_break_reassembly() { func test_duplicate_fragment_does_not_break_reassembly() {
let ble = BLEService() let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
@@ -132,7 +147,7 @@ final class FragmentationTests: XCTestCase {
} }
func test_invalid_fragment_header_is_ignored() { func test_invalid_fragment_header_is_ignored() {
let ble = BLEService() let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
@@ -14,6 +14,7 @@ final class IntegrationTests: XCTestCase {
var nodes: [String: MockBluetoothMeshService] = [:] var nodes: [String: MockBluetoothMeshService] = [:]
var noiseManagers: [String: NoiseSessionManager] = [:] var noiseManagers: [String: NoiseSessionManager] = [:]
private var mockKeychain: MockKeychain!
override func setUp() { override func setUp() {
super.setUp() super.setUp()
@@ -21,6 +22,7 @@ final class IntegrationTests: XCTestCase {
// broadcast propagation across a larger mesh. Integration-only. // broadcast propagation across a larger mesh. Integration-only.
MockBLEService.resetTestBus() MockBLEService.resetTestBus()
MockBLEService.autoFloodEnabled = true MockBLEService.autoFloodEnabled = true
mockKeychain = MockKeychain()
// Create a network of nodes // Create a network of nodes
createNode("Alice", peerID: TestConstants.testPeerID1) createNode("Alice", peerID: TestConstants.testPeerID1)
@@ -34,6 +36,7 @@ final class IntegrationTests: XCTestCase {
MockBLEService.autoFloodEnabled = false MockBLEService.autoFloodEnabled = false
nodes.removeAll() nodes.removeAll()
noiseManagers.removeAll() noiseManagers.removeAll()
mockKeychain = nil
super.tearDown() super.tearDown()
} }
@@ -307,7 +310,7 @@ final class IntegrationTests: XCTestCase {
// Simulate Bob restart by recreating his Noise manager // Simulate Bob restart by recreating his Noise manager
let bobKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey()
noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey) noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Re-establish Noise handshake explicitly via managers // Re-establish Noise handshake explicitly via managers
do { do {
@@ -593,7 +596,7 @@ final class IntegrationTests: XCTestCase {
// Create Noise manager // Create Noise manager
let key = Curve25519.KeyAgreement.PrivateKey() let key = Curve25519.KeyAgreement.PrivateKey()
noiseManagers[name] = NoiseSessionManager(localStaticKey: key) noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
} }
private func connect(_ node1: String, _ node2: String) { private func connect(_ node1: String, _ node2: String) {
+3 -1
View File
@@ -36,6 +36,8 @@ final class MockBLEService: NSObject {
var myPeerID: String = "MOCK1234" var myPeerID: String = "MOCK1234"
var myNickname: String = "MockUser" var myNickname: String = "MockUser"
private let mockKeychain = MockKeychain()
// Test-specific properties // Test-specific properties
var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = [] var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = []
var sentPackets: [BitchatPacket] = [] var sentPackets: [BitchatPacket] = []
@@ -272,7 +274,7 @@ final class MockBLEService: NSObject {
} }
func getNoiseService() -> NoiseEncryptionService { func getNoiseService() -> NoiseEncryptionService {
return NoiseEncryptionService() return NoiseEncryptionService(keychain: mockKeychain)
} }
func getFingerprint(for peerID: String) -> String? { func getFingerprint(for peerID: String) -> String? {
@@ -0,0 +1,80 @@
//
// MockIdentityManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@testable import bitchat
final class MockIdentityManager: SecureIdentityStateManagerProtocol {
private let keychain: KeychainManagerProtocol
init(_ keychain: KeychainManagerProtocol) {
self.keychain = keychain
}
func loadIdentityCache() {}
func saveIdentityCache() {}
func forceSave() {}
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
nil
}
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
[]
}
func updateSocialIdentity(_ identity: SocialIdentity) {}
func getFavorites() -> Set<String> {
Set()
}
func setFavorite(_ fingerprint: String, isFavorite: Bool) {}
func isFavorite(fingerprint: String) -> Bool {
false
}
func isBlocked(fingerprint: String) -> Bool {
false
}
func setBlocked(_ fingerprint: String, isBlocked: Bool) {}
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
true
}
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {}
func getBlockedNostrPubkeys() -> Set<String> {
Set()
}
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState) {}
func updateHandshakeState(peerID: String, state: HandshakeState) {}
func clearAllIdentityData() {}
func removeEphemeralSession(peerID: String) {}
func setVerified(fingerprint: String, verified: Bool) {}
func isVerified(fingerprint: String) -> Bool {
true
}
func getVerifiedFingerprints() -> Set<String> {
Set()
}
}
+46
View File
@@ -0,0 +1,46 @@
//
// MockKeychain.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@testable import bitchat
final class MockKeychain: KeychainManagerProtocol {
private var storage: [String: Data] = [:]
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
storage[key] = keyData
return true
}
func getIdentityKey(forKey key: String) -> Data? {
storage[key]
}
func deleteIdentityKey(forKey key: String) -> Bool {
storage.removeValue(forKey: key)
return true
}
func deleteAllKeychainData() -> Bool {
storage.removeAll()
return true
}
func secureClear(_ data: inout Data) {
//
data = Data()
}
func secureClear(_ string: inout String) {
string = ""
}
func verifyIdentityKeyExists() -> Bool {
storage["identity_noiseStaticKey"] != nil
}
}
+36 -27
View File
@@ -16,16 +16,19 @@ final class NoiseProtocolTests: XCTestCase {
var bobKey: Curve25519.KeyAgreement.PrivateKey! var bobKey: Curve25519.KeyAgreement.PrivateKey!
var aliceSession: NoiseSession! var aliceSession: NoiseSession!
var bobSession: NoiseSession! var bobSession: NoiseSession!
private var mockKeychain: MockKeychain!
override func setUp() { override func setUp() {
super.setUp() super.setUp()
aliceKey = Curve25519.KeyAgreement.PrivateKey() aliceKey = Curve25519.KeyAgreement.PrivateKey()
bobKey = Curve25519.KeyAgreement.PrivateKey() bobKey = Curve25519.KeyAgreement.PrivateKey()
mockKeychain = MockKeychain()
} }
override func tearDown() { override func tearDown() {
aliceSession = nil aliceSession = nil
bobSession = nil bobSession = nil
mockKeychain = nil
super.tearDown() super.tearDown()
} }
@@ -36,12 +39,14 @@ final class NoiseProtocolTests: XCTestCase {
aliceSession = NoiseSession( aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2, peerID: TestConstants.testPeerID2,
role: .initiator, role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey localStaticKey: aliceKey
) )
bobSession = NoiseSession( bobSession = NoiseSession(
peerID: TestConstants.testPeerID1, peerID: TestConstants.testPeerID1,
role: .responder, role: .responder,
keychain: mockKeychain,
localStaticKey: bobKey localStaticKey: bobKey
) )
@@ -80,6 +85,7 @@ final class NoiseProtocolTests: XCTestCase {
aliceSession = NoiseSession( aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2, peerID: TestConstants.testPeerID2,
role: .initiator, role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey localStaticKey: aliceKey
) )
@@ -144,6 +150,7 @@ final class NoiseProtocolTests: XCTestCase {
aliceSession = NoiseSession( aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2, peerID: TestConstants.testPeerID2,
role: .initiator, role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey localStaticKey: aliceKey
) )
@@ -157,7 +164,7 @@ final class NoiseProtocolTests: XCTestCase {
// MARK: - Session Manager Tests // MARK: - Session Manager Tests
func testSessionManagerBasicOperations() throws { func testSessionManagerBasicOperations() throws {
let manager = NoiseSessionManager(localStaticKey: aliceKey) let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
// Create session // Create session
let session = manager.createSession(for: TestConstants.testPeerID2, role: .initiator) let session = manager.createSession(for: TestConstants.testPeerID2, role: .initiator)
@@ -174,7 +181,7 @@ final class NoiseProtocolTests: XCTestCase {
} }
func testSessionManagerHandshakeInitiation() throws { func testSessionManagerHandshakeInitiation() throws {
let manager = NoiseSessionManager(localStaticKey: aliceKey) let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
// Initiate handshake // Initiate handshake
let handshakeData = try manager.initiateHandshake(with: TestConstants.testPeerID2) let handshakeData = try manager.initiateHandshake(with: TestConstants.testPeerID2)
@@ -187,8 +194,8 @@ final class NoiseProtocolTests: XCTestCase {
} }
func testSessionManagerIncomingHandshake() throws { func testSessionManagerIncomingHandshake() throws {
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Alice initiates // Alice initiates
let message1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2) let message1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
@@ -211,8 +218,8 @@ final class NoiseProtocolTests: XCTestCase {
} }
func testSessionManagerEncryptionDecryption() throws { func testSessionManagerEncryptionDecryption() throws {
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions // Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -256,11 +263,11 @@ final class NoiseProtocolTests: XCTestCase {
func testSessionIsolation() throws { func testSessionIsolation() throws {
// Create two separate session pairs // Create two separate session pairs
let aliceSession1 = NoiseSession(peerID: "peer1", role: .initiator, localStaticKey: aliceKey) let aliceSession1 = NoiseSession(peerID: "peer1", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession1 = NoiseSession(peerID: "alice1", role: .responder, localStaticKey: bobKey) let bobSession1 = NoiseSession(peerID: "alice1", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
let aliceSession2 = NoiseSession(peerID: "peer2", role: .initiator, localStaticKey: aliceKey) let aliceSession2 = NoiseSession(peerID: "peer2", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession2 = NoiseSession(peerID: "alice2", role: .responder, localStaticKey: bobKey) let bobSession2 = NoiseSession(peerID: "alice2", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
// Establish both pairs // Establish both pairs
try performHandshake(initiator: aliceSession1, responder: bobSession1) try performHandshake(initiator: aliceSession1, responder: bobSession1)
@@ -282,8 +289,8 @@ final class NoiseProtocolTests: XCTestCase {
func testPeerRestartDetection() throws { func testPeerRestartDetection() throws {
// Establish initial sessions // Establish initial sessions
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -295,7 +302,7 @@ final class NoiseProtocolTests: XCTestCase {
_ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2) _ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2)
// Simulate Bob restart by creating new manager with same key // Simulate Bob restart by creating new manager with same key
let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey) let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Bob initiates new handshake after restart // Bob initiates new handshake after restart
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1) let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1)
@@ -318,8 +325,8 @@ final class NoiseProtocolTests: XCTestCase {
func testNonceDesynchronizationRecovery() throws { func testNonceDesynchronizationRecovery() throws {
// Create two sessions // Create two sessions
aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, localStaticKey: aliceKey) aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, localStaticKey: bobKey) bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
// Establish sessions // Establish sessions
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
@@ -342,8 +349,8 @@ final class NoiseProtocolTests: XCTestCase {
func testConcurrentEncryption() throws { func testConcurrentEncryption() throws {
// Test thread safety of encryption operations // Test thread safety of encryption operations
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -380,8 +387,8 @@ final class NoiseProtocolTests: XCTestCase {
func testSessionStaleDetection() throws { func testSessionStaleDetection() throws {
// Test that sessions are properly marked as stale // Test that sessions are properly marked as stale
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -394,8 +401,8 @@ final class NoiseProtocolTests: XCTestCase {
func testHandshakeAfterDecryptionFailure() throws { func testHandshakeAfterDecryptionFailure() throws {
// Test that handshake is properly initiated after decryption failure // Test that handshake is properly initiated after decryption failure
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions // Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -413,8 +420,8 @@ final class NoiseProtocolTests: XCTestCase {
func testHandshakeAlwaysAcceptedWithExistingSession() throws { func testHandshakeAlwaysAcceptedWithExistingSession() throws {
// Test that handshake is always accepted even with existing valid session // Test that handshake is always accepted even with existing valid session
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions // Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -453,8 +460,8 @@ final class NoiseProtocolTests: XCTestCase {
func testNonceDesynchronizationCausesRehandshake() throws { func testNonceDesynchronizationCausesRehandshake() throws {
// Test that nonce desynchronization leads to proper re-handshake // Test that nonce desynchronization leads to proper re-handshake
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions // Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -499,8 +506,8 @@ final class NoiseProtocolTests: XCTestCase {
func testHandshakePerformance() throws { func testHandshakePerformance() throws {
measure { measure {
do { do {
let alice = NoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey) let alice = NoiseSession(peerID: "bob", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bob = NoiseSession(peerID: "alice", role: .responder, localStaticKey: bobKey) let bob = NoiseSession(peerID: "alice", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
try performHandshake(initiator: alice, responder: bob) try performHandshake(initiator: alice, responder: bob)
} catch { } catch {
XCTFail("Handshake failed: \(error)") XCTFail("Handshake failed: \(error)")
@@ -530,12 +537,14 @@ final class NoiseProtocolTests: XCTestCase {
aliceSession = NoiseSession( aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2, peerID: TestConstants.testPeerID2,
role: .initiator, role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey localStaticKey: aliceKey
) )
bobSession = NoiseSession( bobSession = NoiseSession(
peerID: TestConstants.testPeerID1, peerID: TestConstants.testPeerID1,
role: .responder, role: .responder,
keychain: mockKeychain,
localStaticKey: bobKey localStaticKey: bobKey
) )