Compare commits

..
82 changed files with 3681 additions and 4209 deletions
-20
View File
@@ -1,20 +0,0 @@
# Prevent Github Languages stats skewing:
# Binaries and assets
**/*.xcframework/** linguist-vendored
**/*.xcassets/** linguist-vendored
# Generated files
**/*.pbxproj linguist-generated
**/*.storyboard linguist-generated
Package.resolved linguist-generated
# Downloaded CSVs
relays/online_relays_gps.csv linguist-vendored
# Docs
**/*.md linguist-documentation
# Configs
Configs/*.xcconfig linguist-documentation
**/*.plist linguist-documentation
-3
View File
@@ -75,6 +75,3 @@ TestResult.xcresult/
*.xcresult/ *.xcresult/
build.log build.log
*.log *.log
# Local configs
Local.xcconfig
-4
View File
@@ -1,4 +0,0 @@
#include "Release.xcconfig"
// Optional include of local configs
#include? "Local.xcconfig"
-5
View File
@@ -1,5 +0,0 @@
// Your Apple Developer Team ID - https://stackoverflow.com/a/18727947
DEVELOPMENT_TEAM = ABC123
// Unique bundle id to be able to register and run locally
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
-11
View File
@@ -1,11 +0,0 @@
MARKETING_VERSION = 1.4.2
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
MACOSX_DEPLOYMENT_TARGET = 13.0
SWIFT_VERSION = 5.0
DEVELOPMENT_TEAM = L3N5LHJD5Y
CODE_SIGN_STYLE = Automatic
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
+16 -4
View File
@@ -14,6 +14,7 @@ default:
# Check prerequisites # Check prerequisites
check: check:
@echo "Checking prerequisites..." @echo "Checking prerequisites..."
@command -v xcodegen >/dev/null 2>&1 || (echo "❌ XcodeGen not found. Install with: brew install xcodegen" && exit 1)
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1) @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0) @security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met" @echo "✅ All prerequisites met"
@@ -21,6 +22,8 @@ check:
# Backup original files # Backup original files
backup: backup:
@echo "Backing up original project configuration..." @echo "Backing up original project configuration..."
@cp project.yml project.yml.backup 2>/dev/null || true
@# Backup other files that get modified by xcodegen
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi @if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi @if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
@@ -41,10 +44,15 @@ patch-for-macos: backup
@# Move iOS-specific files out of the way temporarily @# Move iOS-specific files out of the way temporarily
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi @if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
# Generate Xcode project with patches
generate: patch-for-macos
@echo "Generating Xcode project..."
@xcodegen generate
# Build the macOS app # Build the macOS app
build: #check generate build: check generate
@echo "Building BitChat for macOS..." @echo "Building BitChat for macOS..."
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build @xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
# Run the macOS app # Run the macOS app
run: build run: build
@@ -67,7 +75,9 @@ clean: restore
# Quick run without cleaning (for development) # Quick run without cleaning (for development)
dev-run: check dev-run: check
@echo "Quick development build..." @echo "Quick development build..."
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build @if [ ! -f project.yml.backup ]; then just patch-for-macos; fi
@xcodegen generate
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}" @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
# Show app info # Show app info
@@ -96,9 +106,11 @@ nuke:
@echo "🧨 Nuclear clean - removing all build artifacts and backups..." @echo "🧨 Nuclear clean - removing all build artifacts and backups..."
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
@rm -rf bitchat.xcodeproj 2>/dev/null || true @rm -rf bitchat.xcodeproj 2>/dev/null || true
@rm -f project.yml.backup 2>/dev/null || true
@rm -f project-macos.yml 2>/dev/null || true
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true @rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
@rm -f bitchat/Info.plist.backup 2>/dev/null || true @rm -f bitchat/Info.plist.backup 2>/dev/null || true
@# Restore iOS-specific files if they were moved @# Restore iOS-specific files if they were moved
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi @if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore" @git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
@echo "✅ Nuclear clean complete" @echo "✅ Nuclear clean complete"
-2
View File
@@ -15,7 +15,6 @@ let package = Package(
), ),
], ],
dependencies:[ dependencies:[
.package(path: "localPackages/BitLogger"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1") .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
], ],
targets: [ targets: [
@@ -23,7 +22,6 @@ let package = Package(
name: "bitchat", name: "bitchat",
dependencies: [ dependencies: [
.product(name: "P256K", package: "swift-secp256k1"), .product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitLogger", package: "BitLogger"),
.target(name: "TorC"), .target(name: "TorC"),
.target(name: "tor-nolzma") .target(name: "tor-nolzma")
], ],
+29 -9
View File
@@ -94,25 +94,45 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
## Setup ## Setup
### Option 1: Using Xcode ### Option 1: Using XcodeGen (Recommended)
1. Install XcodeGen if you haven't already:
```bash
brew install xcodegen
```
2. Generate the Xcode project:
```bash ```bash
cd bitchat cd bitchat
xcodegen generate
```
3. Open the generated project:
```bash
open bitchat.xcodeproj open bitchat.xcodeproj
``` ```
To run on a device there're a few steps to prepare the code: ### Option 2: Using Swift Package Manager
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
- Entitlements need to be updated manually (TODO: Automate):
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
### Option 2: Using `just` 1. Open the project in Xcode:
```bash ```bash
brew install just cd bitchat
open Package.swift
``` ```
2. Select your target device and run
### Option 3: Manual Xcode Project
1. Open Xcode and create a new iOS/macOS App
2. Copy all Swift files from the `bitchat` directory into your project
3. Update Info.plist with Bluetooth permissions
4. Set deployment target to iOS 16.0 / macOS 13.0
### Option 4: just
Want to try this on macos: `just run` will set it up and run from source. Want to try this on macos: `just run` will set it up and run from source.
Run `just clean` afterwards to restore things to original state for mobile app building and development. Run `just clean` afterwards to restore things to original state for mobile app building and development.
File diff suppressed because it is too large Load Diff
@@ -1,38 +0,0 @@
{
"images" : [
{
"filename" : "image-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "image-1024 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "image-1024 2.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

+4 -11
View File
@@ -11,9 +11,6 @@ import UserNotifications
@main @main
struct BitchatApp: App { struct BitchatApp: App {
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
static let groupID = "group.\(bundleID)"
@StateObject private var chatViewModel: ChatViewModel @StateObject private var chatViewModel: ChatViewModel
#if os(iOS) #if os(iOS)
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@@ -57,8 +54,8 @@ struct BitchatApp: App {
#elseif os(macOS) #elseif os(macOS)
appDelegate.chatViewModel = chatViewModel appDelegate.chatViewModel = chatViewModel
#endif #endif
// Initialize network activation policy; will start Tor/Nostr only when allowed // Spin up Tor early; all internet will gate on Tor 100%
NetworkActivationService.shared.start() TorManager.shared.startIfNeeded()
// Check for shared content // Check for shared content
checkForSharedContent() checkForSharedContent()
} }
@@ -70,7 +67,7 @@ struct BitchatApp: App {
switch newPhase { switch newPhase {
case .background: case .background:
// Keep BLE mesh running in background; BLEService adapts scanning automatically // Keep BLE mesh running in background; BLEService adapts scanning automatically
// Always send Tor to dormant on background for a clean restart later. // Optionally nudge Tor to dormant to save power
TorManager.shared.setAppForeground(false) TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground() TorManager.shared.goDormantOnBackground()
// Stop geohash sampling while backgrounded // Stop geohash sampling while backgrounded
@@ -87,14 +84,11 @@ struct BitchatApp: App {
// On initial cold launch, Tor was just started in onAppear. // On initial cold launch, Tor was just started in onAppear.
// Skip the deterministic restart the first time we become active. // Skip the deterministic restart the first time we become active.
if didHandleInitialActive && didEnterBackground { if didHandleInitialActive && didEnterBackground {
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
TorManager.shared.ensureRunningOnForeground() TorManager.shared.ensureRunningOnForeground()
}
} else { } else {
didHandleInitialActive = true didHandleInitialActive = true
} }
didEnterBackground = false didEnterBackground = false
if TorManager.shared.isAutoStartAllowed() {
Task.detached { Task.detached {
let _ = await TorManager.shared.awaitReady(timeout: 60) let _ = await TorManager.shared.awaitReady(timeout: 60)
await MainActor.run { await MainActor.run {
@@ -104,7 +98,6 @@ struct BitchatApp: App {
NostrRelayManager.shared.resetAllConnections() NostrRelayManager.shared.resetAllConnections()
} }
} }
}
checkForSharedContent() checkForSharedContent()
case .inactive: case .inactive:
break break
@@ -137,7 +130,7 @@ struct BitchatApp: App {
private func checkForSharedContent() { private func checkForSharedContent() {
// Check app group for shared content from extension // Check app group for shared content from extension
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
return return
} }
+1 -1
View File
@@ -87,7 +87,7 @@ import Foundation
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy. /// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships. /// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
struct EphemeralIdentity { struct EphemeralIdentity {
let peer: Peer // 8 random bytes let peerID: String // 8 random bytes
let sessionStart: Date let sessionStart: Date
var handshakeState: HandshakeState var handshakeState: HandshakeState
} }
@@ -90,7 +90,6 @@
/// - Advanced conflict resolution /// - Advanced conflict resolution
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -103,7 +102,7 @@ protocol SecureIdentityStateManagerProtocol {
// MARK: Cryptographic Identities // MARK: Cryptographic Identities
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
func getCryptoIdentitiesByPeerIDPrefix(_ peer: Peer) -> [CryptographicIdentity] func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
func updateSocialIdentity(_ identity: SocialIdentity) func updateSocialIdentity(_ identity: SocialIdentity)
// MARK: Favorites Management // MARK: Favorites Management
@@ -121,12 +120,12 @@ protocol SecureIdentityStateManagerProtocol {
func getBlockedNostrPubkeys() -> Set<String> func getBlockedNostrPubkeys() -> Set<String>
// MARK: Ephemeral Session Management // MARK: Ephemeral Session Management
func registerEphemeralSession(peer: Peer, handshakeState: HandshakeState) func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
func updateHandshakeState(peer: Peer, state: HandshakeState) func updateHandshakeState(peerID: String, state: HandshakeState)
// MARK: Cleanup // MARK: Cleanup
func clearAllIdentityData() func clearAllIdentityData()
func removeEphemeralSession(peer: Peer) func removeEphemeralSession(peerID: String)
// MARK: Verification // MARK: Verification
func setVerified(fingerprint: String, verified: Bool) func setVerified(fingerprint: String, verified: Bool)
@@ -143,7 +142,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
private let encryptionKeyName = "identityCacheEncryptionKey" private let encryptionKeyName = "identityCacheEncryptionKey"
// In-memory state // In-memory state
private var ephemeralSessions: [Peer: EphemeralIdentity] = [:] private var ephemeralSessions: [String: EphemeralIdentity] = [:]
private var cryptographicIdentities: [String: CryptographicIdentity] = [:] private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
private var cache: IdentityCache = IdentityCache() private var cache: IdentityCache = IdentityCache()
@@ -321,11 +320,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID /// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
func getCryptoIdentitiesByPeerIDPrefix(_ peer: Peer) -> [CryptographicIdentity] { func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
queue.sync { queue.sync {
// Defensive: ensure hex and correct length // Defensive: ensure hex and correct length
guard peer.isShort, peer.id.allSatisfy({ $0.isHexDigit }) else { return [] } guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peer.id) } return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
} }
} }
@@ -455,19 +454,19 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Ephemeral Session Management // MARK: - Ephemeral Session Management
func registerEphemeralSession(peer: Peer, handshakeState: HandshakeState = .none) { func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peer] = EphemeralIdentity( self.ephemeralSessions[peerID] = EphemeralIdentity(
peer: peer, peerID: peerID,
sessionStart: Date(), sessionStart: Date(),
handshakeState: handshakeState handshakeState: handshakeState
) )
} }
} }
func updateHandshakeState(peer: Peer, state: HandshakeState) { func updateHandshakeState(peerID: String, state: HandshakeState) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peer]?.handshakeState = state self.ephemeralSessions[peerID]?.handshakeState = state
// If handshake completed, update last interaction // If handshake completed, update last interaction
if case .completed(let fingerprint) = state { if case .completed(let fingerprint) = state {
@@ -493,9 +492,9 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
func removeEphemeralSession(peer: Peer) { func removeEphemeralSession(peerID: String) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peer) self.ephemeralSessions.removeValue(forKey: peerID)
} }
} }
-356
View File
@@ -1,356 +0,0 @@
//
// BitchatMessage.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
final class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
let timestamp: Date
let isRelay: Bool
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeer: Peer?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, mentions, deliveryStatus
case senderPeer = "senderPeerID" // backwards compatibility
}
init(
id: String? = nil,
sender: String,
content: String,
timestamp: Date,
isRelay: Bool,
originalSender: String? = nil,
isPrivate: Bool = false,
recipientNickname: String? = nil,
senderPeer: Peer? = nil,
mentions: [String]? = nil,
deliveryStatus: DeliveryStatus? = nil
) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
self.timestamp = timestamp
self.isRelay = isRelay
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeer = senderPeer
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
}
// MARK: - Equatable Conformance
extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
lhs.timestamp == rhs.timestamp &&
lhs.isRelay == rhs.isRelay &&
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeer == rhs.senderPeer &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
}
// MARK: - Binary encoding
extension BitchatMessage {
func toBinaryPayload() -> Data? {
var data = Data()
// Message format:
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
// - Sender length: 1 byte
// - Sender: variable
// - Content length: 2 bytes
// - Content: variable
// Optional fields based on flags:
// - Original sender length + data
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeer != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
// Timestamp (in milliseconds)
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
// Encode as 8 bytes, big-endian
for i in (0..<8).reversed() {
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
}
// ID
if let idData = id.data(using: .utf8) {
data.append(UInt8(min(idData.count, 255)))
data.append(idData.prefix(255))
} else {
data.append(0)
}
// Sender
if let senderData = sender.data(using: .utf8) {
data.append(UInt8(min(senderData.count, 255)))
data.append(senderData.prefix(255))
} else {
data.append(0)
}
// Content
if let contentData = content.data(using: .utf8) {
let length = UInt16(min(contentData.count, 65535))
// Encode length as 2 bytes, big-endian
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(contentData.prefix(Int(length)))
} else {
data.append(contentsOf: [0, 0])
}
// Optional fields
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
data.append(UInt8(min(origData.count, 255)))
data.append(origData.prefix(255))
}
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
data.append(UInt8(min(recipData.count, 255)))
data.append(recipData.prefix(255))
}
if let peerData = senderPeer?.data {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
// Mentions array
if let mentions = mentions {
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
for mention in mentions.prefix(255) {
if let mentionData = mention.data(using: .utf8) {
data.append(UInt8(min(mentionData.count, 255)))
data.append(mentionData.prefix(255))
} else {
data.append(0)
}
}
}
return data
}
convenience init?(_ data: Data) {
// Create an immutable copy to prevent threading issues
let dataCopy = Data(data)
guard dataCopy.count >= 13 else {
return nil
}
var offset = 0
// Flags
guard offset < dataCopy.count else {
return nil
}
let flags = dataCopy[offset]; offset += 1
let isRelay = (flags & 0x01) != 0
let isPrivate = (flags & 0x02) != 0
let hasOriginalSender = (flags & 0x04) != 0
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
return nil
}
let timestampData = dataCopy[offset..<offset+8]
let timestampMillis = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
offset += 8
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
// ID
guard offset < dataCopy.count else {
return nil
}
let idLength = Int(dataCopy[offset]); offset += 1
guard offset + idLength <= dataCopy.count else {
return nil
}
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
offset += idLength
// Sender
guard offset < dataCopy.count else {
return nil
}
let senderLength = Int(dataCopy[offset]); offset += 1
guard offset + senderLength <= dataCopy.count else {
return nil
}
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
offset += senderLength
// Content
guard offset + 2 <= dataCopy.count else {
return nil
}
let contentLengthData = dataCopy[offset..<offset+2]
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
guard offset + contentLength <= dataCopy.count else {
return nil
}
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
offset += contentLength
// Optional fields
var originalSender: String?
if hasOriginalSender && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var recipientNickname: String?
if hasRecipientNickname && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var senderPeer: Peer?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeer = Peer(data: dataCopy[offset..<offset+length])
offset += length
}
}
// Mentions array
var mentions: [String]?
if hasMentions && offset < dataCopy.count {
let mentionCount = Int(dataCopy[offset]); offset += 1
if mentionCount > 0 {
mentions = []
for _ in 0..<mentionCount {
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention)
}
offset += length
}
}
}
}
}
self.init(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: isRelay,
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeer: senderPeer,
mentions: mentions
)
}
}
// MARK: - Helpers
extension BitchatMessage {
private static let timestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
var formattedTimestamp: String {
Self.timestampFormatter.string(from: timestamp)
}
}
extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
guard arr.count > 1 else {
return arr
}
var seen = Set<String>()
var dedup: [BitchatMessage] = []
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(m.id) {
dedup.append(m)
seen.insert(m.id)
}
}
return dedup
}
}
-91
View File
@@ -1,91 +0,0 @@
//
// BitchatPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network,
/// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
struct BitchatPacket: Codable {
let version: UInt8
let type: UInt8
let senderID: Data
let recipientID: Data?
let timestamp: UInt64
let payload: Data
var signature: Data?
var ttl: UInt8
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = 1
self.type = type
self.senderID = senderID
self.recipientID = recipientID
self.timestamp = timestamp
self.payload = payload
self.signature = signature
self.ttl = ttl
}
// Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
self.version = 1
self.type = type
// Convert hex string peer ID to binary data (8 bytes)
var senderData = Data()
var tempID = senderID
while tempID.count >= 2 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
senderData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
self.senderID = senderData
self.recipientID = nil
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
self.payload = payload
self.signature = nil
self.ttl = ttl
}
var data: Data? {
BinaryProtocol.encode(self)
}
func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding)
}
// Backward-compatible helper (defaults to padded encoding)
func toBinaryData() -> Data? {
toBinaryData(padding: true)
}
/// Create binary representation for signing (without signature and TTL fields)
/// TTL is excluded because it changes during packet relay operations
func toBinaryDataForSigning() -> Data? {
// Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: nil, // Remove signature for signing
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
)
return BinaryProtocol.encode(unsignedPacket)
}
static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data)
}
}
-61
View File
@@ -1,61 +0,0 @@
//
// MessagePadding.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
struct MessagePadding {
// Standard block sizes for padding
static let blockSizes = [256, 512, 1024, 2048]
// Add PKCS#7-style padding to reach target size
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
guard data.count < targetSize else { return data }
let paddingNeeded = targetSize - data.count
// Constrain to 255 to fit a single-byte pad length marker
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
var padded = data
// PKCS#7: All pad bytes are equal to the pad length
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
return padded
}
// Remove padding from data
static func unpad(_ data: Data) -> Data {
guard !data.isEmpty else { return data }
let last = data.last!
let paddingLength = Int(last)
// Must have at least 1 pad byte and not exceed data length
guard paddingLength > 0 && paddingLength <= data.count else { return data }
// Verify PKCS#7: all last N bytes equal to pad length
let start = data.count - paddingLength
let tail = data[start...]
for b in tail { if b != last { return data } }
return Data(data[..<start])
}
// Find optimal block size for data
static func optimalBlockSize(for dataSize: Int) -> Int {
// Account for encryption overhead (~16 bytes for AES-GCM tag)
let totalSize = dataSize + 16
// Find smallest block that fits
for blockSize in blockSizes {
if totalSize <= blockSize {
return blockSize
}
}
// For very large messages, just use the original size
// (will be fragmented anyway)
return dataSize
}
}
-41
View File
@@ -1,41 +0,0 @@
//
// NoisePayload.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Helper to create typed Noise payloads
struct NoisePayload {
let type: NoisePayloadType
let data: Data
/// Encode payload with type prefix
func encode() -> Data {
var encoded = Data()
encoded.append(type.rawValue)
encoded.append(data)
return encoded
}
/// Decode payload from data
static func decode(_ data: Data) -> NoisePayload? {
// Ensure we have at least 1 byte for the type
guard !data.isEmpty else {
return nil
}
// Safely get the first byte
let firstByte = data[data.startIndex]
guard let type = NoisePayloadType(rawValue: firstByte) else {
return nil
}
// Create a proper Data copy (not a subsequence) for thread safety
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
return NoisePayload(type: type, data: payloadData)
}
}
-139
View File
@@ -1,139 +0,0 @@
//
// Peer.swift
// BitLogger
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import struct CryptoKit.SHA256
struct Peer: Equatable, Hashable {
let id: String
}
extension Peer {
var data: Data? {
id.data(using: .utf8)
}
var isNostr: Bool {
id.hasPrefix("nostr")
}
var isNostrColon: Bool {
id.hasPrefix("nostr:")
}
}
// MARK: - Validation
extension Peer {
private enum Constants {
static let maxIDLength = 64
static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
var isValid: Bool {
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
if isShort || isNoiseKeyHex {
return true
}
// If length equals short or full but isn't valid hex, reject
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
return false
}
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !id.isEmpty &&
id.count < Constants.maxIDLength &&
id.rangeOfCharacter(from: validCharset.inverted) == nil
}
/// Short routing IDs (exact 16-hex)
var isShort: Bool {
id.count == Constants.hexIDLength && Data(hexString: id) != nil
}
/// Full Noise key hex (exact 64-hex)
var isNoiseKeyHex: Bool {
noiseKey != nil
}
/// Full Noise key (exact 64-hex) as Data
var noiseKey: Data? {
guard id.count == Constants.maxIDLength else { return nil }
return Data(hexString: id)
}
}
// MARK: - ExpressibleByStringLiteral
extension Peer: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
self.init(str: value)
}
}
// MARK: - ExpressibleByStringInterpolation
extension Peer: ExpressibleByStringInterpolation {
init(extendedGraphemeClusterLiteral value: String) {
self.init(str: value)
}
}
// MARK: - Codable
extension Peer: Codable {
init(from decoder: any Decoder) throws {
id = try decoder.singleValueContainer().decode(String.self)
}
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(id)
}
}
// MARK: - Convenience Inits
extension Peer {
init(str: String) {
id = str.lowercased()
}
init(str: String.SubSequence) {
self.init(str: String(str))
}
init?(data: Data) {
guard let str = String(data: data, encoding: .utf8) else {
return nil
}
self.init(str: str)
}
}
// MARK: - Noise Public Key Helpers
extension Peer {
/// Derive the stable 16-hex peer ID from a Noise static public key
init(publicKey: Data) {
let digest = SHA256.hash(data: publicKey)
let hex = digest.map { String(format: "%02x", $0) }.joined()
self.init(str: hex.prefix(16))
}
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
func toShort() -> Peer {
if id.count == Constants.maxIDLength, let data = Data(hexString: id) {
return Peer(publicKey: data)
}
return self
}
}
-95
View File
@@ -1,95 +0,0 @@
//
// ReadReceipt.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct ReadReceipt: Codable {
let originalMessageID: String
let receiptID: String
var readerID: String // Who read it
let readerNickname: String
let timestamp: Date
init(originalMessageID: String, readerID: String, readerNickname: String) {
self.originalMessageID = originalMessageID
self.receiptID = UUID().uuidString
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = Date()
}
// For binary decoding
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
self.originalMessageID = originalMessageID
self.receiptID = receiptID
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = timestamp
}
func encode() -> Data? {
try? JSONEncoder().encode(self)
}
static func decode(from data: Data) -> ReadReceipt? {
try? JSONDecoder().decode(ReadReceipt.self, from: data)
}
// MARK: - Binary Encoding
func toBinaryData() -> Data {
var data = Data()
data.appendUUID(originalMessageID)
data.appendUUID(receiptID)
// ReaderID as 8-byte hex string
var readerData = Data()
var tempID = readerID
while tempID.count >= 2 && readerData.count < 8 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
readerData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
while readerData.count < 8 {
readerData.append(0)
}
data.append(readerData)
data.appendDate(timestamp)
data.appendString(readerNickname)
return data
}
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
// Create defensive copy
let dataCopy = Data(data)
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
guard dataCopy.count >= 49 else { return nil }
var offset = 0
guard let originalMessageID = dataCopy.readUUID(at: &offset),
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = readerIDData.hexEncodedString()
guard Peer(str: readerID).isValid else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp),
let readerNicknameRaw = dataCopy.readString(at: &offset),
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
return ReadReceipt(originalMessageID: originalMessageID,
receiptID: receiptID,
readerID: readerID,
readerNickname: readerNickname,
timestamp: timestamp)
}
}
-63
View File
@@ -1,63 +0,0 @@
import Foundation
// REQUEST_SYNC payload TLV (type, length16, value)
// - 0x01: P (uint8) Golomb-Rice parameter
// - 0x02: M (uint32, big-endian) hash range (N * 2^P)
// - 0x03: data (opaque) GR bitstream bytes (MSB-first)
struct RequestSyncPacket {
let p: Int
let m: UInt32
let data: Data
func encode() -> Data {
var out = Data()
func putTLV(_ t: UInt8, _ v: Data) {
out.append(t)
let len = UInt16(v.count)
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(v)
}
// P
putTLV(0x01, Data([UInt8(p & 0xFF)]))
// M (uint32)
var mBE = m.bigEndian
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
// data
putTLV(0x03, data)
return out
}
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
var off = 0
var p: Int? = nil
var m: UInt32? = nil
var payload: Data? = nil
while off + 3 <= data.count {
let t = Int(data[off]); off += 1
guard off + 2 <= data.count else { return nil }
let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2
guard off + len <= data.count else { return nil }
let v = data.subdata(in: off..<(off+len)); off += len
switch t {
case 0x01:
if v.count == 1 { p = Int(v[0]) }
case 0x02:
if v.count == 4 {
var mm: UInt32 = 0
for b in v { mm = (mm << 8) | UInt32(b) }
m = mm
}
case 0x03:
if v.count > maxAcceptBytes { return nil }
payload = v
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd)
}
}
@@ -6,7 +6,6 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment /// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
-1
View File
@@ -77,7 +77,6 @@
/// - Noise Specification: http://www.noiseprotocol.org/noise.html /// - Noise Specification: http://www.noiseprotocol.org/noise.html
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
+18 -23
View File
@@ -6,7 +6,6 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -53,6 +52,11 @@ struct NoiseSecurityValidator {
static func validateHandshakeMessageSize(_ data: Data) -> Bool { static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
} }
/// Validate peer ID format using unified validator
static func validatePeerID(_ peerID: String) -> Bool {
return InputValidator.validatePeerID(peerID)
}
} }
// MARK: - Enhanced Noise Session with Security // MARK: - Enhanced Noise Session with Security
@@ -132,8 +136,8 @@ final class SecureNoiseSession: NoiseSession {
// MARK: - Rate Limiter // MARK: - Rate Limiter
final class NoiseRateLimiter { final class NoiseRateLimiter {
private var handshakeTimestamps: [Peer: [Date]] = [:] // Peer -> timestamps private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
private var messageTimestamps: [Peer: [Date]] = [:] // Peer -> timestamps private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
// Global rate limiting // Global rate limiting
private var globalHandshakeTimestamps: [Date] = [] private var globalHandshakeTimestamps: [Date] = []
@@ -141,7 +145,7 @@ final class NoiseRateLimiter {
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent) private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
func allowHandshake(from peer: Peer) -> Bool { func allowHandshake(from peerID: String) -> Bool {
return queue.sync(flags: .barrier) { return queue.sync(flags: .barrier) {
let now = Date() let now = Date()
let oneMinuteAgo = now.addingTimeInterval(-60) let oneMinuteAgo = now.addingTimeInterval(-60)
@@ -154,23 +158,23 @@ final class NoiseRateLimiter {
} }
// Check per-peer rate limit // Check per-peer rate limit
var timestamps = handshakeTimestamps[peer] ?? [] var timestamps = handshakeTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneMinuteAgo } timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute { if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peer.id): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security) SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
return false return false
} }
// Record new handshake // Record new handshake
timestamps.append(now) timestamps.append(now)
handshakeTimestamps[peer] = timestamps handshakeTimestamps[peerID] = timestamps
globalHandshakeTimestamps.append(now) globalHandshakeTimestamps.append(now)
return true return true
} }
} }
func allowMessage(from peer: Peer) -> Bool { func allowMessage(from peerID: String) -> Bool {
return queue.sync(flags: .barrier) { return queue.sync(flags: .barrier) {
let now = Date() let now = Date()
let oneSecondAgo = now.addingTimeInterval(-1) let oneSecondAgo = now.addingTimeInterval(-1)
@@ -183,35 +187,26 @@ final class NoiseRateLimiter {
} }
// Check per-peer rate limit // Check per-peer rate limit
var timestamps = messageTimestamps[peer] ?? [] var timestamps = messageTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneSecondAgo } timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond { if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.warning("Per-peer message rate limit exceeded for \(peer.id): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security) SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
return false return false
} }
// Record new message // Record new message
timestamps.append(now) timestamps.append(now)
messageTimestamps[peer] = timestamps messageTimestamps[peerID] = timestamps
globalMessageTimestamps.append(now) globalMessageTimestamps.append(now)
return true return true
} }
} }
func reset(for peer: Peer) { func reset(for peerID: String) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.handshakeTimestamps.removeValue(forKey: peer) self.handshakeTimestamps.removeValue(forKey: peerID)
self.messageTimestamps.removeValue(forKey: peer) self.messageTimestamps.removeValue(forKey: peerID)
}
}
func resetAll() {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeAll()
self.messageTimestamps.removeAll()
self.globalHandshakeTimestamps.removeAll()
self.globalMessageTimestamps.removeAll()
} }
} }
} }
-10
View File
@@ -6,7 +6,6 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -309,15 +308,6 @@ final class NoiseSessionManager {
} }
} }
func removeAllSessions() {
managerQueue.sync(flags: .barrier) {
for (_, session) in sessions {
session.reset()
}
sessions.removeAll()
}
}
func getEstablishedSessions() -> [String: NoiseSession] { func getEstablishedSessions() -> [String: NoiseSession] {
return managerQueue.sync { return managerQueue.sync {
return sessions.filter { $0.value.isEstablished() } return sessions.filter { $0.value.isEstablished() }
-1
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
+1 -1
View File
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
if let maybeData = Data(hexString: recipientPeerID) { if let maybeData = Data(hexString: recipientPeerID) {
if maybeData.count == 32 { if maybeData.count == 32 {
// Treat as Noise static public key; derive peerID from fingerprint // Treat as Noise static public key; derive peerID from fingerprint
return Peer(publicKey: maybeData).id return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
} else if maybeData.count == 8 { } else if maybeData.count == 8 {
// Already an 8-byte peer ID // Already an 8-byte peer ID
return recipientPeerID return recipientPeerID
+6 -24
View File
@@ -150,31 +150,13 @@ struct NostrIdentityBridge {
/// Clear all Nostr identity associations and current identity /// Clear all Nostr identity associations and current identity
static func clearAllAssociations() { static func clearAllAssociations() {
let query: [String: Any] = [ // Delete current Nostr identity
kSecClass as String: kSecClassGenericPassword, KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
kSecAttrService as String: keychainService, KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject? // Note: We can't efficiently delete all noise-nostr associations
let status = SecItemCopyMatching(query as CFDictionary, &result) // without tracking them, but they'll be orphaned and eventually cleaned up
if status == errSecSuccess, let items = result as? [[String: Any]] { // The important part is deleting the current identity so a new one is generated
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
}
deviceSeedCache = nil
} }
// MARK: - Per-Geohash Identities (Location Channels) // MARK: - Per-Geohash Identities (Location Channels)
-1
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import P256K import P256K
+14 -108
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
import Network import Network
import Combine import Combine
@@ -35,12 +34,10 @@ final class NostrRelayManager: ObservableObject {
"wss://nostr21.com" "wss://nostr21.com"
// For local testing, you can add: "ws://localhost:8080" // For local testing, you can add: "ws://localhost:8080"
] ]
private static let defaultRelaySet = Set(defaultRelays)
@Published private(set) var relays: [Relay] = [] @Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false @Published private(set) var isConnected = false
private var allowDefaultRelays: Bool = false
private var connections: [String: URLSessionWebSocketTask] = [:] private var connections: [String: URLSessionWebSocketTask] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
@@ -67,8 +64,6 @@ final class NostrRelayManager: ObservableObject {
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private var networkService: NetworkActivationService { NetworkActivationService.shared }
private var shouldUseTor: Bool { networkService.userTorEnabled }
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -82,26 +77,14 @@ final class NostrRelayManager: ObservableObject {
private var connectionGeneration: Int = 0 private var connectionGeneration: Int = 0
init() { init() {
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty // Initialize with default relays
allowDefaultRelays = hasMutual
if hasMutual {
self.relays = Self.defaultRelays.map { Relay(url: $0) } self.relays = Self.defaultRelays.map { Relay(url: $0) }
}
// Deterministic JSON shape for outbound requests // Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys self.encoder.outputFormatting = .sortedKeys
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
self?.updateDefaultRelayPolicy(hasMutual: !favorites.isEmpty)
}
.store(in: &cancellables)
} }
/// Connect to all configured relays /// Connect to all configured relays
func connect() { func connect() {
// Global network policy gate
guard networkService.activationAllowed else { return }
if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect. // Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
Task.detached { Task.detached {
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
@@ -116,12 +99,6 @@ final class NostrRelayManager: ObservableObject {
} }
} }
} }
} else {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
for relay in self.relays {
connectToRelay(relay.url)
}
}
} }
/// Disconnect from all relays /// Disconnect from all relays
@@ -139,11 +116,7 @@ final class NostrRelayManager: ObservableObject {
/// Ensure connections exist to the given relay URLs (idempotent). /// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) { func ensureConnections(to relayUrls: [String]) {
// Global network policy gate if TorManager.shared.torEnforced && !TorManager.shared.isReady {
guard networkService.activationAllowed else { return }
let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer until Tor is fully ready; avoid queuing connection attempts early // Defer until Tor is fully ready; avoid queuing connection attempts early
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -152,21 +125,20 @@ final class NostrRelayManager: ObservableObject {
} }
return return
} }
var existing = Set(relays.map { $0.url }) let existing = Set(relays.map { $0.url })
for url in targets where !existing.contains(url) { for url in Set(relayUrls) {
if !existing.contains(url) {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
existing.insert(url)
} }
for url in targets where connections[url] == nil { if connections[url] == nil {
connectToRelay(url) connectToRelay(url)
} }
} }
}
/// Send an event to specified relays (or all if none specified) /// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate if TorManager.shared.torEnforced && !TorManager.shared.isReady {
guard networkService.activationAllowed else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer sends until Tor is ready to avoid premature queueing // Defer sends until Tor is ready to avoid premature queueing
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -175,9 +147,7 @@ final class NostrRelayManager: ObservableObject {
} }
return return
} }
let requestedRelays = relayUrls ?? Self.defaultRelays let targetRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
guard !targetRelays.isEmpty else { return }
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
// Attempt immediate send to relays with active connections; queue the rest // Attempt immediate send to relays with active connections; queue the rest
@@ -242,8 +212,6 @@ final class NostrRelayManager: ObservableObject {
handler: @escaping (NostrEvent) -> Void, handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil onEOSE: (() -> Void)? = nil
) { ) {
// Global network policy gate
guard networkService.activationAllowed else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists // Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = Date() let now = Date()
if messageHandlers[id] != nil { if messageHandlers[id] != nil {
@@ -252,7 +220,7 @@ final class NostrRelayManager: ObservableObject {
} }
} }
subscribeCoalesce[id] = now subscribeCoalesce[id] = now
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { if TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer subscription setup until Tor is ready; avoid queuing subs early // Defer subscription setup until Tor is ready; avoid queuing subs early
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -280,23 +248,19 @@ final class NostrRelayManager: ObservableObject {
// Target specific relays if provided; else default. Filter permanently failed relays. // Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays let baseUrls = relayUrls ?? Self.defaultRelays
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) } let urls = baseUrls.filter { !isPermanentlyFailed($0) }
let urls = allowedRelayList(from: candidateUrls)
// Always queue subscriptions; sending happens when a relay reports connected // Always queue subscriptions; sending happens when a relay reports connected
let existingSet = Set(relays.map { $0.url }) let existingSet = Set(relays.map { $0.url })
for url in urls where !existingSet.contains(url) { for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
} }
for url in candidateUrls { for url in urls {
var map = self.pendingSubscriptions[url] ?? [:] var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString map[id] = messageString
self.pendingSubscriptions[url] = map self.pendingSubscriptions[url] = map
} }
// Initialize EOSE tracking if requested // Initialize EOSE tracking if requested
if let onEOSE = onEOSE { if let onEOSE = onEOSE {
if urls.isEmpty {
onEOSE()
} else {
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil) var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE // Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
@@ -311,7 +275,6 @@ final class NostrRelayManager: ObservableObject {
} }
eoseTrackers[id] = tracker eoseTrackers[id] = tracker
} }
}
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session) SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
// Ensure we actually have sockets opening to these relays so queued REQs can flush // Ensure we actually have sockets opening to these relays so queued REQs can flush
ensureConnections(to: urls) ensureConnections(to: urls)
@@ -326,54 +289,6 @@ final class NostrRelayManager: ObservableObject {
} }
} }
private func updateDefaultRelayPolicy(hasMutual: Bool) {
guard hasMutual != allowDefaultRelays else { return }
allowDefaultRelays = hasMutual
if hasMutual {
var existing = Set(relays.map { $0.url })
for url in Self.defaultRelays where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
if networkService.activationAllowed {
ensureConnections(to: Self.defaultRelays)
}
} else {
for url in Self.defaultRelays {
if let connection = connections[url] {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url)
}
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(Self.defaultRelaySet)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
updateConnectionStatus()
}
}
private func allowedRelayList(from urls: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for url in urls {
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
if seen.insert(url).inserted {
result.append(url)
}
}
return result
}
/// Unsubscribe from a subscription /// Unsubscribe from a subscription
func unsubscribe(id: String) { func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
@@ -401,15 +316,13 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Private Methods // MARK: - Private Methods
private func connectToRelay(_ urlString: String) { private func connectToRelay(_ urlString: String) {
// Global network policy gate
guard networkService.activationAllowed else { return }
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
return return
} }
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground // Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() { if TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
return return
} }
@@ -424,7 +337,7 @@ final class NostrRelayManager: ObservableObject {
// Attempting to connect to Nostr relay via the proxied session // Attempting to connect to Nostr relay via the proxied session
// If Tor is enforced but not ready, delay connection until it is. // If Tor is enforced but not ready, delay connection until it is.
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { if TorManager.shared.torEnforced && !TorManager.shared.isReady {
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
@@ -609,13 +522,6 @@ final class NostrRelayManager: ObservableObject {
} }
private func handleDisconnection(relayUrl: String, error: Error) { private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection
if !networkService.activationAllowed {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
return
}
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error) updateRelayStatus(relayUrl, isConnected: false, error: error)
+233
View File
@@ -328,3 +328,236 @@ struct BinaryProtocol {
} }
} }
} }
// Binary encoding for BitchatMessage
extension BitchatMessage {
func toBinaryPayload() -> Data? {
var data = Data()
// Message format:
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
// - Sender length: 1 byte
// - Sender: variable
// - Content length: 2 bytes
// - Content: variable
// Optional fields based on flags:
// - Original sender length + data
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
// Timestamp (in milliseconds)
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
// Encode as 8 bytes, big-endian
for i in (0..<8).reversed() {
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
}
// ID
if let idData = id.data(using: .utf8) {
data.append(UInt8(min(idData.count, 255)))
data.append(idData.prefix(255))
} else {
data.append(0)
}
// Sender
if let senderData = sender.data(using: .utf8) {
data.append(UInt8(min(senderData.count, 255)))
data.append(senderData.prefix(255))
} else {
data.append(0)
}
// Content
if let contentData = content.data(using: .utf8) {
let length = UInt16(min(contentData.count, 65535))
// Encode length as 2 bytes, big-endian
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(contentData.prefix(Int(length)))
} else {
data.append(contentsOf: [0, 0])
}
// Optional fields
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
data.append(UInt8(min(origData.count, 255)))
data.append(origData.prefix(255))
}
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
data.append(UInt8(min(recipData.count, 255)))
data.append(recipData.prefix(255))
}
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
// Mentions array
if let mentions = mentions {
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
for mention in mentions.prefix(255) {
if let mentionData = mention.data(using: .utf8) {
data.append(UInt8(min(mentionData.count, 255)))
data.append(mentionData.prefix(255))
} else {
data.append(0)
}
}
}
return data
}
static func fromBinaryPayload(_ data: Data) -> BitchatMessage? {
// Create an immutable copy to prevent threading issues
let dataCopy = Data(data)
guard dataCopy.count >= 13 else {
return nil
}
var offset = 0
// Flags
guard offset < dataCopy.count else {
return nil
}
let flags = dataCopy[offset]; offset += 1
let isRelay = (flags & 0x01) != 0
let isPrivate = (flags & 0x02) != 0
let hasOriginalSender = (flags & 0x04) != 0
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
return nil
}
let timestampData = dataCopy[offset..<offset+8]
let timestampMillis = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
offset += 8
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
// ID
guard offset < dataCopy.count else {
return nil
}
let idLength = Int(dataCopy[offset]); offset += 1
guard offset + idLength <= dataCopy.count else {
return nil
}
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
offset += idLength
// Sender
guard offset < dataCopy.count else {
return nil
}
let senderLength = Int(dataCopy[offset]); offset += 1
guard offset + senderLength <= dataCopy.count else {
return nil
}
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
offset += senderLength
// Content
guard offset + 2 <= dataCopy.count else {
return nil
}
let contentLengthData = dataCopy[offset..<offset+2]
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
guard offset + contentLength <= dataCopy.count else {
return nil
}
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
offset += contentLength
// Optional fields
var originalSender: String?
if hasOriginalSender && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var recipientNickname: String?
if hasRecipientNickname && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var senderPeerID: String?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
// Mentions array
var mentions: [String]?
if hasMentions && offset < dataCopy.count {
let mentionCount = Int(dataCopy[offset]); offset += 1
if mentionCount > 0 {
mentions = []
for _ in 0..<mentionCount {
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention)
}
offset += length
}
}
}
}
}
let message = BitchatMessage(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: isRelay,
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
)
return message
}
}
+338 -2
View File
@@ -59,6 +59,61 @@
/// ///
import Foundation import Foundation
import CryptoKit
// MARK: - Message Padding
/// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
struct MessagePadding {
// Standard block sizes for padding
static let blockSizes = [256, 512, 1024, 2048]
// Add PKCS#7-style padding to reach target size
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
guard data.count < targetSize else { return data }
let paddingNeeded = targetSize - data.count
// Constrain to 255 to fit a single-byte pad length marker
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
var padded = data
// PKCS#7: All pad bytes are equal to the pad length
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
return padded
}
// Remove padding from data
static func unpad(_ data: Data) -> Data {
guard !data.isEmpty else { return data }
let last = data.last!
let paddingLength = Int(last)
// Must have at least 1 pad byte and not exceed data length
guard paddingLength > 0 && paddingLength <= data.count else { return data }
// Verify PKCS#7: all last N bytes equal to pad length
let start = data.count - paddingLength
let tail = data[start...]
for b in tail { if b != last { return data } }
return Data(data[..<start])
}
// Find optimal block size for data
static func optimalBlockSize(for dataSize: Int) -> Int {
// Account for encryption overhead (~16 bytes for AES-GCM tag)
let totalSize = dataSize + 16
// Find smallest block that fits
for blockSize in blockSizes {
if totalSize <= blockSize {
return blockSize
}
}
// For very large messages, just use the original size
// (will be fragmented anyway)
return dataSize
}
}
// MARK: - Message Types // MARK: - Message Types
@@ -70,7 +125,6 @@ enum MessageType: UInt8 {
case announce = 0x01 // "I'm here" with nickname case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving" case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption // Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload) case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
@@ -84,7 +138,6 @@ enum MessageType: UInt8 {
case .announce: return "announce" case .announce: return "announce"
case .message: return "message" case .message: return "message"
case .leave: return "leave" case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment" case .fragment: return "fragment"
@@ -128,6 +181,187 @@ enum LazyHandshakeState {
case failed(Error) // Handshake failed case failed(Error) // Handshake failed
} }
//
// MARK: - Core Protocol Structures
/// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network,
/// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
struct BitchatPacket: Codable {
let version: UInt8
let type: UInt8
let senderID: Data
let recipientID: Data?
let timestamp: UInt64
let payload: Data
var signature: Data?
var ttl: UInt8
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = 1
self.type = type
self.senderID = senderID
self.recipientID = recipientID
self.timestamp = timestamp
self.payload = payload
self.signature = signature
self.ttl = ttl
}
// Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
self.version = 1
self.type = type
// Convert hex string peer ID to binary data (8 bytes)
var senderData = Data()
var tempID = senderID
while tempID.count >= 2 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
senderData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
self.senderID = senderData
self.recipientID = nil
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
self.payload = payload
self.signature = nil
self.ttl = ttl
}
var data: Data? {
BinaryProtocol.encode(self)
}
func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding)
}
// Backward-compatible helper (defaults to padded encoding)
func toBinaryData() -> Data? {
toBinaryData(padding: true)
}
/// Create binary representation for signing (without signature and TTL fields)
/// TTL is excluded because it changes during packet relay operations
func toBinaryDataForSigning() -> Data? {
// Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: nil, // Remove signature for signing
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
)
return BinaryProtocol.encode(unsignedPacket)
}
static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data)
}
}
//
// MARK: - Read Receipts
// Read receipt structure
struct ReadReceipt: Codable {
let originalMessageID: String
let receiptID: String
var readerID: String // Who read it
let readerNickname: String
let timestamp: Date
init(originalMessageID: String, readerID: String, readerNickname: String) {
self.originalMessageID = originalMessageID
self.receiptID = UUID().uuidString
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = Date()
}
// For binary decoding
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
self.originalMessageID = originalMessageID
self.receiptID = receiptID
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = timestamp
}
func encode() -> Data? {
try? JSONEncoder().encode(self)
}
static func decode(from data: Data) -> ReadReceipt? {
try? JSONDecoder().decode(ReadReceipt.self, from: data)
}
// MARK: - Binary Encoding
func toBinaryData() -> Data {
var data = Data()
data.appendUUID(originalMessageID)
data.appendUUID(receiptID)
// ReaderID as 8-byte hex string
var readerData = Data()
var tempID = readerID
while tempID.count >= 2 && readerData.count < 8 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
readerData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
while readerData.count < 8 {
readerData.append(0)
}
data.append(readerData)
data.appendDate(timestamp)
data.appendString(readerNickname)
return data
}
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
// Create defensive copy
let dataCopy = Data(data)
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
guard dataCopy.count >= 49 else { return nil }
var offset = 0
guard let originalMessageID = dataCopy.readUUID(at: &offset),
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = readerIDData.hexEncodedString()
guard InputValidator.validatePeerID(readerID) else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp),
let readerNicknameRaw = dataCopy.readString(at: &offset),
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
return ReadReceipt(originalMessageID: originalMessageID,
receiptID: receiptID,
readerID: readerID,
readerNickname: readerNickname,
timestamp: timestamp)
}
}
//
// MARK: - Delivery Status // MARK: - Delivery Status
// Delivery status for messages // Delivery status for messages
@@ -157,6 +391,74 @@ enum DeliveryStatus: Codable, Equatable {
} }
} }
// MARK: - Message Model
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
final class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
let timestamp: Date
let isRelay: Bool
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
}
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
self.timestamp = timestamp
self.isRelay = isRelay
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
}
// Equatable conformance for BitchatMessage
extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
lhs.timestamp == rhs.timestamp &&
lhs.isRelay == rhs.isRelay &&
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
}
// MARK: - Delegate Protocol // MARK: - Delegate Protocol
protocol BitchatDelegate: AnyObject { protocol BitchatDelegate: AnyObject {
@@ -193,3 +495,37 @@ extension BitchatDelegate {
// Default empty implementation // Default empty implementation
} }
} }
// MARK: - Noise Payload Helpers
/// Helper to create typed Noise payloads
struct NoisePayload {
let type: NoisePayloadType
let data: Data
/// Encode payload with type prefix
func encode() -> Data {
var encoded = Data()
encoded.append(type.rawValue)
encoded.append(data)
return encoded
}
/// Decode payload from data
static func decode(_ data: Data) -> NoisePayload? {
// Ensure we have at least 1 byte for the type
guard !data.isEmpty else {
return nil
}
// Safely get the first byte
let firstByte = data[data.startIndex]
guard let type = NoisePayloadType(rawValue: firstByte) else {
return nil
}
// Create a proper Data copy (not a subsequence) for thread safety
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
return NoisePayload(type: type, data: payloadData)
}
}
+14
View File
@@ -0,0 +1,14 @@
import Foundation
import CryptoKit
// MARK: - Peer ID Utilities
struct PeerIDUtils {
/// Derive the stable 16-hex peer ID from a Noise static public key
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
let digest = SHA256.hash(data: publicKey)
let hex = digest.map { String(format: "%02x", $0) }.joined()
return String(hex.prefix(16))
}
}
+60 -193
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import Combine import Combine
@@ -89,9 +88,8 @@ final class BLEService: NSObject {
var myPeerID: String = "" var myPeerID: String = ""
var myNickname: String = "anon" var myNickname: String = "anon"
private var noiseService: NoiseEncryptionService private let noiseService: NoiseEncryptionService
private let identityManager: SecureIdentityStateManagerProtocol private let identityManager: SecureIdentityStateManagerProtocol
private let keychain: KeychainManagerProtocol
private var myPeerIDData: Data = Data() private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy // MARK: - Advertising Privacy
@@ -141,9 +139,6 @@ final class BLEService: NSObject {
// Debounce for 'reconnected' logs // Debounce for 'reconnected' logs
private var lastReconnectLogAt: [String: Date] = [:] private var lastReconnectLogAt: [String: Date] = [:]
// MARK: - Gossip Sync
private var gossipSyncManager: GossipSyncManager?
// MARK: - Maintenance Timer // MARK: - Maintenance Timer
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
@@ -331,45 +326,34 @@ final class BLEService: NSObject {
} }
} }
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID)
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
}
self?.messageQueue.async { [weak self] in
self?.sendAnnounce(forceSend: true)
}
}
}
private func refreshPeerIdentity() {
let fingerprint = noiseService.getIdentityFingerprint()
myPeerID = String(fingerprint.prefix(16))
myPeerIDData = Data(hexString: myPeerID) ?? Data()
}
private func restartGossipManager() {
gossipSyncManager?.stop()
let sync = GossipSyncManager(myPeerID: myPeerID)
sync.delegate = self
sync.start()
gossipSyncManager = sync
}
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) { init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
self.keychain = keychain
noiseService = NoiseEncryptionService(keychain: keychain) noiseService = NoiseEncryptionService(keychain: keychain)
self.identityManager = identityManager self.identityManager = identityManager
super.init() super.init()
configureNoiseServiceCallbacks(for: noiseService) // Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars)
refreshPeerIdentity() let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
self.myPeerID = String(fingerprint.prefix(16))
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
// Set queue key for identification // Set queue key for identification
messageQueue.setSpecific(key: messageQueueKey, value: ()) messageQueue.setSpecific(key: messageQueueKey, value: ())
// Set up Noise session establishment callback
// This ensures we send pending messages only when session is truly established
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
// Send any messages that were queued during handshake
self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID)
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
}
// Proactive presence nudge: announce immediately after handshake
self?.messageQueue.async { [weak self] in
self?.sendAnnounce(forceSend: true)
}
}
// Set up application state tracking (iOS only) // Set up application state tracking (iOS only)
#if os(iOS) #if os(iOS)
// Check initial state on main thread // Check initial state on main thread
@@ -417,9 +401,6 @@ final class BLEService: NSObject {
// Publish initial empty state // Publish initial empty state
requestPeerDataPublish() requestPeerDataPublish()
// Initialize gossip sync manager
restartGossipManager()
} }
func setNickname(_ nickname: String) { func setNickname(_ nickname: String) {
@@ -556,13 +537,23 @@ final class BLEService: NSObject {
func isPeerConnected(_ peerID: String) -> Bool { func isPeerConnected(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys // Accept both 16-hex short IDs and 64-hex Noise keys
let shortID = Peer(str: peerID).toShort().id let shortID: String = {
if peerID.count == 64, let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
}()
return collectionsQueue.sync { peers[shortID]?.isConnected ?? false } return collectionsQueue.sync { peers[shortID]?.isConnected ?? false }
} }
func isPeerReachable(_ peerID: String) -> Bool { func isPeerReachable(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys // Accept both 16-hex short IDs and 64-hex Noise keys
let shortID = Peer(str: peerID).toShort().id let shortID: String = {
if peerID.count == 64, let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
}()
return collectionsQueue.sync { return collectionsQueue.sync {
// Must be mesh-attached: at least one live direct link to the mesh // Must be mesh-attached: at least one live direct link to the mesh
let meshAttached = peers.values.contains { $0.isConnected } let meshAttached = peers.values.contains { $0.isConnected }
@@ -616,11 +607,10 @@ final class BLEService: NSObject {
var payload = Data([NoisePayloadType.readReceipt.rawValue]) var payload = Data([NoisePayloadType.readReceipt.rawValue])
payload.append(contentsOf: receipt.originalMessageID.utf8) payload.append(contentsOf: receipt.originalMessageID.utf8)
let peer = Peer(str: peerID) if noiseService.hasEstablishedSession(with: peerID) {
if noiseService.hasEstablishedSession(with: peer) {
SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session) SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session)
do { do {
let encrypted = try noiseService.encrypt(payload, for: peer) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData, senderID: myPeerIDData,
@@ -644,7 +634,7 @@ final class BLEService: NSObject {
guard let self = self else { return } guard let self = self else { return }
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
} }
if !noiseService.hasSession(with: peer) { initiateNoiseHandshake(with: peerID) } if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session) SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session)
} }
} }
@@ -665,13 +655,13 @@ final class BLEService: NSObject {
} }
private func sendNoisePayload(_ typedPayload: Data, to peerID: String) { private func sendNoisePayload(_ typedPayload: Data, to peerID: String) {
guard noiseService.hasSession(with: Peer(str: peerID)) else { guard noiseService.hasSession(with: peerID) else {
// Lazy-handshake path: queue? For now, initiate handshake and drop // Lazy-handshake path: queue? For now, initiate handshake and drop
initiateNoiseHandshake(with: peerID) initiateNoiseHandshake(with: peerID)
return return
} }
do { do {
let encrypted = try noiseService.encrypt(typedPayload, for: Peer(str: peerID)) let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData, senderID: myPeerIDData,
@@ -706,9 +696,9 @@ final class BLEService: NSObject {
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { func getNoiseSessionState(for peerID: String) -> LazyHandshakeState {
if noiseService.hasEstablishedSession(with: Peer(str: peerID)) { if noiseService.hasEstablishedSession(with: peerID) {
return .established return .established
} else if noiseService.hasSession(with: Peer(str: peerID)) { } else if noiseService.hasSession(with: peerID) {
return .handshaking return .handshaking
} else { } else {
return .none return .none
@@ -739,46 +729,6 @@ final class BLEService: NSObject {
centralToPeerID.removeAll() centralToPeerID.removeAll()
} }
func resetIdentityForPanic(currentNickname: String) {
messageQueue.sync(flags: .barrier) {
pendingMessagesAfterHandshake.removeAll()
pendingNoisePayloadsAfterHandshake.removeAll()
}
collectionsQueue.sync(flags: .barrier) {
recentAnnounceBySender.removeAll()
recentAnnounceOrder.removeAll()
pendingPeripheralWrites.removeAll()
pendingNotifications.removeAll()
pendingDirectedRelays.removeAll()
ingressByMessageID.removeAll()
recentPacketTimestamps.removeAll()
scheduledRelays.values.forEach { $0.cancel() }
scheduledRelays.removeAll()
}
bleQueue.sync {
pendingWriteBuffers.removeAll()
recentConnectTimeouts.removeAll()
}
recentDisconnectNotifies.removeAll()
noiseService.clearEphemeralStateForPanic()
noiseService.clearPersistentIdentity()
let newNoise = NoiseEncryptionService(keychain: keychain)
noiseService = newNoise
configureNoiseServiceCallbacks(for: newNoise)
refreshPeerIdentity()
restartGossipManager()
setNickname(currentNickname)
messageDeduplicator.reset()
requestPeerDataPublish()
startServices()
}
func getNoiseService() -> NoiseEncryptionService { func getNoiseService() -> NoiseEncryptionService {
return noiseService return noiseService
} }
@@ -825,8 +775,6 @@ final class BLEService: NSObject {
self.messageDeduplicator.markProcessed(dedupID) self.messageDeduplicator.markProcessed(dedupID)
// Call synchronously since we're already on background queue // Call synchronously since we're already on background queue
self.broadcastPacket(signedPacket) self.broadcastPacket(signedPacket)
// Track our own broadcast for sync
self.gossipSyncManager?.onPublicPacketSeen(signedPacket)
} }
} }
} }
@@ -839,7 +787,7 @@ final class BLEService: NSObject {
SecureLogger.debug("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: .session) SecureLogger.debug("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: .session)
// Check if we have an established Noise session // Check if we have an established Noise session
if noiseService.hasEstablishedSession(with: Peer(str: recipientID)) { if noiseService.hasEstablishedSession(with: recipientID) {
// Encrypt and send // Encrypt and send
do { do {
// Create TLV-encoded private message // Create TLV-encoded private message
@@ -853,7 +801,7 @@ final class BLEService: NSObject {
var messagePayload = Data([NoisePayloadType.privateMessage.rawValue]) var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])
messagePayload.append(tlvData) messagePayload.append(tlvData)
let encrypted = try noiseService.encrypt(messagePayload, for: Peer(str: recipientID)) let encrypted = try noiseService.encrypt(messagePayload, for: recipientID)
// Convert recipientID to Data (assuming it's a hex string) // Convert recipientID to Data (assuming it's a hex string)
var recipientData = Data() var recipientData = Data()
@@ -919,10 +867,10 @@ final class BLEService: NSObject {
private func initiateNoiseHandshake(with peerID: String) { private func initiateNoiseHandshake(with peerID: String) {
// Use NoiseEncryptionService for handshake // Use NoiseEncryptionService for handshake
guard !noiseService.hasSession(with: Peer(str: peerID)) else { return } guard !noiseService.hasSession(with: peerID) else { return }
do { do {
let handshakeData = try noiseService.initiateHandshake(with: Peer(str: peerID)) let handshakeData = try noiseService.initiateHandshake(with: peerID)
// Send handshake init // Send handshake init
let packet = BitchatPacket( let packet = BitchatPacket(
@@ -972,7 +920,7 @@ final class BLEService: NSObject {
var messagePayload = Data([NoisePayloadType.privateMessage.rawValue]) var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])
messagePayload.append(tlvData) messagePayload.append(tlvData)
let encrypted = try noiseService.encrypt(messagePayload, for: Peer(str: peerID)) let encrypted = try noiseService.encrypt(messagePayload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
@@ -1151,13 +1099,10 @@ final class BLEService: NSObject {
} }
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically // For broadcast (no directed peer) and non-fragment, choose a subset deterministically
// Special-case control/presence messages: do NOT subset to maximize immediate coverage // Special-case announces: do NOT subset to maximize reach for presence
var selectedPeripheralIDs = Set(allowedPeripheralIDs) var selectedPeripheralIDs = Set(allowedPeripheralIDs)
var selectedCentralIDs = Set(allowedCentralIDs) var selectedCentralIDs = Set(allowedCentralIDs)
if directedOnlyPeer == nil if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue {
&& packet.type != MessageType.fragment.rawValue
&& packet.type != MessageType.announce.rawValue
&& packet.type != MessageType.requestSync.rawValue {
let kp = subsetSizeForFanout(allowedPeripheralIDs.count) let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
let kc = subsetSizeForFanout(allowedCentralIDs.count) let kc = subsetSizeForFanout(allowedCentralIDs.count)
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID) selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
@@ -1188,12 +1133,6 @@ final class BLEService: NSObject {
} }
} }
// Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: String) {
guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
}
// MARK: - Directed store-and-forward // MARK: - Directed store-and-forward
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) { private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(for: packet)
@@ -1400,10 +1339,7 @@ final class BLEService: NSObject {
// Efficient deduplication // Efficient deduplication
// Important: do not dedup fragment packets globally (each piece must pass) // Important: do not dedup fragment packets globally (each piece must pass)
// Special case: allow our own packets recovered via sync (TTL==0) to pass if packet.type != MessageType.fragment.rawValue && messageDeduplicator.isDuplicate(messageID) {
// through even if we've marked them as seen at send time.
let allowSelfSyncReplay = (packet.ttl == 0) && (senderID == myPeerID)
if packet.type != MessageType.fragment.rawValue && !allowSelfSyncReplay && messageDeduplicator.isDuplicate(messageID) {
// Announce packets (type 1) are sent every 10 seconds for peer discovery // Announce packets (type 1) are sent every 10 seconds for peer discovery
// It's normal to see these as duplicates - don't log them to reduce noise // It's normal to see these as duplicates - don't log them to reduce noise
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
@@ -1447,9 +1383,6 @@ final class BLEService: NSObject {
case .message: case .message:
handleMessage(packet, from: senderID) handleMessage(packet, from: senderID)
case .requestSync:
handleRequestSync(packet, from: senderID)
case .noiseHandshake: case .noiseHandshake:
handleNoiseHandshake(packet, from: senderID) handleNoiseHandshake(packet, from: senderID)
@@ -1509,7 +1442,7 @@ final class BLEService: NSObject {
// Verify that the sender's derived ID from the announced noise public key matches the packet senderID // Verify that the sender's derived ID from the announced noise public key matches the packet senderID
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug. // This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
let derivedFromKey = Peer(publicKey: announcement.noisePublicKey).id let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.noisePublicKey)
if derivedFromKey != peerID { if derivedFromKey != peerID {
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))", category: .security) SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))", category: .security)
@@ -1650,17 +1583,12 @@ final class BLEService: NSObject {
// Only notify of connection for new or reconnected peers when it is a direct announce // Only notify of connection for new or reconnected peers when it is a direct announce
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) { if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
self.delegate?.didConnectToPeer(peerID) self.delegate?.didConnectToPeer(peerID)
// Schedule initial unicast sync to this peer
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
} }
self.requestPeerDataPublish() self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
// Track for sync (include our own and others' announces)
gossipSyncManager?.onPublicPacketSeen(packet)
// Send announce back for bidirectional discovery (only once per peer) // Send announce back for bidirectional discovery (only once per peer)
let announceBackID = "announce-back-\(peerID)" let announceBackID = "announce-back-\(peerID)"
let shouldSendBack = !messageDeduplicator.contains(announceBackID) let shouldSendBack = !messageDeduplicator.contains(announceBackID)
@@ -1683,32 +1611,16 @@ final class BLEService: NSObject {
} }
} }
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
private func handleRequestSync(_ packet: BitchatPacket, from peerID: String) {
guard let req = RequestSyncPacket.decode(from: packet.payload) else {
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID)", category: .session)
return
}
gossipSyncManager?.handleRequestSync(fromPeerID: peerID, request: req)
}
// Mention parsing moved to ChatViewModel // Mention parsing moved to ChatViewModel
private func handleMessage(_ packet: BitchatPacket, from peerID: String) { private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
// Ignore self-origin public messages except when returned via sync (TTL==0). // Ignore self-origin public messages that may be seen again via relay
// This allows our own messages to be surfaced when they come back via if peerID == myPeerID { return }
// the sync path without re-processing regular relayed copies.
if peerID == myPeerID && packet.ttl != 0 { return }
var accepted = false var accepted = false
var senderNickname: String = "" var senderNickname: String = ""
// If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately if let info = peers[peerID], info.isVerifiedNickname {
if peerID == myPeerID {
accepted = true
senderNickname = myNickname
}
else if let info = peers[peerID], info.isVerifiedNickname {
// Known verified peer path // Known verified peer path
accepted = true accepted = true
senderNickname = info.nickname senderNickname = info.nickname
@@ -1721,7 +1633,7 @@ final class BLEService: NSObject {
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix // Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
// Find candidate identities by peerID prefix (16 hex) // Find candidate identities by peerID prefix (16 hex)
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(Peer(str: peerID)) let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
for candidate in candidates { for candidate in candidates {
if let signingKey = candidate.signingPublicKey, if let signingKey = candidate.signingPublicKey,
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
@@ -1736,22 +1648,6 @@ final class BLEService: NSObject {
} }
} }
} }
// If still not accepted and this is a sync-returned packet (TTL==0),
// accept with a generic nickname so history can be restored even for
// peers we haven't verified yet.
if !accepted && packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.prefix(4))
}
}
// Track broadcast messages for sync (treat nil or 0xFF..0xFF as broadcast)
let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
}()
if isBroadcastRecipient && packet.type == MessageType.message.rawValue {
gossipSyncManager?.onPublicPacketSeen(packet)
} }
guard accepted else { guard accepted else {
@@ -1786,7 +1682,7 @@ final class BLEService: NSObject {
recipientID.hexEncodedString() == myPeerID { recipientID.hexEncodedString() == myPeerID {
// Handshake is for us // Handshake is for us
do { do {
if let response = try noiseService.processHandshakeMessage(from: Peer(str: peerID), message: packet.payload) { if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) {
// Send response // Send response
let responsePacket = BitchatPacket( let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue, type: MessageType.noiseHandshake.rawValue,
@@ -1806,7 +1702,7 @@ final class BLEService: NSObject {
} catch { } catch {
SecureLogger.error("Failed to process handshake: \(error)") SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake // Try initiating a new handshake
if !noiseService.hasSession(with: Peer(str: peerID)) { if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID) initiateNoiseHandshake(with: peerID)
} }
} }
@@ -1831,7 +1727,7 @@ final class BLEService: NSObject {
updatePeerLastSeen(peerID) updatePeerLastSeen(peerID)
do { do {
let decrypted = try noiseService.decrypt(packet.payload, from: Peer(str: peerID)) let decrypted = try noiseService.decrypt(packet.payload, from: peerID)
guard decrypted.count > 0 else { return } guard decrypted.count > 0 else { return }
// First byte indicates the payload type // First byte indicates the payload type
@@ -1871,7 +1767,7 @@ final class BLEService: NSObject {
// We received an encrypted message before establishing a session with this peer. // We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted. // Trigger a handshake so future messages can be decrypted.
SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake") SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake")
if !noiseService.hasSession(with: Peer(str: peerID)) { if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID) initiateNoiseHandshake(with: peerID)
} }
} catch { } catch {
@@ -1884,8 +1780,6 @@ final class BLEService: NSObject {
// Remove the peer when they leave // Remove the peer when they leave
peers.removeValue(forKey: peerID) peers.removeValue(forKey: peerID)
} }
// Remove any stored announcement for sync purposes
gossipSyncManager?.removeAnnouncementForPeer(peerID)
// Send on main thread // Send on main thread
notifyUI { [weak self] in notifyUI { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -1967,8 +1861,6 @@ final class BLEService: NSObject {
self?.broadcastPacket(signedPacket) self?.broadcastPacket(signedPacket)
} }
} }
// Ensure our own announce is included in sync state
gossipSyncManager?.onPublicPacketSeen(signedPacket)
} }
func sendDeliveryAck(for messageID: String, to peerID: String) { func sendDeliveryAck(for messageID: String, to peerID: String) {
@@ -1976,9 +1868,9 @@ final class BLEService: NSObject {
var payload = Data([NoisePayloadType.delivered.rawValue]) var payload = Data([NoisePayloadType.delivered.rawValue])
payload.append(contentsOf: messageID.utf8) payload.append(contentsOf: messageID.utf8)
if noiseService.hasEstablishedSession(with: Peer(str: peerID)) { if noiseService.hasEstablishedSession(with: peerID) {
do { do {
let encrypted = try noiseService.encrypt(payload, for: Peer(str: peerID)) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData, senderID: myPeerIDData,
@@ -1998,7 +1890,7 @@ final class BLEService: NSObject {
guard let self = self else { return } guard let self = self else { return }
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
} }
if !noiseService.hasSession(with: Peer(str: peerID)) { initiateNoiseHandshake(with: peerID) } if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", category: .session) SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", category: .session)
} }
} }
@@ -2013,7 +1905,7 @@ final class BLEService: NSObject {
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", category: .session) SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", category: .session)
for payload in payloads { for payload in payloads {
do { do {
let encrypted = try noiseService.encrypt(payload, for: Peer(str: peerID)) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData, senderID: myPeerIDData,
@@ -2196,8 +2088,6 @@ final class BLEService: NSObject {
if !peer.isConnected { if !peer.isConnected {
if age > retention { if age > retention {
SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session) SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session)
// Also remove any stored announcement from sync candidates
gossipSyncManager?.removeAnnouncementForPeer(peerID)
peers.removeValue(forKey: peerID) peers.removeValue(forKey: peerID)
removedOfflineCount += 1 removedOfflineCount += 1
} }
@@ -2359,29 +2249,6 @@ final class BLEService: NSObject {
} }
} }
// MARK: - GossipSyncManager Delegate
extension BLEService: GossipSyncManager.Delegate {
func sendPacket(_ packet: BitchatPacket) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
}
func sendPacket(to peerID: String, packet: BitchatPacket) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
sendPacketDirected(packet, to: peerID)
} else {
messageQueue.async { [weak self] in self?.sendPacketDirected(packet, to: peerID) }
}
}
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
return noiseService.signPacket(packet) ?? packet
}
}
// MARK: - CBCentralManagerDelegate // MARK: - CBCentralManagerDelegate
extension BLEService: CBCentralManagerDelegate { extension BLEService: CBCentralManagerDelegate {
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
import Combine import Combine
@@ -179,11 +178,12 @@ final class FavoritesPersistenceService: ObservableObject {
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey) /// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
/// Falls back to scanning favorites and matching on derived peer ID. /// Falls back to scanning favorites and matching on derived peer ID.
func getFavoriteStatus(for peer: Peer) -> FavoriteRelationship? { func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
// Quick sanity: peer.id should be 16 hex chars (8 bytes) // Quick sanity: peerID should be 16 hex chars (8 bytes)
guard peer.isShort else { return nil } guard peerID.count == 16 else { return nil }
for (pubkey, rel) in favorites where Peer(publicKey: pubkey) == peer { for (pubkey, rel) in favorites {
return rel let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
if derived == peerID { return rel }
} }
return nil return nil
} }
+2 -4
View File
@@ -6,7 +6,6 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
import Security import Security
@@ -24,8 +23,8 @@ protocol KeychainManagerProtocol {
final class KeychainManager: KeychainManagerProtocol { final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items // Use consistent service name for all keychain items
private let service = BitchatApp.bundleID private let service = "chat.bitchat"
private let appGroup = "group.\(BitchatApp.bundleID)" private let appGroup = "group.chat.bitchat"
private func isSandboxed() -> Bool { private func isSandboxed() -> Bool {
#if os(macOS) #if os(macOS)
@@ -282,7 +281,6 @@ final class KeychainManager: KeychainManagerProtocol {
"com.bitchat.deviceidentity", "com.bitchat.deviceidentity",
"com.bitchat.noise.identity", "com.bitchat.noise.identity",
"chat.bitchat.passwords", "chat.bitchat.passwords",
"chat.bitchat.nostr",
"bitchat.keychain", "bitchat.keychain",
"bitchat", "bitchat",
"com.bitchat" "com.bitchat"
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
import Combine import Combine
+4 -5
View File
@@ -1,6 +1,6 @@
import Foundation import Foundation
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars). /// Lightweight background counter for location notes (kind 1) at block-level geohash.
@MainActor @MainActor
final class LocationNotesCounter: ObservableObject { final class LocationNotesCounter: ObservableObject {
static let shared = LocationNotesCounter() static let shared = LocationNotesCounter()
@@ -16,11 +16,10 @@ final class LocationNotesCounter: ObservableObject {
func subscribe(geohash gh: String) { func subscribe(geohash gh: String) {
let norm = gh.lowercased() let norm = gh.lowercased()
if geohash == norm, subscriptionID != nil { return } if geohash == norm { return }
// Unsubscribe previous without clearing count to avoid flicker cancel()
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
subscriptionID = nil
geohash = norm geohash = norm
count = 0
noteIDs.removeAll() noteIDs.removeAll()
initialLoadComplete = false initialLoadComplete = false
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7). /// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
+45 -46
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
/// Routes messages between BLE and Nostr transports /// Routes messages between BLE and Nostr transports
@@ -6,7 +5,7 @@ import Foundation
final class MessageRouter { final class MessageRouter {
private let mesh: Transport private let mesh: Transport
private let nostr: NostrTransport private let nostr: NostrTransport
private var outbox: [Peer: [(content: String, nickname: String, messageID: String)]] = [:] // Peer -> queued messages private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
init(mesh: Transport, nostr: NostrTransport) { init(mesh: Transport, nostr: NostrTransport) {
self.mesh = mesh self.mesh = mesh
@@ -21,80 +20,80 @@ final class MessageRouter {
) { [weak self] note in ) { [weak self] note in
guard let self = self else { return } guard let self = self else { return }
if let data = note.userInfo?["peerPublicKey"] as? Data { if let data = note.userInfo?["peerPublicKey"] as? Data {
let peer = Peer(publicKey: data) let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
Task { @MainActor in Task { @MainActor in
self.flushOutbox(for: peer) self.flushOutbox(for: peerID)
} }
} }
// Handle key updates // Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data, if let newKey = note.userInfo?["peerPublicKey"] as? Data,
let _ = note.userInfo?["isKeyUpdate"] as? Bool { let _ = note.userInfo?["isKeyUpdate"] as? Bool {
let peer = Peer(publicKey: newKey) let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
Task { @MainActor in Task { @MainActor in
self.flushOutbox(for: peer) self.flushOutbox(for: peerID)
} }
} }
} }
} }
func sendPrivate(_ content: String, to peer: Peer, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
let reachableMesh = mesh.isPeerReachable(peer.id) let reachableMesh = mesh.isPeerReachable(peerID)
if reachableMesh { if reachableMesh {
SecureLogger.debug("Routing PM via mesh (reachable) to \(peer.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// BLEService will initiate a handshake if needed and queue the message // BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peer.id, recipientNickname: recipientNickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peer: peer) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Routing PM via Nostr to \(peer.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peer.id, recipientNickname: recipientNickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else { } else {
// Queue for later (when mesh connects or Nostr mapping appears) // Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peer] == nil { outbox[peer] = [] } if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peer]?.append((content, recipientNickname, messageID)) outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.debug("Queued PM for \(peer.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session)
} }
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peer: Peer) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
// Prefer mesh for reachable peers; BLE will queue if handshake is needed // Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerReachable(peer.id) { if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peer.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session) SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
mesh.sendReadReceipt(receipt, to: peer.id) mesh.sendReadReceipt(receipt, to: peerID)
} else { } else {
SecureLogger.debug("Routing READ ack via Nostr to \(peer.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session) SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
nostr.sendReadReceipt(receipt, to: peer.id) nostr.sendReadReceipt(receipt, to: peerID)
} }
} }
func sendDeliveryAck(_ messageID: String, to peer: Peer) { func sendDeliveryAck(_ messageID: String, to peerID: String) {
if mesh.isPeerReachable(peer.id) { if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peer.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendDeliveryAck(for: messageID, to: peer.id) mesh.sendDeliveryAck(for: messageID, to: peerID)
} else { } else {
nostr.sendDeliveryAck(for: messageID, to: peer.id) nostr.sendDeliveryAck(for: messageID, to: peerID)
} }
} }
func sendFavoriteNotification(to peer: Peer, isFavorite: Bool) { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
// Route via mesh when connected; else use Nostr // Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peer.id) { if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peer.id, isFavorite: isFavorite) mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else { } else {
nostr.sendFavoriteNotification(to: peer.id, isFavorite: isFavorite) nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} }
} }
// MARK: - Outbox Management // MARK: - Outbox Management
private func canSendViaNostr(peer: Peer) -> Bool { private func canSendViaNostr(peerID: String) -> Bool {
// Two forms are supported: // Two forms are supported:
// - 64-hex Noise public key (32 bytes) // - 64-hex Noise public key (32 bytes)
// - 16-hex short peer ID (derived from Noise pubkey) // - 16-hex short peer ID (derived from Noise pubkey)
if let noiseKey = peer.noiseKey { if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil { fav.peerNostrPublicKey != nil {
return true return true
} }
} else if peer.isShort { } else if peerID.count == 16 {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer), if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
fav.peerNostrPublicKey != nil { fav.peerNostrPublicKey != nil {
return true return true
} }
@@ -102,18 +101,18 @@ final class MessageRouter {
return false return false
} }
func flushOutbox(for peer: Peer) { func flushOutbox(for peerID: String) {
guard let queued = outbox[peer], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peer.id.prefix(8))… count=\(queued.count)", category: .session) SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
var remaining: [(content: String, nickname: String, messageID: String)] = [] var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists // Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued { for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peer.id) { if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Outbox -> mesh for \(peer.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendPrivateMessage(content, to: peer.id, recipientNickname: nickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peer: peer) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Outbox -> Nostr for \(peer.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peer.id, recipientNickname: nickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else { } else {
// Keep unsent items queued // Keep unsent items queued
remaining.append((content, nickname, messageID)) remaining.append((content, nickname, messageID))
@@ -121,9 +120,9 @@ final class MessageRouter {
} }
// Persist only items we could not send // Persist only items we could not send
if remaining.isEmpty { if remaining.isEmpty {
outbox.removeValue(forKey: peer) outbox.removeValue(forKey: peerID)
} else { } else {
outbox[peer] = remaining outbox[peerID] = remaining
} }
} }
@@ -1,113 +0,0 @@
import Foundation
import BitLogger
import Combine
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
/// Policy: permit start when either location permissions are authorized OR
/// there exists at least one mutual favorite. Otherwise, do not start.
@MainActor
final class NetworkActivationService: ObservableObject {
static let shared = NetworkActivationService()
@Published private(set) var activationAllowed: Bool = false
@Published private(set) var userTorEnabled: Bool = true
private var cancellables = Set<AnyCancellable>()
private var started = false
private let torPreferenceKey = "networkActivationService.userTorEnabled"
private var torAutoStartDesired: Bool = false
private init() {}
func start() {
guard !started else { return }
started = true
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
userTorEnabled = stored
} else {
userTorEnabled = true
}
// Initial compute
let allowed = basePolicyAllowed()
activationAllowed = allowed
torAutoStartDesired = allowed && userTorEnabled
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
applyTorState(torDesired: torAutoStartDesired)
if allowed {
NostrRelayManager.shared.connect()
} else {
NostrRelayManager.shared.disconnect()
}
// React to location permission changes
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
}
.store(in: &cancellables)
// React to mutual favorites changes
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
}
.store(in: &cancellables)
}
func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return }
userTorEnabled = enabled
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
NotificationCenter.default.post(
name: .TorUserPreferenceChanged,
object: nil,
userInfo: ["enabled": enabled]
)
reevaluate()
}
private func reevaluate() {
let allowed = basePolicyAllowed()
let torDesired = allowed && userTorEnabled
let statusChanged = allowed != activationAllowed
let torChanged = torDesired != torAutoStartDesired
if statusChanged {
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
activationAllowed = allowed
}
if statusChanged || torChanged {
torAutoStartDesired = torDesired
TorManager.shared.setAutoStartAllowed(torDesired)
applyTorState(torDesired: torDesired)
}
if allowed {
if torChanged {
// Reset relay sockets when switching transport path (Tor direct)
NostrRelayManager.shared.disconnect()
}
NostrRelayManager.shared.connect()
} else if statusChanged {
NostrRelayManager.shared.disconnect()
}
}
private func basePolicyAllowed() -> Bool {
let permOK = LocationChannelManager.shared.permissionState == .authorized
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
return permOK || hasMutual
}
private func applyTorState(torDesired: Bool) {
TorURLSession.shared.setProxyMode(useTor: torDesired)
if torDesired {
TorManager.shared.startIfNeeded()
} else {
TorManager.shared.shutdownCompletely()
}
}
}
+47 -57
View File
@@ -83,7 +83,6 @@
/// - Background queue for CPU-intensive operations /// - Background queue for CPU-intensive operations
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -148,8 +147,8 @@ final class NoiseEncryptionService {
private let sessionManager: NoiseSessionManager private let sessionManager: NoiseSessionManager
// Peer fingerprints (SHA256 hash of static public key) // Peer fingerprints (SHA256 hash of static public key)
private var peerFingerprints: [Peer: String] = [:] // Peer -> fingerprint private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
private var fingerprintToPeer: [String: Peer] = [:] // fingerprint -> Peer private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
// Thread safety // Thread safety
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent) private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
@@ -237,7 +236,7 @@ final class NoiseEncryptionService {
// Set up session callbacks // Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
self?.handleSessionEstablished(peer: Peer(str: peerID), remoteStaticKey: remoteStaticKey) self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
} }
// Start session maintenance timer // Start session maintenance timer
@@ -263,8 +262,8 @@ final class NoiseEncryptionService {
} }
/// Get peer's public key data /// Get peer's public key data
func getPeerPublicKeyData(_ peer: Peer) -> Data? { func getPeerPublicKeyData(_ peerID: String) -> Data? {
return sessionManager.getRemoteStaticKey(for: peer.id)?.rawRepresentation return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
} }
/// Clear persistent identity (for panic mode) /// Clear persistent identity (for panic mode)
@@ -391,52 +390,52 @@ final class NoiseEncryptionService {
// MARK: - Handshake Management // MARK: - Handshake Management
/// Initiate a Noise handshake with a peer /// Initiate a Noise handshake with a peer
func initiateHandshake(with peer: Peer) throws -> Data { func initiateHandshake(with peerID: String) throws -> Data {
// Validate peer ID // Validate peer ID
guard peer.isValid else { guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.warning(.authenticationFailed(peerID: peer.id)) SecureLogger.warning(.authenticationFailed(peerID: peerID))
throw NoiseSecurityError.invalidPeerID throw NoiseSecurityError.invalidPeerID
} }
// Check rate limit // Check rate limit
guard rateLimiter.allowHandshake(from: peer) else { guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peer.id)")) SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
throw NoiseSecurityError.rateLimitExceeded throw NoiseSecurityError.rateLimitExceeded
} }
SecureLogger.info(.handshakeStarted(peerID: peer.id)) SecureLogger.info(.handshakeStarted(peerID: peerID))
// Return raw handshake data without wrapper // Return raw handshake data without wrapper
// The Noise protocol handles its own message format // The Noise protocol handles its own message format
let handshakeData = try sessionManager.initiateHandshake(with: peer.id) let handshakeData = try sessionManager.initiateHandshake(with: peerID)
return handshakeData return handshakeData
} }
/// Process an incoming handshake message /// Process an incoming handshake message
func processHandshakeMessage(from peer: Peer, message: Data) throws -> Data? { func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
// Validate peer ID // Validate peer ID
guard peer.isValid else { guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.warning(.authenticationFailed(peerID: peer.id)) SecureLogger.warning(.authenticationFailed(peerID: peerID))
throw NoiseSecurityError.invalidPeerID throw NoiseSecurityError.invalidPeerID
} }
// Validate message size // Validate message size
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else { guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
SecureLogger.warning(.handshakeFailed(peerID: peer.id, error: "Message too large")) SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large"))
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
// Check rate limit // Check rate limit
guard rateLimiter.allowHandshake(from: peer) else { guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peer.id)")) SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
throw NoiseSecurityError.rateLimitExceeded throw NoiseSecurityError.rateLimitExceeded
} }
// For handshakes, we process the raw data directly without NoiseMessage wrapper // For handshakes, we process the raw data directly without NoiseMessage wrapper
// The Noise protocol handles its own message format // The Noise protocol handles its own message format
let responsePayload = try sessionManager.handleIncomingHandshake(from: peer.id, message: message) let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message)
// Return raw response without wrapper // Return raw response without wrapper
@@ -444,117 +443,108 @@ final class NoiseEncryptionService {
} }
/// Check if we have an established session with a peer /// Check if we have an established session with a peer
func hasEstablishedSession(with peer: Peer) -> Bool { func hasEstablishedSession(with peerID: String) -> Bool {
return sessionManager.getSession(for: peer.id)?.isEstablished() ?? false return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
} }
/// Check if we have a session (established or handshaking) with a peer /// Check if we have a session (established or handshaking) with a peer
func hasSession(with peer: Peer) -> Bool { func hasSession(with peerID: String) -> Bool {
return sessionManager.getSession(for: peer.id) != nil return sessionManager.getSession(for: peerID) != nil
} }
// MARK: - Encryption/Decryption // MARK: - Encryption/Decryption
/// Encrypt data for a specific peer /// Encrypt data for a specific peer
func encrypt(_ data: Data, for peer: Peer) throws -> Data { func encrypt(_ data: Data, for peerID: String) throws -> Data {
// Validate message size // Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else { guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
// Check rate limit // Check rate limit
guard rateLimiter.allowMessage(from: peer) else { guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded throw NoiseSecurityError.rateLimitExceeded
} }
// Check if we have an established session // Check if we have an established session
guard hasEstablishedSession(with: peer) else { guard hasEstablishedSession(with: peerID) else {
// Signal that handshake is needed // Signal that handshake is needed
onHandshakeRequired?(peer.id) onHandshakeRequired?(peerID)
throw NoiseEncryptionError.handshakeRequired throw NoiseEncryptionError.handshakeRequired
} }
return try sessionManager.encrypt(data, for: peer.id) return try sessionManager.encrypt(data, for: peerID)
} }
/// Decrypt data from a specific peer /// Decrypt data from a specific peer
func decrypt(_ data: Data, from peer: Peer) throws -> Data { func decrypt(_ data: Data, from peerID: String) throws -> Data {
// Validate message size // Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else { guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
// Check rate limit // Check rate limit
guard rateLimiter.allowMessage(from: peer) else { guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded throw NoiseSecurityError.rateLimitExceeded
} }
// Check if we have an established session // Check if we have an established session
guard hasEstablishedSession(with: peer) else { guard hasEstablishedSession(with: peerID) else {
throw NoiseEncryptionError.sessionNotEstablished throw NoiseEncryptionError.sessionNotEstablished
} }
return try sessionManager.decrypt(data, from: peer.id) return try sessionManager.decrypt(data, from: peerID)
} }
// MARK: - Peer Management // MARK: - Peer Management
/// Get fingerprint for a peer /// Get fingerprint for a peer
func getPeerFingerprint(_ peer: Peer) -> String? { func getPeerFingerprint(_ peerID: String) -> String? {
return serviceQueue.sync { return serviceQueue.sync {
return peerFingerprints[peer] return peerFingerprints[peerID]
} }
} }
/// Get peer ID for a fingerprint /// Get peer ID for a fingerprint
func getPeer(for fingerprint: String) -> Peer? { func getPeerID(for fingerprint: String) -> String? {
return serviceQueue.sync { return serviceQueue.sync {
return fingerprintToPeer[fingerprint] return fingerprintToPeerID[fingerprint]
} }
} }
/// Remove a peer session /// Remove a peer session
func removePeer(_ peer: Peer) { func removePeer(_ peerID: String) {
sessionManager.removeSession(for: peer.id) sessionManager.removeSession(for: peerID)
serviceQueue.sync(flags: .barrier) { serviceQueue.sync(flags: .barrier) {
if let fingerprint = peerFingerprints[peer] { if let fingerprint = peerFingerprints[peerID] {
fingerprintToPeer.removeValue(forKey: fingerprint) fingerprintToPeerID.removeValue(forKey: fingerprint)
} }
peerFingerprints.removeValue(forKey: peer) peerFingerprints.removeValue(forKey: peerID)
} }
SecureLogger.info(.sessionExpired(peerID: peer.id)) SecureLogger.info(.sessionExpired(peerID: peerID))
}
func clearEphemeralStateForPanic() {
sessionManager.removeAllSessions()
serviceQueue.sync(flags: .barrier) {
peerFingerprints.removeAll()
fingerprintToPeer.removeAll()
}
rateLimiter.resetAll()
} }
// MARK: - Private Helpers // MARK: - Private Helpers
private func handleSessionEstablished(peer: Peer, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
// Calculate fingerprint // Calculate fingerprint
let fingerprint = calculateFingerprint(for: remoteStaticKey) let fingerprint = calculateFingerprint(for: remoteStaticKey)
// Store fingerprint mapping // Store fingerprint mapping
serviceQueue.sync(flags: .barrier) { serviceQueue.sync(flags: .barrier) {
peerFingerprints[peer] = fingerprint peerFingerprints[peerID] = fingerprint
fingerprintToPeer[fingerprint] = peer fingerprintToPeerID[fingerprint] = peerID
} }
// Log security event // Log security event
SecureLogger.info(.handshakeCompleted(peerID: peer.id)) SecureLogger.info(.handshakeCompleted(peerID: peerID))
// Notify all handlers about authentication // Notify all handlers about authentication
serviceQueue.async { [weak self] in serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedHandlers.forEach { handler in self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peer.id, fingerprint) handler(peerID, fingerprint)
} }
} }
} }
+1 -2
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
import Combine import Combine
@@ -174,7 +173,7 @@ final class NostrTransport: Transport {
return npub return npub
} }
if peerID.count == 16, if peerID.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Peer(str: peerID)), let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
} }
+3 -3
View File
@@ -61,11 +61,11 @@ final class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier) sendLocalNotification(title: title, body: body, identifier: identifier)
} }
func sendPrivateMessageNotification(from sender: String, message: String, peer: Peer) { func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
let title = "🔒 DM from \(sender)" let title = "🔒 private message from \(sender)"
let body = message let body = message
let identifier = "private-\(UUID().uuidString)" let identifier = "private-\(UUID().uuidString)"
let userInfo = ["peerID": peer.id, "senderName": sender] let userInfo = ["peerID": peerID, "senderName": sender]
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
} }
+6 -7
View File
@@ -6,7 +6,6 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import BitLogger
import Foundation import Foundation
import SwiftUI import SwiftUI
@@ -73,7 +72,7 @@ final class PrivateChatManager: ObservableObject {
originalSender: nil, originalSender: nil,
isPrivate: true, isPrivate: true,
recipientNickname: peerNickname, recipientNickname: peerNickname,
senderPeer: Peer(str: meshService.myPeerID), senderPeerID: meshService.myPeerID,
mentions: nil, mentions: nil,
deliveryStatus: .sending deliveryStatus: .sending
) )
@@ -94,7 +93,7 @@ final class PrivateChatManager: ObservableObject {
/// Handle incoming private message /// Handle incoming private message
func handleIncomingMessage(_ message: BitchatMessage) { func handleIncomingMessage(_ message: BitchatMessage) {
guard let senderPeerID = message.senderPeer?.id else { return } guard let senderPeerID = message.senderPeerID else { return }
// Initialize chat if needed // Initialize chat if needed
if privateChats[senderPeerID] == nil { if privateChats[senderPeerID] == nil {
@@ -126,7 +125,7 @@ final class PrivateChatManager: ObservableObject {
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
from: message.sender, from: message.sender,
message: message.content, message: message.content,
peer: Peer(str: senderPeerID) peerID: senderPeerID
) )
} }
} else { } else {
@@ -161,7 +160,7 @@ final class PrivateChatManager: ObservableObject {
// Send read receipts for unread messages that haven't been sent yet // Send read receipts for unread messages that haven't been sent yet
if let messages = privateChats[peerID] { if let messages = privateChats[peerID] {
for message in messages { for message in messages {
if message.senderPeer?.id == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) { if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message) sendReadReceipt(for: message)
} }
} }
@@ -214,7 +213,7 @@ final class PrivateChatManager: ObservableObject {
private func sendReadReceipt(for message: BitchatMessage) { private func sendReadReceipt(for message: BitchatMessage) {
guard !sentReadReceipts.contains(message.id), guard !sentReadReceipts.contains(message.id),
let senderPeerID = message.senderPeer?.id else { let senderPeerID = message.senderPeerID else {
return return
} }
@@ -231,7 +230,7 @@ final class PrivateChatManager: ObservableObject {
if let router = messageRouter { if let router = messageRouter {
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session) SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
Task { @MainActor in Task { @MainActor in
router.sendReadReceipt(receipt, to: Peer(str: senderPeerID)) router.sendReadReceipt(receipt, to: senderPeerID)
} }
} else { } else {
// Fallback: preserve previous behavior // Fallback: preserve previous behavior
+1 -5
View File
@@ -40,11 +40,7 @@ static void *tor_thread_main(void *arg) {
int rc = tor_run_main(cfg); // blocks until tor exits int rc = tor_run_main(cfg); // blocks until tor exits
tor_main_configuration_free(cfg); tor_main_configuration_free(cfg);
if (argv_owned) { free(argv_owned); argv_owned = NULL; argv_owned_argc = 0; } if (argv_owned) { free(argv_owned); argv_owned = NULL; argv_owned_argc = 0; }
// Do not close owning_fd_tor here: Tor may have already closed it and the if (owning_fd_tor != -1) { close(owning_fd_tor); owning_fd_tor = -1; }
// fd number could have been re-used by the time we get here. On iOS 18,
// attempting to close a re-used guarded fd can trigger EXC_GUARD. Treat the
// tor-side end as owned by Tor and simply forget our reference.
owning_fd_tor = -1;
return (void*)(intptr_t)rc; return (void*)(intptr_t)rc;
} }
+3 -134
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
import Network import Network
import Darwin import Darwin
@@ -68,27 +67,19 @@ final class TorManager: ObservableObject {
private var controlMonitorStarted = false private var controlMonitorStarted = false
private var pathMonitor: NWPathMonitor? private var pathMonitor: NWPathMonitor?
private var isAppForeground: Bool = true private var isAppForeground: Bool = true
private var isDormant: Bool = false
private var lastRestartAt: Date? = nil private var lastRestartAt: Date? = nil
// Global policy gate: only allow Tor to start when true
private(set) var allowAutoStart: Bool = false
private init() {} private init() {}
// MARK: - Public API // MARK: - Public API
func startIfNeeded() { func startIfNeeded() {
// Respect global start policy
guard allowAutoStart else { return }
// Do not start in background; caller should wait for foreground // Do not start in background; caller should wait for foreground
guard isAppForeground else { return } guard isAppForeground else { return }
guard !didStart else { return } guard !didStart else { return }
didStart = true didStart = true
isDormant = false
isStarting = true isStarting = true
lastError = nil lastError = nil
// Announce initial start so UI can show a status message
NotificationCenter.default.post(name: .TorWillStart, object: nil)
ensureFilesystemLayout() ensureFilesystemLayout()
startTor() startTor()
startPathMonitorIfNeeded() startPathMonitorIfNeeded()
@@ -481,8 +472,6 @@ final class TorManager: ObservableObject {
// MARK: - Foreground recovery and control helpers // MARK: - Foreground recovery and control helpers
func ensureRunningOnForeground() { func ensureRunningOnForeground() {
// Respect global start policy
if !allowAutoStart { return }
// iOS can suspend Tor harshly; the most reliable approach for // iOS can suspend Tor harshly; the most reliable approach for
// embedding is to restart Tor every time we become active. // embedding is to restart Tor every time we become active.
Task.detached(priority: .userInitiated) { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in
@@ -494,39 +483,18 @@ final class TorManager: ObservableObject {
return true return true
} }
if !claimed { return } if !claimed { return }
if await self.resumeTorIfPossible() {
await MainActor.run {
self.restarting = false
self.isStarting = false
}
return
}
await self.restartTor() await self.restartTor()
await MainActor.run { self.restarting = false } await MainActor.run { self.restarting = false }
} }
} }
func goDormantOnBackground() { func goDormantOnBackground() {
// Prefer Tor's DORMANT mode so we can resume on foreground without a full restart. // Stricter model: fully stop Tor when app backgrounds to save power
// If the control port is unreachable, fall back to a hard shutdown. // and avoid half-suspended states. We'll restart cleanly on .active.
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let signaled = await self.controlSendSignal("DORMANT")
if signaled {
SecureLogger.info("TorManager: signalled DORMANT", category: .session)
await MainActor.run {
self.isDormant = true
self.isReady = false
self.socksReady = false
self.isStarting = false
}
return
}
SecureLogger.warning("TorManager: DORMANT signal failed; shutting down", category: .session)
_ = tor_host_shutdown() _ = tor_host_shutdown()
await MainActor.run { await MainActor.run {
self.isDormant = false
self.isReady = false self.isReady = false
self.socksReady = false self.socksReady = false
self.bootstrapProgress = 0 self.bootstrapProgress = 0
@@ -539,24 +507,6 @@ final class TorManager: ObservableObject {
} }
} }
func shutdownCompletely() {
Task.detached { [weak self] in
guard let self = self else { return }
_ = tor_host_shutdown()
await MainActor.run {
self.isDormant = false
self.isReady = false
self.socksReady = false
self.bootstrapProgress = 0
self.bootstrapSummary = ""
self.isStarting = false
self.didStart = false
self.restarting = false
self.controlMonitorStarted = false
}
}
}
private func restartTor() async { private func restartTor() async {
await MainActor.run { await MainActor.run {
// Announce restart so UI can notify the user // Announce restart so UI can notify the user
@@ -566,28 +516,14 @@ final class TorManager: ObservableObject {
self.bootstrapProgress = 0 self.bootstrapProgress = 0
self.bootstrapSummary = "" self.bootstrapSummary = ""
self.isStarting = true self.isStarting = true
self.isDormant = false
self.lastRestartAt = Date() self.lastRestartAt = Date()
} }
// Prefer clean shutdown via owning controller FD; join the tor thread // Prefer clean shutdown via owning controller FD; join the tor thread
_ = tor_host_shutdown() _ = tor_host_shutdown()
// As a fallback, try control signal if needed (harmless if tor already down) // As a fallback, try control signal if needed (harmless if tor already down)
_ = await controlSendSignal("SHUTDOWN") _ = await controlSendSignal("SHUTDOWN")
// Allow Tor thread to fully terminate before re-starting.
var waited = 0
while tor_host_is_running() != 0 && waited < 40 {
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
waited += 1
}
if waited >= 40 {
SecureLogger.warning("TorManager: tor_host_is_running still true before restart", category: .session)
}
// Allow control monitor and start logic to reinitialize cleanly
await MainActor.run {
self.controlMonitorStarted = false
self.didStart = false
}
// Now start fresh // Now start fresh
await MainActor.run { self.didStart = false }
await MainActor.run { self.startIfNeeded() } await MainActor.run { self.startIfNeeded() }
} }
@@ -653,62 +589,6 @@ final class TorManager: ObservableObject {
return (text?.contains("250")) == true return (text?.contains("250")) == true
} }
private func resumeTorIfPossible() async -> Bool {
let wasDormant = await MainActor.run { self.isDormant }
let pendingReady = await MainActor.run { self.socksReady && !self.isReady }
let needsWake = wasDormant || pendingReady
if !needsWake {
return false
}
let activated = await controlSendSignal("ACTIVE")
let pinged = await controlPingBootstrap(timeout: 3.0)
if !activated && !pinged {
SecureLogger.warning("TorManager: ACTIVE signal failed", category: .session)
return false
}
if let info = await controlGetBootstrapInfo() {
await MainActor.run {
self.bootstrapProgress = info.progress
self.bootstrapSummary = info.summary
}
}
await MainActor.run {
self.isDormant = false
self.isStarting = true
self.socksReady = false
}
let firstReady = await waitForSocksReady(timeout: 12.0)
if firstReady {
await MainActor.run {
self.socksReady = true
self.isStarting = false
}
SecureLogger.info("TorManager: resumed Tor via ACTIVE signal", category: .session)
return true
}
if pinged {
let secondReady = await waitForSocksReady(timeout: 20.0)
await MainActor.run {
self.socksReady = secondReady
self.isStarting = !secondReady
}
if secondReady {
SecureLogger.info("TorManager: resumed Tor after extended wait", category: .session)
return true
}
} else {
await MainActor.run { self.isStarting = false }
}
SecureLogger.warning("TorManager: ACTIVE resume failed; will restart", category: .session)
return false
}
private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? { private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? {
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"), guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"),
let cookie = try? Data(contentsOf: cookiePath) else { return nil } let cookie = try? Data(contentsOf: cookiePath) else { return nil }
@@ -755,14 +635,3 @@ final class TorManager: ObservableObject {
return resultText return resultText
} }
} }
// MARK: - Start policy configuration
extension TorManager {
@MainActor
func setAutoStartAllowed(_ allow: Bool) {
allowAutoStart = allow
}
@MainActor
func isAutoStartAllowed() -> Bool { allowAutoStart }
}
@@ -3,6 +3,4 @@ import Foundation
extension Notification.Name { extension Notification.Name {
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady") static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
static let TorWillRestart = Notification.Name("TorWillRestart") static let TorWillRestart = Notification.Name("TorWillRestart")
static let TorWillStart = Notification.Name("TorWillStart")
static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged")
} }
+22 -19
View File
@@ -4,34 +4,43 @@ import CFNetwork
#endif #endif
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy /// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
/// when Tor is enforced/ready. Allows swapping between proxied and direct /// when Tor is enforced/ready. Falls back to a default session only when
/// sessions so UI can toggle Tor usage at runtime. /// compiled with the `BITCHAT_DEV_ALLOW_CLEARNET` flag.
final class TorURLSession { final class TorURLSession {
static let shared = TorURLSession() static let shared = TorURLSession()
// Default (no proxy) session for direct Nostr access when Tor is disabled. // Default (no proxy) session for local development when dev bypass is enabled.
private var defaultSession: URLSession = TorURLSession.makeDefaultSession() private var defaultSession: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}()
// Proxied (SOCKS5) session that routes through Tor. // Proxied (SOCKS5) session that routes through Tor.
private var torSession: URLSession = TorURLSession.makeTorSession() private var torSession: URLSession = TorURLSession.makeTorSession()
private var useTorProxy: Bool = true
var session: URLSession { var session: URLSession {
useTorProxy ? torSession : defaultSession #if BITCHAT_DEV_ALLOW_CLEARNET
// Dev bypass: use direct session. Call sites may still await Tor if desired.
return defaultSession
#else
// Production: always use the Tor-proxied session. Call sites ensure readiness.
return torSession
#endif
} }
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart. // Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
func rebuild() { func rebuild() {
defaultSession = TorURLSession.makeDefaultSession() #if BITCHAT_DEV_ALLOW_CLEARNET
defaultSession = {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}()
#endif
torSession = TorURLSession.makeTorSession() torSession = TorURLSession.makeTorSession()
} }
func setProxyMode(useTor: Bool) {
guard useTorProxy != useTor else { return }
useTorProxy = useTor
rebuild()
}
private static func makeTorSession() -> URLSession { private static func makeTorSession() -> URLSession {
let cfg = URLSessionConfiguration.ephemeral let cfg = URLSessionConfiguration.ephemeral
cfg.waitsForConnectivity = true cfg.waitsForConnectivity = true
@@ -54,10 +63,4 @@ final class TorURLSession {
#endif #endif
return URLSession(configuration: cfg) return URLSession(configuration: cfg)
} }
private static func makeDefaultSession() -> URLSession {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}
} }
+1 -1
View File
@@ -188,7 +188,7 @@ enum TransportConfig {
static let uiWindowStepCount: Int = 200 static let uiWindowStepCount: Int = 200
// Share extension // Share extension
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0 static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0 static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60 static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
} }
+1 -2
View File
@@ -6,7 +6,6 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import BitLogger
import Foundation import Foundation
import Combine import Combine
import SwiftUI import SwiftUI
@@ -307,7 +306,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Send favorite notification to the peer via router (mesh or Nostr) // Send favorite notification to the peer via router (mesh or Nostr)
if let router = messageRouter { if let router = messageRouter {
router.sendFavoriteNotification(to: Peer(str: peerID), isFavorite: !wasFavorite) router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
} else { } else {
// Fallback to mesh-only if router not yet wired // Fallback to mesh-only if router not yet wired
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite) meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
-183
View File
@@ -1,183 +0,0 @@
import Foundation
import CryptoKit
// Golomb-Coded Set (GCS) filter utilities for sync.
// Hashing:
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
// - Map to [0, M) via (h64 % M).
// Encoding (v1):
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
// - Bitstream is MSB-first within each byte.
enum GCSFilter {
struct Params { let p: Int; let m: UInt32; let data: Data }
// Derive P from FPR (~ 1 / 2^P)
static func deriveP(targetFpr: Double) -> Int {
let f = max(0.000001, min(0.25, targetFpr))
// ceil(log2(1/f))
let p = Int(ceil(log2(1.0 / f)))
return max(1, p)
}
// Estimate max elements that fit in size bytes: bits per element ~= P + 2 (approx)
static func estimateMaxElements(sizeBytes: Int, p: Int) -> Int {
let bits = max(8, sizeBytes * 8)
let per = max(3, p + 2)
return max(1, bits / per)
}
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
let p = deriveP(targetFpr: targetFpr)
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
let n = min(ids.count, cap)
let selected = Array(ids.prefix(n))
// Map to [0, M)
let mInit = UInt32(n << p)
var mapped = selected.map { id16 -> UInt64 in
let h = h64(id16)
return UInt64(h % UInt64(max(1, mInit)))
}.sorted()
var encoded = encode(sorted: mapped, p: p)
var trimmedN = n
// Trim if over budget
while encoded.count > maxBytes && trimmedN > 0 {
trimmedN = (trimmedN * 9) / 10 // drop ~10%
mapped = Array(mapped.prefix(trimmedN))
encoded = encode(sorted: mapped, p: p)
}
let finalM = UInt32(max(1, trimmedN << p))
return Params(p: p, m: finalM, data: encoded)
}
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
var values: [UInt64] = []
let reader = BitReader(data)
var acc: UInt64 = 0
while true {
guard let q = reader.readUnary() else { break }
guard let r = reader.readBits(count: p) else { break }
let x = (UInt64(q) << UInt64(p)) + UInt64(r) + 1
acc &+= x
if acc >= UInt64(m) { break }
values.append(acc)
}
return values
}
static func contains(sortedValues: [UInt64], candidate: UInt64) -> Bool {
var lo = 0
var hi = sortedValues.count - 1
while lo <= hi {
let mid = (lo + hi) >> 1
let v = sortedValues[mid]
if v == candidate { return true }
if v < candidate { lo = mid + 1 } else { hi = mid - 1 }
}
return false
}
private static func h64(_ id16: Data) -> UInt64 {
var hasher = SHA256()
hasher.update(data: id16)
let d = hasher.finalize()
let db = Data(d)
var x: UInt64 = 0
let take = min(8, db.count)
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
return x & 0x7fff_ffff_ffff_ffff
}
private static func encode(sorted: [UInt64], p: Int) -> Data {
let writer = BitWriter()
var prev: UInt64 = 0
let mask: UInt64 = (p >= 64) ? ~0 : ((1 << UInt64(p)) - 1)
for v in sorted {
let delta = v &- prev
prev = v
let x = delta
let q = (x &- 1) >> UInt64(p)
let r = (x &- 1) & mask
// unary q ones then zero
if q > 0 { writer.writeOnes(count: Int(q)) }
writer.writeBit(0)
writer.writeBits(value: r, count: p)
}
return writer.toData()
}
// MARK: - Bit helpers (MSB-first)
private final class BitWriter {
private var buf = Data()
private var cur: UInt8 = 0
private var nbits: Int = 0
func writeBit(_ bit: Int) { // 0 or 1
cur = UInt8((Int(cur) << 1) | (bit & 1))
nbits += 1
if nbits == 8 {
buf.append(cur)
cur = 0; nbits = 0
}
}
func writeOnes(count: Int) {
guard count > 0 else { return }
for _ in 0..<count { writeBit(1) }
}
func writeBits(value: UInt64, count: Int) {
guard count > 0 else { return }
for i in stride(from: count - 1, through: 0, by: -1) {
let bit = Int((value >> UInt64(i)) & 1)
writeBit(bit)
}
}
func toData() -> Data {
if nbits > 0 {
let rem = UInt8(Int(cur) << (8 - nbits))
buf.append(rem)
cur = 0; nbits = 0
}
return buf
}
}
private final class BitReader {
private let data: Data
private var idx: Int = 0
private var cur: UInt8 = 0
private var left: Int = 0
init(_ data: Data) {
self.data = data
if !data.isEmpty {
cur = data[0]
left = 8
}
}
func readBit() -> Int? {
if idx >= data.count { return nil }
let bit = (Int(cur) >> 7) & 1
cur = UInt8((Int(cur) << 1) & 0xFF)
left -= 1
if left == 0 {
idx += 1
if idx < data.count { cur = data[idx]; left = 8 }
}
return bit
}
func readUnary() -> Int? {
var q = 0
while true {
guard let b = readBit() else { return nil }
if b == 1 { q += 1 } else { break }
}
return q
}
func readBits(count: Int) -> UInt64? {
var v: UInt64 = 0
for _ in 0..<count {
guard let b = readBit() else { return nil }
v = (v << 1) | UInt64(b)
}
return v
}
}
}
-200
View File
@@ -1,200 +0,0 @@
import Foundation
import CryptoKit
// Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager {
protocol Delegate: AnyObject {
func sendPacket(_ packet: BitchatPacket)
func sendPacket(to peerID: String, packet: BitchatPacket)
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
}
struct Config {
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
var gcsTargetFpr: Double = 0.01 // 1%
}
private let myPeerID: String
private let config: Config
weak var delegate: Delegate?
// Storage: broadcast messages (ordered by insert), and latest announce per sender
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
private var messageOrder: [String] = []
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
// Timer
private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
init(myPeerID: String, config: Config = Config()) {
self.myPeerID = myPeerID
self.config = config
}
func start() {
stop()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
timer.resume()
periodicTimer = timer
}
func stop() {
periodicTimer?.cancel(); periodicTimer = nil
}
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) {
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
self?.sendRequestSync(to: peerID)
}
}
func onPublicPacketSeen(_ packet: BitchatPacket) {
let mt = MessageType(rawValue: packet.type)
let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
}()
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
let isAnnounce = (mt == .announce)
guard isBroadcastMessage || isAnnounce else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
if isBroadcastMessage {
if messages[idHex] == nil {
messages[idHex] = packet
messageOrder.append(idHex)
// Enforce capacity
let cap = max(1, config.seenCapacity)
while messageOrder.count > cap {
let victim = messageOrder.removeFirst()
messages.removeValue(forKey: victim)
}
}
} else if isAnnounce {
let sender = packet.senderID.hexEncodedString()
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
}
}
private func sendRequestSync() {
let payload = buildGcsPayload()
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: nil, // broadcast
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0 // local-only
)
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
delegate?.sendPacket(signed)
}
private func sendRequestSync(to peerID: String) {
let payload = buildGcsPayload()
var recipient = Data()
var temp = peerID
while temp.count >= 2 && recipient.count < 8 {
let hexByte = String(temp.prefix(2))
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
temp = String(temp.dropFirst(2))
}
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: recipient,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0 // local-only
)
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
delegate?.sendPacket(to: peerID, packet: signed)
}
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
// Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool {
var hasher = SHA256()
hasher.update(data: id) // 16-byte PacketId
let digest = hasher.finalize()
let db = Data(digest)
var x: UInt64 = 0
let take = min(8, db.count)
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
let v = (x & 0x7fff_ffff_ffff_ffff) % UInt64(request.m)
return GCSFilter.contains(sortedValues: sorted, candidate: v)
}
// 1) Announcements: send latest per peer if requester lacks them
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: fromPeerID, packet: toSend)
}
}
// 2) Broadcast messages: send all missing
let toSendMsgs = messageOrder.compactMap { messages[$0] }
for pkt in toSendMsgs {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: fromPeerID, packet: toSend)
}
}
}
// Build REQUEST_SYNC payload using current candidates and GCS params
private func buildGcsPayload() -> Data {
// Collect candidates: latest announce per peer + broadcast messages
var candidates: [BitchatPacket] = []
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
for id in messageOrder { if let p = messages[id] { candidates.append(p) } }
// Sort by timestamp desc
candidates.sort { $0.timestamp > $1.timestamp }
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
let cap = max(1, config.seenCapacity)
let takeN = min(candidates.count, min(nMax, cap))
if takeN <= 0 {
let req = RequestSyncPacket(p: p, m: 1, data: Data())
return req.encode()
}
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
return req.encode()
}
// Explicit removal hook for LEAVE/stale peer
func removeAnnouncementForPeer(_ peerID: String) {
let normalizedPeerID = peerID.lowercased()
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
// Remove messages from this peer
// Collect IDs to remove first to avoid concurrent modification
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
}
// Remove messages and update messageOrder
for id in messageIdsToRemove {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
}
}
-21
View File
@@ -1,21 +0,0 @@
import Foundation
import CryptoKit
// Deterministic packet ID used for gossip sync membership
// ID = first 16 bytes of SHA-256 over: [type | senderID | timestamp | payload]
enum PacketIdUtil {
static func computeId(_ packet: BitchatPacket) -> Data {
var hasher = SHA256()
hasher.update(data: Data([packet.type]))
hasher.update(data: packet.senderID)
var tsBE = packet.timestamp.bigEndian
withUnsafeBytes(of: &tsBE) { raw in hasher.update(data: Data(raw)) }
hasher.update(data: packet.payload)
let digest = hasher.finalize()
return Data(digest.prefix(16))
}
static func computeIdHex(_ packet: BitchatPacket) -> String {
return computeId(packet).hexEncodedString()
}
}
-37
View File
@@ -1,37 +0,0 @@
//
// Color+Peer.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
extension Color {
private static var peerColorCache: [String: Color] = [:]
init(peerSeed: String, isDark: Bool) {
let cacheKey = peerSeed + (isDark ? "|dark" : "|light")
if let cached = Self.peerColorCache[cacheKey] {
self = cached
}
let h = peerSeed.djb2()
var hue = Double(h % 1000) / 1000.0
let orange = 30.0 / 360.0
if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta {
hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0)
}
let sRand = Double((h >> 17) & 0x3FF) / 1023.0
let bRand = Double((h >> 27) & 0x3FF) / 1023.0
let sBase: Double = isDark ? 0.80 : 0.70
let sRange: Double = 0.20
let bBase: Double = isDark ? 0.75 : 0.45
let bRange: Double = isDark ? 0.16 : 0.14
let saturation = min(1.0, max(0.50, sBase + (sRand - 0.5) * sRange))
let brightness = min(1.0, max(0.35, bBase + (bRand - 0.5) * bRange))
let c = Color(hue: hue, saturation: saturation, brightness: brightness)
Self.peerColorCache[cacheKey] = c
self = c
}
}
+21
View File
@@ -10,6 +10,27 @@ struct InputValidator {
static let maxNicknameLength = 50 static let maxNicknameLength = 50
static let maxMessageLength = 10_000 static let maxMessageLength = 10_000
static let maxReasonLength = 200 static let maxReasonLength = 200
static let maxPeerIDLength = 64
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
}
// MARK: - Peer ID Validation
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
static func validatePeerID(_ peerID: String) -> Bool {
// Accept short routing IDs (exact 16-hex)
if PeerIDResolver.isShortID(peerID) { return true }
// If length equals short-hex length but isn't valid hex, reject
if peerID.count == Limits.hexPeerIDLength { return false }
// Accept full Noise key hex (exact 64-hex)
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
// If length equals full key length but isn't valid hex, reject
if peerID.count == Limits.maxPeerIDLength { return false }
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !peerID.isEmpty &&
peerID.count < Limits.maxPeerIDLength &&
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
} }
// MARK: - String Content Validation // MARK: - String Content Validation
@@ -1,6 +1,6 @@
// //
// OSLog+Categories.swift // OSLog+Categories.swift
// BitLogger // bitchat
// //
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
@@ -8,7 +8,7 @@
import os.log import os.log
public extension OSLog { extension OSLog {
private static let subsystem = "chat.bitchat" private static let subsystem = "chat.bitchat"
static let noise = OSLog(subsystem: subsystem, category: "noise") static let noise = OSLog(subsystem: subsystem, category: "noise")
+20
View File
@@ -0,0 +1,20 @@
import Foundation
struct PeerIDResolver {
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
static func toShortID(_ id: String) -> String {
if id.count == 64, let data = Data(hexString: id) {
return PeerIDUtils.derivePeerID(fromPublicKey: data)
}
return id
}
static func isShortID(_ id: String) -> Bool {
return id.count == 16 && Data(hexString: id) != nil
}
static func isNoiseKeyHex(_ id: String) -> Bool {
return id.count == 64 && Data(hexString: id) != nil
}
}
@@ -1,6 +1,6 @@
// //
// SecureLogger.swift // SecureLogger.swift
// BitLogger // bitchat
// //
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
@@ -11,7 +11,7 @@ import os.log
/// Centralized security-aware logging framework /// Centralized security-aware logging framework
/// Provides safe logging that filters sensitive data and security events /// Provides safe logging that filters sensitive data and security events
public final class SecureLogger { final class SecureLogger {
// MARK: - Timestamp Formatter // MARK: - Timestamp Formatter
@@ -85,50 +85,8 @@ public final class SecureLogger {
private static func shouldLog(_ level: LogLevel) -> Bool { private static func shouldLog(_ level: LogLevel) -> Bool {
return level.order >= minimumLevel.order return level.order >= minimumLevel.order
} }
}
// MARK: - Public Logging Methods // MARK: - Security Event Types
public extension SecureLogger {
static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .debug, file: file, line: line, function: function)
}
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .info, file: file, line: line, function: function)
}
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .warning, file: file, line: line, function: function)
}
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .error, file: file, line: line, function: function)
}
/// Log errors with context
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context())
let errorDesc = sanitize(error.localizedDescription)
#if DEBUG
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
#else
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
#endif
}
}
// MARK: Security Event Logging
public extension SecureLogger {
enum SecurityEvent { enum SecurityEvent {
case handshakeStarted(peerID: String) case handshakeStarted(peerID: String)
@@ -153,6 +111,30 @@ public extension SecureLogger {
} }
} }
// MARK: - Public Logging Methods
static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .debug, file: file, line: line, function: function)
}
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .info, file: file, line: line, function: function)
}
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .warning, file: file, line: line, function: function)
}
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .error, file: file, line: line, function: function)
}
// MARK: Security Event Logging
static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
logSecurityEvent(event, level: .debug, file: file, line: line, function: function) logSecurityEvent(event, level: .debug, file: file, line: line, function: function)
} }
@@ -168,11 +150,25 @@ public extension SecureLogger {
static func error(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { static func error(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
logSecurityEvent(event, level: .error, file: file, line: line, function: function) logSecurityEvent(event, level: .error, file: file, line: line, function: function)
} }
/// Log errors with context
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context())
let errorDesc = sanitize(error.localizedDescription)
#if DEBUG
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
#else
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
#endif
}
} }
// MARK: - Convenience Extensions // MARK: - Convenience Extensions
public extension SecureLogger { extension SecureLogger {
enum KeyOperation: String, CustomStringConvertible { enum KeyOperation: String, CustomStringConvertible {
case load case load
@@ -181,7 +177,7 @@ public extension SecureLogger {
case delete case delete
case save case save
public var description: String { rawValue } var description: String { rawValue }
} }
/// Log key management operations /// Log key management operations
@@ -294,7 +290,7 @@ private extension SecureLogger {
/// Helper to migrate from print statements to SecureLogger /// Helper to migrate from print statements to SecureLogger
/// Usage: Replace print(...) with secureLog(...) /// Usage: Replace print(...) with secureLog(...)
public func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n", func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n",
file: String = #file, line: Int = #line, function: String = #function) { file: String = #file, line: Int = #line, function: String = #function) {
#if DEBUG #if DEBUG
let message = items.map { String(describing: $0) }.joined(separator: separator) let message = items.map { String(describing: $0) }.joined(separator: separator)
-17
View File
@@ -1,17 +0,0 @@
//
// String+DJB2.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
extension String {
func djb2() -> UInt64 {
var hash: UInt64 = 5381
for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) }
return hash
}
}
-25
View File
@@ -1,25 +0,0 @@
//
// String+Nickname.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
extension String {
/// Split a nickname into base and a '#abcd' suffix if present
func splitSuffix() -> (String, String) {
let name = self.replacingOccurrences(of: "@", with: "")
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
}
File diff suppressed because it is too large Load Diff
+3 -7
View File
@@ -86,14 +86,10 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) { Button("close") {
Image(systemName: "xmark") dismiss()
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
} }
.buttonStyle(.plain) .foregroundColor(textColor)
.accessibilityLabel("Close")
} }
} }
} }
+27 -44
View File
@@ -459,16 +459,11 @@ struct ContentView: View {
if let name = viewModel.meshService.peerNickname(peerID: peerID) { if let name = viewModel.meshService.peerNickname(peerID: peerID) {
selectedMessageSender = name selectedMessageSender = name
} else { } else {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeer?.id == peerID && $0.sender != "system" })?.sender selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
} }
} }
if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) {
selectedMessageSender = nil
selectedMessageSenderID = nil
} else {
showMessageActions = true showMessageActions = true
} }
}
.onOpenURL { url in .onOpenURL { url in
guard url.scheme == "bitchat", url.host == "geohash" else { return } guard url.scheme == "bitchat", url.host == "geohash" else { return }
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
@@ -1145,31 +1140,7 @@ struct ContentView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Open unread private chat") .accessibilityLabel("Open unread private chat")
} }
// Notes icon (mesh only and when location is authorized), to the left of #mesh // Bookmark toggle for current geohash (not shown for mesh)
if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized {
Button(action: {
// Kick a one-shot refresh and show the sheet immediately.
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.refreshChannels()
// If we already have a block geohash, pass it; otherwise wait in the sheet.
notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
showLocationNotes = true
}) {
HStack(alignment: .center, spacing: 4) {
let currentCount = (notesCounter.count ?? 0)
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
Image(systemName: "long.text.page.and.pencil")
.font(.system(size: 12))
.foregroundColor(hasNotes ? textColor : Color.gray)
.padding(.top, 1)
}
.fixedSize(horizontal: true, vertical: false)
}
.buttonStyle(.plain)
.accessibilityLabel("Location notes for this place")
}
// Bookmark toggle (geochats): to the left of #geohash
if case .location(let ch) = locationManager.selectedChannel { if case .location(let ch) = locationManager.selectedChannel {
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) { Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark") Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
@@ -1178,7 +1149,6 @@ struct ContentView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)") .accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
} }
// Location channels button '#' // Location channels button '#'
Button(action: { showLocationChannelsSheet = true }) { Button(action: { showLocationChannelsSheet = true }) {
let badgeText: String = { let badgeText: String = {
@@ -1204,8 +1174,29 @@ struct ContentView: View {
.accessibilityLabel("location channels") .accessibilityLabel("location channels")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 4)
.padding(.trailing, 2) // Notes icon (mesh only and when location is authorized), to the right of #mesh
if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized {
Button(action: {
// Kick a one-shot refresh and show the sheet immediately.
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.refreshChannels()
// If we already have a block geohash, pass it; otherwise wait in the sheet.
notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
showLocationNotes = true
}) {
HStack(alignment: .center, spacing: 4) {
let hasNotes = ((notesCounter.count ?? 0) > 0) || (sheetNotesCount > 0)
Image(systemName: "long.text.page.and.pencil")
.font(.system(size: 12))
.foregroundColor(hasNotes ? Color(hue: 0.60, saturation: 0.85, brightness: 0.82) : Color.gray)
.padding(.top, 1)
}
.fixedSize(horizontal: true, vertical: false)
}
.buttonStyle(.plain)
.accessibilityLabel("Location notes for this place")
}
HStack(spacing: 4) { HStack(spacing: 4) {
// People icon with count // People icon with count
@@ -1217,7 +1208,6 @@ struct ContentView: View {
.accessibilityHidden(true) .accessibilityHidden(true)
} }
.foregroundColor(headerCountColor) .foregroundColor(headerCountColor)
.padding(.leading, 2)
.lineLimit(1) .lineLimit(1)
.fixedSize(horizontal: true, vertical: false) .fixedSize(horizontal: true, vertical: false)
@@ -1287,6 +1277,7 @@ struct ContentView: View {
} }
.onDisappear { .onDisappear {
LocationChannelManager.shared.endLiveRefresh() LocationChannelManager.shared.endLiveRefresh()
sheetNotesCount = 0
} }
.onChange(of: locationManager.availableChannels) { channels in .onChange(of: locationManager.availableChannels) { channels in
if let current = channels.first(where: { $0.level == .building })?.geohash, if let current = channels.first(where: { $0.level == .building })?.geohash,
@@ -1372,7 +1363,7 @@ struct ContentView: View {
!fav.peerNickname.isEmpty { return fav.peerNickname } !fav.peerNickname.isEmpty { return fav.peerNickname }
// Fallback: resolve from persisted social identity via fingerprint mapping // Fallback: resolve from persisted social identity via fingerprint mapping
if headerPeerID.count == 16 { if headerPeerID.count == 16 {
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(Peer(str: headerPeerID)) let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let id = candidates.first, if let id = candidates.first,
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) { let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet } if let pet = social.localPetname, !pet.isEmpty { return pet }
@@ -1533,16 +1524,8 @@ extension ContentView {
if locationManager.permissionState == .authorized { if locationManager.permissionState == .authorized {
LocationChannelManager.shared.refreshChannels() LocationChannelManager.shared.refreshChannels()
} }
if locationManager.permissionState == .authorized {
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
LocationNotesCounter.shared.subscribe(geohash: building) LocationNotesCounter.shared.subscribe(geohash: building)
} else {
// Keep existing subscription if we had one to avoid flicker
// Only cancel if we have no known geohash
if LocationNotesCounter.shared.geohash == nil {
LocationNotesCounter.shared.cancel()
}
}
} else { } else {
LocationNotesCounter.shared.cancel() LocationNotesCounter.shared.cancel()
} }
+9 -89
View File
@@ -9,20 +9,17 @@ struct LocationChannelsSheet: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared @ObservedObject private var manager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared @ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@ObservedObject private var network = NetworkActivationService.shared
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@State private var customGeohash: String = "" @State private var customGeohash: String = ""
@State private var customError: String? = nil @State private var customError: String? = nil
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("#location channels") Text("#location channels")
.font(.system(size: 18, design: .monospaced)) .font(.system(size: 18, design: .monospaced))
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.") Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps.")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -57,30 +54,19 @@ struct LocationChannelsSheet: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(backgroundColor)
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { isPresented = false }) { Button("close") { isPresented = false }
Image(systemName: "xmark") .font(.system(size: 14, design: .monospaced))
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
} }
#else #else
.toolbar { .toolbar {
ToolbarItem(placement: .automatic) { ToolbarItem(placement: .automatic) {
Button(action: { isPresented = false }) { Button("close") { isPresented = false }
Image(systemName: "xmark") .font(.system(size: 14, design: .monospaced))
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 20, height: 20)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
} }
#endif #endif
@@ -91,7 +77,6 @@ struct LocationChannelsSheet: View {
#if os(macOS) #if os(macOS)
.frame(minWidth: 420, minHeight: 520) .frame(minWidth: 420, minHeight: 520)
#endif #endif
.background(backgroundColor)
.onAppear { .onAppear {
// Refresh channels when opening // Refresh channels when opening
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
@@ -220,11 +205,12 @@ struct LocationChannelsSheet: View {
// Bookmarked geohashes // Bookmarked geohashes
if !bookmarks.bookmarks.isEmpty { if !bookmarks.bookmarks.isEmpty {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 6) {
Text("bookmarked") Text("bookmarked")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
VStack(spacing: 6) { }
.listRowSeparator(.hidden)
ForEach(bookmarks.bookmarks, id: \.self) { gh in ForEach(bookmarks.bookmarks, id: \.self) { gh in
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh) let channel = GeohashChannel(level: level, geohash: gh)
@@ -258,16 +244,9 @@ struct LocationChannelsSheet: View {
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) } .onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
} }
} }
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
}
.listRowSeparator(.hidden)
}
// Footer action inside the list // Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
torToggleSection
Button(action: { Button(action: {
openSystemLocationSettings() openSystemLocationSettings()
}) { }) {
@@ -281,12 +260,9 @@ struct LocationChannelsSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} }
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden)
.background(backgroundColor)
} }
private func isSelected(_ channel: GeohashChannel) -> Bool { private func isSelected(_ channel: GeohashChannel) -> Bool {
@@ -411,36 +387,8 @@ struct LocationChannelsSheet: View {
} }
} }
// MARK: - TOR Toggle & Standardized Colors // MARK: - Standardized Colors
extension LocationChannelsSheet { extension LocationChannelsSheet {
private var torToggleBinding: Binding<Bool> {
Binding(
get: { network.userTorEnabled },
set: { network.setUserTorEnabled($0) }
)
}
private var torToggleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text("tor routing")
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(.primary)
Text("hides your ip for location channels. recommended: on.")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: standardGreen))
}
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
private var standardGreen: Color { private var standardGreen: Color {
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
} }
@@ -449,34 +397,6 @@ extension LocationChannelsSheet {
} }
} }
private struct IRCToggleStyle: ToggleStyle {
let accent: Color
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
HStack(spacing: 12) {
configuration.label
Spacer()
Text(configuration.isOn ? "on" : "off")
.textCase(.uppercase)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
)
}
}
.buttonStyle(.plain)
}
}
// MARK: - Coverage helpers // MARK: - Coverage helpers
extension LocationChannelsSheet { extension LocationChannelsSheet {
private func coverageString(forPrecision len: Int) -> String { private func coverageString(forPrecision len: Int) -> String {
+65
View File
@@ -0,0 +1,65 @@
import SwiftUI
struct LocationNotesSheet: View {
@EnvironmentObject var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@Binding var notesGeohash: String?
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
private var textColor: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
private var secondaryTextColor: Color { textColor.opacity(0.8) }
var body: some View {
Group {
if let gh = notesGeohash ?? locationManager.availableChannels.first(where: { $0.level == .block })?.geohash {
// Found block geohash: show notes view
LocationNotesView(geohash: gh)
.environmentObject(viewModel)
} else {
// Acquire location: keep a loading overlay (Matrix) until we either get a block geohash
ZStack {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("notes")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Text("acquiring location…")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
Spacer()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.frame(height: 44)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
Spacer()
}
MatrixRainView()
.transition(.opacity)
}
.background(backgroundColor)
.foregroundColor(textColor)
.onAppear {
LocationChannelManager.shared.enableLocationChannels()
// Nudge a fresh fix
LocationChannelManager.shared.refreshChannels()
}
.onChange(of: locationManager.availableChannels) { channels in
if notesGeohash == nil, let block = channels.first(where: { $0.level == .block }) {
notesGeohash = block.geohash
}
}
}
}
}
}
+4 -32
View File
@@ -27,10 +27,6 @@ struct LocationNotesView: View {
private var secondaryTextColor: Color { private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
} }
// Slightly darker green for hash suffix emphasis
private var darkerTextColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.4, blue: 0)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -59,11 +55,8 @@ struct LocationNotesView: View {
let c = manager.notes.count let c = manager.notes.count
Text("\(c) \(c == 1 ? "note" : "notes") ") Text("\(c) \(c == 1 ? "note" : "notes") ")
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
Text("@ ") Text("@ #\(geohash)")
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
Text("#\(geohash)")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
} }
if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty { if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty {
Text(buildingName) Text(buildingName)
@@ -97,18 +90,10 @@ struct LocationNotesView: View {
ForEach(manager.notes) { note in ForEach(manager.notes) { note in
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) { HStack(spacing: 6) {
// Show @name without the #abcd suffix; timestamp in brackets Text(note.displayName)
HStack(spacing: 0) {
Text("@")
.font(.system(size: 12, weight: .semibold, design: .monospaced)) .font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(secondaryTextColor)
let parts = splitSuffix(from: note.displayName) Text(timestampText(for: note.createdAt))
Text(parts.0)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
}
let ts = timestampText(for: note.createdAt)
Text(ts.isEmpty ? "" : "[\(ts)]")
.font(.system(size: 11, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8)) .foregroundColor(secondaryTextColor.opacity(0.8))
} }
@@ -189,16 +174,3 @@ struct LocationNotesView: View {
return f return f
}() }()
} }
// Helper to split a trailing #abcd suffix
private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
@@ -12,9 +12,6 @@ import UniformTypeIdentifiers
/// Modern share extension using UIKit + UTTypes. /// Modern share extension using UIKit + UTTypes.
/// Avoids deprecated Social framework and SLComposeServiceViewController. /// Avoids deprecated Social framework and SLComposeServiceViewController.
final class ShareViewController: UIViewController { final class ShareViewController: UIViewController {
// Bundle.main.bundleIdentifier would get the extension's bundleID
private static let groupID = "group.chat.bitchat"
private let statusLabel: UILabel = { private let statusLabel: UILabel = {
let l = UILabel() let l = UILabel()
l.translatesAutoresizingMaskIntoConstraints = false l.translatesAutoresizingMaskIntoConstraints = false
@@ -35,9 +32,8 @@ final class ShareViewController: UIViewController {
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor) statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
]) ])
DispatchQueue.global().async {
self.processShare() processShare()
}
} }
// MARK: - Processing // MARK: - Processing
@@ -153,7 +149,7 @@ final class ShareViewController: UIViewController {
} }
private func saveToSharedDefaults(content: String, type: String) { private func saveToSharedDefaults(content: String, type: String) {
guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return } guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return }
userDefaults.set(content, forKey: "sharedContent") userDefaults.set(content, forKey: "sharedContent")
userDefaults.set(type, forKey: "sharedContentType") userDefaults.set(type, forKey: "sharedContentType")
userDefaults.set(Date(), forKey: "sharedContentDate") userDefaults.set(Date(), forKey: "sharedContentDate")
@@ -164,7 +160,7 @@ final class ShareViewController: UIViewController {
statusLabel.text = msg statusLabel.text = msg
// Complete shortly after showing status // Complete shortly after showing status
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
} }
} }
} }
+4 -4
View File
@@ -134,7 +134,7 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil, originalSender: nil,
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeer: "REMOTE123", senderPeerID: "REMOTE123",
mentions: nil mentions: nil
) )
@@ -161,7 +161,7 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil, originalSender: nil,
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeer: "PACKET123", senderPeerID: "PACKET123",
mentions: nil mentions: nil
) )
@@ -215,7 +215,7 @@ final class BLEServiceTests: XCTestCase {
let expectation = XCTestExpectation(description: "Delivery handler called") let expectation = XCTestExpectation(description: "Delivery handler called")
service.packetDeliveryHandler = { packet in service.packetDeliveryHandler = { packet in
if let msg = BitchatMessage(packet.payload) { if let msg = BitchatMessage.fromBinaryPayload(packet.payload) {
XCTAssertEqual(msg.content, "Test delivery") XCTAssertEqual(msg.content, "Test delivery")
expectation.fulfill() expectation.fulfill()
} }
@@ -243,7 +243,7 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil, originalSender: nil,
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeer: "TEST123", senderPeerID: "TEST123",
mentions: nil mentions: nil
) )
@@ -139,7 +139,7 @@ final class PrivateChatE2ETests: XCTestCase {
alice.packetDeliveryHandler = { packet in alice.packetDeliveryHandler = { packet in
// Encrypt outgoing private messages // Encrypt outgoing private messages
if packet.type == 0x01, if packet.type == 0x01,
let message = BitchatMessage(packet.payload), let message = BitchatMessage.fromBinaryPayload(packet.payload),
message.isPrivate { message.isPrivate {
do { do {
let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2) let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2)
@@ -164,7 +164,7 @@ final class PrivateChatE2ETests: XCTestCase {
if packet.type == 0x02 { if packet.type == 0x02 {
do { do {
let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1) let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1)
if let message = BitchatMessage(decrypted) { if let message = BitchatMessage.fromBinaryPayload(decrypted) {
XCTAssertEqual(message.content, TestConstants.testMessage1) XCTAssertEqual(message.content, TestConstants.testMessage1)
XCTAssertTrue(message.isPrivate) XCTAssertTrue(message.isPrivate)
expectation.fulfill() expectation.fulfill()
@@ -98,7 +98,7 @@ final class PublicChatE2ETests: XCTestCase {
// Set up relay in Bob // Set up relay in Bob
bob.packetDeliveryHandler = { packet in bob.packetDeliveryHandler = { packet in
// Bob should relay to Charlie // Bob should relay to Charlie
if let message = BitchatMessage(packet.payload), if let message = BitchatMessage.fromBinaryPayload(packet.payload),
message.sender == TestConstants.testNickname1 { message.sender == TestConstants.testNickname1 {
// Create relay message // Create relay message
@@ -111,7 +111,7 @@ final class PublicChatE2ETests: XCTestCase {
originalSender: message.sender, originalSender: message.sender,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname, recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeer?.id, senderPeerID: message.senderPeerID,
mentions: message.mentions mentions: message.mentions
) )
@@ -437,9 +437,9 @@ final class PublicChatE2ETests: XCTestCase {
// Check if should relay // Check if should relay
guard packet.ttl > 1 else { return } guard packet.ttl > 1 else { return }
if let message = BitchatMessage(packet.payload) { if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
// Don't relay own messages // Don't relay own messages
guard message.senderPeer?.id != node.peerID else { return } guard message.senderPeerID != node.peerID else { return }
// Create relay message // Create relay message
let relayMessage = BitchatMessage( let relayMessage = BitchatMessage(
@@ -451,7 +451,7 @@ final class PublicChatE2ETests: XCTestCase {
originalSender: message.isRelay ? message.originalSender : message.sender, originalSender: message.isRelay ? message.originalSender : message.sender,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname, recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeer?.id, senderPeerID: message.senderPeerID,
mentions: message.mentions mentions: message.mentions
) )
@@ -531,7 +531,7 @@ final class IntegrationTests: XCTestCase {
// Setup encryption at Alice // Setup encryption at Alice
nodes["Alice"]!.packetDeliveryHandler = { packet in nodes["Alice"]!.packetDeliveryHandler = { packet in
if packet.type == 0x01, if packet.type == 0x01,
let message = BitchatMessage(packet.payload), let message = BitchatMessage.fromBinaryPayload(packet.payload),
message.isPrivate && packet.recipientID != nil { message.isPrivate && packet.recipientID != nil {
// Encrypt private messages // Encrypt private messages
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) { if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
@@ -553,7 +553,7 @@ final class IntegrationTests: XCTestCase {
nodes["Bob"]!.packetDeliveryHandler = { packet in nodes["Bob"]!.packetDeliveryHandler = { packet in
if packet.type == 0x02 { if packet.type == 0x02 {
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1), if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
let message = BitchatMessage(decrypted) { let message = BitchatMessage.fromBinaryPayload(decrypted) {
bobDecrypted = message.content == "Secret message" bobDecrypted = message.content == "Secret message"
expectation.fulfill() expectation.fulfill()
} }
@@ -626,8 +626,8 @@ final class IntegrationTests: XCTestCase {
node.packetDeliveryHandler = { packet in node.packetDeliveryHandler = { packet in
guard packet.ttl > 1 else { return } guard packet.ttl > 1 else { return }
if let message = BitchatMessage(packet.payload) { if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
guard message.senderPeer?.id != node.peerID else { return } guard message.senderPeerID != node.peerID else { return }
let relayMessage = BitchatMessage( let relayMessage = BitchatMessage(
id: message.id, id: message.id,
@@ -638,7 +638,7 @@ final class IntegrationTests: XCTestCase {
originalSender: message.isRelay ? message.originalSender : message.sender, originalSender: message.isRelay ? message.originalSender : message.sender,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname, recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeer?.id, senderPeerID: message.senderPeerID,
mentions: message.mentions mentions: message.mentions
) )
+2 -2
View File
@@ -309,7 +309,7 @@ final class MockBLEService: NSObject {
func simulateIncomingPacket(_ packet: BitchatPacket) { func simulateIncomingPacket(_ packet: BitchatPacket) {
// Process through the actual handling logic // Process through the actual handling logic
if let message = BitchatMessage(packet.payload) { if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
var shouldDeliver = false var shouldDeliver = false
seenLock.lock() seenLock.lock()
if !seenMessageIDs.contains(message.id) { if !seenMessageIDs.contains(message.id) {
@@ -331,7 +331,7 @@ final class MockBLEService: NSObject {
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0 let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
for neighbor in neighbors() { for neighbor in neighbors() {
// Avoid immediate echo loopback to sender if known // Avoid immediate echo loopback to sender if known
if let sender = message.senderPeer?.id, sender == neighbor.peerID { continue } if let sender = message.senderPeerID, sender == neighbor.peerID { continue }
var relay = packet var relay = packet
relay.ttl = nextTTL relay.ttl = nextTTL
neighbor.simulateIncomingPacket(relay) neighbor.simulateIncomingPacket(relay)
@@ -197,14 +197,14 @@ final class BinaryProtocolTests: XCTestCase {
return return
} }
guard let decodedMessage = BitchatMessage(payload) else { guard let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
XCTFail("Failed to decode message from binary") XCTFail("Failed to decode message from binary")
return return
} }
XCTAssertEqual(decodedMessage.content, message.content) XCTAssertEqual(decodedMessage.content, message.content)
XCTAssertEqual(decodedMessage.sender, message.sender) XCTAssertEqual(decodedMessage.sender, message.sender)
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeer?.id) XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeerID)
XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate) XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate)
// Timestamp should be close (within 1 second due to conversion) // Timestamp should be close (within 1 second due to conversion)
@@ -219,7 +219,7 @@ final class BinaryProtocolTests: XCTestCase {
) )
guard let payload = message.toBinaryPayload(), guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage(payload) else { let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
XCTFail("Failed to encode/decode private message") XCTFail("Failed to encode/decode private message")
return return
} }
@@ -233,7 +233,7 @@ final class BinaryProtocolTests: XCTestCase {
let message = TestHelpers.createTestMessage(mentions: mentions) let message = TestHelpers.createTestMessage(mentions: mentions)
guard let payload = message.toBinaryPayload(), guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage(payload) else { let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
XCTFail("Failed to encode/decode message with mentions") XCTFail("Failed to encode/decode message with mentions")
return return
} }
@@ -256,7 +256,7 @@ final class BinaryProtocolTests: XCTestCase {
) )
guard let payload = message.toBinaryPayload(), guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage(payload) else { let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
XCTFail("Failed to encode/decode relay message") XCTFail("Failed to encode/decode relay message")
return return
} }
@@ -294,7 +294,7 @@ final class BinaryProtocolTests: XCTestCase {
let message = TestHelpers.createTestMessage(content: largeContent) let message = TestHelpers.createTestMessage(content: largeContent)
guard let payload = message.toBinaryPayload(), guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage(payload) else { let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
XCTFail("Failed to handle large message") XCTFail("Failed to handle large message")
return return
} }
@@ -307,7 +307,7 @@ final class BinaryProtocolTests: XCTestCase {
let emptyMessage = TestHelpers.createTestMessage(content: "") let emptyMessage = TestHelpers.createTestMessage(content: "")
guard let payload = emptyMessage.toBinaryPayload(), guard let payload = emptyMessage.toBinaryPayload(),
let decodedMessage = BitchatMessage(payload) else { let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
XCTFail("Failed to handle empty message") XCTFail("Failed to handle empty message")
return return
} }
+1 -1
View File
@@ -44,7 +44,7 @@ final class TestHelpers {
originalSender: nil, originalSender: nil,
isPrivate: isPrivate, isPrivate: isPrivate,
recipientNickname: recipientNickname, recipientNickname: recipientNickname,
senderPeer: senderPeerID, senderPeerID: senderPeerID,
mentions: mentions mentions: mentions
) )
} }
-23
View File
@@ -1,23 +0,0 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "BitLogger",
platforms: [
.iOS(.v16),
.macOS(.v13)
],
products: [
.library(
name: "BitLogger",
targets: ["BitLogger"]
)
],
targets: [
.target(
name: "BitLogger",
path: "Sources"
)
]
)
+236
View File
@@ -0,0 +1,236 @@
name: bitchat
options:
bundleIdPrefix: chat.bitchat
deploymentTarget:
iOS: 16.0
macOS: 13.0
createIntermediateGroups: true
settings:
MARKETING_VERSION: 1.0.0
CURRENT_PROJECT_VERSION: 1
packages:
P256K:
url: https://github.com/21-DOT-DEV/swift-secp256k1
majorVersion: 0.21.1
targets:
bitchat_iOS:
type: application
platform: iOS
sources:
- bitchat
resources:
- bitchat/Assets.xcassets
- bitchat/LaunchScreen.storyboard
info:
path: bitchat/Info.plist
properties:
CFBundleDisplayName: bitchat
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
UIBackgroundModes:
- bluetooth-central
- bluetooth-peripheral
UILaunchStoryboardName: LaunchScreen
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UIRequiresFullScreen: false
CFBundleURLTypes:
- CFBundleURLSchemes:
- bitchat
# xcodegen quirk: include some macOS properties in iOS target
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat
PRODUCT_NAME: bitchat
INFOPLIST_FILE: bitchat/Info.plist
ENABLE_PREVIEWS: YES
SWIFT_VERSION: 5.0
IPHONEOS_DEPLOYMENT_TARGET: 16.0
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: L3N5LHJD5Y
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
CODE_SIGN_ENTITLEMENTS: bitchat/bitchat.entitlements
dependencies:
- target: bitchatShareExtension
embed: true
- package: P256K
- framework: Frameworks/tor-nolzma.xcframework
embed: true
codeSign: true
- sdk: libz.tbd
bitchat_macOS:
type: application
platform: macOS
sources:
- bitchat
resources:
- bitchat/Assets.xcassets
info:
path: bitchat/Info.plist
properties:
CFBundleDisplayName: bitchat
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
CFBundleURLTypes:
- CFBundleURLSchemes:
- bitchat
# xcodegen quirk: include some iOS properties in macOS target
UIBackgroundModes:
- bluetooth-central
- bluetooth-peripheral
UILaunchStoryboardName: LaunchScreen
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UIRequiresFullScreen: true
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat
PRODUCT_NAME: bitchat
INFOPLIST_FILE: bitchat/Info.plist
ENABLE_PREVIEWS: NO
SWIFT_VERSION: 5.0
MACOSX_DEPLOYMENT_TARGET: 13.0
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: L3N5LHJD5Y
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
CODE_SIGN_ENTITLEMENTS: bitchat/bitchat-macOS.entitlements
dependencies:
- package: P256K
- framework: Frameworks/tor-nolzma.xcframework
embed: true
codeSign: true
- sdk: libz.tbd
bitchatShareExtension:
type: app-extension
platform: iOS
sources:
- bitchatShareExtension
- bitchat/Services/TransportConfig.swift
info:
path: bitchatShareExtension/Info.plist
properties:
CFBundleDisplayName: bitchat
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
NSExtensionAttributes:
NSExtensionActivationRule:
NSExtensionActivationSupportsText: true
NSExtensionActivationSupportsWebURLWithMaxCount: 1
NSExtensionActivationSupportsImageWithMaxCount: 1
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.ShareExtension
INFOPLIST_FILE: bitchatShareExtension/Info.plist
SWIFT_VERSION: 5.0
IPHONEOS_DEPLOYMENT_TARGET: 16.0
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: L3N5LHJD5Y
CODE_SIGN_ENTITLEMENTS: bitchatShareExtension/bitchatShareExtension.entitlements
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES
bitchatTests_iOS:
type: bundle.unit-test
platform: iOS
sources:
- bitchatTests
dependencies:
- target: bitchat_iOS
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
INFOPLIST_FILE: bitchatTests/Info.plist
SWIFT_VERSION: 5.0
IPHONEOS_DEPLOYMENT_TARGET: 16.0
TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat
BUNDLE_LOADER: $(TEST_HOST)
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: L3N5LHJD5Y
bitchatTests_macOS:
type: bundle.unit-test
platform: macOS
sources:
- bitchatTests
dependencies:
- target: bitchat_macOS
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
INFOPLIST_FILE: bitchatTests/Info.plist
SWIFT_VERSION: 5.0
MACOSX_DEPLOYMENT_TARGET: 13.0
TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat
BUNDLE_LOADER: $(TEST_HOST)
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: L3N5LHJD5Y
schemes:
bitchat (iOS):
build:
targets:
bitchat_iOS: all
bitchatShareExtension: all
run:
config: Debug
executable: bitchat_iOS
test:
config: Debug
targets:
- bitchatTests_iOS
profile:
config: Release
executable: bitchat_iOS
analyze:
config: Debug
archive:
config: Release
bitchat (macOS):
build:
targets:
bitchat_macOS: all
run:
config: Debug
executable: bitchat_macOS
test:
config: Debug
targets:
- bitchatTests_macOS
profile:
config: Release
executable: bitchat_macOS
analyze:
config: Debug
archive:
config: Release
+288 -259
View File
@@ -1,264 +1,293 @@
Relay URL,Latitude,Longitude Relay URL,Latitude,Longitude
relay.laantungir.net,-19.4692,-42.5315 relay.damus.io,37.7621,-122.3971
relay.endfiat.money,43.6532,-79.3832 nostr-pub.wellorder.net,45.5229,-122.9898
relay.zone667.com,60.1699,24.9384 nostr.mom,50.4779,12.3713
shu04.shugur.net,25.2604,55.2989 nostr.slothy.win,37.7621,-122.3971
relay.bitcoinartclock.com,50.4754,12.3683 nostr.einundzwanzig.space,50.1155,8.6842
relay.nostromo.social,49.4543,11.0746 nos.lol,50.4779,12.3713
nostr.liberty.fans,36.9104,-89.5875 relay.nostr.band,60.1695,24.9354
roles-az-achieving-somebody.trycloudflare.com,43.6532,-79.3832 no.str.cr,9.9339,-84.0849
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 nostr.massmux.com,50.1155,8.6842
relay.nostr.wirednet.jp,34.706,135.493 nostr-relay.schnitzel.world,39.0437,-77.4875
nostr.einundzwanzig.space,50.1109,8.68213 relay.nostr.com.au,37.7621,-122.3971
relay.21e6.cz,50.1682,14.0546 knostr.neutrine.com,48.8534,2.3488
relay04.lnfi.network,39.0997,-94.5786 nostr.nodeofsven.com,47.4875,8.2965
relay.chorus.community,50.1109,8.68213 nostr.vulpem.com,49.4542,11.0775
relay.nostr.place,32.7767,-96.797 nostr-verif.slothy.win,37.7621,-122.3971
relay.vrtmrz.net,43.6532,-79.3832 relay.lexingtonbitcoin.org,37.7621,-122.3971
noxir.kpherox.dev,34.8587,135.509 nostr-1.nbo.angani.co,-1.2615,36.7903
wot.nostr.net,43.6532,-79.3832 relay.wellorder.net,45.5229,-122.9898
relay.cypherflow.ai,48.8566,2.35222 nostr.easydns.ca,43.7064,-79.3986
wot.sudocarlos.com,51.5072,-0.127586 relay.dwadziesciajeden.pl,52.2298,21.0118
nostr.jerrynya.fun,31.2304,121.474 nostr.data.haus,50.4779,12.3713
nostr2.girino.org,43.6532,-79.3832 nostr.einundzwanzig.space,50.1155,8.6842
nostrings-relay-dev.fly.dev,41.8781,-87.6298 nostr.mom,50.4779,12.3713
fanfares.nostr1.com,40.7128,-74.006 nos.lol,50.4779,12.3713
nostr.red5d.dev,43.6532,-79.3832 relay.nostr.band,60.1695,24.9354
nostr.hifish.org,47.4043,8.57398 nostr.massmux.com,50.1155,8.6842
nostr.now,36.55,139.733 nostr.vulpem.com,49.4542,11.0775
relay.nostr.band,60.1699,24.9384 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 relay.wavlake.com,41.2619,-95.8608
nostr.bilthon.dev,25.8128,-80.2377 nostr.sagaciousd.com,49.2497,-123.1193
khatru.nostrver.se,51.8933,4.42083 nostr.fbxl.net,43.7064,-79.3986
relay.bitcoindistrict.org,43.6532,-79.3832 ithurtswhenip.ee,50.7990,-1.0913
nostr.makibisskey.work,43.6532,-79.3832 relay2.nostrchat.io,49.4542,11.0775
relay.nostraddress.com,43.6532,-79.3832 relay1.nostrchat.io,60.1695,24.9354
relay.jmoose.rocks,60.1699,24.9384 nostr-01.yakihonne.com,1.3215,103.6957
relay.davidebtc.me,51.5072,-0.127586 nostr.sathoarder.com,48.5839,7.7455
a.nos.lol,50.4754,12.3683 nostr.overmind.lol,37.7621,-122.3971
nostr.tadryanom.me,43.6532,-79.3832 relay.verified-nostr.com,37.7621,-122.3971
relay.nostrdice.com,-33.8688,151.209 purplerelay.com,50.1155,8.6842
relay.lumina.rocks,49.0291,8.35695 relay.orangepill.ovh,49.0127,1.9694
relay.goodmorningbitcoin.com,43.6532,-79.3832 nostr-relay.psfoundation.info,39.0437,-77.4875
nostr.rtvslawenia.com,49.4543,11.0746 soloco.nl,37.7621,-122.3971
relay.mattybs.lol,43.6532,-79.3832 relay.froth.zone,60.1695,24.9354
relay-dev.satlantis.io,40.8302,-74.1299 nostr.stakey.net,52.5250,5.7181
nostream.breadslice.com,43.6532,-79.3832 nostr.2b9t.xyz,34.0522,-118.2437
nostr.vulpem.com,49.4543,11.0746 pyramid.fiatjaf.com,50.1155,8.6842
nostr.rohoss.com,50.1109,8.68213 a.nos.lol,50.4779,12.3713
articles.layer3.news,37.3387,-121.885 relay.magiccity.live,25.8130,-80.2320
nos.lol,50.4754,12.3683 nostr.notribe.net,40.8344,-74.1377
relay.artx.market,43.652,-79.3633 freelay.sovbit.host,64.1355,-21.8954
wot.sebastix.social,51.8933,4.42083 relay.credenso.cafe,43.4254,-80.5112
alien.macneilmediagroup.com,43.6532,-79.3832 nostr.huszonegy.world,47.4984,19.0404
relay.unknown.cloud,43.6532,-79.3832 multiplexer.huszonegy.world,47.4984,19.0404
nostr.lojong.info,43.6532,-79.3832 bucket.coracle.social,37.7621,-122.3971
nostr.zenon.network,43.5009,-70.4428 nostr.kungfu-g.rip,33.7865,-84.4454
orangesync.tech,50.1109,8.68213 relay.artx.market,43.7064,-79.3986
nostr.davidebtc.me,51.5072,-0.127586 relay.notoshi.win,13.3622,100.9835
internationalright-wing.org,-22.5022,-48.7114 vitor.nostr1.com,40.7143,-74.0060
nostr.rikmeijer.nl,50.4754,12.3683 nostr-02.yakihonne.com,1.3215,103.6957
ynostr.yael.at,60.1699,24.9384 nostr-03.dorafactory.org,1.2897,103.8501
ithurtswhenip.ee,51.223,6.78245 n.ok0.org,-36.8485,174.7635
relay.wellorder.net,45.5201,-122.99 nostr.0x7e.xyz,47.5056,8.7241
nostr.sathoarder.com,48.5734,7.75211 relay.nostr.net,50.4779,12.3713
purplerelay.com,50.1109,8.68213 strfry.openhoofd.nl,51.5717,3.7042
yabu.me,35.6092,139.73
nostr.88mph.life,43.6532,-79.3832
nostr.overmind.lol,43.6532,-79.3832
rnostr.breadslice.com,43.6532,-79.3832
zap.watch,45.5029,-73.5723
wot.basspistol.org,49.4521,11.0767
shu01.shugur.net,21.4902,39.2246
relay.electriclifestyle.com,26.2897,-80.1293
relay.mccormick.cx,52.3563,4.95714
nostr.middling.mydns.jp,35.8099,140.12
nostr.smut.cloud,43.6532,-79.3832
satsage.xyz,37.3986,-121.964
srtrelay.c-stellar.net,43.6532,-79.3832
nostr.0x7e.xyz,47.4988,8.72369
shu02.shugur.net,21.4902,39.2246
nostrelites.org,41.8781,-87.6298
relay-admin.thaliyal.com,40.8218,-74.45
wot.soundhsa.com,34.0479,-118.256
nostrcheck.me,43.6532,-79.3832
relay.nostrhub.tech,49.4543,11.0746
relay.stream.labs.h3.se,59.4016,17.9455
nostrelay.memory-art.xyz,43.6532,-79.3832
nostr.n7ekb.net,47.4941,-122.294
relay.nosto.re,51.8933,4.42083
nostr.girino.org,43.6532,-79.3832
relay.siamdev.cc,13.9178,100.424
nostr.mehdibekhtaoui.com,49.4939,-1.54813
orangepiller.org,60.1699,24.9384
nostr.plantroon.com,50.1013,8.62643
nostr-verified.wellorder.net,45.5201,-122.99
relay.primal.net,43.6532,-79.3832
relay.bitcoinveneto.org,64.1466,-21.9426
relay.hasenpfeffr.com,39.0438,-77.4874
strfry.openhoofd.nl,51.9229,4.40833
relay.aloftus.io,34.0881,-118.379
nostr.spaceshell.xyz,43.6532,-79.3832
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
ribo.af.nostria.app,-26.2041,28.0473
nostr.tac.lol,47.4748,-122.273
relay.satlantis.io,32.8769,-80.0114
nostr.azzamo.net,52.2633,21.0283
strfry.bonsai.com,37.8715,-122.273
relay.agora.social,50.7383,15.0648
nostr-relay.amethyst.name,39.0067,-77.4291
relay.toastr.net,40.8054,-74.0241
nostr.thebiglake.org,32.71,-96.6745
nostr-relay.nextblockvending.com,47.674,-122.122
vitor.nostr1.com,40.7057,-74.0136
relay.btcforplebs.com,43.6532,-79.3832
relay.g1sms.fr,43.9432,2.07537
nostr.jfischer.org,49.0291,8.35696
nostr.mikoshi.de,52.52,13.405
relay.notoshi.win,13.7829,100.546
pyramid.fiatjaf.com,50.1109,8.68213
relay.coinos.io,43.6532,-79.3832
relay.freeplace.nl,52.3676,4.90414
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.copylaradio.com,51.223,6.78245
relay.exit.pub,50.4754,12.3683
freelay.sovbit.host,64.1476,-21.9392
nostr.satstralia.com,64.1476,-21.9392
nostr.l484.com,30.2944,-97.6223
nostr.rblb.it,43.4633,11.8796
nostr.2b9t.xyz,34.0549,-118.243
nostr.dlsouza.lol,50.1109,8.68213
strfry.shock.network,41.8959,-88.2169
offchain.pub,36.1809,-115.241
nostr-01.yakihonne.com,1.32123,103.695
nostr.kungfu-g.rip,33.7946,-84.4488
relay.letsfo.com,51.098,17.0321
relay.lifpay.me,1.35208,103.82
relay.damus.io,43.6532,-79.3832
relay2.angor.io,48.1046,11.6002
relayrs.notoshi.win,43.6532,-79.3832
relay2.ngengine.org,43.6532,-79.3832
portal-relay.pareto.space,49.4543,11.0746
inbox.azzamo.net,52.2633,21.0283
nostr-dev.wellorder.net,45.5201,-122.99
nostr.stakey.net,52.3676,4.90414
relay.13room.space,43.6532,-79.3832
relay.fountain.fm,39.0997,-94.5786 relay.fountain.fm,39.0997,-94.5786
black.nostrcity.club,41.8781,-87.6298 relay.usefusion.ai,38.7135,-78.1594
nostr-2.21crypto.ch,47.4988,8.72369 relay.varke.eu,52.6958,6.1944
dev-nostr.bityacht.io,25.0797,121.234 nostr.satstralia.com,64.1355,-21.8954
santo.iguanatech.net,40.8302,-74.1299 relay.13room.space,37.7621,-122.3971
relay.angor.io,48.1046,11.6002 nostr.myshosholoza.co.za,52.3710,4.9042
relay.tagayasu.xyz,43.6715,-79.38 nostr.carroarmato0.be,50.8517,3.6089
relay.npubhaus.com,43.6532,-79.3832 nostr.dbtc.link,37.7621,-122.3971
relay01.lnfi.network,39.0997,-94.5786 orangepiller.org,60.1695,24.9354
nostr.myshosholoza.co.za,52.3676,4.90414 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 relay02.lnfi.network,39.0997,-94.5786
gnostr.com,40.9017,29.1616 nostr-rs-relay.dev.fedibtc.com,39.0437,-77.4875
nostr.sagaciousd.com,49.2827,-123.121 relay.davidebtc.me,50.1155,8.6842
nostr.night7.space,50.4754,12.3683 wot.dtonon.com,37.7621,-122.3971
schnorr.me,43.6532,-79.3832 relay.goodmorningbitcoin.com,37.7621,-122.3971
nostr.blankfors.se,60.1699,24.9384 articles.layer3.news,37.3394,-121.8950
relay.mostro.network,40.8302,-74.1299 bostr.syobon.net,37.7621,-122.3971
purpura.cloud,43.6532,-79.3832 nostr.agentcampfire.com,52.3740,4.8897
ribo.eu.nostria.app,52.3676,4.90414 nostr.thebiglake.org,32.7244,-96.6755
vidono.apps.slidestr.net,48.8566,2.35222 schnorr.me,37.7621,-122.3971
wheat.happytavern.co,43.6532,-79.3832 relay.wolfcoil.com,35.6090,139.7302
nostr.faultables.net,43.6532,-79.3832 nostr.camalolo.com,24.1469,120.6839
relay5.bitransfer.org,43.6532,-79.3832 nostr.tac.lol,47.4740,-122.2610
relay.nostrhub.fr,48.1046,11.6002
nostr.thaliyal.com,40.8218,-74.45
relay.holzeis.me,43.6532,-79.3832
relay.nostriot.com,41.5695,-83.9786
nostr.openhoofd.nl,51.9229,4.40833
relay.nostr.vet,52.6467,4.7395
nostr.camalolo.com,24.1469,120.684
relay.origin.land,35.6673,139.751
relay.chakany.systems,43.6532,-79.3832
relay.0xchat.com,1.35208,103.82
nostr.mom,50.4754,12.3683
4u2ni0zjbjvni.clorecloud.net,43.6532,-79.3832
prl.plus,55.7623,37.6381
relay.moinsen.com,50.4754,12.3683
nostr-02.czas.top,53.471,9.88208
relay.sigit.io,50.4754,12.3683
relay.nostrcheck.me,43.6532,-79.3832
relay03.lnfi.network,39.0997,-94.5786
relay.sincensura.org,43.6532,-79.3832
nostr.coincards.com,53.5501,-113.469
nostr-03.dorafactory.org,1.35208,103.82
relay.credenso.cafe,43.1149,-80.7228
nostr.fbxl.net,48.3809,-89.2477
relay.bullishbounty.com,43.6532,-79.3832
nos.xmark.cc,50.6924,3.20113
x.kojira.io,43.6532,-79.3832
wot.sovbit.host,64.1466,-21.9426
shu05.shugur.net,48.8566,2.35222
nostr.carroarmato0.be,50.9928,3.26317
relay.cosmicbolt.net,37.3986,-121.964
r.bitcoinhold.net,43.6532,-79.3832
nostr.diakod.com,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
nostr.coincrowd.fund,39.0438,-77.4874
cyberspace.nostr1.com,40.7128,-74.006
relay.barine.co,43.6532,-79.3832
relay.orangepill.ovh,49.1689,-0.358841
no.str.cr,9.92857,-84.0528
nostr.casa21.space,43.6532,-79.3832
relay.mwaters.net,50.9871,2.12554
relay.magiccity.live,25.8128,-80.2377
relayone.soundhsa.com,34.0479,-118.256
slick.mjex.me,39.048,-77.4817
relay.utxo.farm,35.6916,139.768
theoutpost.life,64.1476,-21.9392
nostr.hekster.org,37.3986,-121.964
strfry.felixzieger.de,50.1013,8.62643
relay.mess.ch,47.3591,8.55292
wot.codingarena.top,50.4754,12.3683
nostrelay.circum.space,51.2217,6.77616
nostr-relay.online,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832
wot.dergigi.com,64.1476,-21.9392
wot.brightbolt.net,47.6735,-116.781
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
wot.nostr.place,30.2672,-97.7431
ribo.us.nostria.app,41.5868,-93.625
relay.nostr.net,50.4754,12.3683
nostr-02.dorafactory.org,1.35208,103.82
relay.tapestry.ninja,40.8054,-74.0241
adre.su,59.9311,30.3609
librerelay.aaroniumii.com,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
kitchen.zap.cooking,43.6532,-79.3832
nostr.21crypto.ch,47.4988,8.72369
nostr-02.yakihonne.com,1.32123,103.695
relay.javi.space,43.4633,11.8796
nostr.ser1.net,12.9716,77.5946
relay-rpi.edufeed.org,49.4543,11.0746
premium.primal.net,43.6532,-79.3832
relay.degmods.com,50.4754,12.3683
relay.arx-ccn.com,50.4754,12.3683
nostr.chaima.info,51.223,6.78245
relay.illuminodes.com,47.6061,-122.333
relay.nostx.io,43.6532,-79.3832
relay.puresignal.news,43.6532,-79.3832
fenrir-s.notoshi.win,43.6532,-79.3832
relay.getsafebox.app,43.6532,-79.3832
relay.conduit.market,43.6532,-79.3832
relay.jeffg.fyi,43.6532,-79.3832
nproxy.kristapsk.lv,60.1699,24.9384
relay.olas.app,50.4754,12.3683
relay.dwadziesciajeden.pl,52.2297,21.0122
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.pleb.one,38.6327,-90.1961
relay.digitalezukunft.cyou,45.5019,-73.5674
relay.evanverma.com,40.8302,-74.1299
wot.dtonon.com,43.6532,-79.3832
relay.seq1.net,43.6532,-79.3832
nostr.kalf.org,52.3676,4.90414
nostr.snowbla.de,60.1699,24.9384
nostr.spicyz.io,43.6532,-79.3832
nostr-relay.zimage.com,34.282,-118.439
nostr.spacecitynode.com,29.7057,-95.2706
dev-relay.lnfi.network,39.0997,-94.5786 dev-relay.lnfi.network,39.0997,-94.5786
itanostr.space,52.2931,4.79099 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.laantungir.net relay.damus.io -19.4692 37.7621 -42.5315 -122.3971
3 relay.endfiat.money nostr-pub.wellorder.net 43.6532 45.5229 -79.3832 -122.9898
4 relay.zone667.com nostr.mom 60.1699 50.4779 24.9384 12.3713
5 shu04.shugur.net nostr.slothy.win 25.2604 37.7621 55.2989 -122.3971
6 relay.bitcoinartclock.com nostr.einundzwanzig.space 50.4754 50.1155 12.3683 8.6842
7 relay.nostromo.social nos.lol 49.4543 50.4779 11.0746 12.3713
8 nostr.liberty.fans relay.nostr.band 36.9104 60.1695 -89.5875 24.9354
9 roles-az-achieving-somebody.trycloudflare.com no.str.cr 43.6532 9.9339 -79.3832 -84.0849
10 nostr-rs-relay.dev.fedibtc.com nostr.massmux.com 39.0438 50.1155 -77.4874 8.6842
11 relay.nostr.wirednet.jp nostr-relay.schnitzel.world 34.706 39.0437 135.493 -77.4875
12 nostr.einundzwanzig.space relay.nostr.com.au 50.1109 37.7621 8.68213 -122.3971
13 relay.21e6.cz knostr.neutrine.com 50.1682 48.8534 14.0546 2.3488
14 relay04.lnfi.network nostr.nodeofsven.com 39.0997 47.4875 -94.5786 8.2965
15 relay.chorus.community nostr.vulpem.com 50.1109 49.4542 8.68213 11.0775
16 relay.nostr.place nostr-verif.slothy.win 32.7767 37.7621 -96.797 -122.3971
17 relay.vrtmrz.net relay.lexingtonbitcoin.org 43.6532 37.7621 -79.3832 -122.3971
18 noxir.kpherox.dev nostr-1.nbo.angani.co 34.8587 -1.2615 135.509 36.7903
19 wot.nostr.net relay.wellorder.net 43.6532 45.5229 -79.3832 -122.9898
20 relay.cypherflow.ai nostr.easydns.ca 48.8566 43.7064 2.35222 -79.3986
21 wot.sudocarlos.com relay.dwadziesciajeden.pl 51.5072 52.2298 -0.127586 21.0118
22 nostr.jerrynya.fun nostr.data.haus 31.2304 50.4779 121.474 12.3713
23 nostr2.girino.org nostr.einundzwanzig.space 43.6532 50.1155 -79.3832 8.6842
24 nostrings-relay-dev.fly.dev nostr.mom 41.8781 50.4779 -87.6298 12.3713
25 fanfares.nostr1.com nos.lol 40.7128 50.4779 -74.006 12.3713
26 nostr.red5d.dev relay.nostr.band 43.6532 60.1695 -79.3832 24.9354
27 nostr.hifish.org nostr.massmux.com 47.4043 50.1155 8.57398 8.6842
28 nostr.now nostr.vulpem.com 36.55 49.4542 139.733 11.0775
29 relay.nostr.band relay.damus.io 60.1699 37.7621 24.9384 -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.bilthon.dev nostr.sagaciousd.com 25.8128 49.2497 -80.2377 -123.1193
63 khatru.nostrver.se nostr.fbxl.net 51.8933 43.7064 4.42083 -79.3986
64 relay.bitcoindistrict.org ithurtswhenip.ee 43.6532 50.7990 -79.3832 -1.0913
65 nostr.makibisskey.work relay2.nostrchat.io 43.6532 49.4542 -79.3832 11.0775
66 relay.nostraddress.com relay1.nostrchat.io 43.6532 60.1695 -79.3832 24.9354
67 relay.jmoose.rocks nostr-01.yakihonne.com 60.1699 1.3215 24.9384 103.6957
68 relay.davidebtc.me nostr.sathoarder.com 51.5072 48.5839 -0.127586 7.7455
69 a.nos.lol nostr.overmind.lol 50.4754 37.7621 12.3683 -122.3971
70 nostr.tadryanom.me relay.verified-nostr.com 43.6532 37.7621 -79.3832 -122.3971
71 relay.nostrdice.com purplerelay.com -33.8688 50.1155 151.209 8.6842
72 relay.lumina.rocks relay.orangepill.ovh 49.0291 49.0127 8.35695 1.9694
73 relay.goodmorningbitcoin.com nostr-relay.psfoundation.info 43.6532 39.0437 -79.3832 -77.4875
74 nostr.rtvslawenia.com soloco.nl 49.4543 37.7621 11.0746 -122.3971
75 relay.mattybs.lol relay.froth.zone 43.6532 60.1695 -79.3832 24.9354
76 relay-dev.satlantis.io nostr.stakey.net 40.8302 52.5250 -74.1299 5.7181
77 nostream.breadslice.com nostr.2b9t.xyz 43.6532 34.0522 -79.3832 -118.2437
78 nostr.vulpem.com pyramid.fiatjaf.com 49.4543 50.1155 11.0746 8.6842
79 nostr.rohoss.com a.nos.lol 50.1109 50.4779 8.68213 12.3713
80 articles.layer3.news relay.magiccity.live 37.3387 25.8130 -121.885 -80.2320
81 nos.lol nostr.notribe.net 50.4754 40.8344 12.3683 -74.1377
82 relay.artx.market freelay.sovbit.host 43.652 64.1355 -79.3633 -21.8954
83 wot.sebastix.social relay.credenso.cafe 51.8933 43.4254 4.42083 -80.5112
84 alien.macneilmediagroup.com nostr.huszonegy.world 43.6532 47.4984 -79.3832 19.0404
85 relay.unknown.cloud multiplexer.huszonegy.world 43.6532 47.4984 -79.3832 19.0404
86 nostr.lojong.info bucket.coracle.social 43.6532 37.7621 -79.3832 -122.3971
87 nostr.zenon.network nostr.kungfu-g.rip 43.5009 33.7865 -70.4428 -84.4454
88 orangesync.tech relay.artx.market 50.1109 43.7064 8.68213 -79.3986
89 nostr.davidebtc.me relay.notoshi.win 51.5072 13.3622 -0.127586 100.9835
90 internationalright-wing.org vitor.nostr1.com -22.5022 40.7143 -48.7114 -74.0060
91 nostr.rikmeijer.nl nostr-02.yakihonne.com 50.4754 1.3215 12.3683 103.6957
92 ynostr.yael.at nostr-03.dorafactory.org 60.1699 1.2897 24.9384 103.8501
93 ithurtswhenip.ee n.ok0.org 51.223 -36.8485 6.78245 174.7635
94 relay.wellorder.net nostr.0x7e.xyz 45.5201 47.5056 -122.99 8.7241
95 nostr.sathoarder.com relay.nostr.net 48.5734 50.4779 7.75211 12.3713
96 purplerelay.com strfry.openhoofd.nl 50.1109 51.5717 8.68213 3.7042
yabu.me 35.6092 139.73
nostr.88mph.life 43.6532 -79.3832
nostr.overmind.lol 43.6532 -79.3832
rnostr.breadslice.com 43.6532 -79.3832
zap.watch 45.5029 -73.5723
wot.basspistol.org 49.4521 11.0767
shu01.shugur.net 21.4902 39.2246
relay.electriclifestyle.com 26.2897 -80.1293
relay.mccormick.cx 52.3563 4.95714
nostr.middling.mydns.jp 35.8099 140.12
nostr.smut.cloud 43.6532 -79.3832
satsage.xyz 37.3986 -121.964
srtrelay.c-stellar.net 43.6532 -79.3832
nostr.0x7e.xyz 47.4988 8.72369
shu02.shugur.net 21.4902 39.2246
nostrelites.org 41.8781 -87.6298
relay-admin.thaliyal.com 40.8218 -74.45
wot.soundhsa.com 34.0479 -118.256
nostrcheck.me 43.6532 -79.3832
relay.nostrhub.tech 49.4543 11.0746
relay.stream.labs.h3.se 59.4016 17.9455
nostrelay.memory-art.xyz 43.6532 -79.3832
nostr.n7ekb.net 47.4941 -122.294
relay.nosto.re 51.8933 4.42083
nostr.girino.org 43.6532 -79.3832
relay.siamdev.cc 13.9178 100.424
nostr.mehdibekhtaoui.com 49.4939 -1.54813
orangepiller.org 60.1699 24.9384
nostr.plantroon.com 50.1013 8.62643
nostr-verified.wellorder.net 45.5201 -122.99
relay.primal.net 43.6532 -79.3832
relay.bitcoinveneto.org 64.1466 -21.9426
relay.hasenpfeffr.com 39.0438 -77.4874
strfry.openhoofd.nl 51.9229 4.40833
relay.aloftus.io 34.0881 -118.379
nostr.spaceshell.xyz 43.6532 -79.3832
nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
ribo.af.nostria.app -26.2041 28.0473
nostr.tac.lol 47.4748 -122.273
relay.satlantis.io 32.8769 -80.0114
nostr.azzamo.net 52.2633 21.0283
strfry.bonsai.com 37.8715 -122.273
relay.agora.social 50.7383 15.0648
nostr-relay.amethyst.name 39.0067 -77.4291
relay.toastr.net 40.8054 -74.0241
nostr.thebiglake.org 32.71 -96.6745
nostr-relay.nextblockvending.com 47.674 -122.122
vitor.nostr1.com 40.7057 -74.0136
relay.btcforplebs.com 43.6532 -79.3832
relay.g1sms.fr 43.9432 2.07537
nostr.jfischer.org 49.0291 8.35696
nostr.mikoshi.de 52.52 13.405
relay.notoshi.win 13.7829 100.546
pyramid.fiatjaf.com 50.1109 8.68213
relay.coinos.io 43.6532 -79.3832
relay.freeplace.nl 52.3676 4.90414
nostr-relay.psfoundation.info 39.0438 -77.4874
relay.copylaradio.com 51.223 6.78245
relay.exit.pub 50.4754 12.3683
freelay.sovbit.host 64.1476 -21.9392
nostr.satstralia.com 64.1476 -21.9392
nostr.l484.com 30.2944 -97.6223
nostr.rblb.it 43.4633 11.8796
nostr.2b9t.xyz 34.0549 -118.243
nostr.dlsouza.lol 50.1109 8.68213
strfry.shock.network 41.8959 -88.2169
offchain.pub 36.1809 -115.241
nostr-01.yakihonne.com 1.32123 103.695
nostr.kungfu-g.rip 33.7946 -84.4488
relay.letsfo.com 51.098 17.0321
relay.lifpay.me 1.35208 103.82
relay.damus.io 43.6532 -79.3832
relay2.angor.io 48.1046 11.6002
relayrs.notoshi.win 43.6532 -79.3832
relay2.ngengine.org 43.6532 -79.3832
portal-relay.pareto.space 49.4543 11.0746
inbox.azzamo.net 52.2633 21.0283
nostr-dev.wellorder.net 45.5201 -122.99
nostr.stakey.net 52.3676 4.90414
relay.13room.space 43.6532 -79.3832
97 relay.fountain.fm 39.0997 -94.5786
98 black.nostrcity.club relay.usefusion.ai 41.8781 38.7135 -87.6298 -78.1594
99 nostr-2.21crypto.ch relay.varke.eu 47.4988 52.6958 8.72369 6.1944
100 dev-nostr.bityacht.io nostr.satstralia.com 25.0797 64.1355 121.234 -21.8954
101 santo.iguanatech.net relay.13room.space 40.8302 37.7621 -74.1299 -122.3971
102 relay.angor.io nostr.myshosholoza.co.za 48.1046 52.3710 11.6002 4.9042
103 relay.tagayasu.xyz nostr.carroarmato0.be 43.6715 50.8517 -79.38 3.6089
104 relay.npubhaus.com nostr.dbtc.link 43.6532 37.7621 -79.3832 -122.3971
105 relay01.lnfi.network orangepiller.org 39.0997 60.1695 -94.5786 24.9354
106 nostr.myshosholoza.co.za adre.su 52.3676 59.9386 4.90414 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 gnostr.com nostr-rs-relay.dev.fedibtc.com 40.9017 39.0437 29.1616 -77.4875
168 nostr.sagaciousd.com relay.davidebtc.me 49.2827 50.1155 -123.121 8.6842
169 nostr.night7.space wot.dtonon.com 50.4754 37.7621 12.3683 -122.3971
170 schnorr.me relay.goodmorningbitcoin.com 43.6532 37.7621 -79.3832 -122.3971
171 nostr.blankfors.se articles.layer3.news 60.1699 37.3394 24.9384 -121.8950
172 relay.mostro.network bostr.syobon.net 40.8302 37.7621 -74.1299 -122.3971
173 purpura.cloud nostr.agentcampfire.com 43.6532 52.3740 -79.3832 4.8897
174 ribo.eu.nostria.app nostr.thebiglake.org 52.3676 32.7244 4.90414 -96.6755
175 vidono.apps.slidestr.net schnorr.me 48.8566 37.7621 2.35222 -122.3971
176 wheat.happytavern.co relay.wolfcoil.com 43.6532 35.6090 -79.3832 139.7302
177 nostr.faultables.net nostr.camalolo.com 43.6532 24.1469 -79.3832 120.6839
178 relay5.bitransfer.org nostr.tac.lol 43.6532 47.4740 -79.3832 -122.2610
relay.nostrhub.fr 48.1046 11.6002
nostr.thaliyal.com 40.8218 -74.45
relay.holzeis.me 43.6532 -79.3832
relay.nostriot.com 41.5695 -83.9786
nostr.openhoofd.nl 51.9229 4.40833
relay.nostr.vet 52.6467 4.7395
nostr.camalolo.com 24.1469 120.684
relay.origin.land 35.6673 139.751
relay.chakany.systems 43.6532 -79.3832
relay.0xchat.com 1.35208 103.82
nostr.mom 50.4754 12.3683
4u2ni0zjbjvni.clorecloud.net 43.6532 -79.3832
prl.plus 55.7623 37.6381
relay.moinsen.com 50.4754 12.3683
nostr-02.czas.top 53.471 9.88208
relay.sigit.io 50.4754 12.3683
relay.nostrcheck.me 43.6532 -79.3832
relay03.lnfi.network 39.0997 -94.5786
relay.sincensura.org 43.6532 -79.3832
nostr.coincards.com 53.5501 -113.469
nostr-03.dorafactory.org 1.35208 103.82
relay.credenso.cafe 43.1149 -80.7228
nostr.fbxl.net 48.3809 -89.2477
relay.bullishbounty.com 43.6532 -79.3832
nos.xmark.cc 50.6924 3.20113
x.kojira.io 43.6532 -79.3832
wot.sovbit.host 64.1466 -21.9426
shu05.shugur.net 48.8566 2.35222
nostr.carroarmato0.be 50.9928 3.26317
relay.cosmicbolt.net 37.3986 -121.964
r.bitcoinhold.net 43.6532 -79.3832
nostr.diakod.com 43.6532 -79.3832
nostr-relay.cbrx.io 43.6532 -79.3832
nostr.coincrowd.fund 39.0438 -77.4874
cyberspace.nostr1.com 40.7128 -74.006
relay.barine.co 43.6532 -79.3832
relay.orangepill.ovh 49.1689 -0.358841
no.str.cr 9.92857 -84.0528
nostr.casa21.space 43.6532 -79.3832
relay.mwaters.net 50.9871 2.12554
relay.magiccity.live 25.8128 -80.2377
relayone.soundhsa.com 34.0479 -118.256
slick.mjex.me 39.048 -77.4817
relay.utxo.farm 35.6916 139.768
theoutpost.life 64.1476 -21.9392
nostr.hekster.org 37.3986 -121.964
strfry.felixzieger.de 50.1013 8.62643
relay.mess.ch 47.3591 8.55292
wot.codingarena.top 50.4754 12.3683
nostrelay.circum.space 51.2217 6.77616
nostr-relay.online 43.6532 -79.3832
temp.iris.to 43.6532 -79.3832
wot.dergigi.com 64.1476 -21.9392
wot.brightbolt.net 47.6735 -116.781
nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
wot.nostr.place 30.2672 -97.7431
ribo.us.nostria.app 41.5868 -93.625
relay.nostr.net 50.4754 12.3683
nostr-02.dorafactory.org 1.35208 103.82
relay.tapestry.ninja 40.8054 -74.0241
adre.su 59.9311 30.3609
librerelay.aaroniumii.com 43.6532 -79.3832
nostr-pub.wellorder.net 45.5201 -122.99
kitchen.zap.cooking 43.6532 -79.3832
nostr.21crypto.ch 47.4988 8.72369
nostr-02.yakihonne.com 1.32123 103.695
relay.javi.space 43.4633 11.8796
nostr.ser1.net 12.9716 77.5946
relay-rpi.edufeed.org 49.4543 11.0746
premium.primal.net 43.6532 -79.3832
relay.degmods.com 50.4754 12.3683
relay.arx-ccn.com 50.4754 12.3683
nostr.chaima.info 51.223 6.78245
relay.illuminodes.com 47.6061 -122.333
relay.nostx.io 43.6532 -79.3832
relay.puresignal.news 43.6532 -79.3832
fenrir-s.notoshi.win 43.6532 -79.3832
relay.getsafebox.app 43.6532 -79.3832
relay.conduit.market 43.6532 -79.3832
relay.jeffg.fyi 43.6532 -79.3832
nproxy.kristapsk.lv 60.1699 24.9384
relay.olas.app 50.4754 12.3683
relay.dwadziesciajeden.pl 52.2297 21.0122
relay-testnet.k8s.layer3.news 37.3387 -121.885
nostr.pleb.one 38.6327 -90.1961
relay.digitalezukunft.cyou 45.5019 -73.5674
relay.evanverma.com 40.8302 -74.1299
wot.dtonon.com 43.6532 -79.3832
relay.seq1.net 43.6532 -79.3832
nostr.kalf.org 52.3676 4.90414
nostr.snowbla.de 60.1699 24.9384
nostr.spicyz.io 43.6532 -79.3832
nostr-relay.zimage.com 34.282 -118.439
nostr.spacecitynode.com 29.7057 -95.2706
179 dev-relay.lnfi.network 39.0997 -94.5786
180 itanostr.space relay.bitcoinveneto.org 52.2931 64.1355 4.79099 -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
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
echo "bitchat Setup Script"
echo "==================="
# Check if XcodeGen is installed
if command -v xcodegen &> /dev/null; then
echo "✓ XcodeGen found"
echo "Generating Xcode project..."
xcodegen generate
echo "✓ Project generated successfully"
echo ""
echo "To open the project, run:"
echo " open bitchat.xcodeproj"
else
echo "⚠️ XcodeGen not found"
echo ""
echo "You have several options:"
echo "1. Install XcodeGen:"
echo " brew install xcodegen"
echo ""
echo "2. Open with Swift Package Manager:"
echo " open Package.swift"
echo ""
echo "3. Create a new Xcode project manually and add the source files"
fi
echo ""
echo "Project Structure:"
echo "- bitchat/ Main source files"
echo " - BitchatApp.swift App entry point"
echo " - Views/ SwiftUI views"
echo " - ViewModels/ View models"
echo " - Services/ Bluetooth and encryption"
echo " - Protocols/ Protocol definitions"
echo ""
echo "Remember to:"
echo "1. Enable Bluetooth in device settings"
echo "2. Run on physical devices (Bluetooth doesn't work in simulator)"
echo "3. Test with multiple devices for mesh functionality"