mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:25:20 +00:00
Compare commits
5
Commits
feat/georelay
...
v1.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc11101ad | ||
|
|
5fd9140ffa | ||
|
|
e6903456ab | ||
|
|
85f99984c4 | ||
|
|
75c8933091 |
@@ -126,6 +126,10 @@
|
|||||||
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
||||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */; };
|
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */; };
|
||||||
D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */; };
|
D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */; };
|
||||||
|
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; };
|
||||||
|
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; };
|
||||||
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */; };
|
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */; };
|
||||||
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; };
|
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; };
|
||||||
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; };
|
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; };
|
||||||
@@ -136,6 +140,12 @@
|
|||||||
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 */; };
|
||||||
|
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
||||||
|
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
||||||
|
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
||||||
|
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
||||||
|
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
||||||
|
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -243,6 +253,8 @@
|
|||||||
C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBluetoothMeshService.swift; sourceTree = "<group>"; };
|
C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBluetoothMeshService.swift; sourceTree = "<group>"; };
|
||||||
D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicChatE2ETests.swift; sourceTree = "<group>"; };
|
D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicChatE2ETests.swift; sourceTree = "<group>"; };
|
||||||
D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoRelayDirectory.swift; sourceTree = "<group>"; };
|
||||||
|
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
||||||
E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
|
E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
|
||||||
E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocolTests.swift; sourceTree = "<group>"; };
|
E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocolTests.swift; sourceTree = "<group>"; };
|
||||||
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
@@ -252,6 +264,9 @@
|
|||||||
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>"; };
|
||||||
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>"; };
|
||||||
|
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
|
||||||
|
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
|
||||||
|
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -302,6 +317,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
2F82C5FC8433F4064F079D1F /* bitchat */,
|
2F82C5FC8433F4064F079D1F /* bitchat */,
|
||||||
|
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */,
|
||||||
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
||||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
||||||
9F37F9F2C353B58AC809E93B /* Products */,
|
9F37F9F2C353B58AC809E93B /* Products */,
|
||||||
@@ -321,8 +337,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */,
|
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */,
|
||||||
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
|
|
||||||
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */,
|
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */,
|
||||||
|
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
|
||||||
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */,
|
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */,
|
||||||
EA706D8E5097785414646A8E /* Info.plist */,
|
EA706D8E5097785414646A8E /* Info.plist */,
|
||||||
95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */,
|
95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */,
|
||||||
@@ -421,6 +437,7 @@
|
|||||||
A55126E93155456CAA8D6656 /* Views */ = {
|
A55126E93155456CAA8D6656 /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
|
||||||
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
|
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
|
||||||
047502B32E55FED60083520F /* MeshPeerList.swift */,
|
047502B32E55FED60083520F /* MeshPeerList.swift */,
|
||||||
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
|
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
|
||||||
@@ -494,6 +511,7 @@
|
|||||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||||
047502B82E560F690083520F /* RelayController.swift */,
|
047502B82E560F690083520F /* RelayController.swift */,
|
||||||
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
||||||
049BD3B02E51F319001A566B /* MessageRouter.swift */,
|
049BD3B02E51F319001A566B /* MessageRouter.swift */,
|
||||||
@@ -515,10 +533,12 @@
|
|||||||
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
|
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
|
||||||
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
|
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
|
||||||
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
||||||
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
|
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
|
||||||
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */,
|
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */,
|
||||||
|
E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */,
|
||||||
);
|
);
|
||||||
path = Nostr;
|
path = Nostr;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -683,6 +703,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
|
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
|
||||||
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -692,6 +713,7 @@
|
|||||||
files = (
|
files = (
|
||||||
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */,
|
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */,
|
||||||
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */,
|
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */,
|
||||||
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -710,6 +732,9 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
||||||
|
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
||||||
|
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||||
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
|
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
|
||||||
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
|
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
|
||||||
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
|
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
|
||||||
@@ -756,6 +781,7 @@
|
|||||||
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
|
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
|
||||||
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
|
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
|
||||||
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
|
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
|
||||||
|
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -763,6 +789,9 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
||||||
|
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
||||||
|
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||||
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
|
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
|
||||||
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
|
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
|
||||||
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
|
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
|
||||||
@@ -809,6 +838,7 @@
|
|||||||
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
|
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
|
||||||
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
|
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
|
||||||
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
|
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
|
||||||
|
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ struct BitchatApp: App {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
|
// Warm up georelay directory and refresh if stale (once/day)
|
||||||
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -29,6 +31,13 @@ struct BitchatApp: App {
|
|||||||
.environmentObject(chatViewModel)
|
.environmentObject(chatViewModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
NotificationDelegate.shared.chatViewModel = chatViewModel
|
||||||
|
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||||
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-central</string>
|
<string>bluetooth-central</string>
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
|
@MainActor
|
||||||
|
final class GeoRelayDirectory {
|
||||||
|
struct Entry: Hashable {
|
||||||
|
let host: String
|
||||||
|
let lat: Double
|
||||||
|
let lon: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = GeoRelayDirectory()
|
||||||
|
private(set) var entries: [Entry] = []
|
||||||
|
private let cacheFileName = "georelays_cache.csv"
|
||||||
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
|
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
|
private let fetchInterval: TimeInterval = 60 * 60 * 24 // 24h
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// Load cached or bundled data synchronously
|
||||||
|
self.entries = self.loadLocalEntries()
|
||||||
|
// Fire-and-forget remote refresh if stale
|
||||||
|
prefetchIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
|
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||||
|
let center = Geohash.decodeCenter(geohash)
|
||||||
|
return closestRelays(toLat: center.lat, lon: center.lon, count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||||
|
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||||
|
guard !entries.isEmpty else { return [] }
|
||||||
|
let sorted = entries
|
||||||
|
.sorted { a, b in
|
||||||
|
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||||
|
}
|
||||||
|
.prefix(count)
|
||||||
|
return sorted.map { "wss://\($0.host)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote Fetch
|
||||||
|
func prefetchIfNeeded() {
|
||||||
|
let now = Date()
|
||||||
|
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
|
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||||
|
fetchRemote()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRemote() {
|
||||||
|
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||||
|
let task = URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||||
|
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||||
|
if !parsed.isEmpty {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.entries = parsed
|
||||||
|
self.persistCache(text)
|
||||||
|
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||||
|
SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning)
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistCache(_ text: String) {
|
||||||
|
guard let url = cacheURL() else { return }
|
||||||
|
do {
|
||||||
|
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.log("GeoRelayDirectory: failed to write cache: \(error)", category: SecureLogger.session, level: .warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading
|
||||||
|
private func loadLocalEntries() -> [Entry] {
|
||||||
|
// Prefer cached file if present
|
||||||
|
if let cache = self.cacheURL(),
|
||||||
|
let data = try? Data(contentsOf: cache),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
|
let arr = Self.parseCSV(text)
|
||||||
|
if !arr.isEmpty { return arr }
|
||||||
|
}
|
||||||
|
// Try bundled resource(s)
|
||||||
|
let bundleCandidates = [
|
||||||
|
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
|
].compactMap { $0 }
|
||||||
|
for url in bundleCandidates {
|
||||||
|
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||||
|
let arr = Self.parseCSV(text)
|
||||||
|
if !arr.isEmpty { return arr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try filesystem path (development/test)
|
||||||
|
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||||
|
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
|
return Self.parseCSV(text)
|
||||||
|
}
|
||||||
|
SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
|
var result: Set<Entry> = []
|
||||||
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
|
// Skip header if present
|
||||||
|
for (idx, raw) in lines.enumerated() {
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if line.isEmpty { continue }
|
||||||
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
|
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count >= 3 else { continue }
|
||||||
|
var host = parts[0]
|
||||||
|
host = host.replacingOccurrences(of: "https://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "http://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "wss://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "ws://", with: "")
|
||||||
|
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||||
|
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||||
|
}
|
||||||
|
return Array(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cacheURL() -> URL? {
|
||||||
|
do {
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir.appendingPathComponent(cacheFileName)
|
||||||
|
} catch { return nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Distance
|
||||||
|
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
|
||||||
|
let r = 6371.0 // Earth radius in km
|
||||||
|
let dLat = (lat2 - lat1) * .pi / 180
|
||||||
|
let dLon = (lon2 - lon1) * .pi / 180
|
||||||
|
let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)
|
||||||
|
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
return r * c
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
|
import Security
|
||||||
|
|
||||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||||
@@ -12,8 +13,9 @@ struct NostrProtocol {
|
|||||||
enum EventKind: Int {
|
enum EventKind: Int {
|
||||||
case metadata = 0
|
case metadata = 0
|
||||||
case textNote = 1
|
case textNote = 1
|
||||||
|
case dm = 14 // NIP-17 DM rumor kind
|
||||||
case seal = 13 // NIP-17 sealed event
|
case seal = 13 // NIP-17 sealed event
|
||||||
case giftWrap = 1059 // NIP-17 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ struct NostrProtocol {
|
|||||||
let rumor = NostrEvent(
|
let rumor = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .textNote,
|
kind: .dm, // NIP-17: DM rumor kind 14
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
@@ -227,7 +229,7 @@ struct NostrProtocol {
|
|||||||
return try NostrEvent(from: rumorDict)
|
return try NostrEvent(from: rumorDict)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption (NIP-44 style)
|
// MARK: - Encryption (NIP-44 v2)
|
||||||
|
|
||||||
private static func encrypt(
|
private static func encrypt(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
@@ -239,33 +241,31 @@ struct NostrProtocol {
|
|||||||
throw NostrError.invalidPublicKey
|
throw NostrError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypting message
|
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
||||||
|
|
||||||
// Derive shared secret
|
// Derive shared secret
|
||||||
let sharedSecret = try deriveSharedSecret(
|
let sharedSecret = try deriveSharedSecret(
|
||||||
privateKey: senderKey,
|
privateKey: senderKey,
|
||||||
publicKey: recipientPubkeyData
|
publicKey: recipientPubkeyData
|
||||||
)
|
)
|
||||||
|
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
||||||
|
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||||
|
|
||||||
// Derived shared secret
|
// 24-byte random nonce for XChaCha20-Poly1305
|
||||||
|
var nonce24 = Data(count: 24)
|
||||||
|
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
|
||||||
// Generate nonce
|
let pt = Data(plaintext.utf8)
|
||||||
let nonce = AES.GCM.Nonce()
|
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||||
|
|
||||||
// Encrypt
|
// v2: base64url(nonce24 || ciphertext || tag)
|
||||||
let sealed = try AES.GCM.seal(
|
var combined = Data()
|
||||||
plaintext.data(using: .utf8)!,
|
combined.append(nonce24)
|
||||||
using: SymmetricKey(data: sharedSecret),
|
combined.append(sealed.ciphertext)
|
||||||
nonce: nonce
|
combined.append(sealed.tag)
|
||||||
)
|
return "v2:" + base64URLEncode(combined)
|
||||||
|
|
||||||
// Combine nonce + ciphertext + tag
|
|
||||||
var result = Data()
|
|
||||||
result.append(nonce.withUnsafeBytes { Data($0) })
|
|
||||||
result.append(sealed.ciphertext)
|
|
||||||
result.append(sealed.tag)
|
|
||||||
|
|
||||||
return result.base64EncodedString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
@@ -273,87 +273,46 @@ struct NostrProtocol {
|
|||||||
senderPubkey: String,
|
senderPubkey: String,
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
recipientKey: P256K.Schnorr.PrivateKey
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
|
// Expect NIP-44 v2 format
|
||||||
// Decrypting message
|
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||||
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
guard let data = Data(base64Encoded: ciphertext),
|
guard let data = base64URLDecode(encoded),
|
||||||
|
data.count > (24 + 16),
|
||||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||||
SecureLogger.log("❌ Invalid ciphertext or sender pubkey format",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
throw NostrError.invalidCiphertext
|
throw NostrError.invalidCiphertext
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ciphertext data parsed
|
let nonce24 = data.prefix(24)
|
||||||
|
let rest = data.dropFirst(24)
|
||||||
|
let tag = rest.suffix(16)
|
||||||
|
let ct = rest.dropLast(16)
|
||||||
|
|
||||||
// Extract components
|
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
||||||
let nonceData = data.prefix(12)
|
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
||||||
let ciphertextData = data.dropFirst(12).dropLast(16)
|
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||||
let tagData = data.suffix(16)
|
let key = try deriveNIP44V2Key(from: ss)
|
||||||
|
return try XChaCha20Poly1305Compat.open(
|
||||||
// Components parsed
|
ciphertext: Data(ct),
|
||||||
|
tag: Data(tag),
|
||||||
// Derive shared secret - try with default Y coordinate first
|
key: key,
|
||||||
var sharedSecret: Data
|
nonce24: Data(nonce24)
|
||||||
var decrypted: Data? = nil
|
|
||||||
|
|
||||||
do {
|
|
||||||
sharedSecret = try deriveSharedSecret(
|
|
||||||
privateKey: recipientKey,
|
|
||||||
publicKey: senderPubkeyData
|
|
||||||
)
|
)
|
||||||
// Derived shared secret with first Y coordinate
|
}
|
||||||
|
|
||||||
// Try to decrypt
|
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||||
let sealedBox = try AES.GCM.SealedBox(
|
|
||||||
nonce: AES.GCM.Nonce(data: nonceData),
|
|
||||||
ciphertext: ciphertextData,
|
|
||||||
tag: tagData
|
|
||||||
)
|
|
||||||
|
|
||||||
do {
|
|
||||||
decrypted = try AES.GCM.open(
|
|
||||||
sealedBox,
|
|
||||||
using: SymmetricKey(data: sharedSecret)
|
|
||||||
)
|
|
||||||
// AES-GCM decryption successful
|
|
||||||
} catch {
|
|
||||||
// AES-GCM decryption failed, trying alternate
|
|
||||||
|
|
||||||
// If the sender pubkey is x-only (32 bytes), try the other Y coordinate
|
|
||||||
if senderPubkeyData.count == 32 {
|
if senderPubkeyData.count == 32 {
|
||||||
// Trying alternate Y coordinate
|
let even = Data([0x02]) + senderPubkeyData
|
||||||
|
if let pt = try? attemptDecrypt(using: even) {
|
||||||
// Force deriveSharedSecret to use odd Y by manipulating the data
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
var altPubkey = Data()
|
}
|
||||||
altPubkey.append(0x03) // Force odd Y
|
let odd = Data([0x03]) + senderPubkeyData
|
||||||
altPubkey.append(senderPubkeyData)
|
let pt = try attemptDecrypt(using: odd)
|
||||||
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
sharedSecret = try deriveSharedSecretDirect(
|
|
||||||
privateKey: recipientKey,
|
|
||||||
publicKey: altPubkey
|
|
||||||
)
|
|
||||||
|
|
||||||
decrypted = try AES.GCM.open(
|
|
||||||
sealedBox,
|
|
||||||
using: SymmetricKey(data: sharedSecret)
|
|
||||||
)
|
|
||||||
// AES-GCM decryption successful with alternate Y
|
|
||||||
} else {
|
} else {
|
||||||
throw error
|
let pt = try attemptDecrypt(using: senderPubkeyData)
|
||||||
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let finalDecrypted = decrypted else {
|
|
||||||
throw NostrError.encryptionFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(data: finalDecrypted, encoding: .utf8) ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func deriveSharedSecret(
|
private static func deriveSharedSecret(
|
||||||
privateKey: P256K.Schnorr.PrivateKey,
|
privateKey: P256K.Schnorr.PrivateKey,
|
||||||
@@ -412,17 +371,8 @@ struct NostrProtocol {
|
|||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
// ECDH shared secret derived
|
// ECDH shared secret derived
|
||||||
|
|
||||||
// Derive key using HKDF for NIP-44 v2
|
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
return sharedSecretData
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
|
||||||
salt: "nip44-v2".data(using: .utf8)!,
|
|
||||||
info: Data(),
|
|
||||||
outputByteCount: 32
|
|
||||||
)
|
|
||||||
|
|
||||||
let result = derivedKey.withUnsafeBytes { Data($0) }
|
|
||||||
// Final derived key ready
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct version that doesn't try to add prefixes
|
// Direct version that doesn't try to add prefixes
|
||||||
@@ -452,15 +402,8 @@ struct NostrProtocol {
|
|||||||
// Convert SharedSecret to Data
|
// Convert SharedSecret to Data
|
||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
|
|
||||||
// Derive key using HKDF for NIP-44 v2
|
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
return sharedSecretData
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
|
||||||
salt: "nip44-v2".data(using: .utf8)!,
|
|
||||||
info: Data(),
|
|
||||||
outputByteCount: 32
|
|
||||||
)
|
|
||||||
|
|
||||||
return derivedKey.withUnsafeBytes { Data($0) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func randomizedTimestamp() -> Date {
|
private static func randomizedTimestamp() -> Date {
|
||||||
@@ -537,7 +480,10 @@ struct NostrEvent: Codable {
|
|||||||
|
|
||||||
// Sign with Schnorr
|
// Sign with Schnorr
|
||||||
var messageBytes = [UInt8](eventIdHash)
|
var messageBytes = [UInt8](eventIdHash)
|
||||||
var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing
|
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||||
|
_ = auxRand.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
|
}
|
||||||
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||||
|
|
||||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||||
@@ -581,3 +527,32 @@ enum NostrError: Error {
|
|||||||
case signingFailed
|
case signingFailed
|
||||||
case encryptionFailed
|
case encryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
||||||
|
|
||||||
|
private extension NostrProtocol {
|
||||||
|
static func base64URLEncode(_ data: Data) -> String {
|
||||||
|
return data.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func base64URLDecode(_ s: String) -> Data? {
|
||||||
|
var str = s
|
||||||
|
let pad = (4 - (str.count % 4)) % 4
|
||||||
|
if pad > 0 { str += String(repeating: "=", count: pad) }
|
||||||
|
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
return Data(base64Encoded: str)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||||
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
|
salt: Data(),
|
||||||
|
info: "nip44-v2".data(using: .utf8)!,
|
||||||
|
outputByteCount: 32
|
||||||
|
)
|
||||||
|
return derivedKey.withUnsafeBytes { Data($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,9 +78,23 @@ class NostrRelayManager: ObservableObject {
|
|||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||||
|
func ensureConnections(to relayUrls: [String]) {
|
||||||
|
let existing = Set(relays.map { $0.url })
|
||||||
|
for url in Set(relayUrls) {
|
||||||
|
if !existing.contains(url) {
|
||||||
|
relays.append(Relay(url: url))
|
||||||
|
}
|
||||||
|
if connections[url] == nil {
|
||||||
|
connectToRelay(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send an event to specified relays (or all if none specified)
|
/// Send an event to specified relays (or all if none specified)
|
||||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||||
let targetRelays = relayUrls ?? relays.map { $0.url }
|
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||||
|
ensureConnections(to: targetRelays)
|
||||||
|
|
||||||
// Add to queue for reliability
|
// Add to queue for reliability
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
@@ -95,10 +109,11 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to events matching a filter
|
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
||||||
func subscribe(
|
func subscribe(
|
||||||
filter: NostrFilter,
|
filter: NostrFilter,
|
||||||
id: String = UUID().uuidString,
|
id: String = UUID().uuidString,
|
||||||
|
relayUrls: [String]? = nil,
|
||||||
handler: @escaping (NostrEvent) -> Void
|
handler: @escaping (NostrEvent) -> Void
|
||||||
) {
|
) {
|
||||||
messageHandlers[id] = handler
|
messageHandlers[id] = handler
|
||||||
@@ -120,8 +135,14 @@ class NostrRelayManager: ObservableObject {
|
|||||||
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
|
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
|
||||||
// category: SecureLogger.session, level: .debug)
|
// category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Send subscription to all connected relays
|
// Target specific relays if provided; else all connections
|
||||||
for (relayUrl, connection) in connections {
|
let urls = relayUrls ?? Self.defaultRelays
|
||||||
|
ensureConnections(to: urls)
|
||||||
|
let targets: [(String, URLSessionWebSocketTask)] = urls.compactMap { url in
|
||||||
|
connections[url].map { (url, $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (relayUrl, connection) in targets {
|
||||||
connection.send(.string(messageString)) { error in
|
connection.send(.string(messageString)) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
|
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly.
|
||||||
|
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||||
|
/// as per XChaCha20 construction.
|
||||||
|
enum XChaCha20Poly1305Compat {
|
||||||
|
struct SealBox {
|
||||||
|
let ciphertext: Data
|
||||||
|
let tag: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||||
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
|
|
||||||
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
|
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||||
|
let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data())
|
||||||
|
return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||||
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
|
|
||||||
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
|
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||||
|
return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func derive12ByteNonce(from24 nonce24: Data) -> Data {
|
||||||
|
// XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce
|
||||||
|
var out = Data(count: 12)
|
||||||
|
out.replaceSubrange(0..<4, with: [0, 0, 0, 0])
|
||||||
|
out.replaceSubrange(4..<12, with: nonce24.suffix(8))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||||
|
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||||
|
precondition(key.count == 32)
|
||||||
|
precondition(nonce16.count == 16)
|
||||||
|
|
||||||
|
// Constants "expand 32-byte k"
|
||||||
|
var state: [UInt32] = [
|
||||||
|
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
|
||||||
|
// key (8 words)
|
||||||
|
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
|
||||||
|
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
|
||||||
|
// nonce (4 words)
|
||||||
|
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
|
||||||
|
]
|
||||||
|
|
||||||
|
// 20 rounds (10 double rounds)
|
||||||
|
for _ in 0..<10 {
|
||||||
|
// Column rounds
|
||||||
|
quarterRound(&state, 0, 4, 8, 12)
|
||||||
|
quarterRound(&state, 1, 5, 9, 13)
|
||||||
|
quarterRound(&state, 2, 6, 10, 14)
|
||||||
|
quarterRound(&state, 3, 7, 11, 15)
|
||||||
|
// Diagonal rounds
|
||||||
|
quarterRound(&state, 0, 5, 10, 15)
|
||||||
|
quarterRound(&state, 1, 6, 11, 12)
|
||||||
|
quarterRound(&state, 2, 7, 8, 13)
|
||||||
|
quarterRound(&state, 3, 4, 9, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output subkey: state[0..3] and state[12..15]
|
||||||
|
var out = Data(count: 32)
|
||||||
|
out.storeLEWord(state[0], at: 0)
|
||||||
|
out.storeLEWord(state[1], at: 4)
|
||||||
|
out.storeLEWord(state[2], at: 8)
|
||||||
|
out.storeLEWord(state[3], at: 12)
|
||||||
|
out.storeLEWord(state[12], at: 16)
|
||||||
|
out.storeLEWord(state[13], at: 20)
|
||||||
|
out.storeLEWord(state[14], at: 24)
|
||||||
|
out.storeLEWord(state[15], at: 28)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
|
||||||
|
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16)
|
||||||
|
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20)
|
||||||
|
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8) | (s[d] >> 24)
|
||||||
|
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7) | (s[b] >> 25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
func loadLEWord(_ offset: Int) -> UInt32 {
|
||||||
|
let range = offset..<(offset+4)
|
||||||
|
let bytes = self[range]
|
||||||
|
return bytes.withUnsafeBytes { ptr -> UInt32 in
|
||||||
|
let b = ptr.bindMemory(to: UInt8.self)
|
||||||
|
return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func storeLEWord(_ value: UInt32, at offset: Int) {
|
||||||
|
let bytes: [UInt8] = [
|
||||||
|
UInt8(value & 0xff),
|
||||||
|
UInt8((value >> 8) & 0xff),
|
||||||
|
UInt8((value >> 16) & 0xff),
|
||||||
|
UInt8((value >> 24) & 0xff)
|
||||||
|
]
|
||||||
|
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -155,12 +155,17 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case privateMessage = 0x01 // Private chat message
|
case privateMessage = 0x01 // Private chat message
|
||||||
case readReceipt = 0x02 // Message was read
|
case readReceipt = 0x02 // Message was read
|
||||||
case delivered = 0x03 // Message was delivered
|
case delivered = 0x03 // Message was delivered
|
||||||
|
// Verification (QR-based OOB binding)
|
||||||
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
|
case verifyResponse = 0x11 // Verification response
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
case .readReceipt: return "readReceipt"
|
case .readReceipt: return "readReceipt"
|
||||||
case .delivered: return "delivered"
|
case .delivered: return "delivered"
|
||||||
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
|
case .verifyResponse: return "verifyResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import Foundation
|
|||||||
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
|
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
|
||||||
enum Geohash {
|
enum Geohash {
|
||||||
private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
|
private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
private static let base32Map: [Character: Int] = {
|
||||||
|
var map: [Character: Int] = [:]
|
||||||
|
for (i, c) in base32Chars.enumerated() { map[c] = i }
|
||||||
|
return map
|
||||||
|
}()
|
||||||
|
|
||||||
/// Encodes the provided coordinates into a geohash string.
|
/// Encodes the provided coordinates into a geohash string.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -56,5 +61,30 @@ enum Geohash {
|
|||||||
|
|
||||||
return String(geohash)
|
return String(geohash)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Decodes a geohash into the center latitude/longitude of its bounding box.
|
||||||
|
/// - Parameter geohash: Base32 geohash string.
|
||||||
|
/// - Returns: (lat, lon) center coordinate.
|
||||||
|
static func decodeCenter(_ geohash: String) -> (lat: Double, lon: Double) {
|
||||||
|
var latInterval: (Double, Double) = (-90.0, 90.0)
|
||||||
|
var lonInterval: (Double, Double) = (-180.0, 180.0)
|
||||||
|
|
||||||
|
var isEven = true
|
||||||
|
for ch in geohash.lowercased() {
|
||||||
|
guard let cd = base32Map[ch] else { continue }
|
||||||
|
for mask in [16, 8, 4, 2, 1] {
|
||||||
|
if isEven {
|
||||||
|
let mid = (lonInterval.0 + lonInterval.1) / 2
|
||||||
|
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
|
||||||
|
} else {
|
||||||
|
let mid = (latInterval.0 + latInterval.1) / 2
|
||||||
|
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
|
||||||
|
}
|
||||||
|
isEven.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lat = (latInterval.0 + latInterval.1) / 2
|
||||||
|
let lon = (lonInterval.0 + lonInterval.1) / 2
|
||||||
|
return (lat, lon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -509,10 +509,50 @@ final class BLEService: NSObject {
|
|||||||
sendAnnounce()
|
sendAnnounce()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - QR Verification over Noise
|
||||||
|
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {
|
||||||
|
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||||
|
sendNoisePayload(payload, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {
|
||||||
|
guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
|
||||||
|
sendNoisePayload(payload, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendNoisePayload(_ typedPayload: Data, to peerID: String) {
|
||||||
|
guard noiseService.hasSession(with: peerID) else {
|
||||||
|
// Lazy-handshake path: queue? For now, initiate handshake and drop
|
||||||
|
initiateNoiseHandshake(with: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||||
|
recipientID: Data(hexString: peerID),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: encrypted,
|
||||||
|
signature: nil,
|
||||||
|
ttl: messageTTL
|
||||||
|
)
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
|
broadcastPacket(packet)
|
||||||
|
} else {
|
||||||
|
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.log("Failed to send verification payload: \(error)", category: SecureLogger.noise, level: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
func getPeerFingerprint(_ peerID: String) -> String? {
|
||||||
return collectionsQueue.sync {
|
return collectionsQueue.sync {
|
||||||
if let publicKey = peers[peerID]?.noisePublicKey {
|
if let publicKey = peers[peerID]?.noisePublicKey {
|
||||||
return publicKey.hexEncodedString()
|
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
|
||||||
|
let hash = SHA256.hash(data: publicKey)
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1383,6 +1423,16 @@ final class BLEService: NSObject {
|
|||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
|
case .verifyChallenge:
|
||||||
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
|
notifyUI { [weak self] in
|
||||||
|
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
|
||||||
|
}
|
||||||
|
case .verifyResponse:
|
||||||
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
|
notifyUI { [weak self] in
|
||||||
|
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
|
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,8 +165,12 @@ class CommandProcessor {
|
|||||||
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
|
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
|
||||||
var geoNames: [String] = []
|
var geoNames: [String] = []
|
||||||
if let vm = chatViewModel {
|
if let vm = chatViewModel {
|
||||||
|
#if os(iOS)
|
||||||
let visible = vm.visibleGeohashPeople()
|
let visible = vm.visibleGeohashPeople()
|
||||||
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
||||||
|
#else
|
||||||
|
let visibleIndex: [String: String] = [:]
|
||||||
|
#endif
|
||||||
for pk in geoBlocked {
|
for pk in geoBlocked {
|
||||||
if let name = visibleIndex[pk.lowercased()] {
|
if let name = visibleIndex[pk.lowercased()] {
|
||||||
geoNames.append(name)
|
geoNames.append(name)
|
||||||
|
|||||||
@@ -46,11 +46,20 @@ protocol Transport: AnyObject {
|
|||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||||
|
|
||||||
|
// QR verification (optional for transports)
|
||||||
|
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||||
|
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
// Peer snapshots (for non-UI services)
|
// Peer snapshots (for non-UI services)
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Transport {
|
||||||
|
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||||
|
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||||
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||||
|
final class VerificationService {
|
||||||
|
static let shared = VerificationService()
|
||||||
|
|
||||||
|
// Injected Noise service from the running transport (do NOT create new BLEService)
|
||||||
|
private var noise: NoiseEncryptionService?
|
||||||
|
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
|
||||||
|
|
||||||
|
/// Encapsulates the data encoded into a verification QR
|
||||||
|
struct VerificationQR: Codable {
|
||||||
|
let v: Int
|
||||||
|
let noiseKeyHex: String
|
||||||
|
let signKeyHex: String
|
||||||
|
let npub: String?
|
||||||
|
let nickname: String
|
||||||
|
let ts: Int64
|
||||||
|
let nonceB64: String
|
||||||
|
var sigHex: String
|
||||||
|
|
||||||
|
static let context = "bitchat-verify-v1"
|
||||||
|
|
||||||
|
/// Canonical bytes used for signature (deterministic ordering)
|
||||||
|
func canonicalBytes() -> Data {
|
||||||
|
var out = Data()
|
||||||
|
func appendField(_ s: String) {
|
||||||
|
let d = s.data(using: .utf8) ?? Data()
|
||||||
|
out.append(UInt8(min(d.count, 255)))
|
||||||
|
out.append(d.prefix(255))
|
||||||
|
}
|
||||||
|
appendField(Self.context)
|
||||||
|
appendField(String(v))
|
||||||
|
appendField(noiseKeyHex.lowercased())
|
||||||
|
appendField(signKeyHex.lowercased())
|
||||||
|
appendField(npub ?? "")
|
||||||
|
appendField(nickname)
|
||||||
|
appendField(String(ts))
|
||||||
|
appendField(nonceB64)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toURLString() -> String {
|
||||||
|
var comps = URLComponents()
|
||||||
|
comps.scheme = "bitchat"
|
||||||
|
comps.host = "verify"
|
||||||
|
comps.queryItems = [
|
||||||
|
URLQueryItem(name: "v", value: String(v)),
|
||||||
|
URLQueryItem(name: "noise", value: noiseKeyHex),
|
||||||
|
URLQueryItem(name: "sign", value: signKeyHex),
|
||||||
|
URLQueryItem(name: "nick", value: nickname),
|
||||||
|
URLQueryItem(name: "ts", value: String(ts)),
|
||||||
|
URLQueryItem(name: "nonce", value: nonceB64),
|
||||||
|
URLQueryItem(name: "sig", value: sigHex)
|
||||||
|
] + (npub != nil ? [URLQueryItem(name: "npub", value: npub)] : [])
|
||||||
|
return comps.string ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromURL(_ url: URL) -> VerificationQR? {
|
||||||
|
guard url.scheme == "bitchat", url.host == "verify",
|
||||||
|
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil }
|
||||||
|
func val(_ name: String) -> String? { items.first(where: { $0.name == name })?.value }
|
||||||
|
guard let vStr = val("v"), let v = Int(vStr),
|
||||||
|
let noise = val("noise"), let sign = val("sign"),
|
||||||
|
let nick = val("nick"), let tsStr = val("ts"), let ts = Int64(tsStr),
|
||||||
|
let nonce = val("nonce"), let sig = val("sig") else { return nil }
|
||||||
|
return VerificationQR(v: v, noiseKeyHex: noise, signKeyHex: sign, npub: val("npub"), nickname: nick, ts: ts, nonceB64: nonce, sigHex: sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Build a signed QR string for the current identity
|
||||||
|
func buildMyQRString(nickname: String, npub: String?) -> String? {
|
||||||
|
// Simple short-lived cache to speed up sheet opening
|
||||||
|
struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? }
|
||||||
|
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
|
||||||
|
return c.value
|
||||||
|
}
|
||||||
|
guard let noise = noise else { return nil }
|
||||||
|
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
|
||||||
|
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
|
||||||
|
let ts = Int64(Date().timeIntervalSince1970)
|
||||||
|
var nonce = Data(count: 16)
|
||||||
|
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||||
|
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||||
|
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
|
||||||
|
let msg = payload.canonicalBytes()
|
||||||
|
guard let sig = noise.signData(msg) else { return nil }
|
||||||
|
let signed = VerificationQR(v: payload.v,
|
||||||
|
noiseKeyHex: payload.noiseKeyHex,
|
||||||
|
signKeyHex: payload.signKeyHex,
|
||||||
|
npub: payload.npub,
|
||||||
|
nickname: payload.nickname,
|
||||||
|
ts: payload.ts,
|
||||||
|
nonceB64: payload.nonceB64,
|
||||||
|
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
||||||
|
let out = signed.toURLString()
|
||||||
|
Cache.last = (nickname, npub, Date(), out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
|
||||||
|
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = 5 * 60) -> VerificationQR? {
|
||||||
|
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
|
||||||
|
// Freshness
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
if now - Double(qr.ts) > maxAge { return nil }
|
||||||
|
// Verify signature using embedded ed25519 signKey
|
||||||
|
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
||||||
|
guard let noise = noise else { return nil }
|
||||||
|
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
||||||
|
return ok ? qr : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise payloads (scaffold only)
|
||||||
|
|
||||||
|
func buildVerifyChallenge(noiseKeyHex: String, nonceA: Data) -> Data {
|
||||||
|
// TLV: [0x01 len noiseKeyHex ascii] [0x02 len nonceA]
|
||||||
|
var tlv = Data()
|
||||||
|
let n0: [UInt8] = [0x01, UInt8(min(noiseKeyHex.count, 255))]
|
||||||
|
tlv.append(contentsOf: n0)
|
||||||
|
tlv.append(noiseKeyHex.data(using: .utf8)!.prefix(255))
|
||||||
|
tlv.append(0x02)
|
||||||
|
tlv.append(UInt8(min(nonceA.count, 255)))
|
||||||
|
tlv.append(nonceA.prefix(255))
|
||||||
|
return NoisePayload(type: .verifyChallenge, data: tlv).encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVerifyResponse(noiseKeyHex: String, nonceA: Data) -> Data? {
|
||||||
|
// Sign context: verify-response | noiseKeyHex | nonceA
|
||||||
|
var msg = Data("bitchat-verify-resp-v1".utf8)
|
||||||
|
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||||
|
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||||
|
msg.append(nonceA)
|
||||||
|
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
|
||||||
|
var tlv = Data()
|
||||||
|
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
|
||||||
|
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
|
||||||
|
tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255))
|
||||||
|
return NoisePayload(type: .verifyResponse, data: tlv).encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? {
|
||||||
|
var idx = 0
|
||||||
|
func take(_ n: Int) -> Data? {
|
||||||
|
guard idx + n <= data.count else { return nil }
|
||||||
|
let d = data[idx..<(idx+n)]
|
||||||
|
idx += n
|
||||||
|
return Data(d)
|
||||||
|
}
|
||||||
|
// Expect type already stripped; we receive only TLV here
|
||||||
|
// TLV 0x01 noiseKeyHex
|
||||||
|
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
|
||||||
|
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
|
||||||
|
// TLV 0x02 nonceA
|
||||||
|
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
|
||||||
|
return (noiseStr, nA)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? {
|
||||||
|
var idx = 0
|
||||||
|
func take(_ n: Int) -> Data? {
|
||||||
|
guard idx + n <= data.count else { return nil }
|
||||||
|
let d = data[idx..<(idx+n)]
|
||||||
|
idx += n
|
||||||
|
return Data(d)
|
||||||
|
}
|
||||||
|
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
|
||||||
|
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
|
||||||
|
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
|
||||||
|
guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil }
|
||||||
|
return (noiseStr, nA, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool {
|
||||||
|
var msg = Data("bitchat-verify-resp-v1".utf8)
|
||||||
|
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||||
|
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||||
|
msg.append(nonceA)
|
||||||
|
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
|
||||||
|
return noise.verifySignature(signature, for: msg, publicKey: pub)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -412,6 +412,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Delivery tracking
|
// Delivery tracking
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - QR Verification (pending state)
|
||||||
|
private struct PendingVerification {
|
||||||
|
let noiseKeyHex: String
|
||||||
|
let signKeyHex: String
|
||||||
|
let nonceA: Data
|
||||||
|
let startedAt: Date
|
||||||
|
var sent: Bool
|
||||||
|
}
|
||||||
|
private var pendingQRVerifications: [String: PendingVerification] = [:] // peerID -> pending
|
||||||
|
// Last handled challenge nonce per peer to avoid duplicate responses
|
||||||
|
private var lastVerifyNonceByPeer: [String: Data] = [:]
|
||||||
|
// Track when we last received a verify challenge from a peer (fingerprint-keyed)
|
||||||
|
private var lastInboundVerifyChallengeAt: [String: Date] = [:] // key: fingerprint
|
||||||
|
// Throttle mutual verification toasts per fingerprint
|
||||||
|
private var lastMutualToastAt: [String: Date] = [:] // key: fingerprint
|
||||||
|
|
||||||
// MARK: - Public message batching (UI perf)
|
// MARK: - Public message batching (UI perf)
|
||||||
// Buffer incoming public messages and flush in small batches to reduce UI invalidations
|
// Buffer incoming public messages and flush in small batches to reduce UI invalidations
|
||||||
private var publicBuffer: [BitchatMessage] = []
|
private var publicBuffer: [BitchatMessage] = []
|
||||||
@@ -451,6 +467,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Throttle verification response toasts per peer to avoid spam
|
||||||
|
private var lastVerifyToastAt: [String: Date] = [:]
|
||||||
|
|
||||||
// Track processed Nostr ACKs to avoid duplicate processing
|
// Track processed Nostr ACKs to avoid duplicate processing
|
||||||
private var processedNostrAcks: Set<String> = [] // "messageId:ackType:senderPubkey" format
|
private var processedNostrAcks: Set<String> = [] // "messageId:ackType:senderPubkey" format
|
||||||
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
|
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
|
||||||
@@ -711,14 +730,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Unsubscribe + resubscribe
|
// Unsubscribe + resubscribe
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||||
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
|
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
|
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
if self.processedNostrEvents.contains(event.id) { return }
|
if self.processedNostrEvents.contains(event.id) { return }
|
||||||
self.processedNostrEvents.insert(event.id)
|
self.processedNostrEvents.insert(event.id)
|
||||||
if let gh = self.currentGeohash,
|
if let gh = self.currentGeohash,
|
||||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
||||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||||
|
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
|
||||||
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
if Date().timeIntervalSince(eventTime) < 15 { return }
|
||||||
|
}
|
||||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||||
let nick = nickTag[1]
|
let nick = nickTag[1]
|
||||||
self.geoNicknames[event.pubkey.lowercased()] = nick
|
self.geoNicknames[event.pubkey.lowercased()] = nick
|
||||||
@@ -832,16 +856,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.sentReadReceipts.insert(messageId)
|
self.sentReadReceipts.insert(messageId)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Optionally notify if app backgrounded and message is truly unread
|
// Notify for truly unread and recent messages when not viewing
|
||||||
#if os(iOS)
|
if shouldMarkUnread {
|
||||||
if shouldMarkUnread && UIApplication.shared.applicationState != .active {
|
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: senderName,
|
from: senderName,
|
||||||
message: pm.content,
|
message: pm.content,
|
||||||
peerID: convKey
|
peerID: convKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
case .delivered:
|
case .delivered:
|
||||||
@@ -868,6 +890,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
category: SecureLogger.session, level: .warning)
|
category: SecureLogger.session, level: .warning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case .verifyChallenge, .verifyResponse:
|
||||||
|
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
@@ -1275,7 +1300,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
nickname: self.nickname,
|
nickname: self.nickname,
|
||||||
teleported: LocationChannelManager.shared.teleported
|
teleported: LocationChannelManager.shared.teleported
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning)
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
// Track ourselves as active participant
|
// Track ourselves as active participant
|
||||||
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
|
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
|
||||||
@@ -1359,7 +1389,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
geoSubscriptionID = subID
|
geoSubscriptionID = subID
|
||||||
startGeoParticipantsTimer()
|
startGeoParticipantsTimer()
|
||||||
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
|
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
|
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
// Only handle ephemeral kind 20000 with matching tag
|
// Only handle ephemeral kind 20000 with matching tag
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
@@ -1382,11 +1413,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Skip our own events (we already locally echoed)
|
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||||
if let gh = self.currentGeohash,
|
if let gh = self.currentGeohash,
|
||||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
||||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||||
return
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
if Date().timeIntervalSince(eventTime) < 15 { return }
|
||||||
}
|
}
|
||||||
// Cache nickname from tag if present
|
// Cache nickname from tag if present
|
||||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||||
@@ -1514,19 +1546,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
} else {
|
} else {
|
||||||
// pared back: omit defer READ log
|
// pared back: omit defer READ log
|
||||||
}
|
}
|
||||||
// Notify only when app is backgrounded and not viewing, and only if not already read
|
// Notify for truly unread and recent messages when not viewing
|
||||||
#if os(iOS)
|
|
||||||
if !isViewing && shouldMarkUnread {
|
if !isViewing && shouldMarkUnread {
|
||||||
if UIApplication.shared.applicationState != .active {
|
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: senderName,
|
from: senderName,
|
||||||
message: pm.content,
|
message: pm.content,
|
||||||
peerID: convKey
|
peerID: convKey
|
||||||
)
|
)
|
||||||
// pared back: omit notification log
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#endif
|
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
default:
|
default:
|
||||||
// Handle delivered/read receipts for our sent messages
|
// Handle delivered/read receipts for our sent messages
|
||||||
@@ -1730,7 +1757,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let subID = "geo-sample-\(gh)"
|
let subID = "geo-sample-\(gh)"
|
||||||
geoSamplingSubs[subID] = gh
|
geoSamplingSubs[subID] = gh
|
||||||
let filter = NostrFilter.geohashEphemeral(gh, since: Date().addingTimeInterval(-300), limit: 100)
|
let filter = NostrFilter.geohashEphemeral(gh, since: Date().addingTimeInterval(-300), limit: 100)
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
|
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
// Update participants for this specific geohash
|
// Update participants for this specific geohash
|
||||||
@@ -2478,7 +2506,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
nickname: self.nickname,
|
nickname: self.nickname,
|
||||||
teleported: LocationChannelManager.shared.teleported
|
teleported: LocationChannelManager.shared.teleported
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning)
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
// Track ourselves as active participant
|
// Track ourselves as active participant
|
||||||
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -3145,12 +3183,28 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let token = String(matchText.dropFirst()).lowercased()
|
let token = String(matchText.dropFirst()).lowercased()
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
|
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
|
||||||
|
// Do not link if this hashtag is directly attached to an @mention (e.g., @name#geohash)
|
||||||
|
let attachedToMention: Bool = {
|
||||||
|
// nsRange is the Range<String.Index> for this match within content
|
||||||
|
// Walk left until whitespace/newline; if we encounter '@' first, treat as part of mention
|
||||||
|
if nsRange.lowerBound > content.startIndex {
|
||||||
|
var i = content.index(before: nsRange.lowerBound)
|
||||||
|
while true {
|
||||||
|
let ch = content[i]
|
||||||
|
if ch.isWhitespace || ch.isNewline { break }
|
||||||
|
if ch == "@" { return true }
|
||||||
|
if i == content.startIndex { break }
|
||||||
|
i = content.index(before: i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
var tagStyle = AttributeContainer()
|
var tagStyle = AttributeContainer()
|
||||||
tagStyle.font = isSelf
|
tagStyle.font = isSelf
|
||||||
? .system(size: 14, weight: .bold, design: .monospaced)
|
? .system(size: 14, weight: .bold, design: .monospaced)
|
||||||
: .system(size: 14, design: .monospaced)
|
: .system(size: 14, design: .monospaced)
|
||||||
tagStyle.foregroundColor = baseColor
|
tagStyle.foregroundColor = baseColor
|
||||||
if isGeohash, let url = URL(string: "bitchat://geohash/\(token)") {
|
if isGeohash && !attachedToMention, let url = URL(string: "bitchat://geohash/\(token)") {
|
||||||
tagStyle.link = url
|
tagStyle.link = url
|
||||||
tagStyle.underlineStyle = .single
|
tagStyle.underlineStyle = .single
|
||||||
}
|
}
|
||||||
@@ -3711,9 +3765,34 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
updateEncryptionStatus(for: peerID)
|
updateEncryptionStatus(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func unverifyFingerprint(for peerID: String) {
|
||||||
|
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
||||||
|
SecureIdentityStateManager.shared.setVerified(fingerprint: fingerprint, verified: false)
|
||||||
|
SecureIdentityStateManager.shared.forceSave()
|
||||||
|
verifiedFingerprints.remove(fingerprint)
|
||||||
|
updateEncryptionStatus(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func loadVerifiedFingerprints() {
|
func loadVerifiedFingerprints() {
|
||||||
// Load verified fingerprints directly from secure storage
|
// Load verified fingerprints directly from secure storage
|
||||||
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints()
|
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints()
|
||||||
|
// Log snapshot for debugging persistence
|
||||||
|
let sample = Array(verifiedFingerprints.prefix(3)).map { $0.prefix(8) }.joined(separator: ", ")
|
||||||
|
SecureLogger.log("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: SecureLogger.security, level: .info)
|
||||||
|
// Also log any offline favorites and whether we consider them verified
|
||||||
|
let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected }
|
||||||
|
for fav in offlineFavorites {
|
||||||
|
let fp = unifiedPeerService.getFingerprint(for: fav.id)
|
||||||
|
let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false
|
||||||
|
let fpShort = fp?.prefix(8) ?? "nil"
|
||||||
|
SecureLogger.log("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: SecureLogger.security, level: .info)
|
||||||
|
}
|
||||||
|
// Invalidate cached encryption statuses so offline favorites can show verified badges immediately
|
||||||
|
invalidateEncryptionCache()
|
||||||
|
// Trigger UI refresh of peer list
|
||||||
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNoiseCallbacks() {
|
private func setupNoiseCallbacks() {
|
||||||
@@ -3747,6 +3826,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a QR verification is pending but not sent yet, send it now that session is authenticated
|
||||||
|
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
||||||
|
self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA)
|
||||||
|
pending.sent = true
|
||||||
|
self.pendingQRVerifications[peerID] = pending
|
||||||
|
SecureLogger.log("📤 Sent deferred verify challenge to \(peerID) after handshake", category: SecureLogger.security, level: .debug)
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule UI update
|
// Schedule UI update
|
||||||
// UI will update automatically
|
// UI will update automatically
|
||||||
}
|
}
|
||||||
@@ -3850,6 +3937,72 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case .verifyChallenge:
|
||||||
|
// Parse and respond
|
||||||
|
guard let tlv = VerificationService.shared.parseVerifyChallenge(payload) else { return }
|
||||||
|
// Ensure intended for our noise key
|
||||||
|
let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased()
|
||||||
|
guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return }
|
||||||
|
// Deduplicate: ignore if we've already responded to this nonce for this peer
|
||||||
|
if let last = lastVerifyNonceByPeer[peerID], last == tlv.nonceA { return }
|
||||||
|
lastVerifyNonceByPeer[peerID] = tlv.nonceA
|
||||||
|
// Record inbound challenge time keyed by stable fingerprint if available
|
||||||
|
if let fp = getFingerprint(for: peerID) {
|
||||||
|
lastInboundVerifyChallengeAt[fp] = Date()
|
||||||
|
// If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side)
|
||||||
|
if verifiedFingerprints.contains(fp) {
|
||||||
|
let now = Date()
|
||||||
|
let last = lastMutualToastAt[fp] ?? .distantPast
|
||||||
|
if now.timeIntervalSince(last) > 60 { // 1-minute throttle
|
||||||
|
lastMutualToastAt[fp] = now
|
||||||
|
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)
|
||||||
|
NotificationService.shared.sendLocalNotification(
|
||||||
|
title: "Mutual verification",
|
||||||
|
body: "You and \(name) verified each other",
|
||||||
|
identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA)
|
||||||
|
// Silent response: no toast needed on responder
|
||||||
|
case .verifyResponse:
|
||||||
|
guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return }
|
||||||
|
// Check pending for this peer
|
||||||
|
guard let pending = pendingQRVerifications[peerID] else { return }
|
||||||
|
guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return }
|
||||||
|
// Verify signature with expected sign key
|
||||||
|
let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex)
|
||||||
|
if ok {
|
||||||
|
pendingQRVerifications.removeValue(forKey: peerID)
|
||||||
|
if let fp = getFingerprint(for: peerID) {
|
||||||
|
let short = fp.prefix(8)
|
||||||
|
SecureLogger.log("🔐 Marking verified fingerprint: \(short)", category: SecureLogger.security, level: .info)
|
||||||
|
SecureIdentityStateManager.shared.setVerified(fingerprint: fp, verified: true)
|
||||||
|
SecureIdentityStateManager.shared.forceSave()
|
||||||
|
verifiedFingerprints.insert(fp)
|
||||||
|
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)
|
||||||
|
NotificationService.shared.sendLocalNotification(
|
||||||
|
title: "Verified",
|
||||||
|
body: "You verified \(name)",
|
||||||
|
identifier: "verify-success-\(peerID)-\(UUID().uuidString)"
|
||||||
|
)
|
||||||
|
// If we also recently responded to their challenge, flag mutual and toast (initiator side)
|
||||||
|
if let t = lastInboundVerifyChallengeAt[fp], Date().timeIntervalSince(t) < 600 {
|
||||||
|
let now = Date()
|
||||||
|
let lastToast = lastMutualToastAt[fp] ?? .distantPast
|
||||||
|
if now.timeIntervalSince(lastToast) > 60 {
|
||||||
|
lastMutualToastAt[fp] = now
|
||||||
|
NotificationService.shared.sendLocalNotification(
|
||||||
|
title: "Mutual verification",
|
||||||
|
body: "You and \(name) verified each other",
|
||||||
|
identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateEncryptionStatus(for: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3875,6 +4028,36 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - QR Verification API
|
||||||
|
@MainActor
|
||||||
|
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||||
|
// Find a matching peer by Noise key
|
||||||
|
let targetNoise = qr.noiseKeyHex.lowercased()
|
||||||
|
guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let peerID = peer.id
|
||||||
|
// If we already have a pending verification with this peer, don't send another
|
||||||
|
if pendingQRVerifications[peerID] != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Generate nonceA
|
||||||
|
var nonce = Data(count: 16)
|
||||||
|
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||||
|
var pending = PendingVerification(noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, nonceA: nonce, startedAt: Date(), sent: false)
|
||||||
|
pendingQRVerifications[peerID] = pending
|
||||||
|
// If Noise session is established, send immediately; otherwise trigger handshake and send on auth
|
||||||
|
let noise = meshService.getNoiseService()
|
||||||
|
if noise.hasEstablishedSession(with: peerID) {
|
||||||
|
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
|
||||||
|
pending.sent = true
|
||||||
|
pendingQRVerifications[peerID] = pending
|
||||||
|
} else {
|
||||||
|
meshService.triggerHandshake(with: peerID)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Mention parsing moved from BLE – use the existing non-optional helper below
|
// Mention parsing moved from BLE – use the existing non-optional helper below
|
||||||
// MARK: - Peer Connection Events
|
// MARK: - Peer Connection Events
|
||||||
|
|
||||||
@@ -4276,7 +4459,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
nickname: self.nickname,
|
nickname: self.nickname,
|
||||||
teleported: LocationChannelManager.shared.teleported
|
teleported: LocationChannelManager.shared.teleported
|
||||||
)
|
)
|
||||||
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error)
|
SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error)
|
||||||
}
|
}
|
||||||
@@ -4459,10 +4647,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
privateChatManager.sanitizeChat(for: ephemeralPeerID)
|
privateChatManager.sanitizeChat(for: ephemeralPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key
|
// Send delivery ack via Nostr embedded
|
||||||
if !wasReadBefore, let key = actualSenderNoiseKey {
|
if !wasReadBefore {
|
||||||
|
if let key = actualSenderNoiseKey {
|
||||||
SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
|
SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
|
||||||
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
|
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
|
||||||
|
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
|
||||||
|
// Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey
|
||||||
|
let nt = NostrTransport()
|
||||||
|
nt.senderPeerID = meshService.myPeerID
|
||||||
|
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
|
||||||
|
SecureLogger.log("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))…", category: SecureLogger.session, level: .debug)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if wasReadBefore {
|
if wasReadBefore {
|
||||||
@@ -4473,11 +4669,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
|
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
|
||||||
unreadPrivateMessages.remove(ephemeralPeerID)
|
unreadPrivateMessages.remove(ephemeralPeerID)
|
||||||
}
|
}
|
||||||
if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey {
|
if !sentReadReceipts.contains(messageId) {
|
||||||
|
if let key = actualSenderNoiseKey {
|
||||||
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
|
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||||
SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
|
SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
|
||||||
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() {
|
||||||
|
let nt = NostrTransport()
|
||||||
|
nt.senderPeerID = meshService.myPeerID
|
||||||
|
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
|
||||||
|
sentReadReceipts.insert(messageId)
|
||||||
|
SecureLogger.log("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))…", category: SecureLogger.session, level: .debug)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if shouldMarkAsUnread {
|
if shouldMarkAsUnread {
|
||||||
@@ -4515,6 +4719,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
privateChats[targetPeerID]?[idx].deliveryStatus = .read(by: peerName, at: Date())
|
privateChats[targetPeerID]?[idx].deliveryStatus = .read(by: peerName, at: Date())
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
case .verifyChallenge, .verifyResponse:
|
||||||
|
// Ignore verification payloads arriving via Nostr path for now
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
@@ -4867,10 +5074,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
||||||
@MainActor
|
@MainActor
|
||||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||||
// Look up current visible geohash participants for an exact displayName match
|
// Look up current visible geohash participants for an exact displayName match (iOS only)
|
||||||
|
#if os(iOS)
|
||||||
for p in visibleGeohashPeople() {
|
for p in visibleGeohashPeople() {
|
||||||
if p.displayName == name { return p.id }
|
if p.displayName == name { return p.id }
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ struct ContentView: View {
|
|||||||
@State private var scrollThrottleTimer: Timer?
|
@State private var scrollThrottleTimer: Timer?
|
||||||
@State private var autocompleteDebounceTimer: Timer?
|
@State private var autocompleteDebounceTimer: Timer?
|
||||||
@State private var showLocationChannelsSheet = false
|
@State private var showLocationChannelsSheet = false
|
||||||
|
@State private var showVerifySheet = false
|
||||||
@State private var expandedMessageIDs: Set<String> = []
|
@State private var expandedMessageIDs: Set<String> = []
|
||||||
// Window sizes for rendering (infinite scroll up)
|
// Window sizes for rendering (infinite scroll up)
|
||||||
@State private var windowCountPublic: Int = 300
|
@State private var windowCountPublic: Int = 300
|
||||||
@@ -291,13 +292,17 @@ struct ContentView: View {
|
|||||||
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
|
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
|
if let peer = privatePeer { return "dm:\(peer)" }
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
case .mesh: return "mesh"
|
case .mesh: return "mesh"
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
#else
|
#else
|
||||||
let contextKey: String = "mesh"
|
let contextKey: String = {
|
||||||
|
if let peer = privatePeer { return "dm:\(peer)" }
|
||||||
|
return "mesh"
|
||||||
|
}()
|
||||||
#endif
|
#endif
|
||||||
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
|
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
|
||||||
|
|
||||||
@@ -396,13 +401,17 @@ struct ContentView: View {
|
|||||||
let step = 200
|
let step = 200
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
|
if let peer = privatePeer { return "dm:\(peer)" }
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
case .mesh: return "mesh"
|
case .mesh: return "mesh"
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
#else
|
#else
|
||||||
let contextKey: String = "mesh"
|
let contextKey: String = {
|
||||||
|
if let peer = privatePeer { return "dm:\(peer)" }
|
||||||
|
return "mesh"
|
||||||
|
}()
|
||||||
#endif
|
#endif
|
||||||
let preserveID = "\(contextKey)|\(message.id)"
|
let preserveID = "\(contextKey)|\(message.id)"
|
||||||
if let peer = privatePeer {
|
if let peer = privatePeer {
|
||||||
@@ -926,6 +935,24 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
// Show QR only on mesh channel's peer list
|
||||||
|
#if os(iOS)
|
||||||
|
if case .mesh = locationManager.selectedChannel {
|
||||||
|
Button(action: { showVerifySheet = true }) {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Verification: show my QR or scan a friend")
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
Button(action: { showVerifySheet = true }) {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Verification: show my QR or scan a friend")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.frame(height: 44) // Match header height
|
.frame(height: 44) // Match header height
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -1216,6 +1243,8 @@ struct ContentView: View {
|
|||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
.foregroundColor(headerCountColor)
|
.foregroundColor(headerCountColor)
|
||||||
|
|
||||||
|
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
@@ -1223,6 +1252,10 @@ struct ContentView: View {
|
|||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showVerifySheet) {
|
||||||
|
VerificationSheetView(isPresented: $showVerifySheet)
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button("DONE") {
|
Button(action: { dismiss() }) {
|
||||||
dismiss()
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
}
|
}
|
||||||
@@ -165,6 +166,20 @@ struct FingerprintView: View {
|
|||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
} else {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.unverifyFingerprint(for: peerID)
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
Text("REMOVE VERIFICATION")
|
||||||
|
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.red)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|||||||
@@ -44,9 +44,24 @@ struct MeshPeerList: View {
|
|||||||
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
||||||
let baseColor = isMe ? Color.orange : assigned
|
let baseColor = isMe ? Color.orange : assigned
|
||||||
if isMe {
|
if isMe {
|
||||||
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(baseColor)
|
Image(systemName: "person.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
} else if peer.isConnected {
|
||||||
|
// Mesh-connected peer: radio icon
|
||||||
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
} else if peer.isMutualFavorite {
|
||||||
|
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||||
|
Image(systemName: "globe")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.purple)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 10)).foregroundColor(baseColor)
|
// Fallback icon for others (dimmed)
|
||||||
|
Image(systemName: "person")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||||
@@ -70,11 +85,28 @@ struct MeshPeerList: View {
|
|||||||
.help("Blocked")
|
.help("Blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let icon = item.enc.icon, !isMe {
|
if !isMe {
|
||||||
|
if peer.isConnected {
|
||||||
|
if let icon = item.enc.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Offline: prefer showing verified badge from persisted fingerprints
|
||||||
|
if let fp = viewModel.getFingerprint(for: peer.id),
|
||||||
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
} else if let icon = item.enc.icon {
|
||||||
|
// Fallback to whatever status says (likely lock if we had a past session)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import CoreImage
|
||||||
|
import CoreImage.CIFilterBuiltins
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Placeholder view to display the user's verification QR payload as text.
|
||||||
|
struct MyQRView: View {
|
||||||
|
let qrString: String
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("scan to verify me")
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
QRCodeImage(data: qrString, size: 240)
|
||||||
|
.accessibilityLabel("verification qr code")
|
||||||
|
|
||||||
|
// Non-scrolling, fully visible URL (wraps across lines)
|
||||||
|
Text(qrString)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(8)
|
||||||
|
.background(boxColor)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(boxColor)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a QR code image for a given string using CoreImage
|
||||||
|
struct QRCodeImage: View {
|
||||||
|
let data: String
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
private let context = CIContext()
|
||||||
|
private let filter = CIFilter.qrCodeGenerator()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let image = generateImage() {
|
||||||
|
ImageWrapper(image: image)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.overlay(
|
||||||
|
Text("qr unavailable")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateImage() -> CGImage? {
|
||||||
|
let inputData = Data(data.utf8)
|
||||||
|
filter.message = inputData
|
||||||
|
filter.correctionLevel = "M"
|
||||||
|
guard let outputImage = filter.outputImage else { return nil }
|
||||||
|
let scale = max(1, Int(size / 32))
|
||||||
|
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale)))
|
||||||
|
return context.createCGImage(transformed, from: transformed.extent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform-specific wrapper to display CGImage in SwiftUI
|
||||||
|
struct ImageWrapper: View {
|
||||||
|
let image: CGImage
|
||||||
|
var body: some View {
|
||||||
|
#if os(iOS)
|
||||||
|
let ui = UIImage(cgImage: image)
|
||||||
|
return Image(uiImage: ui)
|
||||||
|
.interpolation(.none)
|
||||||
|
.resizable()
|
||||||
|
#else
|
||||||
|
let ns = NSImage(cgImage: image, size: .zero)
|
||||||
|
return Image(nsImage: ns)
|
||||||
|
.interpolation(.none)
|
||||||
|
.resizable()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder scanner UI; real camera scanning will be added later.
|
||||||
|
struct QRScanView: View {
|
||||||
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
|
var isActive: Bool = true
|
||||||
|
@State private var input = ""
|
||||||
|
@State private var result: String = "" // not shown for iOS scanner
|
||||||
|
@State private var lastValid: String = ""
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
#if os(iOS)
|
||||||
|
CameraScannerView(isActive: isActive) { code in
|
||||||
|
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
||||||
|
let ok = viewModel.beginQRVerification(with: qr)
|
||||||
|
if !ok { /* already pending; continue scanning */ }
|
||||||
|
lastValid = code
|
||||||
|
} else {
|
||||||
|
// ignore invalid reads; continue scanning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 260)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
#else
|
||||||
|
Text("paste qr content to validate:")
|
||||||
|
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
||||||
|
TextEditor(text: $input)
|
||||||
|
.frame(height: 100)
|
||||||
|
.border(Color.gray.opacity(0.4))
|
||||||
|
Button("validate") {
|
||||||
|
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
||||||
|
let ok = viewModel.beginQRVerification(with: qr)
|
||||||
|
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
|
||||||
|
} else {
|
||||||
|
result = "invalid or expired qr payload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
#endif
|
||||||
|
// No status text under camera per design
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
struct CameraScannerView: UIViewRepresentable {
|
||||||
|
typealias UIViewType = PreviewView
|
||||||
|
var isActive: Bool
|
||||||
|
var onCode: (String) -> Void
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> PreviewView {
|
||||||
|
let view = PreviewView()
|
||||||
|
context.coordinator.setup(sessionOwner: view, onCode: onCode)
|
||||||
|
context.coordinator.setActive(isActive)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: PreviewView, context: Context) {
|
||||||
|
context.coordinator.setActive(isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||||
|
private var onCode: ((String) -> Void)?
|
||||||
|
private weak var owner: PreviewView?
|
||||||
|
private let session = AVCaptureSession()
|
||||||
|
private var isRunning = false
|
||||||
|
private var permissionGranted = false
|
||||||
|
private var desiredActive = false
|
||||||
|
|
||||||
|
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
|
||||||
|
self.owner = sessionOwner
|
||||||
|
self.onCode = onCode
|
||||||
|
session.beginConfiguration()
|
||||||
|
session.sessionPreset = .high
|
||||||
|
guard let device = AVCaptureDevice.default(for: .video),
|
||||||
|
let input = try? AVCaptureDeviceInput(device: device),
|
||||||
|
session.canAddInput(input) else { return }
|
||||||
|
session.addInput(input)
|
||||||
|
let output = AVCaptureMetadataOutput()
|
||||||
|
guard session.canAddOutput(output) else { return }
|
||||||
|
session.addOutput(output)
|
||||||
|
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||||
|
if output.availableMetadataObjectTypes.contains(.qr) {
|
||||||
|
output.metadataObjectTypes = [.qr]
|
||||||
|
}
|
||||||
|
session.commitConfiguration()
|
||||||
|
sessionOwner.videoPreviewLayer.session = session
|
||||||
|
// Request permission and start
|
||||||
|
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||||
|
self.permissionGranted = granted
|
||||||
|
if granted && self.desiredActive && !self.isRunning {
|
||||||
|
self.setActive(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setActive(_ active: Bool) {
|
||||||
|
desiredActive = active
|
||||||
|
guard permissionGranted else { return }
|
||||||
|
if active && !isRunning {
|
||||||
|
isRunning = true
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
if !self.session.isRunning { self.session.startRunning() }
|
||||||
|
}
|
||||||
|
} else if !active && isRunning {
|
||||||
|
isRunning = false
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
if self.session.isRunning { self.session.stopRunning() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||||
|
for obj in metadataObjects {
|
||||||
|
guard let m = obj as? AVMetadataMachineReadableCodeObject,
|
||||||
|
m.type == .qr,
|
||||||
|
let str = m.stringValue else { continue }
|
||||||
|
onCode?(str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PreviewView: UIView {
|
||||||
|
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||||
|
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
videoPreviewLayer.videoGravity = .resizeAspectFill
|
||||||
|
}
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Combined sheet: shows my QR by default with a button to scan instead
|
||||||
|
struct VerificationSheetView: View {
|
||||||
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@State private var showingScanner = false
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white }
|
||||||
|
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
|
||||||
|
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||||
|
|
||||||
|
private func myQRString() -> String {
|
||||||
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Top header (always at top)
|
||||||
|
HStack {
|
||||||
|
Text("VERIFY")
|
||||||
|
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
showingScanner = false
|
||||||
|
isPresented = false
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Content area
|
||||||
|
Group {
|
||||||
|
if showingScanner {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("scan a friend's qr")
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
#if os(iOS)
|
||||||
|
QRScanView(isActive: showingScanner)
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
.frame(height: 280)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
#else
|
||||||
|
QRScanView()
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(boxColor)
|
||||||
|
.cornerRadius(8)
|
||||||
|
} else {
|
||||||
|
let qr = myQRString()
|
||||||
|
MyQRView(qrString: qr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
|
||||||
|
// Centered controls moved up
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
if showingScanner {
|
||||||
|
Button(action: { showingScanner = false }) {
|
||||||
|
Label("show my qr", systemImage: "qrcode")
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
} else {
|
||||||
|
Button(action: { showingScanner = true }) {
|
||||||
|
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
||||||
|
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.gray)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Remove verification for selected peer (if verified)
|
||||||
|
if let pid = viewModel.selectedPrivateChatPeer,
|
||||||
|
let fp = viewModel.getFingerprint(for: pid),
|
||||||
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
|
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||||
|
Label("remove verification", systemImage: "minus.circle")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
#endif
|
||||||
|
.onDisappear { showingScanner = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,4 +112,109 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
print("Expected error when decrypting with wrong key: \(error)")
|
print("Expected error when decrypting with wrong key: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAckRoundTripNIP44V2_Delivered() throws {
|
||||||
|
// Identities
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
||||||
|
let messageID = "TEST-MSG-DELIVERED-1"
|
||||||
|
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||||
|
XCTFail("Failed to embed delivered ack")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
||||||
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
|
content: embedded,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure v2 format was used for ciphertext
|
||||||
|
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
||||||
|
|
||||||
|
// Decrypt as recipient
|
||||||
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify sender is correct
|
||||||
|
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
||||||
|
|
||||||
|
// Parse BitChat payload
|
||||||
|
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
||||||
|
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||||
|
guard let packetData = Self.base64URLDecode(base64url),
|
||||||
|
let packet = BitchatPacket.from(packetData) else {
|
||||||
|
return XCTFail("Failed to decode bitchat packet")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
||||||
|
guard let payload = NoisePayload.decode(packet.payload) else {
|
||||||
|
return XCTFail("Failed to decode NoisePayload")
|
||||||
|
}
|
||||||
|
switch payload.type {
|
||||||
|
case .delivered:
|
||||||
|
let mid = String(data: payload.data, encoding: .utf8)
|
||||||
|
XCTAssertEqual(mid, messageID)
|
||||||
|
default:
|
||||||
|
XCTFail("Unexpected payload type: \(payload.type)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAckRoundTripNIP44V2_ReadReceipt() throws {
|
||||||
|
// Identities
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
let messageID = "TEST-MSG-READ-1"
|
||||||
|
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||||
|
XCTFail("Failed to embed read ack")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
|
content: embedded,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
||||||
|
|
||||||
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
||||||
|
|
||||||
|
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
||||||
|
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||||
|
guard let packetData = Self.base64URLDecode(base64url),
|
||||||
|
let packet = BitchatPacket.from(packetData) else {
|
||||||
|
return XCTFail("Failed to decode bitchat packet")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
||||||
|
guard let payload = NoisePayload.decode(packet.payload) else {
|
||||||
|
return XCTFail("Failed to decode NoisePayload")
|
||||||
|
}
|
||||||
|
switch payload.type {
|
||||||
|
case .readReceipt:
|
||||||
|
let mid = String(data: payload.data, encoding: .utf8)
|
||||||
|
XCTAssertEqual(mid, messageID)
|
||||||
|
default:
|
||||||
|
XCTFail("Unexpected payload type: \(payload.type)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
let rem = str.count % 4
|
||||||
|
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
|
||||||
|
return Data(base64Encoded: str)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
Relay URL,Latitude,Longitude
|
||||||
|
relay.damus.io,37.7621,-122.3971
|
||||||
|
nostr-pub.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr.mom,50.4779,12.3713
|
||||||
|
nostr.slothy.win,37.7621,-122.3971
|
||||||
|
nostr.einundzwanzig.space,50.1155,8.6842
|
||||||
|
nos.lol,50.4779,12.3713
|
||||||
|
relay.nostr.band,60.1695,24.9354
|
||||||
|
no.str.cr,9.9339,-84.0849
|
||||||
|
nostr.massmux.com,50.1155,8.6842
|
||||||
|
nostr-relay.schnitzel.world,39.0437,-77.4875
|
||||||
|
relay.nostr.com.au,37.7621,-122.3971
|
||||||
|
knostr.neutrine.com,48.8534,2.3488
|
||||||
|
nostr.nodeofsven.com,47.4875,8.2965
|
||||||
|
nostr.vulpem.com,49.4542,11.0775
|
||||||
|
nostr-verif.slothy.win,37.7621,-122.3971
|
||||||
|
relay.lexingtonbitcoin.org,37.7621,-122.3971
|
||||||
|
nostr-1.nbo.angani.co,-1.2615,36.7903
|
||||||
|
relay.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr.easydns.ca,43.7064,-79.3986
|
||||||
|
relay.dwadziesciajeden.pl,52.2298,21.0118
|
||||||
|
nostr.data.haus,50.4779,12.3713
|
||||||
|
nostr.einundzwanzig.space,50.1155,8.6842
|
||||||
|
nostr.mom,50.4779,12.3713
|
||||||
|
nos.lol,50.4779,12.3713
|
||||||
|
relay.nostr.band,60.1695,24.9354
|
||||||
|
nostr.massmux.com,50.1155,8.6842
|
||||||
|
nostr.vulpem.com,49.4542,11.0775
|
||||||
|
relay.damus.io,37.7621,-122.3971
|
||||||
|
nostr-pub.wellorder.net,45.5229,-122.9898
|
||||||
|
no.str.cr,9.9339,-84.0849
|
||||||
|
relay.dwadziesciajeden.pl,52.2298,21.0118
|
||||||
|
nostr.data.haus,50.4779,12.3713
|
||||||
|
relay.wellorder.net,45.5229,-122.9898
|
||||||
|
relay.nostromo.social,49.4542,11.0775
|
||||||
|
offchain.pub,34.0522,-118.2437
|
||||||
|
relay.nostr.wirednet.jp,35.9356,139.3044
|
||||||
|
relay.nostrcheck.me,37.7621,-122.3971
|
||||||
|
nostrue.com,40.8043,-74.0121
|
||||||
|
nostr-relay.schnitzel.world,39.0437,-77.4875
|
||||||
|
nproxy.kristapsk.lv,60.1695,24.9354
|
||||||
|
nostr.spaceshell.xyz,37.7621,-122.3971
|
||||||
|
nostr-dev.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr-verified.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr.roundrockbitcoiners.com,40.8043,-74.0121
|
||||||
|
slick.mjex.me,39.0437,-77.4875
|
||||||
|
nostr.yael.at,52.3740,4.8897
|
||||||
|
relay.primal.net,37.7621,-122.3971
|
||||||
|
nostr.oxtr.dev,50.4779,12.3713
|
||||||
|
nostr.21crypto.ch,46.5160,6.6328
|
||||||
|
nostr.liberty.fans,38.8003,-90.6265
|
||||||
|
nostr-02.dorafactory.org,1.2897,103.8501
|
||||||
|
relay.hodl.ar,-32.9468,-60.6393
|
||||||
|
nostr.middling.mydns.jp,35.8089,140.1185
|
||||||
|
nostr.namek.link,37.7621,-122.3971
|
||||||
|
nostrja-kari.heguro.com,37.7621,-122.3971
|
||||||
|
nostr.hifish.org,47.3667,8.5500
|
||||||
|
nostr.rikmeijer.nl,50.4779,12.3713
|
||||||
|
black.nostrcity.club,41.8119,-87.6873
|
||||||
|
nostr.hekster.org,37.3924,-121.9623
|
||||||
|
relay.wavlake.com,41.2619,-95.8608
|
||||||
|
nostr.sagaciousd.com,49.2497,-123.1193
|
||||||
|
nostr.fbxl.net,43.7064,-79.3986
|
||||||
|
ithurtswhenip.ee,50.7990,-1.0913
|
||||||
|
relay2.nostrchat.io,49.4542,11.0775
|
||||||
|
relay1.nostrchat.io,60.1695,24.9354
|
||||||
|
nostr-01.yakihonne.com,1.3215,103.6957
|
||||||
|
nostr.sathoarder.com,48.5839,7.7455
|
||||||
|
nostr.overmind.lol,37.7621,-122.3971
|
||||||
|
relay.verified-nostr.com,37.7621,-122.3971
|
||||||
|
purplerelay.com,50.1155,8.6842
|
||||||
|
relay.orangepill.ovh,49.0127,1.9694
|
||||||
|
nostr-relay.psfoundation.info,39.0437,-77.4875
|
||||||
|
soloco.nl,37.7621,-122.3971
|
||||||
|
relay.froth.zone,60.1695,24.9354
|
||||||
|
nostr.stakey.net,52.5250,5.7181
|
||||||
|
nostr.2b9t.xyz,34.0522,-118.2437
|
||||||
|
pyramid.fiatjaf.com,50.1155,8.6842
|
||||||
|
a.nos.lol,50.4779,12.3713
|
||||||
|
relay.magiccity.live,25.8130,-80.2320
|
||||||
|
nostr.notribe.net,40.8344,-74.1377
|
||||||
|
freelay.sovbit.host,64.1355,-21.8954
|
||||||
|
relay.credenso.cafe,43.4254,-80.5112
|
||||||
|
nostr.huszonegy.world,47.4984,19.0404
|
||||||
|
multiplexer.huszonegy.world,47.4984,19.0404
|
||||||
|
bucket.coracle.social,37.7621,-122.3971
|
||||||
|
nostr.kungfu-g.rip,33.7865,-84.4454
|
||||||
|
relay.artx.market,43.7064,-79.3986
|
||||||
|
relay.notoshi.win,13.3622,100.9835
|
||||||
|
vitor.nostr1.com,40.7143,-74.0060
|
||||||
|
nostr-02.yakihonne.com,1.3215,103.6957
|
||||||
|
nostr-03.dorafactory.org,1.2897,103.8501
|
||||||
|
n.ok0.org,-36.8485,174.7635
|
||||||
|
nostr.0x7e.xyz,47.5056,8.7241
|
||||||
|
relay.nostr.net,50.4779,12.3713
|
||||||
|
strfry.openhoofd.nl,51.5717,3.7042
|
||||||
|
relay.fountain.fm,39.0997,-94.5786
|
||||||
|
relay.usefusion.ai,38.7135,-78.1594
|
||||||
|
relay.varke.eu,52.6958,6.1944
|
||||||
|
nostr.satstralia.com,64.1355,-21.8954
|
||||||
|
relay.13room.space,37.7621,-122.3971
|
||||||
|
nostr.myshosholoza.co.za,52.3710,4.9042
|
||||||
|
nostr.carroarmato0.be,50.8517,3.6089
|
||||||
|
nostr.dbtc.link,37.7621,-122.3971
|
||||||
|
orangepiller.org,60.1695,24.9354
|
||||||
|
adre.su,59.9386,30.3141
|
||||||
|
relay.sincensura.org,37.7621,-122.3971
|
||||||
|
relay.freeplace.nl,52.3740,4.8897
|
||||||
|
bostr.bitcointxoko.com,64.1355,-21.8954
|
||||||
|
nostr.plantroon.com,50.1025,8.6299
|
||||||
|
srtrelay.c-stellar.net,37.7621,-122.3971
|
||||||
|
nostr.jfischer.org,49.4453,11.0222
|
||||||
|
nostr.novacisko.cz,52.2298,21.0118
|
||||||
|
relay.lumina.rocks,49.4453,11.0222
|
||||||
|
nostr.tavux.tech,50.9519,1.8563
|
||||||
|
relay.nostrhub.fr,50.1155,8.6842
|
||||||
|
relay.agorist.space,52.3740,4.8897
|
||||||
|
chorus.pjv.me,45.5229,-122.9898
|
||||||
|
relay.cosmicbolt.net,37.3924,-121.9623
|
||||||
|
santo.iguanatech.net,40.8344,-74.1377
|
||||||
|
relay.tagayasu.xyz,45.4112,-75.6981
|
||||||
|
relay.mostro.network,40.8344,-74.1377
|
||||||
|
relay.zone667.com,60.1695,24.9354
|
||||||
|
relay5.bitransfer.org,37.7621,-122.3971
|
||||||
|
relay.illuminodes.com,47.6062,-122.3321
|
||||||
|
relay2.angor.io,50.1155,8.6842
|
||||||
|
relay.satsdays.com,1.2897,103.8501
|
||||||
|
relay.angor.io,50.1155,8.6842
|
||||||
|
orangesync.tech,50.9333,6.9500
|
||||||
|
nostr-relay.cbrx.io,37.7621,-122.3971
|
||||||
|
relay.21e6.cz,50.0880,14.4208
|
||||||
|
nostr.chaima.info,50.1155,8.6842
|
||||||
|
relay.satlantis.io,32.8546,-79.9748
|
||||||
|
relay.digitalezukunft.cyou,45.5088,-73.5878
|
||||||
|
relay.tapestry.ninja,40.8043,-74.0121
|
||||||
|
relay.minibolt.info,37.7621,-122.3971
|
||||||
|
nostr.bilthon.dev,25.8130,-80.2320
|
||||||
|
nostr.makibisskey.work,37.7621,-122.3971
|
||||||
|
relay.mattybs.lol,37.7621,-122.3971
|
||||||
|
noxir.kpherox.dev,34.8436,135.5084
|
||||||
|
sendit.nosflare.com,37.7621,-122.3971
|
||||||
|
relay.coinos.io,37.7621,-122.3971
|
||||||
|
relay.nostraddress.com,37.7621,-122.3971
|
||||||
|
wot.nostr.party,36.1659,-86.7844
|
||||||
|
nostrelites.org,41.8500,-87.6500
|
||||||
|
relay.nostriot.com,43.7064,-79.3986
|
||||||
|
prl.plus,55.7522,37.6156
|
||||||
|
zap.watch,45.5088,-73.5878
|
||||||
|
wot.codingarena.top,50.4779,12.3713
|
||||||
|
nostr.azzamo.net,52.2284,21.0522
|
||||||
|
wot.sudocarlos.com,43.7064,-79.3986
|
||||||
|
relay.lnfi.network,45.6241,8.7851
|
||||||
|
wot.nostr.net,37.7621,-122.3971
|
||||||
|
relay.nostrdice.com,-33.8678,151.2073
|
||||||
|
wot.sebastix.social,51.3700,6.1681
|
||||||
|
wheat.happytavern.co,37.7621,-122.3971
|
||||||
|
relay.sigit.io,50.4779,12.3713
|
||||||
|
strfry.bonsai.com,37.8716,-122.2728
|
||||||
|
travis-shears-nostr-relay-v2.fly.dev,41.8119,-87.6873
|
||||||
|
satsage.xyz,37.3924,-121.9623
|
||||||
|
relay.degmods.com,50.4779,12.3713
|
||||||
|
nostr.community.ath.cx,45.5088,-73.5878
|
||||||
|
nostr.coincrowd.fund,39.0437,-77.4875
|
||||||
|
strfry.shock.network,41.8847,-88.2040
|
||||||
|
cyberspace.nostr1.com,40.7143,-74.0060
|
||||||
|
relay02.lnfi.network,39.0997,-94.5786
|
||||||
|
nostr-rs-relay.dev.fedibtc.com,39.0437,-77.4875
|
||||||
|
relay.davidebtc.me,50.1155,8.6842
|
||||||
|
wot.dtonon.com,37.7621,-122.3971
|
||||||
|
relay.goodmorningbitcoin.com,37.7621,-122.3971
|
||||||
|
articles.layer3.news,37.3394,-121.8950
|
||||||
|
bostr.syobon.net,37.7621,-122.3971
|
||||||
|
nostr.agentcampfire.com,52.3740,4.8897
|
||||||
|
nostr.thebiglake.org,32.7244,-96.6755
|
||||||
|
schnorr.me,37.7621,-122.3971
|
||||||
|
relay.wolfcoil.com,35.6090,139.7302
|
||||||
|
nostr.camalolo.com,24.1469,120.6839
|
||||||
|
nostr.tac.lol,47.4740,-122.2610
|
||||||
|
dev-relay.lnfi.network,39.0997,-94.5786
|
||||||
|
relay.bitcoinveneto.org,64.1355,-21.8954
|
||||||
|
nostr.red5d.dev,37.7621,-122.3971
|
||||||
|
relay-testnet.k8s.layer3.news,37.3394,-121.8950
|
||||||
|
promenade.fiatjaf.com,50.1155,8.6842
|
||||||
|
nostrelay.memory-art.xyz,37.7621,-122.3971
|
||||||
|
inbox.azzamo.net,52.2284,21.0522
|
||||||
|
social.proxymana.net,60.1695,24.9354
|
||||||
|
relay.netstr.io,53.3331,-6.2489
|
||||||
|
premium.primal.net,37.7621,-122.3971
|
||||||
|
nostr.lojong.info,37.7621,-122.3971
|
||||||
|
nostr-rs-relay-ishosta.phamthanh.me,37.7621,-122.3971
|
||||||
|
relay.stream.labs.h3.se,59.3294,18.0687
|
||||||
|
tollbooth.stens.dev,51.4566,7.0123
|
||||||
|
relay.chakany.systems,37.7621,-122.3971
|
||||||
|
relay.mwaters.net,50.9519,1.8563
|
||||||
|
nostr-relay.shirogaku.xyz,37.7621,-122.3971
|
||||||
|
kitchen.zap.cooking,37.7621,-122.3971
|
||||||
|
relay.arx-ccn.com,50.4779,12.3713
|
||||||
|
relay.fr13nd5.com,50.1155,8.6842
|
||||||
|
nostr.tegila.com.br,39.0437,-77.4875
|
||||||
|
relay.jeffg.fyi,43.7064,-79.3986
|
||||||
|
relay.bullishbounty.com,37.7621,-122.3971
|
||||||
|
nostr.spicyz.io,37.7621,-122.3971
|
||||||
|
relay04.lnfi.network,39.0997,-94.5786
|
||||||
|
vidono.apps.slidestr.net,48.8534,2.3488
|
||||||
|
relay03.lnfi.network,39.0997,-94.5786
|
||||||
|
communities.nos.social,40.8344,-74.1377
|
||||||
|
relay.evanverma.com,40.8344,-74.1377
|
||||||
|
nostrelay.circum.space,51.4566,7.0123
|
||||||
|
wot.brightbolt.net,47.6928,-116.7850
|
||||||
|
relayrs.notoshi.win,37.7621,-122.3971
|
||||||
|
fenrir-s.notoshi.win,37.7621,-122.3971
|
||||||
|
relay.nsnip.io,60.1695,24.9354
|
||||||
|
x.kojira.io,37.7621,-122.3971
|
||||||
|
relay.hasenpfeffr.com,39.0437,-77.4875
|
||||||
|
relay01.lnfi.network,39.0997,-94.5786
|
||||||
|
nostr.rtvslawenia.com,49.4542,11.0775
|
||||||
|
relay.g1sms.fr,43.9298,2.1480
|
||||||
|
nostr.kalf.org,52.3740,4.8897
|
||||||
|
nostr.rblb.it,37.7621,-122.3971
|
||||||
|
nostr.4rs.nl,49.4453,11.0222
|
||||||
|
relay.vrtmrz.net,37.7621,-122.3971
|
||||||
|
nostr.hoppe-relay.it.com,45.5946,-121.1787
|
||||||
|
relay-rpi.edufeed.org,49.4453,11.0222
|
||||||
|
relay.copylaradio.com,50.7990,-1.0913
|
||||||
|
relay.ru.ac.th,13.7540,100.5014
|
||||||
|
relay.bitcoinartclock.com,50.4779,12.3713
|
||||||
|
wot.downisontheup.ca,47.6062,-122.3321
|
||||||
|
nostr.coincards.com,43.7064,-79.3986
|
||||||
|
relay.etch.social,41.2619,-95.8608
|
||||||
|
relay.mess.ch,47.1345,9.0964
|
||||||
|
relay.holzeis.me,37.7621,-122.3971
|
||||||
|
relay-admin.thaliyal.com,40.8220,-74.4488
|
||||||
|
nostr.thaliyal.com,40.8220,-74.4488
|
||||||
|
strfry.felixzieger.de,50.1025,8.6299
|
||||||
|
nostr.smut.cloud,37.7621,-122.3971
|
||||||
|
r.bitcoinhold.net,37.7621,-122.3971
|
||||||
|
nostr.blankfors.se,60.1695,24.9354
|
||||||
|
portal-relay.pareto.space,49.4453,11.0222
|
||||||
|
relay.getsafebox.app,43.7064,-79.3986
|
||||||
|
relay.anzenkodo.workers.dev,37.7621,-122.3971
|
||||||
|
relay.nostrhub.tech,49.4453,11.0222
|
||||||
|
nostr.prl.plus,52.3740,4.8897
|
||||||
|
nostr-2.21crypto.ch,46.5160,6.6328
|
||||||
|
nostr.zenon.network,40.7143,-74.0060
|
||||||
|
nostr-relay.amethyst.name,35.7721,-78.6386
|
||||||
|
relayone.geektank.ai,17.1210,-61.8433
|
||||||
|
fanfares.nostr1.com,40.7143,-74.0060
|
||||||
|
wot.geektank.ai,17.1210,-61.8433
|
||||||
|
relay-dev.satlantis.io,40.8344,-74.1377
|
||||||
|
relay.siamdev.cc,13.9178,100.4240
|
||||||
|
relay.nosto.re,51.3700,6.1681
|
||||||
|
wot.soundhsa.com,39.0997,-94.5786
|
||||||
|
nostr.n7ekb.net,47.5707,-122.2221
|
||||||
|
relayone.soundhsa.com,39.0997,-94.5786
|
||||||
|
relay.puresignal.news,37.7621,-122.3971
|
||||||
|
relay.nostx.io,37.7621,-122.3971
|
||||||
|
nostr.now,35.6090,139.7302
|
||||||
|
relay.artiostr.ch,37.7621,-122.3971
|
||||||
|
relay.oldenburg.cool,50.1155,8.6842
|
||||||
|
theoutpost.life,64.1355,-21.8954
|
||||||
|
khatru.nostrver.se,51.3700,6.1681
|
||||||
|
relay.wavefunc.live,37.7915,-122.4018
|
||||||
|
nostr-relay.zimage.com,34.0522,-118.2437
|
||||||
|
relay.javi.space,43.4628,11.8807
|
||||||
|
bostr.shop,42.8865,-78.8784
|
||||||
|
relay.letsfo.com,52.2298,21.0118
|
||||||
|
alien.macneilmediagroup.com,37.7621,-122.3971
|
||||||
|
rn1.sotiras.org,37.7621,-122.3971
|
||||||
|
gnostr.com,42.6975,23.3241
|
||||||
|
relay.conduit.market,37.7621,-122.3971
|
||||||
|
relay.hivetalk.org,37.3924,-121.9623
|
||||||
|
nostr.l484.com,30.2960,-97.6396
|
||||||
|
relay.chorus.community,50.1155,8.6842
|
||||||
|
nostr-relay.moe.gift,37.7621,-122.3971
|
||||||
|
relay.nostrcal.com,37.7621,-122.3971
|
||||||
|
temp.iris.to,37.7621,-122.3971
|
||||||
|
librerelay.aaroniumii.com,37.7621,-122.3971
|
||||||
|
nostr-relay-1.trustlessenterprise.com,37.7621,-122.3971
|
||||||
|
relay.barine.co,37.7621,-122.3971
|
||||||
|
nostr.rohoss.com,48.1374,11.5755
|
||||||
|
wot.nostr.place,30.2672,-97.7431
|
||||||
|
relay.utxo.farm,34.7331,135.8183
|
||||||
|
relay.bankless.at,37.7621,-122.3971
|
||||||
|
relay.toastr.net,40.8043,-74.0121
|
||||||
|
nostr.excentered.com,52.5244,13.4105
|
||||||
|
relay.mccormick.cx,52.3740,4.8897
|
||||||
|
relay.cypherflow.ai,48.8534,2.3488
|
||||||
|
relay.laantungir.net,45.3134,-73.8725
|
||||||
|
nostr.veladan.dev,37.7621,-122.3971
|
||||||
|
nostr.tadryanom.me,37.7621,-122.3971
|
||||||
|
nostr-relay.online,37.7621,-122.3971
|
||||||
|
nostr.night7.space,50.4779,12.3713
|
||||||
|
dev-nostr.bityacht.io,25.0531,121.5264
|
||||||
|
Reference in New Issue
Block a user