Compare commits

..
20 changed files with 168 additions and 1774 deletions
+1 -31
View File
@@ -126,10 +126,6 @@
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.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 */; };
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; };
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; };
@@ -140,12 +136,6 @@
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.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 */
/* Begin PBXContainerItemProxy section */
@@ -253,8 +243,6 @@
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>"; };
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>"; };
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>"; };
@@ -264,9 +252,6 @@
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>"; };
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 */
/* Begin PBXFrameworksBuildPhase section */
@@ -317,7 +302,6 @@
isa = PBXGroup;
children = (
2F82C5FC8433F4064F079D1F /* bitchat */,
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */,
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
9F37F9F2C353B58AC809E93B /* Products */,
@@ -337,8 +321,8 @@
isa = PBXGroup;
children = (
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */,
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */,
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */,
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */,
EA706D8E5097785414646A8E /* Info.plist */,
95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */,
@@ -437,7 +421,6 @@
A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup;
children = (
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */,
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
@@ -511,7 +494,6 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup;
children = (
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
049BD3B02E51F319001A566B /* MessageRouter.swift */,
@@ -533,12 +515,10 @@
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
isa = PBXGroup;
children = (
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */,
E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */,
);
path = Nostr;
sourceTree = "<group>";
@@ -703,7 +683,6 @@
buildActionMask = 2147483647;
files = (
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -713,7 +692,6 @@
files = (
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */,
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */,
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -732,9 +710,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
@@ -781,7 +756,6 @@
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -789,9 +763,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
@@ -838,7 +809,6 @@
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
-9
View File
@@ -21,8 +21,6 @@ struct BitchatApp: App {
init() {
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded()
}
var body: some Scene {
@@ -31,13 +29,6 @@ struct BitchatApp: App {
.environmentObject(chatViewModel)
.onAppear {
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)
appDelegate.chatViewModel = chatViewModel
#elseif os(macOS)
-2
View File
@@ -37,8 +37,6 @@
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<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>
<array>
<string>bluetooth-central</string>
-152
View File
@@ -1,152 +0,0 @@
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
}
+113 -88
View File
@@ -1,7 +1,6 @@
import Foundation
import CryptoKit
import P256K
import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target
@@ -13,9 +12,8 @@ struct NostrProtocol {
enum EventKind: Int {
case metadata = 0
case textNote = 1
case dm = 14 // NIP-17 DM rumor kind
case seal = 13 // NIP-17 sealed event
case giftWrap = 1059 // NIP-59 gift wrap
case giftWrap = 1059 // NIP-17 gift wrap
case ephemeralEvent = 20000
}
@@ -32,7 +30,7 @@ struct NostrProtocol {
let rumor = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm, // NIP-17: DM rumor kind 14
kind: .textNote,
tags: [],
content: content
)
@@ -229,7 +227,7 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict)
}
// MARK: - Encryption (NIP-44 v2)
// MARK: - Encryption (NIP-44 style)
private static func encrypt(
plaintext: String,
@@ -241,31 +239,33 @@ struct NostrProtocol {
throw NostrError.invalidPublicKey
}
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Encrypting message
// Derive shared secret
let sharedSecret = try deriveSharedSecret(
privateKey: senderKey,
publicKey: recipientPubkeyData
)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
// Derived shared secret
let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
// Generate nonce
let nonce = AES.GCM.Nonce()
// v2: base64url(nonce24 || ciphertext || tag)
var combined = Data()
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + base64URLEncode(combined)
// Encrypt
let sealed = try AES.GCM.seal(
plaintext.data(using: .utf8)!,
using: SymmetricKey(data: sharedSecret),
nonce: nonce
)
// 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(
@@ -273,45 +273,86 @@ struct NostrProtocol {
senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> String {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard let data = base64URLDecode(encoded),
data.count > (24 + 16),
// Decrypting message
guard let data = Data(base64Encoded: ciphertext),
let senderPubkeyData = Data(hexString: senderPubkey) else {
SecureLogger.log("❌ Invalid ciphertext or sender pubkey format",
category: SecureLogger.session, level: .error)
throw NostrError.invalidCiphertext
}
let nonce24 = data.prefix(24)
let rest = data.dropFirst(24)
let tag = rest.suffix(16)
let ct = rest.dropLast(16)
// Ciphertext data parsed
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
let key = try deriveNIP44V2Key(from: ss)
return try XChaCha20Poly1305Compat.open(
ciphertext: Data(ct),
tag: Data(tag),
key: key,
nonce24: Data(nonce24)
// Extract components
let nonceData = data.prefix(12)
let ciphertextData = data.dropFirst(12).dropLast(16)
let tagData = data.suffix(16)
// Components parsed
// Derive shared secret - try with default Y coordinate first
var sharedSecret: Data
var decrypted: Data? = nil
do {
sharedSecret = try deriveSharedSecret(
privateKey: recipientKey,
publicKey: senderPubkeyData
)
// Derived shared secret with first Y coordinate
// Try to decrypt
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 {
// Trying alternate Y coordinate
// Force deriveSharedSecret to use odd Y by manipulating the data
var altPubkey = Data()
altPubkey.append(0x03) // Force odd Y
altPubkey.append(senderPubkeyData)
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 {
throw error
}
}
} catch {
SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)",
category: SecureLogger.session, level: .error)
throw error
}
// If 32 bytes (x-only) try both parities, otherwise single try
if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? ""
}
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else {
let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
guard let finalDecrypted = decrypted else {
throw NostrError.encryptionFailed
}
return String(data: finalDecrypted, encoding: .utf8) ?? ""
}
private static func deriveSharedSecret(
@@ -371,8 +412,17 @@ struct NostrProtocol {
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData
// Derive key using HKDF for NIP-44 v2
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
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
@@ -402,8 +452,15 @@ struct NostrProtocol {
// Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData
// Derive key using HKDF for NIP-44 v2
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: "nip44-v2".data(using: .utf8)!,
info: Data(),
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
}
private static func randomizedTimestamp() -> Date {
@@ -480,10 +537,7 @@ struct NostrEvent: Codable {
// Sign with Schnorr
var messageBytes = [UInt8](eventIdHash)
var auxRand = [UInt8](repeating: 0, count: 32)
_ = auxRand.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
@@ -527,32 +581,3 @@ enum NostrError: Error {
case signingFailed
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) }
}
}
+4 -25
View File
@@ -78,23 +78,9 @@ class NostrRelayManager: ObservableObject {
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)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
let targetRelays = relayUrls ?? Self.defaultRelays
ensureConnections(to: targetRelays)
let targetRelays = relayUrls ?? relays.map { $0.url }
// Add to queue for reliability
messageQueueLock.lock()
@@ -109,11 +95,10 @@ class NostrRelayManager: ObservableObject {
}
}
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
/// Subscribe to events matching a filter
func subscribe(
filter: NostrFilter,
id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void
) {
messageHandlers[id] = handler
@@ -135,14 +120,8 @@ class NostrRelayManager: ObservableObject {
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
// category: SecureLogger.session, level: .debug)
// Target specific relays if provided; else all 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 {
// Send subscription to all connected relays
for (relayUrl, connection) in connections {
connection.send(.string(messageString)) { error in
if let error = error {
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
-116
View File
@@ -1,116 +0,0 @@
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,17 +155,12 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
var description: String {
switch self {
case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt"
case .delivered: return "delivered"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
}
}
}
+1 -31
View File
@@ -4,11 +4,6 @@ import Foundation
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
enum Geohash {
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.
/// - Parameters:
@@ -61,30 +56,5 @@ enum 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)
}
}
+1 -51
View File
@@ -509,50 +509,10 @@ final class BLEService: NSObject {
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? {
return collectionsQueue.sync {
if let publicKey = peers[peerID]?.noisePublicKey {
// 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 publicKey.hexEncodedString()
}
return nil
}
@@ -1423,16 +1383,6 @@ final class BLEService: NSObject {
notifyUI { [weak self] in
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:
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
}
-4
View File
@@ -165,12 +165,8 @@ class CommandProcessor {
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
var geoNames: [String] = []
if let vm = chatViewModel {
#if os(iOS)
let visible = vm.visibleGeohashPeople()
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
#else
let visibleIndex: [String: String] = [:]
#endif
for pk in geoBlocked {
if let name = visibleIndex[pk.lowercased()] {
geoNames.append(name)
-9
View File
@@ -46,20 +46,11 @@ protocol Transport: AnyObject {
func sendBroadcastAnnounce()
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)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
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 {
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
}
-185
View File
@@ -1,185 +0,0 @@
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)
}
}
+34 -227
View File
@@ -412,22 +412,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Delivery tracking
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] = []
@@ -467,9 +451,6 @@ 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
private var processedNostrAcks: Set<String> = [] // "messageId:ackType:senderPubkey" format
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
@@ -730,19 +711,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Unsubscribe + resubscribe
NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
if self.processedNostrEvents.contains(event.id) { return }
self.processedNostrEvents.insert(event.id)
if let gh = self.currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
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 }
}
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1]
self.geoNicknames[event.pubkey.lowercased()] = nick
@@ -856,14 +832,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.sentReadReceipts.insert(messageId)
}
} else {
// Notify for truly unread and recent messages when not viewing
if shouldMarkUnread {
// Optionally notify if app backgrounded and message is truly unread
#if os(iOS)
if shouldMarkUnread && UIApplication.shared.applicationState != .active {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
}
#endif
}
self.objectWillChange.send()
case .delivered:
@@ -890,9 +868,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .warning)
}
}
case .verifyChallenge, .verifyResponse:
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
break
}
}
} catch { }
@@ -1300,12 +1275,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
)
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)
}
NostrRelayManager.shared.sendEvent(event)
// Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
@@ -1389,8 +1359,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
geoSubscriptionID = subID
startGeoParticipantsTimer()
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
guard let self = self else { return }
// Only handle ephemeral kind 20000 with matching tag
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
@@ -1413,12 +1382,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .info)
}
}
// Skip only very recent self-echo from relay; include older self events for hydration
// Skip our own events (we already locally echoed)
if let gh = self.currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 { return }
return
}
// Cache nickname from tag if present
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
@@ -1546,14 +1514,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else {
// pared back: omit defer READ log
}
// Notify for truly unread and recent messages when not viewing
// Notify only when app is backgrounded and not viewing, and only if not already read
#if os(iOS)
if !isViewing && shouldMarkUnread {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
if UIApplication.shared.applicationState != .active {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
// pared back: omit notification log
}
}
#endif
self.objectWillChange.send()
default:
// Handle delivered/read receipts for our sent messages
@@ -1757,8 +1730,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let subID = "geo-sample-\(gh)"
geoSamplingSubs[subID] = gh
let filter = NostrFilter.geohashEphemeral(gh, since: Date().addingTimeInterval(-300), limit: 100)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Update participants for this specific geohash
@@ -2506,17 +2478,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
)
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)
}
NostrRelayManager.shared.sendEvent(event)
// Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
} catch {
@@ -3749,34 +3711,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
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() {
// Load verified fingerprints directly from secure storage
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() {
@@ -3810,14 +3747,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
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
// UI will update automatically
}
@@ -3921,72 +3850,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
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)
}
}
}
}
}
@@ -4012,36 +3875,6 @@ 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
// MARK: - Peer Connection Events
@@ -4443,12 +4276,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
if targetRelays.isEmpty {
NostrRelayManager.shared.sendEvent(event)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
NostrRelayManager.shared.sendEvent(event)
} catch {
SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error)
}
@@ -4631,18 +4459,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
privateChatManager.sanitizeChat(for: ephemeralPeerID)
}
// Send delivery ack via Nostr embedded
if !wasReadBefore {
if let key = actualSenderNoiseKey {
SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
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)
}
// Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key
if !wasReadBefore, let key = actualSenderNoiseKey {
SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
}
if wasReadBefore {
@@ -4653,19 +4473,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
unreadPrivateMessages.remove(ephemeralPeerID)
}
if !sentReadReceipts.contains(messageId) {
if let key = actualSenderNoiseKey {
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)
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
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)
}
if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey {
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)
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
sentReadReceipts.insert(messageId)
}
} else {
if shouldMarkAsUnread {
@@ -4703,9 +4515,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
privateChats[targetPeerID]?[idx].deliveryStatus = .read(by: peerName, at: Date())
objectWillChange.send()
}
case .verifyChallenge, .verifyResponse:
// Ignore verification payloads arriving via Nostr path for now
break
}
} catch {
@@ -5058,12 +4867,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Geohash Nickname Resolution (for /block in geohash)
@MainActor
func nostrPubkeyForDisplayName(_ name: String) -> String? {
// Look up current visible geohash participants for an exact displayName match (iOS only)
#if os(iOS)
// Look up current visible geohash participants for an exact displayName match
for p in visibleGeohashPeople() {
if p.displayName == name { return p.id }
}
#endif
return nil
}
+2 -35
View File
@@ -58,7 +58,6 @@ struct ContentView: View {
@State private var scrollThrottleTimer: Timer?
@State private var autocompleteDebounceTimer: Timer?
@State private var showLocationChannelsSheet = false
@State private var showVerifySheet = false
@State private var expandedMessageIDs: Set<String> = []
// Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300
@@ -292,17 +291,13 @@ struct ContentView: View {
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
#if os(iOS)
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
return "mesh"
}()
let contextKey: String = "mesh"
#endif
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
@@ -401,17 +396,13 @@ struct ContentView: View {
let step = 200
#if os(iOS)
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
return "mesh"
}()
let contextKey: String = "mesh"
#endif
let preserveID = "\(contextKey)|\(message.id)"
if let peer = privatePeer {
@@ -935,24 +926,6 @@ struct ContentView: View {
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
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
.padding(.horizontal, 12)
@@ -1243,8 +1216,6 @@ struct ContentView: View {
.accessibilityHidden(true)
}
.foregroundColor(headerCountColor)
// QR moved to the PEOPLE header in the sidebar when on mesh channel
}
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
@@ -1252,10 +1223,6 @@ struct ContentView: View {
sidebarDragOffset = 0
}
}
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(viewModel)
}
}
.frame(height: 44)
.padding(.horizontal, 12)
+2 -17
View File
@@ -32,9 +32,8 @@ struct FingerprintView: View {
Spacer()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
Button("DONE") {
dismiss()
}
.foregroundColor(textColor)
}
@@ -166,20 +165,6 @@ struct FingerprintView: View {
.cornerRadius(8)
}
.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)
+6 -38
View File
@@ -44,24 +44,9 @@ struct MeshPeerList: View {
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
let baseColor = isMe ? Color.orange : assigned
if isMe {
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)
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(baseColor)
} else {
// Fallback icon for others (dimmed)
Image(systemName: "person")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
Image(systemName: "mappin.and.ellipse").font(.system(size: 10)).foregroundColor(baseColor)
}
let displayName = isMe ? viewModel.nickname : peer.nickname
@@ -85,27 +70,10 @@ struct MeshPeerList: View {
.help("Blocked")
}
if !isMe {
if peer.isConnected {
if let icon = item.enc.icon {
Image(systemName: icon)
.font(.system(size: 10))
.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)
}
}
if let icon = item.enc.icon, !isMe {
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(baseColor)
}
Spacer()
-347
View File
@@ -1,347 +0,0 @@
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,109 +112,4 @@ final class NostrProtocolTests: XCTestCase {
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
@@ -1,293 +0,0 @@
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