Compare commits

...
Author SHA1 Message Date
jack 0bc5cbe0b7 QR verification: speed + persistence + UX
- Inject live Noise into VerificationService; prewarm QR on app start
- Keep camera active; remove intermediate responder toast
- One-shot/dupe guards and deferred send on handshake
- Persist verified status immediately; standardize fingerprint (SHA-256)
- Show verified badge for offline favorites; mutual verification toast
- VERIFY sheet styling to match peer sheet; UI polish
- Logs to diagnose verified load + favorites mapping
2025-08-24 23:11:20 +02:00
jack 77f0fa46c7 QR: make NoisePayloadType exhaustive in ChatViewModel switches by ignoring verifyChallenge/verifyResponse for now (placeholder) 2025-08-24 18:26:19 +02:00
jack 81fa77b761 QR: add iOS camera scanner using AVFoundation; integrate into Scan view; add NSCameraUsageDescription to Info.plist 2025-08-24 18:23:37 +02:00
jack 0c7054ce30 QR: fix SwiftUI modifiers — apply .interpolation(.none) and .resizable() to platform Image inside ImageWrapper; remove from wrapper usage 2025-08-24 18:16:42 +02:00
jack 921b9f1be6 QR: render actual QR images with CoreImage; add copy button; keep scanner placeholder for now 2025-08-24 18:13:55 +02:00
jack e86dbc8d38 QR: fix VerificationQR mutability (sigHex var) and remove duplicate Data hex helpers to resolve redeclaration; wire signed payload assembly 2025-08-24 18:09:24 +02:00
jack 652deab8a5 QR verification scaffold: add Noise verify payload types, VerificationService with QR schema/signing, placeholder MyQR/Scan views, and UI entry points in header 2025-08-24 18:06:22 +02:00
e6903456ab NIP‑17/44: XChaCha20 v2 gift wraps, DM kind=14, receipts + UI fixes (#506)
* NIP-17/44: adopt NIP-44 v2 (XChaCha20-Poly1305, v2: base64url), switch rumor kind to 14, randomize BIP-340 aux; add XChaCha20Poly1305Compat and wire-up

* Nostr DMs: ensure delivered/read acks are sent even without Noise key mapping by falling back to direct Nostr (geohash-style) acks to sender pubkey

* NIP-44 v2 decrypt: try both Y parities for x-only sender pubkeys (even then odd) to fix unwrap authentication failures

* Tests: add NIP-44 v2 ACK round-trip tests (delivered/read) using bitchat1 embedding and gift-wrap decrypt path

* UI: fix DM autoscroll IDs by using dm:<peer>|<id> for private chat item IDs and preserve anchors, ensuring scrollTo targets exist when opening/switching/new messages

* UI: fix string interpolation in DM/geohash context keys (remove escaped interpolation) to resolve 'unused ch' warnings and ensure proper IDs

* UI: MeshPeerList shows transport state icon: radio for mesh-connected, purple globe for mutual favorite/Nostr; fallback dim person for others

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 17:56:43 +02:00
85f99984c4 Unify DM notifications and fix geohash self-echo hydration (#505)
* feat(notifications): unify DM notifications across transports; notify for unread+recent even in foreground; delegate suppresses if chat open

* fix(geohash): include own past geohash messages on channel load; skip only very recent self-echo (<15s) to avoid duplicates

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 14:40:06 +02:00
75c8933091 Feat/georelay (#504)
* feat(georelay): route geohash kind 20000 via nearest relays; add GeoRelayDirectory; target geohash subscriptions; avoid duplicate connections

* feat(georelay): fetch daily from remote CSV with fallback to bundled; cache to app support; prefetch on app load

* fix(georelay): make CSV parser nonisolated and call as Self.parseCSV to satisfy Swift 6 actor isolation

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 12:49:52 +02:00
d2bfbbcfd4 Fix/chat perf (#502)
* perf(chat): batch public inserts, sort batch by ts; add per-sender + per-content token buckets; content-based near-dup suppression; reuse compiled regexes; single token scans per row; disable list animations during batches

* perf(chat): conditional animations via isBatchingPublic; late-arrival binary insert; flush public buffer on channel switch; prewarm formatting on flush

* perf(chat): batching, spam rate-limits, near-dup LRU, adaptive flush, faster trims, regex/detector reuse, conditional animations, late-insert, current-mode prewarm, Swift 6-safe timer/closures

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 11:11:49 +02:00
21 changed files with 2093 additions and 267 deletions
+31 -1
View File
@@ -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;
}; };
+9
View File
@@ -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)
+2
View File
@@ -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>
+152
View File
@@ -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
}
+87 -112
View File
@@ -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) }
}
}
+25 -4
View File
@@ -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)",
+116
View File
@@ -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)
}
}
+5
View File
@@ -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"
} }
} }
} }
+31 -1
View File
@@ -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)
}
}
+51 -1
View File
@@ -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)
} }
+4
View File
@@ -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)
+9
View File
@@ -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])
} }
+185
View File
@@ -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)
}
}
+488 -86
View File
@@ -91,9 +91,134 @@ import UIKit
/// Acts as the primary coordinator between UI components and backend services, /// Acts as the primary coordinator between UI components and backend services,
/// implementing the BitchatDelegate protocol to handle network events. /// implementing the BitchatDelegate protocol to handle network events.
class ChatViewModel: ObservableObject, BitchatDelegate { class ChatViewModel: ObservableObject, BitchatDelegate {
// Precompiled regexes and detectors reused across formatting
private enum Regexes {
static let hashtag: NSRegularExpression = {
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
}()
static let mention: NSRegularExpression = {
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
}()
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let linkDetector: NSDataDetector? = {
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
}()
static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
}()
}
// MARK: - Spam resilience: token buckets
private struct TokenBucket {
var capacity: Double
var tokens: Double
var refillPerSec: Double
var lastRefill: Date
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
let dt = now.timeIntervalSince(lastRefill)
if dt > 0 {
tokens = min(capacity, tokens + dt * refillPerSec)
lastRefill = now
}
if tokens >= cost {
tokens -= cost
return true
}
return false
}
}
private var rateBucketsBySender: [String: TokenBucket] = [:]
private var rateBucketsByContent: [String: TokenBucket] = [:]
private let senderBucketCapacity: Double = 5
private let senderBucketRefill: Double = 1 // tokens per second
private let contentBucketCapacity: Double = 3
private let contentBucketRefill: Double = 0.5 // tokens per second
@MainActor
private func normalizedSenderKey(for message: BitchatMessage) -> String {
if let spid = message.senderPeerID {
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
let bare: String = {
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
return spid
}()
let full = (nostrKeyMapping[spid] ?? bare).lowercased()
return "nostr:" + full
} else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
return "noise:" + full
} else {
return "mesh:" + spid.lowercased()
}
}
return "name:" + message.sender.lowercased()
}
private func normalizedContentKey(_ content: String) -> String {
// Lowercase, simplify URLs (strip query/fragment), collapse whitespace, bound length
let lowered = content.lowercased()
let ns = lowered as NSString
let range = NSRange(location: 0, length: ns.length)
var simplified = ""
var last = 0
for m in Regexes.simplifyHTTPURL.matches(in: lowered, options: [], range: range) {
if m.range.location > last {
simplified += ns.substring(with: NSRange(location: last, length: m.range.location - last))
}
let url = ns.substring(with: m.range)
if let q = url.firstIndex(where: { $0 == "?" || $0 == "#" }) {
simplified += String(url[..<q])
} else {
simplified += url
}
last = m.range.location + m.range.length
}
if last < ns.length { simplified += ns.substring(with: NSRange(location: last, length: ns.length - last)) }
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
let prefix = String(collapsed.prefix(256))
// Fast djb2 hash
let h = djb2(prefix)
return String(format: "h:%016llx", h)
}
// Persistent recent content map (LRU) to speed near-duplicate checks
private var contentLRUMap: [String: Date] = [:]
private var contentLRUOrder: [String] = []
private let contentLRUCap = 2000
private func recordContentKey(_ key: String, timestamp: Date) {
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
contentLRUMap[key] = timestamp
if contentLRUOrder.count > contentLRUCap {
let overflow = contentLRUOrder.count - contentLRUCap
for _ in 0..<overflow {
if let victim = contentLRUOrder.first {
contentLRUOrder.removeFirst()
contentLRUMap.removeValue(forKey: victim)
}
}
}
}
// MARK: - Published Properties // MARK: - Published Properties
@Published var messages: [BitchatMessage] = [] @Published var messages: [BitchatMessage] = []
@Published var currentColorScheme: ColorScheme = .light
private let maxMessages = 1337 // Maximum messages before oldest are removed private let maxMessages = 1337 // Maximum messages before oldest are removed
@Published var isConnected = false @Published var isConnected = false
private var hasNotifiedNetworkAvailable = false private var hasNotifiedNetworkAvailable = false
@@ -287,6 +412,32 @@ 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)
// Buffer incoming public messages and flush in small batches to reduce UI invalidations
private var publicBuffer: [BitchatMessage] = []
private var publicBufferTimer: Timer? = nil
private let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
private var dynamicPublicFlushInterval: TimeInterval = 0.08
private var recentBatchSizes: [Int] = []
@Published private(set) var isBatchingPublic: Bool = false
private let lateInsertThreshold: TimeInterval = 15.0
// Track sent read receipts to avoid duplicates (persisted across launches) // Track sent read receipts to avoid duplicates (persisted across launches)
// Note: Persistence happens automatically in didSet, no lifecycle observers needed // Note: Persistence happens automatically in didSet, no lifecycle observers needed
private var sentReadReceipts: Set<String> = [] { // messageID set private var sentReadReceipts: Set<String> = [] { // messageID set
@@ -316,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)
@@ -410,10 +564,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] peers in .sink { [weak self] peers in
guard let self = self else { return } guard let self = self else { return }
// Update peers directly // Update peers directly; @Published drives UI updates
self.allPeers = peers self.allPeers = peers
// Force UI update
self.objectWillChange.send()
// Update peer index for O(1) lookups // Update peer index for O(1) lookups
// Deduplicate peers by ID to prevent crash from duplicate keys // Deduplicate peers by ID to prevent crash from duplicate keys
var uniquePeers: [String: BitchatPeer] = [:] var uniquePeers: [String: BitchatPeer] = [:]
@@ -578,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
@@ -699,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:
@@ -735,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 { }
@@ -1095,6 +1253,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Add to main messages immediately for user feedback // Add to main messages immediately for user feedback
messages.append(message) messages.append(message)
// Update content LRU for near-dup detection
let ckey = normalizedContentKey(message.content)
recordContentKey(ckey, timestamp: message.timestamp)
// Persist to channel-specific timelines // Persist to channel-specific timelines
#if os(iOS) #if os(iOS)
switch activeChannel { switch activeChannel {
@@ -1104,10 +1265,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
case .location(let ch): case .location(let ch):
var arr = geoTimelines[ch.geohash] ?? [] var arr = geoTimelines[ch.geohash] ?? []
arr.append(message) arr.append(message)
if arr.count > geoTimelineCap { if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
let remove = arr.count - geoTimelineCap
arr.removeFirst(remove)
}
geoTimelines[ch.geohash] = arr geoTimelines[ch.geohash] = arr
} }
#else #else
@@ -1142,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)",
@@ -1173,6 +1336,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
#if os(iOS) #if os(iOS)
@MainActor @MainActor
private func switchLocationChannel(to channel: ChannelID) { private func switchLocationChannel(to channel: ChannelID) {
// Flush pending public buffer to avoid cross-channel bleed
publicBufferTimer?.invalidate(); publicBufferTimer = nil
publicBuffer.removeAll(keepingCapacity: false)
activeChannel = channel activeChannel = channel
// Reset deduplication set and optionally hydrate timeline for mesh // Reset deduplication set and optionally hydrate timeline for mesh
processedNostrEvents.removeAll() processedNostrEvents.removeAll()
@@ -1223,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 }
@@ -1246,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 {
@@ -1378,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
@@ -1594,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
@@ -2342,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 {
@@ -2873,11 +3047,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// For extremely long content, render as plain text to avoid heavy regex/layout work, // For extremely long content, render as plain text to avoid heavy regex/layout work,
// unless the content includes Cashu tokens we want to chip-render below // unless the content includes Cashu tokens we want to chip-render below
let containsCashuEarly: Bool = { let containsCashuEarly: Bool = {
let pattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b" let rx = Regexes.quickCashuPresence
if let rx = try? NSRegularExpression(pattern: pattern, options: []) {
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0 return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0
}
return false
}() }()
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
var plainStyle = AttributeContainer() var plainStyle = AttributeContainer()
@@ -2887,33 +3058,29 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
: .system(size: 14, design: .monospaced) : .system(size: 14, design: .monospaced)
result.append(AttributedString(content).mergingAttributes(plainStyle)) result.append(AttributedString(content).mergingAttributes(plainStyle))
} else { } else {
let hashtagPattern = "#([a-zA-Z0-9_]+)" // Reuse compiled regexes and detector
// Allow optional '#abcd' suffix in mentions let hashtagRegex = Regexes.hashtag
let mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)" let mentionRegex = Regexes.mention
// Cashu token detector: cashuA/cashuB + long base64url; allow '.' and shorter variants let cashuRegex = Regexes.cashu
let cashuPattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b" let bolt11Regex = Regexes.bolt11
// Lightning invoices and links let lnurlRegex = Regexes.lnurl
let bolt11Pattern = "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b" let lightningSchemeRegex = Regexes.lightningScheme
let lnurlPattern = "(?i)\\blnurl1[a-z0-9]{20,}\\b" let detector = Regexes.linkDetector
let lightningSchemePattern = "(?i)\\blightning:[^\\s]+"
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) let nsLen = content.count
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) let hasMentionsHint = content.contains("@")
let cashuRegex = try? NSRegularExpression(pattern: cashuPattern, options: []) let hasHashtagsHint = content.contains("#")
let bolt11Regex = try? NSRegularExpression(pattern: bolt11Pattern, options: []) let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
let lnurlRegex = try? NSRegularExpression(pattern: lnurlPattern, options: []) let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
let lightningSchemeRegex = try? NSRegularExpression(pattern: lightningSchemePattern, options: []) let hasCashuHint = content.lowercased().contains("cashu")
// Use NSDataDetector for URL detection let hashtagMatches = hasHashtagsHint ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) let mentionMatches = hasMentionsHint ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let urlMatches = hasURLHint ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : []
let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] let cashuMatches = hasCashuHint ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] let lightningMatches = hasLightningHint ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let urlMatches = detector?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] let bolt11Matches = hasLightningHint ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let cashuMatches = cashuRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] let lnurlMatches = hasLightningHint ? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let lightningMatches = lightningSchemeRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let bolt11Matches = bolt11Regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let lnurlMatches = lnurlRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
// Combine and sort matches, excluding hashtags/URLs overlapping mentions // Combine and sort matches, excluding hashtags/URLs overlapping mentions
let mentionRanges = mentionMatches.map { $0.range(at: 0) } let mentionRanges = mentionMatches.map { $0.range(at: 0) }
@@ -3343,8 +3510,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private func trimMessagesIfNeeded() { private func trimMessagesIfNeeded() {
if messages.count > maxMessages { if messages.count > maxMessages {
let removeCount = messages.count - maxMessages messages = Array(messages.suffix(maxMessages))
messages.removeFirst(removeCount)
} }
} }
@@ -3413,8 +3579,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private func trimMeshTimelineIfNeeded() { private func trimMeshTimelineIfNeeded() {
if meshTimeline.count > meshTimelineCap { if meshTimeline.count > meshTimelineCap {
let removeCount = meshTimeline.count - meshTimelineCap meshTimeline = Array(meshTimeline.suffix(meshTimelineCap))
meshTimeline.removeFirst(removeCount)
} }
} }
@@ -3445,9 +3610,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private func addMessage(_ message: BitchatMessage) { private func addMessage(_ message: BitchatMessage) {
// Check for duplicates // Check for duplicates
guard !messages.contains(where: { $0.id == message.id }) else { return } guard !messages.contains(where: { $0.id == message.id }) else { return }
messages.append(message) messages.append(message)
messages.sort { $0.timestamp < $1.timestamp }
trimMessagesIfNeeded() trimMessagesIfNeeded()
} }
@@ -3586,9 +3749,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() {
@@ -3622,6 +3810,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
} }
@@ -3725,6 +3921,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)
}
}
} }
} }
} }
@@ -3750,6 +4012,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
@@ -4151,7 +4443,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)
} }
@@ -4334,10 +4631,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 {
@@ -4348,11 +4653,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 {
@@ -4390,6 +4703,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 {
@@ -4742,10 +5058,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
} }
@@ -4951,8 +5269,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
privateChats = chats privateChats = chats
trimPrivateChatMessagesIfNeeded(for: peerID) trimPrivateChatMessagesIfNeeded(for: peerID)
// Trigger UI update // UI updates via @Published reassignment above
objectWillChange.send()
// Handle fingerprint-based chat updates // Handle fingerprint-based chat updates
if let chatFingerprint = selectedPrivateChatFingerprint, if let chatFingerprint = selectedPrivateChatFingerprint,
@@ -5025,6 +5342,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system) // Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system)
let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true
// Apply per-sender and per-content rate limits (drop if exceeded)
if finalMessage.sender != "system" {
let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = normalizedContentKey(finalMessage.content)
let now = Date()
var sBucket = rateBucketsBySender[senderKey] ?? TokenBucket(capacity: senderBucketCapacity, tokens: senderBucketCapacity, refillPerSec: senderBucketRefill, lastRefill: now)
let senderAllowed = sBucket.allow(now: now)
rateBucketsBySender[senderKey] = sBucket
var cBucket = rateBucketsByContent[contentKey] ?? TokenBucket(capacity: contentBucketCapacity, tokens: contentBucketCapacity, refillPerSec: contentBucketRefill, lastRefill: now)
let contentAllowed = cBucket.allow(now: now)
rateBucketsByContent[contentKey] = cBucket
if !(senderAllowed && contentAllowed) { return }
}
// Size cap: drop extremely large public messages early
if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return }
// Persist mesh messages to mesh timeline always // Persist mesh messages to mesh timeline always
if !isGeo && finalMessage.sender != "system" { if !isGeo && finalMessage.sender != "system" {
meshTimeline.append(finalMessage) meshTimeline.append(finalMessage)
@@ -5037,10 +5371,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let gh = currentGeohash { if let gh = currentGeohash {
var arr = geoTimelines[gh] ?? [] var arr = geoTimelines[gh] ?? []
arr.append(finalMessage) arr.append(finalMessage)
if arr.count > geoTimelineCap { if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
let remove = arr.count - geoTimelineCap
arr.removeFirst(remove)
}
geoTimelines[gh] = arr geoTimelines[gh] = arr
} }
} }
@@ -5086,32 +5417,103 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
#endif #endif
// Check if this is our own message being echoed back (avoid dup) // Append via batching buffer (skip empty content)
if finalMessage.sender != nickname && finalMessage.sender != "system" {
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
addMessage(finalMessage) enqueuePublic(finalMessage)
} }
return
} }
if finalMessage.sender != "system" { // MARK: - Public message batching helpers
// Check for duplicates @MainActor
let messageExists = messages.contains { existingMsg in private func enqueuePublic(_ message: BitchatMessage) {
if existingMsg.id == finalMessage.id { return true } publicBuffer.append(message)
if existingMsg.content == finalMessage.content && existingMsg.sender == finalMessage.sender { schedulePublicFlush()
let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp))
return timeDiff < 1.0
} }
return false
@MainActor
private func schedulePublicFlush() {
if publicBufferTimer != nil { return }
publicBufferTimer = Timer.scheduledTimer(timeInterval: dynamicPublicFlushInterval,
target: self,
selector: #selector(onPublicBufferTimerFired(_:)),
userInfo: nil,
repeats: false)
} }
if !messageExists && !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
addMessage(finalMessage) @MainActor
private func flushPublicBuffer() {
publicBufferTimer?.invalidate()
publicBufferTimer = nil
guard !publicBuffer.isEmpty else { return }
// Dedup against existing by id and near-duplicate messages by content (within ~1s), across senders
var seenIDs = Set(messages.map { $0.id })
var added: [BitchatMessage] = []
var batchContentLatest: [String: Date] = [:]
for m in publicBuffer {
if seenIDs.contains(m.id) { continue }
let ckey = normalizedContentKey(m.content)
if let ts = contentLRUMap[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue }
if let ts = batchContentLatest[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue }
seenIDs.insert(m.id)
added.append(m)
batchContentLatest[ckey] = m.timestamp
} }
publicBuffer.removeAll(keepingCapacity: true)
guard !added.isEmpty else { return }
// Indicate batching for conditional UI animations
isBatchingPublic = true
// Rough chronological order: sort the batch by timestamp before inserting
added.sort { $0.timestamp < $1.timestamp }
// Insert late arrivals into approximate position; append recent ones
let lastTs = messages.last?.timestamp ?? .distantPast
for m in added {
if m.timestamp < lastTs.addingTimeInterval(-lateInsertThreshold) {
let idx = insertionIndexByTimestamp(m.timestamp)
if idx >= messages.count { messages.append(m) } else { messages.insert(m, at: idx) }
} else { } else {
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { messages.append(m)
addMessage(finalMessage) }
// Record content key for LRU
let ckey = normalizedContentKey(m.content)
recordContentKey(ckey, timestamp: m.timestamp)
}
trimMessagesIfNeeded()
// Update batch size stats and adjust interval
recentBatchSizes.append(added.count)
if recentBatchSizes.count > 10 { recentBatchSizes.removeFirst(recentBatchSizes.count - 10) }
let avg = recentBatchSizes.isEmpty ? 0.0 : Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
dynamicPublicFlushInterval = avg > 100.0 ? 0.12 : basePublicFlushInterval
// Prewarm formatting cache for current UI color scheme only
for m in added {
_ = self.formatMessageAsText(m, colorScheme: currentColorScheme)
}
// Reset batching flag (already on main actor)
isBatchingPublic = false
// If new items arrived during this flush, coalesce by flushing once more next tick
if !publicBuffer.isEmpty { schedulePublicFlush() }
}
// Timer selector to avoid @Sendable closure capture issues under Swift 6
@MainActor @objc
private func onPublicBufferTimerFired(_ timer: Timer) {
flushPublicBuffer()
}
@MainActor
private func insertionIndexByTimestamp(_ ts: Date) -> Int {
var low = 0
var high = messages.count
while low < high {
let mid = (low + high) / 2
if messages[mid].timestamp < ts {
low = mid + 1
} else {
high = mid
} }
} }
return low
} }
/// Check for mentions and send notifications /// Check for mentions and send notifications
+46 -10
View File
@@ -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
@@ -84,6 +85,10 @@ struct ContentView: View {
ZStack { ZStack {
// Base layer - Main public chat (always visible) // Base layer - Main public chat (always visible)
mainChatView mainChatView
.onAppear { viewModel.currentColorScheme = colorScheme }
.onChange(of: colorScheme) { newValue in
viewModel.currentColorScheme = newValue
}
// Private chat slide-over // Private chat slide-over
if viewModel.selectedPrivateChatPeer != nil { if viewModel.selectedPrivateChatPeer != nil {
@@ -287,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) }
@@ -310,9 +319,11 @@ struct ContentView: View {
} else { } else {
// Regular messages with natural text wrapping // Regular messages with natural text wrapping
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Precompute heavy token scans once per row
let cashuTokens = message.content.extractCashuTokens()
let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
// Single text view for natural wrapping let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && message.content.extractCashuTokens().isEmpty
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -328,9 +339,9 @@ struct ContentView: View {
} }
// Expand/Collapse for very long messages // Expand/Collapse for very long messages
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && message.content.extractCashuTokens().isEmpty { if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty {
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
Button(isExpanded ? "Show less" : "Show more") { Button(isExpanded ? "show less" : "show more") {
if isExpanded { expandedMessageIDs.remove(message.id) } if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) } else { expandedMessageIDs.insert(message.id) }
} }
@@ -339,11 +350,7 @@ struct ContentView: View {
.padding(.top, 4) .padding(.top, 4)
} }
// Link previews removed: URLs appear inline and are tappable
// Render payment chips (Lightning / Cashu) with rounded background // Render payment chips (Lightning / Cashu) with rounded background
let lightningLinks = message.content.extractLightningLinks()
let cashuTokens = message.content.extractCashuTokens()
if !lightningLinks.isEmpty || !cashuTokens.isEmpty { if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { i in ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { i in
@@ -394,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 {
@@ -453,6 +464,7 @@ struct ContentView: View {
.padding(.vertical, 2) .padding(.vertical, 2)
} }
} }
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.background(backgroundColor) .background(backgroundColor)
@@ -923,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)
@@ -1213,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)) {
@@ -1220,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)
+17 -2
View File
@@ -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)
+35 -3
View File
@@ -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()
+19 -11
View File
@@ -5,6 +5,21 @@
import Foundation import Foundation
private enum RegexCache {
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
}
extension String { extension String {
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout // Detect if there is an extremely long token (no whitespace/newlines) that could break layout
func hasVeryLongToken(threshold: Int) -> Bool { func hasVeryLongToken(threshold: Int) -> Bool {
@@ -23,8 +38,7 @@ extension String {
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
func extractCashuTokens(max: Int = 3) -> [String] { func extractCashuTokens(max: Int = 3) -> [String] {
let pattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b" let regex = RegexCache.cashu
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
let ns = self as NSString let ns = self as NSString
let range = NSRange(location: 0, length: ns.length) let range = NSRange(location: 0, length: ns.length)
var found: [String] = [] var found: [String] = []
@@ -44,29 +58,23 @@ extension String {
let ns = self as NSString let ns = self as NSString
let full = NSRange(location: 0, length: ns.length) let full = NSRange(location: 0, length: ns.length)
// lightning: scheme // lightning: scheme
if let schemeRx = try? NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: []) { for m in RegexCache.lightningScheme.matches(in: self, options: [], range: full) {
for m in schemeRx.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0)) let s = ns.substring(with: m.range(at: 0))
results.append(s) results.append(s)
if results.count >= max { return results } if results.count >= max { return results }
} }
}
// BOLT11 // BOLT11
if let boltRx = try? NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: []) { for m in RegexCache.bolt11.matches(in: self, options: [], range: full) {
for m in boltRx.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0)) let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)") results.append("lightning:\(s)")
if results.count >= max { return results } if results.count >= max { return results }
} }
}
// LNURL bech32 // LNURL bech32
if let lnurlRx = try? NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: []) { for m in RegexCache.lnurl.matches(in: self, options: [], range: full) {
for m in lnurlRx.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0)) let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)") results.append("lightning:\(s)")
if results.count >= max { return results } if results.count >= max { return results }
} }
}
return results return results
} }
} }
+347
View File
@@ -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 }
}
}
+105
View File
@@ -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)
}
} }
+293
View File
@@ -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
1 Relay URL Latitude Longitude
2 relay.damus.io 37.7621 -122.3971
3 nostr-pub.wellorder.net 45.5229 -122.9898
4 nostr.mom 50.4779 12.3713
5 nostr.slothy.win 37.7621 -122.3971
6 nostr.einundzwanzig.space 50.1155 8.6842
7 nos.lol 50.4779 12.3713
8 relay.nostr.band 60.1695 24.9354
9 no.str.cr 9.9339 -84.0849
10 nostr.massmux.com 50.1155 8.6842
11 nostr-relay.schnitzel.world 39.0437 -77.4875
12 relay.nostr.com.au 37.7621 -122.3971
13 knostr.neutrine.com 48.8534 2.3488
14 nostr.nodeofsven.com 47.4875 8.2965
15 nostr.vulpem.com 49.4542 11.0775
16 nostr-verif.slothy.win 37.7621 -122.3971
17 relay.lexingtonbitcoin.org 37.7621 -122.3971
18 nostr-1.nbo.angani.co -1.2615 36.7903
19 relay.wellorder.net 45.5229 -122.9898
20 nostr.easydns.ca 43.7064 -79.3986
21 relay.dwadziesciajeden.pl 52.2298 21.0118
22 nostr.data.haus 50.4779 12.3713
23 nostr.einundzwanzig.space 50.1155 8.6842
24 nostr.mom 50.4779 12.3713
25 nos.lol 50.4779 12.3713
26 relay.nostr.band 60.1695 24.9354
27 nostr.massmux.com 50.1155 8.6842
28 nostr.vulpem.com 49.4542 11.0775
29 relay.damus.io 37.7621 -122.3971
30 nostr-pub.wellorder.net 45.5229 -122.9898
31 no.str.cr 9.9339 -84.0849
32 relay.dwadziesciajeden.pl 52.2298 21.0118
33 nostr.data.haus 50.4779 12.3713
34 relay.wellorder.net 45.5229 -122.9898
35 relay.nostromo.social 49.4542 11.0775
36 offchain.pub 34.0522 -118.2437
37 relay.nostr.wirednet.jp 35.9356 139.3044
38 relay.nostrcheck.me 37.7621 -122.3971
39 nostrue.com 40.8043 -74.0121
40 nostr-relay.schnitzel.world 39.0437 -77.4875
41 nproxy.kristapsk.lv 60.1695 24.9354
42 nostr.spaceshell.xyz 37.7621 -122.3971
43 nostr-dev.wellorder.net 45.5229 -122.9898
44 nostr-verified.wellorder.net 45.5229 -122.9898
45 nostr.roundrockbitcoiners.com 40.8043 -74.0121
46 slick.mjex.me 39.0437 -77.4875
47 nostr.yael.at 52.3740 4.8897
48 relay.primal.net 37.7621 -122.3971
49 nostr.oxtr.dev 50.4779 12.3713
50 nostr.21crypto.ch 46.5160 6.6328
51 nostr.liberty.fans 38.8003 -90.6265
52 nostr-02.dorafactory.org 1.2897 103.8501
53 relay.hodl.ar -32.9468 -60.6393
54 nostr.middling.mydns.jp 35.8089 140.1185
55 nostr.namek.link 37.7621 -122.3971
56 nostrja-kari.heguro.com 37.7621 -122.3971
57 nostr.hifish.org 47.3667 8.5500
58 nostr.rikmeijer.nl 50.4779 12.3713
59 black.nostrcity.club 41.8119 -87.6873
60 nostr.hekster.org 37.3924 -121.9623
61 relay.wavlake.com 41.2619 -95.8608
62 nostr.sagaciousd.com 49.2497 -123.1193
63 nostr.fbxl.net 43.7064 -79.3986
64 ithurtswhenip.ee 50.7990 -1.0913
65 relay2.nostrchat.io 49.4542 11.0775
66 relay1.nostrchat.io 60.1695 24.9354
67 nostr-01.yakihonne.com 1.3215 103.6957
68 nostr.sathoarder.com 48.5839 7.7455
69 nostr.overmind.lol 37.7621 -122.3971
70 relay.verified-nostr.com 37.7621 -122.3971
71 purplerelay.com 50.1155 8.6842
72 relay.orangepill.ovh 49.0127 1.9694
73 nostr-relay.psfoundation.info 39.0437 -77.4875
74 soloco.nl 37.7621 -122.3971
75 relay.froth.zone 60.1695 24.9354
76 nostr.stakey.net 52.5250 5.7181
77 nostr.2b9t.xyz 34.0522 -118.2437
78 pyramid.fiatjaf.com 50.1155 8.6842
79 a.nos.lol 50.4779 12.3713
80 relay.magiccity.live 25.8130 -80.2320
81 nostr.notribe.net 40.8344 -74.1377
82 freelay.sovbit.host 64.1355 -21.8954
83 relay.credenso.cafe 43.4254 -80.5112
84 nostr.huszonegy.world 47.4984 19.0404
85 multiplexer.huszonegy.world 47.4984 19.0404
86 bucket.coracle.social 37.7621 -122.3971
87 nostr.kungfu-g.rip 33.7865 -84.4454
88 relay.artx.market 43.7064 -79.3986
89 relay.notoshi.win 13.3622 100.9835
90 vitor.nostr1.com 40.7143 -74.0060
91 nostr-02.yakihonne.com 1.3215 103.6957
92 nostr-03.dorafactory.org 1.2897 103.8501
93 n.ok0.org -36.8485 174.7635
94 nostr.0x7e.xyz 47.5056 8.7241
95 relay.nostr.net 50.4779 12.3713
96 strfry.openhoofd.nl 51.5717 3.7042
97 relay.fountain.fm 39.0997 -94.5786
98 relay.usefusion.ai 38.7135 -78.1594
99 relay.varke.eu 52.6958 6.1944
100 nostr.satstralia.com 64.1355 -21.8954
101 relay.13room.space 37.7621 -122.3971
102 nostr.myshosholoza.co.za 52.3710 4.9042
103 nostr.carroarmato0.be 50.8517 3.6089
104 nostr.dbtc.link 37.7621 -122.3971
105 orangepiller.org 60.1695 24.9354
106 adre.su 59.9386 30.3141
107 relay.sincensura.org 37.7621 -122.3971
108 relay.freeplace.nl 52.3740 4.8897
109 bostr.bitcointxoko.com 64.1355 -21.8954
110 nostr.plantroon.com 50.1025 8.6299
111 srtrelay.c-stellar.net 37.7621 -122.3971
112 nostr.jfischer.org 49.4453 11.0222
113 nostr.novacisko.cz 52.2298 21.0118
114 relay.lumina.rocks 49.4453 11.0222
115 nostr.tavux.tech 50.9519 1.8563
116 relay.nostrhub.fr 50.1155 8.6842
117 relay.agorist.space 52.3740 4.8897
118 chorus.pjv.me 45.5229 -122.9898
119 relay.cosmicbolt.net 37.3924 -121.9623
120 santo.iguanatech.net 40.8344 -74.1377
121 relay.tagayasu.xyz 45.4112 -75.6981
122 relay.mostro.network 40.8344 -74.1377
123 relay.zone667.com 60.1695 24.9354
124 relay5.bitransfer.org 37.7621 -122.3971
125 relay.illuminodes.com 47.6062 -122.3321
126 relay2.angor.io 50.1155 8.6842
127 relay.satsdays.com 1.2897 103.8501
128 relay.angor.io 50.1155 8.6842
129 orangesync.tech 50.9333 6.9500
130 nostr-relay.cbrx.io 37.7621 -122.3971
131 relay.21e6.cz 50.0880 14.4208
132 nostr.chaima.info 50.1155 8.6842
133 relay.satlantis.io 32.8546 -79.9748
134 relay.digitalezukunft.cyou 45.5088 -73.5878
135 relay.tapestry.ninja 40.8043 -74.0121
136 relay.minibolt.info 37.7621 -122.3971
137 nostr.bilthon.dev 25.8130 -80.2320
138 nostr.makibisskey.work 37.7621 -122.3971
139 relay.mattybs.lol 37.7621 -122.3971
140 noxir.kpherox.dev 34.8436 135.5084
141 sendit.nosflare.com 37.7621 -122.3971
142 relay.coinos.io 37.7621 -122.3971
143 relay.nostraddress.com 37.7621 -122.3971
144 wot.nostr.party 36.1659 -86.7844
145 nostrelites.org 41.8500 -87.6500
146 relay.nostriot.com 43.7064 -79.3986
147 prl.plus 55.7522 37.6156
148 zap.watch 45.5088 -73.5878
149 wot.codingarena.top 50.4779 12.3713
150 nostr.azzamo.net 52.2284 21.0522
151 wot.sudocarlos.com 43.7064 -79.3986
152 relay.lnfi.network 45.6241 8.7851
153 wot.nostr.net 37.7621 -122.3971
154 relay.nostrdice.com -33.8678 151.2073
155 wot.sebastix.social 51.3700 6.1681
156 wheat.happytavern.co 37.7621 -122.3971
157 relay.sigit.io 50.4779 12.3713
158 strfry.bonsai.com 37.8716 -122.2728
159 travis-shears-nostr-relay-v2.fly.dev 41.8119 -87.6873
160 satsage.xyz 37.3924 -121.9623
161 relay.degmods.com 50.4779 12.3713
162 nostr.community.ath.cx 45.5088 -73.5878
163 nostr.coincrowd.fund 39.0437 -77.4875
164 strfry.shock.network 41.8847 -88.2040
165 cyberspace.nostr1.com 40.7143 -74.0060
166 relay02.lnfi.network 39.0997 -94.5786
167 nostr-rs-relay.dev.fedibtc.com 39.0437 -77.4875
168 relay.davidebtc.me 50.1155 8.6842
169 wot.dtonon.com 37.7621 -122.3971
170 relay.goodmorningbitcoin.com 37.7621 -122.3971
171 articles.layer3.news 37.3394 -121.8950
172 bostr.syobon.net 37.7621 -122.3971
173 nostr.agentcampfire.com 52.3740 4.8897
174 nostr.thebiglake.org 32.7244 -96.6755
175 schnorr.me 37.7621 -122.3971
176 relay.wolfcoil.com 35.6090 139.7302
177 nostr.camalolo.com 24.1469 120.6839
178 nostr.tac.lol 47.4740 -122.2610
179 dev-relay.lnfi.network 39.0997 -94.5786
180 relay.bitcoinveneto.org 64.1355 -21.8954
181 nostr.red5d.dev 37.7621 -122.3971
182 relay-testnet.k8s.layer3.news 37.3394 -121.8950
183 promenade.fiatjaf.com 50.1155 8.6842
184 nostrelay.memory-art.xyz 37.7621 -122.3971
185 inbox.azzamo.net 52.2284 21.0522
186 social.proxymana.net 60.1695 24.9354
187 relay.netstr.io 53.3331 -6.2489
188 premium.primal.net 37.7621 -122.3971
189 nostr.lojong.info 37.7621 -122.3971
190 nostr-rs-relay-ishosta.phamthanh.me 37.7621 -122.3971
191 relay.stream.labs.h3.se 59.3294 18.0687
192 tollbooth.stens.dev 51.4566 7.0123
193 relay.chakany.systems 37.7621 -122.3971
194 relay.mwaters.net 50.9519 1.8563
195 nostr-relay.shirogaku.xyz 37.7621 -122.3971
196 kitchen.zap.cooking 37.7621 -122.3971
197 relay.arx-ccn.com 50.4779 12.3713
198 relay.fr13nd5.com 50.1155 8.6842
199 nostr.tegila.com.br 39.0437 -77.4875
200 relay.jeffg.fyi 43.7064 -79.3986
201 relay.bullishbounty.com 37.7621 -122.3971
202 nostr.spicyz.io 37.7621 -122.3971
203 relay04.lnfi.network 39.0997 -94.5786
204 vidono.apps.slidestr.net 48.8534 2.3488
205 relay03.lnfi.network 39.0997 -94.5786
206 communities.nos.social 40.8344 -74.1377
207 relay.evanverma.com 40.8344 -74.1377
208 nostrelay.circum.space 51.4566 7.0123
209 wot.brightbolt.net 47.6928 -116.7850
210 relayrs.notoshi.win 37.7621 -122.3971
211 fenrir-s.notoshi.win 37.7621 -122.3971
212 relay.nsnip.io 60.1695 24.9354
213 x.kojira.io 37.7621 -122.3971
214 relay.hasenpfeffr.com 39.0437 -77.4875
215 relay01.lnfi.network 39.0997 -94.5786
216 nostr.rtvslawenia.com 49.4542 11.0775
217 relay.g1sms.fr 43.9298 2.1480
218 nostr.kalf.org 52.3740 4.8897
219 nostr.rblb.it 37.7621 -122.3971
220 nostr.4rs.nl 49.4453 11.0222
221 relay.vrtmrz.net 37.7621 -122.3971
222 nostr.hoppe-relay.it.com 45.5946 -121.1787
223 relay-rpi.edufeed.org 49.4453 11.0222
224 relay.copylaradio.com 50.7990 -1.0913
225 relay.ru.ac.th 13.7540 100.5014
226 relay.bitcoinartclock.com 50.4779 12.3713
227 wot.downisontheup.ca 47.6062 -122.3321
228 nostr.coincards.com 43.7064 -79.3986
229 relay.etch.social 41.2619 -95.8608
230 relay.mess.ch 47.1345 9.0964
231 relay.holzeis.me 37.7621 -122.3971
232 relay-admin.thaliyal.com 40.8220 -74.4488
233 nostr.thaliyal.com 40.8220 -74.4488
234 strfry.felixzieger.de 50.1025 8.6299
235 nostr.smut.cloud 37.7621 -122.3971
236 r.bitcoinhold.net 37.7621 -122.3971
237 nostr.blankfors.se 60.1695 24.9354
238 portal-relay.pareto.space 49.4453 11.0222
239 relay.getsafebox.app 43.7064 -79.3986
240 relay.anzenkodo.workers.dev 37.7621 -122.3971
241 relay.nostrhub.tech 49.4453 11.0222
242 nostr.prl.plus 52.3740 4.8897
243 nostr-2.21crypto.ch 46.5160 6.6328
244 nostr.zenon.network 40.7143 -74.0060
245 nostr-relay.amethyst.name 35.7721 -78.6386
246 relayone.geektank.ai 17.1210 -61.8433
247 fanfares.nostr1.com 40.7143 -74.0060
248 wot.geektank.ai 17.1210 -61.8433
249 relay-dev.satlantis.io 40.8344 -74.1377
250 relay.siamdev.cc 13.9178 100.4240
251 relay.nosto.re 51.3700 6.1681
252 wot.soundhsa.com 39.0997 -94.5786
253 nostr.n7ekb.net 47.5707 -122.2221
254 relayone.soundhsa.com 39.0997 -94.5786
255 relay.puresignal.news 37.7621 -122.3971
256 relay.nostx.io 37.7621 -122.3971
257 nostr.now 35.6090 139.7302
258 relay.artiostr.ch 37.7621 -122.3971
259 relay.oldenburg.cool 50.1155 8.6842
260 theoutpost.life 64.1355 -21.8954
261 khatru.nostrver.se 51.3700 6.1681
262 relay.wavefunc.live 37.7915 -122.4018
263 nostr-relay.zimage.com 34.0522 -118.2437
264 relay.javi.space 43.4628 11.8807
265 bostr.shop 42.8865 -78.8784
266 relay.letsfo.com 52.2298 21.0118
267 alien.macneilmediagroup.com 37.7621 -122.3971
268 rn1.sotiras.org 37.7621 -122.3971
269 gnostr.com 42.6975 23.3241
270 relay.conduit.market 37.7621 -122.3971
271 relay.hivetalk.org 37.3924 -121.9623
272 nostr.l484.com 30.2960 -97.6396
273 relay.chorus.community 50.1155 8.6842
274 nostr-relay.moe.gift 37.7621 -122.3971
275 relay.nostrcal.com 37.7621 -122.3971
276 temp.iris.to 37.7621 -122.3971
277 librerelay.aaroniumii.com 37.7621 -122.3971
278 nostr-relay-1.trustlessenterprise.com 37.7621 -122.3971
279 relay.barine.co 37.7621 -122.3971
280 nostr.rohoss.com 48.1374 11.5755
281 wot.nostr.place 30.2672 -97.7431
282 relay.utxo.farm 34.7331 135.8183
283 relay.bankless.at 37.7621 -122.3971
284 relay.toastr.net 40.8043 -74.0121
285 nostr.excentered.com 52.5244 13.4105
286 relay.mccormick.cx 52.3740 4.8897
287 relay.cypherflow.ai 48.8534 2.3488
288 relay.laantungir.net 45.3134 -73.8725
289 nostr.veladan.dev 37.7621 -122.3971
290 nostr.tadryanom.me 37.7621 -122.3971
291 nostr-relay.online 37.7621 -122.3971
292 nostr.night7.space 50.4779 12.3713
293 dev-nostr.bityacht.io 25.0531 121.5264