Compare commits

..
Author SHA1 Message Date
islam f20e65b0d8 Run local packages’ tests as well on CI 2026-04-14 19:50:14 +01:00
119 changed files with 897 additions and 1260 deletions
-6
View File
@@ -19,12 +19,6 @@ jobs:
path: . path: .
- name: BitLogger - name: BitLogger
path: localPackages/BitLogger path: localPackages/BitLogger
- name: BitFoundation
path: localPackages/BitFoundation
- name: Noise
path: localPackages/Noise
- name: Nostr
path: localPackages/Nostr
steps: steps:
- name: Checkout code - name: Checkout code
+3 -12
View File
@@ -17,10 +17,7 @@ let package = Package(
], ],
dependencies:[ dependencies:[
.package(path: "localPackages/Arti"), .package(path: "localPackages/Arti"),
.package(path: "localPackages/Noise"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"), .package(path: "localPackages/BitLogger"),
.package(path: "localPackages/Nostr"),
.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: [
@@ -28,10 +25,7 @@ let package = Package(
name: "bitchat", name: "bitchat",
dependencies: [ dependencies: [
.product(name: "P256K", package: "swift-secp256k1"), .product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "BitLogger", package: "BitLogger"), .product(name: "BitLogger", package: "BitLogger"),
.product(name: "Noise", package: "Noise"),
.product(name: "Nostr", package: "Nostr"),
.product(name: "Tor", package: "Arti") .product(name: "Tor", package: "Arti")
], ],
path: "bitchat", path: "bitchat",
@@ -50,18 +44,15 @@ let package = Package(
), ),
.testTarget( .testTarget(
name: "bitchatTests", name: "bitchatTests",
dependencies: [ dependencies: ["bitchat"],
"bitchat",
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "Nostr", package: "Nostr")
],
path: "bitchatTests", path: "bitchatTests",
exclude: [ exclude: [
"Info.plist", "Info.plist",
"README.md" "README.md"
], ],
resources: [ resources: [
.process("Localization") .process("Localization"),
.process("Noise")
] ]
) )
] ]
-60
View File
@@ -10,12 +10,6 @@
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; }; 3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; }; 885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; };
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; };
A63180832F8103AB00B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180822F8103AB00B8B128 /* Nostr */; };
A63180852F8103B600B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180842F8103B600B8B128 /* Nostr */; };
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; }; A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; }; A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; }; A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
@@ -160,22 +154,16 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
files = ( files = (
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */, A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */,
3EE336D150427F736F32B56C /* P256K in Frameworks */, 3EE336D150427F736F32B56C /* P256K in Frameworks */,
A63180852F8103B600B8B128 /* Nostr in Frameworks */,
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */, A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
); );
}; };
B5A5CC493FFB3D8966548140 /* Frameworks */ = { B5A5CC493FFB3D8966548140 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
files = ( files = (
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */, A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
A63163B62F80CB2500B8B128 /* Noise in Frameworks */,
885BBED78092484A5B069461 /* P256K in Frameworks */, 885BBED78092484A5B069461 /* P256K in Frameworks */,
A63180832F8103AB00B8B128 /* Nostr in Frameworks */,
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */, A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
); );
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
@@ -235,9 +223,6 @@
B1D9136AA0083366353BFA2F /* P256K */, B1D9136AA0083366353BFA2F /* P256K */,
A6E3E5712E7703760032EA8A /* BitLogger */, A6E3E5712E7703760032EA8A /* BitLogger */,
A6E3EA802E7706A80032EA8A /* Tor */, A6E3EA802E7706A80032EA8A /* Tor */,
A6BCF9492F809550001CF9B9 /* BitFoundation */,
A63163B72F80CB2D00B8B128 /* Noise */,
A63180842F8103B600B8B128 /* Nostr */,
); );
productName = bitchat_macOS; productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@@ -318,9 +303,6 @@
4EB6BA1B8464F1EA38F4E286 /* P256K */, 4EB6BA1B8464F1EA38F4E286 /* P256K */,
A6E3E56F2E77036A0032EA8A /* BitLogger */, A6E3E56F2E77036A0032EA8A /* BitLogger */,
A6E3EA7E2E7706720032EA8A /* Tor */, A6E3EA7E2E7706720032EA8A /* Tor */,
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
A63163B52F80CB2500B8B128 /* Noise */,
A63180822F8103AB00B8B128 /* Nostr */,
); );
productName = bitchat_iOS; productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@@ -362,9 +344,6 @@
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */, B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */, A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */, A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */,
); );
preferredProjectObjectVersion = 90; preferredProjectObjectVersion = 90;
projectDirPath = ""; projectDirPath = "";
@@ -930,18 +909,6 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Noise;
};
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Nostr;
};
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitFoundation;
};
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = { A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitLogger; relativePath = localPackages/BitLogger;
@@ -969,33 +936,6 @@
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
productName = P256K; productName = P256K;
}; };
A63163B52F80CB2500B8B128 /* Noise */ = {
isa = XCSwiftPackageProductDependency;
productName = Noise;
};
A63163B72F80CB2D00B8B128 /* Noise */ = {
isa = XCSwiftPackageProductDependency;
package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */;
productName = Noise;
};
A63180822F8103AB00B8B128 /* Nostr */ = {
isa = XCSwiftPackageProductDependency;
productName = Nostr;
};
A63180842F8103B600B8B128 /* Nostr */ = {
isa = XCSwiftPackageProductDependency;
package = A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */;
productName = Nostr;
};
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
productName = BitFoundation;
};
A6BCF9492F809550001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
package = A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */;
productName = BitFoundation;
};
A6E3E56F2E77036A0032EA8A /* BitLogger */ = { A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = BitLogger; productName = BitLogger;
+1 -5
View File
@@ -6,10 +6,8 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Nostr
import Tor import Tor
import SwiftUI import SwiftUI
import BitFoundation
import UserNotifications import UserNotifications
@main @main
@@ -28,11 +26,9 @@ struct BitchatApp: App {
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif #endif
private let idBridge = NostrIdentityBridge(keychain: KeychainManager()) private let idBridge = NostrIdentityBridge()
init() { init() {
GeoRelayDirectory.setupShared(dependencies: .live())
NostrRelayManager.setupShared(dependencies: .live())
let keychain = KeychainManager() let keychain = KeychainManager()
let idBridge = self.idBridge let idBridge = self.idBridge
_chatViewModel = StateObject( _chatViewModel = StateObject(
-1
View File
@@ -81,7 +81,6 @@
/// ///
import Foundation import Foundation
import BitFoundation
// MARK: - Three-Layer Identity Model // MARK: - Three-Layer Identity Model
@@ -91,7 +91,6 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import CryptoKit import CryptoKit
-1
View File
@@ -7,7 +7,6 @@
// //
import Foundation import Foundation
import BitFoundation
/// Represents a user-visible message in the BitChat system. /// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages, /// Handles both broadcast messages and private encrypted messages,
-1
View File
@@ -7,7 +7,6 @@
// //
import Foundation import Foundation
import BitFoundation
/// The core packet structure for all BitChat protocol messages. /// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network, /// Encapsulates all data needed for routing through the mesh network,
-1
View File
@@ -1,6 +1,5 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
/// Represents a peer in the BitChat network with all associated metadata /// Represents a peer in the BitChat network with all associated metadata
struct BitchatPeer: Equatable { struct BitchatPeer: Equatable {
@@ -1,27 +1,15 @@
// //
// PeerID.swift // PeerID.swift
// BitFoundation // 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>
// //
import struct Foundation.Data import Foundation
import struct Foundation.CharacterSet
public struct PeerID: Equatable, Hashable, Sendable { struct PeerID: Equatable, Hashable {
enum Constants { enum Prefix: String, CaseIterable {
/// 16
static let nostrConvKeyPrefixLength = 16
/// 8
static let nostrShortKeyDisplayLength = 8
/// 64
fileprivate static let maxIDLength = 64
/// 16
fileprivate static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
public enum Prefix: String, CaseIterable, Sendable {
/// When no prefix is provided /// When no prefix is provided
case empty = "" case empty = ""
/// `"mesh:"` /// `"mesh:"`
@@ -36,13 +24,13 @@ public struct PeerID: Equatable, Hashable, Sendable {
case geoChat = "nostr:" case geoChat = "nostr:"
} }
public let prefix: Prefix let prefix: Prefix
/// Returns the actual value without any prefix /// Returns the actual value without any prefix
public let bare: String let bare: String
/// Returns the full `id` value by combining `(prefix + bare)` /// Returns the full `id` value by combining `(prefix + bare)`
public var id: String { prefix.rawValue + bare } var id: String { prefix.rawValue + bare }
// Private so the callers have to go through a convenience init // Private so the callers have to go through a convenience init
private init(prefix: Prefix, bare: any StringProtocol) { private init(prefix: Prefix, bare: any StringProtocol) {
@@ -53,15 +41,15 @@ public struct PeerID: Equatable, Hashable, Sendable {
// MARK: - Convenience Inits // MARK: - Convenience Inits
public extension PeerID { extension PeerID {
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey` /// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
init(nostr_ pubKey: String) { init(nostr_ pubKey: String) {
self.init(prefix: .geoDM, bare: pubKey.prefix(Constants.nostrConvKeyPrefixLength)) self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
} }
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey` /// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
init(nostr pubKey: String) { init(nostr pubKey: String) {
self.init(prefix: .geoChat, bare: pubKey.prefix(Constants.nostrShortKeyDisplayLength)) self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
} }
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts /// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
@@ -98,7 +86,7 @@ public extension PeerID {
// MARK: - Noise Public Key Helpers // MARK: - Noise Public Key Helpers
public extension PeerID { extension PeerID {
/// Derive the stable 16-hex peer ID from a Noise static public key /// Derive the stable 16-hex peer ID from a Noise static public key
init(publicKey: Data) { init(publicKey: Data) {
self.init(str: publicKey.sha256Fingerprint().prefix(16)) self.init(str: publicKey.sha256Fingerprint().prefix(16))
@@ -116,11 +104,11 @@ public extension PeerID {
// MARK: - Codable // MARK: - Codable
extension PeerID: Codable { extension PeerID: Codable {
public init(from decoder: any Decoder) throws { init(from decoder: any Decoder) throws {
self.init(str: try decoder.singleValueContainer().decode(String.self)) self.init(str: try decoder.singleValueContainer().decode(String.self))
} }
public func encode(to encoder: any Encoder) throws { func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
try container.encode(id) try container.encode(id)
} }
@@ -128,7 +116,7 @@ extension PeerID: Codable {
// MARK: - Helpers // MARK: - Helpers
public extension PeerID { extension PeerID {
var isEmpty: Bool { var isEmpty: Bool {
id.isEmpty id.isEmpty
} }
@@ -148,7 +136,7 @@ public extension PeerID {
} }
} }
public extension PeerID { extension PeerID {
var routingData: Data? { var routingData: Data? {
if let direct = Data(hexString: id), direct.count == 8 { return direct } if let direct = Data(hexString: id), direct.count == 8 { return direct }
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData } if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
@@ -164,7 +152,12 @@ public extension PeerID {
// MARK: - Validation // MARK: - Validation
public extension PeerID { extension PeerID {
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) /// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
var isValid: Bool { var isValid: Bool {
if prefix != .empty { if prefix != .empty {
@@ -213,7 +206,7 @@ public extension PeerID {
// MARK: - Comparable // MARK: - Comparable
extension PeerID: Comparable { extension PeerID: Comparable {
public static func < (lhs: PeerID, rhs: PeerID) -> Bool { static func < (lhs: PeerID, rhs: PeerID) -> Bool {
lhs.id < rhs.id lhs.id < rhs.id
} }
} }
@@ -222,7 +215,7 @@ extension PeerID: Comparable {
extension PeerID: CustomStringConvertible { extension PeerID: CustomStringConvertible {
/// So it returns the actual `id` like before even inside another String /// So it returns the actual `id` like before even inside another String
public var description: String { var description: String {
id id
} }
} }
-1
View File
@@ -7,7 +7,6 @@
// //
import Foundation import Foundation
import BitFoundation
struct ReadReceipt: Codable { struct ReadReceipt: Codable {
let originalMessageID: String let originalMessageID: String
@@ -78,7 +78,6 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -533,7 +532,7 @@ final class NoiseSymmetricState {
final class NoiseHandshakeState { final class NoiseHandshakeState {
private let role: NoiseRole private let role: NoiseRole
private let pattern: NoisePattern private let pattern: NoisePattern
private let keychain: SecureMemoryCleaner private let keychain: KeychainManagerProtocol
private var symmetricState: NoiseSymmetricState private var symmetricState: NoiseSymmetricState
// Keys // Keys
@@ -556,7 +555,7 @@ final class NoiseHandshakeState {
init( init(
role: NoiseRole, role: NoiseRole,
pattern: NoisePattern, pattern: NoisePattern,
keychain: SecureMemoryCleaner, keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
prologue: Data = Data(), prologue: Data = Data(),
@@ -924,7 +923,7 @@ extension NoisePattern {
// MARK: - Errors // MARK: - Errors
public enum NoiseError: Error { enum NoiseError: Error {
case uninitializedCipher case uninitializedCipher
case invalidCiphertext case invalidCiphertext
case handshakeComplete case handshakeComplete
@@ -7,10 +7,9 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
public final class NoiseRateLimiter { final class NoiseRateLimiter {
private var handshakeTimestamps: [PeerID: [Date]] = [:] private var handshakeTimestamps: [PeerID: [Date]] = [:]
private var messageTimestamps: [PeerID: [Date]] = [:] private var messageTimestamps: [PeerID: [Date]] = [:]
@@ -20,9 +19,7 @@ public 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)
public init() {} func allowHandshake(from peerID: PeerID) -> Bool {
public func allowHandshake(from peerID: PeerID) -> 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)
@@ -51,7 +48,7 @@ public final class NoiseRateLimiter {
} }
} }
public func allowMessage(from peerID: PeerID) -> Bool { func allowMessage(from peerID: PeerID) -> 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)
@@ -87,7 +84,7 @@ public final class NoiseRateLimiter {
} }
} }
public func resetAll() { func resetAll() {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.handshakeTimestamps.removeAll() self.handshakeTimestamps.removeAll()
self.messageTimestamps.removeAll() self.messageTimestamps.removeAll()
@@ -8,7 +8,7 @@
import Foundation import Foundation
public enum NoiseSecurityConstants { enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion // Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec static let maxMessageSize = 65535 // 64KB as per Noise spec
@@ -28,10 +28,10 @@ public enum NoiseSecurityConstants {
static let maxSessionsPerPeer = 3 static let maxSessionsPerPeer = 3
// Rate limiting // Rate limiting
public static let maxHandshakesPerMinute = 10 static let maxHandshakesPerMinute = 10
public static let maxMessagesPerSecond = 100 static let maxMessagesPerSecond = 100
// Global rate limiting (across all peers) // Global rate limiting (across all peers)
public static let maxGlobalHandshakesPerMinute = 30 static let maxGlobalHandshakesPerMinute = 30
public static let maxGlobalMessagesPerSecond = 500 static let maxGlobalMessagesPerSecond = 500
} }
@@ -8,7 +8,7 @@
import Foundation import Foundation
public enum NoiseSecurityError: Error { enum NoiseSecurityError: Error {
case sessionExpired case sessionExpired
case sessionExhausted case sessionExhausted
case messageTooLarge case messageTooLarge
@@ -8,15 +8,15 @@
import Foundation import Foundation
public struct NoiseSecurityValidator { struct NoiseSecurityValidator {
/// Validate message size /// Validate message size
public static func validateMessageSize(_ data: Data) -> Bool { static func validateMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxMessageSize return data.count <= NoiseSecurityConstants.maxMessageSize
} }
/// Validate handshake message size /// Validate handshake message size
public static func validateHandshakeMessageSize(_ data: Data) -> Bool { static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
} }
} }
@@ -9,12 +9,11 @@
import BitLogger import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import BitFoundation
public class NoiseSession { class NoiseSession {
let peerID: PeerID let peerID: PeerID
let role: NoiseRole let role: NoiseRole
private let keychain: SecureMemoryCleaner private let keychain: KeychainManagerProtocol
private var state: NoiseSessionState = .uninitialized private var state: NoiseSessionState = .uninitialized
private var handshakeState: NoiseHandshakeState? private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState? private var sendCipher: NoiseCipherState?
@@ -34,7 +33,7 @@ public class NoiseSession {
init( init(
peerID: PeerID, peerID: PeerID,
role: NoiseRole, role: NoiseRole,
keychain: SecureMemoryCleaner, keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey, localStaticKey: Curve25519.KeyAgreement.PrivateKey,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) { ) {
@@ -182,7 +181,7 @@ public class NoiseSession {
} }
} }
public func isEstablished() -> Bool { func isEstablished() -> Bool {
return sessionQueue.sync { return sessionQueue.sync {
if case .established = state { if case .established = state {
return true return true
@@ -217,8 +216,8 @@ public class NoiseSession {
sentHandshakeMessages.removeAll() sentHandshakeMessages.removeAll()
// Clear handshake hash // Clear handshake hash
if handshakeHash != nil { if var hash = handshakeHash {
keychain.secureClear(&handshakeHash!) keychain.secureClear(&hash)
} }
handshakeHash = nil handshakeHash = nil
@@ -6,7 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
public enum NoiseSessionError: Error, Equatable { enum NoiseSessionError: Error, Equatable {
case invalidState case invalidState
case notEstablished case notEstablished
case sessionNotFound case sessionNotFound
@@ -9,20 +9,19 @@
import BitLogger import BitLogger
import CryptoKit import CryptoKit
import Foundation import Foundation
import BitFoundation
public final class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:] private var sessions: [PeerID: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: SecureMemoryCleaner private let keychain: KeychainManagerProtocol
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks // Callbacks
public var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)? var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)?
public init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: SecureMemoryCleaner) { init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey self.localStaticKey = localStaticKey
self.keychain = keychain self.keychain = keychain
self.sessionFactory = { peerID, role in self.sessionFactory = { peerID, role in
@@ -38,7 +37,7 @@ public final class NoiseSessionManager {
#if DEBUG #if DEBUG
init( init(
localStaticKey: Curve25519.KeyAgreement.PrivateKey, localStaticKey: Curve25519.KeyAgreement.PrivateKey,
keychain: SecureMemoryCleaner, keychain: KeychainManagerProtocol,
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
) { ) {
self.localStaticKey = localStaticKey self.localStaticKey = localStaticKey
@@ -49,13 +48,13 @@ public final class NoiseSessionManager {
// MARK: - Session Management // MARK: - Session Management
public func getSession(for peerID: PeerID) -> NoiseSession? { func getSession(for peerID: PeerID) -> NoiseSession? {
return managerQueue.sync { return managerQueue.sync {
return sessions[peerID] return sessions[peerID]
} }
} }
public func removeSession(for peerID: PeerID) { func removeSession(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) { managerQueue.sync(flags: .barrier) {
if let session = sessions.removeValue(forKey: peerID) { if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing session.reset() // Clear sensitive data before removing
@@ -63,7 +62,7 @@ public final class NoiseSessionManager {
} }
} }
public func removeAllSessions() { func removeAllSessions() {
managerQueue.sync(flags: .barrier) { managerQueue.sync(flags: .barrier) {
for (_, session) in sessions { for (_, session) in sessions {
session.reset() session.reset()
@@ -74,7 +73,7 @@ public final class NoiseSessionManager {
// MARK: - Handshake Helpers // MARK: - Handshake Helpers
public func initiateHandshake(with peerID: PeerID) throws -> Data { func initiateHandshake(with peerID: PeerID) throws -> Data {
return try managerQueue.sync(flags: .barrier) { return try managerQueue.sync(flags: .barrier) {
// Check if we already have an established session // Check if we already have an established session
if let existingSession = sessions[peerID], existingSession.isEstablished() { if let existingSession = sessions[peerID], existingSession.isEstablished() {
@@ -103,7 +102,7 @@ public final class NoiseSessionManager {
} }
} }
public func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions // Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) { return try managerQueue.sync(flags: .barrier) {
var shouldCreateNew = false var shouldCreateNew = false
@@ -173,7 +172,7 @@ public final class NoiseSessionManager {
// MARK: - Encryption/Decryption // MARK: - Encryption/Decryption
public func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data { func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else { guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound throw NoiseSessionError.sessionNotFound
} }
@@ -181,7 +180,7 @@ public final class NoiseSessionManager {
return try session.encrypt(plaintext) return try session.encrypt(plaintext)
} }
public func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data { func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else { guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound throw NoiseSessionError.sessionNotFound
} }
@@ -191,13 +190,13 @@ public final class NoiseSessionManager {
// MARK: - Key Management // MARK: - Key Management
public func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? { func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
return getSession(for: peerID)?.getRemoteStaticPublicKey() return getSession(for: peerID)?.getRemoteStaticPublicKey()
} }
// MARK: - Session Rekeying // MARK: - Session Rekeying
public func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] { func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
return managerQueue.sync { return managerQueue.sync {
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = [] var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
@@ -213,7 +212,7 @@ public final class NoiseSessionManager {
} }
} }
public func initiateRekey(for peerID: PeerID) throws { func initiateRekey(for peerID: PeerID) throws {
// Remove old session // Remove old session
removeSession(for: peerID) removeSession(for: peerID)
@@ -1,11 +1,11 @@
import Foundation import Foundation
/// Bech32 encoding for Nostr (minimal implementation) /// Bech32 encoding for Nostr (minimal implementation)
public enum Bech32 { enum Bech32 {
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
public static func encode(hrp: String, data: Data) throws -> String { static func encode(hrp: String, data: Data) throws -> String {
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data)) let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
let checksum = createChecksum(hrp: hrp, values: values) let checksum = createChecksum(hrp: hrp, values: values)
let combined = values + checksum let combined = values + checksum
@@ -16,7 +16,8 @@ public enum Bech32 {
}.joined() }.joined()
} }
public static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) { static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
// Find the last occurrence of '1'
guard let separatorIndex = bech32String.lastIndex(of: "1") else { guard let separatorIndex = bech32String.lastIndex(of: "1") else {
throw Bech32Error.invalidFormat throw Bech32Error.invalidFormat
} }
@@ -104,14 +105,14 @@ public enum Bech32 {
var result = [UInt8]() var result = [UInt8]()
for c in hrp { for c in hrp {
guard let asciiValue = c.asciiValue else { guard let asciiValue = c.asciiValue else {
return [] return [] // Return empty array for invalid input
} }
result.append(UInt8(asciiValue >> 5)) result.append(UInt8(asciiValue >> 5))
} }
result.append(0) result.append(0)
for c in hrp { for c in hrp {
guard let asciiValue = c.asciiValue else { guard let asciiValue = c.asciiValue else {
return [] return [] // Return empty array for invalid input
} }
result.append(UInt8(asciiValue & 31)) result.append(UInt8(asciiValue & 31))
} }
@@ -1,72 +1,101 @@
import BitLogger import BitLogger
import Foundation import Foundation
import Tor
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
public struct GeoRelayDirectoryDependencies { /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
public var userDefaults: UserDefaults struct GeoRelayDirectoryDependencies {
public var notificationCenter: NotificationCenter var userDefaults: UserDefaults
public var now: () -> Date var notificationCenter: NotificationCenter
public var remoteURL: URL var now: () -> Date
public var fetchInterval: TimeInterval var remoteURL: URL
public var refreshCheckInterval: TimeInterval var fetchInterval: TimeInterval
public var retryInitialSeconds: TimeInterval var refreshCheckInterval: TimeInterval
public var retryMaxSeconds: TimeInterval var retryInitialSeconds: TimeInterval
public var awaitTorReady: @Sendable () async -> Bool var retryMaxSeconds: TimeInterval
public var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data) var awaitTorReady: @Sendable () async -> Bool
public var readData: (URL) -> Data? var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
public var writeData: (Data, URL) throws -> Void var readData: (URL) -> Data?
public var cacheURL: () -> URL? var writeData: (Data, URL) throws -> Void
public var bundledCSVURLs: () -> [URL] var cacheURL: () -> URL?
public var currentDirectoryPath: () -> String? var bundledCSVURLs: () -> [URL]
public var retrySleep: (TimeInterval) async -> Void var currentDirectoryPath: () -> String?
public var torReadyNotificationName: Notification.Name? var retrySleep: (TimeInterval) async -> Void
public var activeNotificationName: Notification.Name? var activeNotificationName: Notification.Name?
public var autoStart: Bool var autoStart: Bool
}
public init( private extension GeoRelayDirectoryDependencies {
userDefaults: UserDefaults, @MainActor
notificationCenter: NotificationCenter, static func live() -> Self {
now: @escaping () -> Date, #if os(iOS)
remoteURL: URL, let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
fetchInterval: TimeInterval, #elseif os(macOS)
refreshCheckInterval: TimeInterval, let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
retryInitialSeconds: TimeInterval, #else
retryMaxSeconds: TimeInterval, let activeNotificationName: Notification.Name? = nil
awaitTorReady: @Sendable @escaping () async -> Bool, #endif
makeFetchData: @MainActor @Sendable @escaping () -> (@Sendable (URLRequest) async throws -> Data),
readData: @escaping (URL) -> Data?, return Self(
writeData: @escaping (Data, URL) throws -> Void, userDefaults: .standard,
cacheURL: @escaping () -> URL?, notificationCenter: .default,
bundledCSVURLs: @escaping () -> [URL], now: Date.init,
currentDirectoryPath: @escaping () -> String?, remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
retrySleep: @escaping (TimeInterval) async -> Void, fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
torReadyNotificationName: Notification.Name?, refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
activeNotificationName: Notification.Name?, retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
autoStart: Bool retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
) { awaitTorReady: { await TorManager.shared.awaitReady() },
self.userDefaults = userDefaults makeFetchData: {
self.notificationCenter = notificationCenter let session = TorURLSession.shared.session
self.now = now return { request in
self.remoteURL = remoteURL let (data, _) = try await session.data(for: request)
self.fetchInterval = fetchInterval return data
self.refreshCheckInterval = refreshCheckInterval }
self.retryInitialSeconds = retryInitialSeconds },
self.retryMaxSeconds = retryMaxSeconds readData: { try? Data(contentsOf: $0) },
self.awaitTorReady = awaitTorReady writeData: { data, url in
self.makeFetchData = makeFetchData try data.write(to: url, options: .atomic)
self.readData = readData },
self.writeData = writeData cacheURL: {
self.cacheURL = cacheURL do {
self.bundledCSVURLs = bundledCSVURLs let base = try FileManager.default.url(
self.currentDirectoryPath = currentDirectoryPath for: .applicationSupportDirectory,
self.retrySleep = retrySleep in: .userDomainMask,
self.torReadyNotificationName = torReadyNotificationName appropriateFor: nil,
self.activeNotificationName = activeNotificationName create: true
self.autoStart = autoStart )
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("georelays_cache.csv")
} catch {
return nil
}
},
bundledCSVURLs: {
[
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
},
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
retrySleep: { delay in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
},
activeNotificationName: activeNotificationName,
autoStart: true
)
} }
} }
@MainActor @MainActor
public final class GeoRelayDirectory { final class GeoRelayDirectory {
private final class CleanupState { private final class CleanupState {
let notificationCenter: NotificationCenter let notificationCenter: NotificationCenter
var observers: [NSObjectProtocol] = [] var observers: [NSObjectProtocol] = []
@@ -84,16 +113,10 @@ public final class GeoRelayDirectory {
} }
} }
public struct Entry: Hashable, Sendable { struct Entry: Hashable, Sendable {
public let host: String let host: String
public let lat: Double let lat: Double
public let lon: Double let lon: Double
public init(host: String, lat: Double, lon: Double) {
self.host = host
self.lat = lat
self.lon = lon
}
} }
private enum DetachedFetchOutcome: Sendable { private enum DetachedFetchOutcome: Sendable {
@@ -103,13 +126,9 @@ public final class GeoRelayDirectory {
case network(String) case network(String)
} }
nonisolated(unsafe) public static var shared: GeoRelayDirectory! static let shared = GeoRelayDirectory()
public static func setupShared(dependencies: GeoRelayDirectoryDependencies) { private(set) var entries: [Entry] = []
shared = GeoRelayDirectory(dependencies: dependencies)
}
private(set) public var entries: [Entry] = []
private let lastFetchKey = "georelay.lastFetchAt" private let lastFetchKey = "georelay.lastFetchAt"
private let dependencies: GeoRelayDirectoryDependencies private let dependencies: GeoRelayDirectoryDependencies
private let cleanupState: CleanupState private let cleanupState: CleanupState
@@ -117,7 +136,18 @@ public final class GeoRelayDirectory {
private var retryAttempt: Int = 0 private var retryAttempt: Int = 0
private var isFetching: Bool = false private var isFetching: Bool = false
public init(dependencies: GeoRelayDirectoryDependencies) { private init() {
self.dependencies = .live()
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
entries = loadLocalEntries()
if dependencies.autoStart {
registerObservers()
startRefreshTimer()
prefetchIfNeeded()
}
}
internal init(dependencies: GeoRelayDirectoryDependencies) {
self.dependencies = dependencies self.dependencies = dependencies
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter) self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
entries = loadLocalEntries() entries = loadLocalEntries()
@@ -129,13 +159,13 @@ public final class GeoRelayDirectory {
} }
/// Returns up to `count` relay URLs (wss://) closest to the geohash center. /// Returns up to `count` relay URLs (wss://) closest to the geohash center.
public func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] { func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
let center = decodeGeohashCenter(geohash) let center = Geohash.decodeCenter(geohash)
return closestRelays(toLat: center.lat, lon: center.lon, count: count) return closestRelays(toLat: center.lat, lon: center.lon, count: count)
} }
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate. /// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
public func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] { func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty, count > 0 else { return [] } guard !entries.isEmpty, count > 0 else { return [] }
if entries.count <= count { if entries.count <= count {
@@ -165,7 +195,7 @@ public final class GeoRelayDirectory {
} }
// MARK: - Remote Fetch // MARK: - Remote Fetch
public func prefetchIfNeeded(force: Bool = false) { func prefetchIfNeeded(force: Bool = false) {
guard !isFetching else { return } guard !isFetching else { return }
let now = dependencies.now() let now = dependencies.now()
@@ -175,6 +205,7 @@ public final class GeoRelayDirectory {
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return } guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
} else if last != .distantPast, } else if last != .distantPast,
now.timeIntervalSince(last) < dependencies.retryInitialSeconds { now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
// Skip forced fetches if we just refreshed moments ago.
return return
} }
@@ -312,6 +343,7 @@ public final class GeoRelayDirectory {
// MARK: - Loading // MARK: - Loading
private func loadLocalEntries() -> [Entry] { private func loadLocalEntries() -> [Entry] {
// Prefer cached file if present
if let cache = dependencies.cacheURL(), if let cache = dependencies.cacheURL(),
let data = dependencies.readData(cache), let data = dependencies.readData(cache),
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
@@ -319,6 +351,7 @@ public final class GeoRelayDirectory {
if !arr.isEmpty { return arr } if !arr.isEmpty { return arr }
} }
// Try bundled resource(s)
let bundleCandidates = dependencies.bundledCSVURLs() let bundleCandidates = dependencies.bundledCSVURLs()
for url in bundleCandidates { for url in bundleCandidates {
@@ -329,6 +362,7 @@ public final class GeoRelayDirectory {
} }
} }
// Try filesystem path (development/test)
if let cwd = dependencies.currentDirectoryPath(), if let cwd = dependencies.currentDirectoryPath(),
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
@@ -339,7 +373,7 @@ public final class GeoRelayDirectory {
return [] return []
} }
nonisolated public static func parseCSV(_ text: String) -> [Entry] { nonisolated static func parseCSV(_ text: String) -> [Entry] {
var result: Set<Entry> = [] var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline }) let lines = text.split(whereSeparator: { $0.isNewline })
for (idx, raw) in lines.enumerated() { for (idx, raw) in lines.enumerated() {
@@ -363,9 +397,8 @@ public final class GeoRelayDirectory {
private func registerObservers() { private func registerObservers() {
let center = dependencies.notificationCenter let center = dependencies.notificationCenter
if let torReadyName = dependencies.torReadyNotificationName {
let torReady = center.addObserver( let torReady = center.addObserver(
forName: torReadyName, forName: .TorDidBecomeReady,
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
@@ -375,7 +408,6 @@ public final class GeoRelayDirectory {
} }
} }
cleanupState.observers.append(torReady) cleanupState.observers.append(torReady)
}
if let activeNotificationName = dependencies.activeNotificationName { if let activeNotificationName = dependencies.activeNotificationName {
let didBecomeActive = center.addObserver( let didBecomeActive = center.addObserver(
@@ -407,49 +439,17 @@ public final class GeoRelayDirectory {
RunLoop.main.add(timer, forMode: .common) RunLoop.main.add(timer, forMode: .common)
} }
public var debugRetryAttempt: Int { retryAttempt } var debugRetryAttempt: Int { retryAttempt }
public var debugHasRetryTask: Bool { cleanupState.retryTask != nil } var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
public var debugObserverCount: Int { cleanupState.observers.count } var debugObserverCount: Int { cleanupState.observers.count }
} }
// MARK: - Distance // MARK: - Distance
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double { private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
let r = 6371.0 let r = 6371.0 // Earth radius in km
let dLat = (lat2 - lat1) * .pi / 180 let dLat = (lat2 - lat1) * .pi / 180
let dLon = (lon2 - lon1) * .pi / 180 let dLon = (lon2 - lon1) * .pi / 180
let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2) let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a)) let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return r * c return r * c
} }
// MARK: - Geohash decode (inline to avoid external dependency)
private let geohashBase32Map: [Character: Int] = {
let chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
var map: [Character: Int] = [:]
for (i, c) in chars.enumerated() { map[c] = i }
return map
}()
private func decodeGeohashCenter(_ geohash: String) -> (lat: Double, lon: Double) {
var latInterval: (Double, Double) = (-90.0, 90.0)
var lonInterval: (Double, Double) = (-180.0, 180.0)
var isEven = true
for ch in geohash.lowercased() {
guard let cd = geohashBase32Map[ch] else { continue }
for mask in [16, 8, 4, 2, 1] {
if isEven {
let mid = (lonInterval.0 + lonInterval.1) / 2
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
} else {
let mid = (latInterval.0 + latInterval.1) / 2
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
}
isEven.toggle()
}
}
let lat = (latInterval.0 + latInterval.1) / 2
let lon = (lonInterval.0 + lonInterval.1) / 2
return (lat, lon)
}
-1
View File
@@ -1,5 +1,4 @@
import Foundation import Foundation
import BitFoundation
// MARK: - BitChat-over-Nostr Adapter // MARK: - BitChat-over-Nostr Adapter
@@ -2,13 +2,14 @@ import Foundation
import P256K import P256K
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging /// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
public struct NostrIdentity: Codable, Sendable { struct NostrIdentity: Codable {
public let privateKey: Data let privateKey: Data
public let publicKey: Data let publicKey: Data
public let npub: String // Bech32-encoded public key let npub: String // Bech32-encoded public key
public let createdAt: Date let createdAt: Date
public init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) { /// Memberwise initializer
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
self.privateKey = privateKey self.privateKey = privateKey
self.publicKey = publicKey self.publicKey = publicKey
self.npub = npub self.npub = npub
@@ -16,21 +17,22 @@ public struct NostrIdentity: Codable, Sendable {
} }
/// Generate a new Nostr identity /// Generate a new Nostr identity
public static func generate() throws -> NostrIdentity { static func generate() throws -> NostrIdentity {
// Generate Schnorr key for Nostr
let schnorrKey = try P256K.Schnorr.PrivateKey() let schnorrKey = try P256K.Schnorr.PrivateKey()
let xOnlyPubkey = Data(schnorrKey.xonly.bytes) let xOnlyPubkey = Data(schnorrKey.xonly.bytes)
let npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey) let npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
return NostrIdentity( return NostrIdentity(
privateKey: schnorrKey.dataRepresentation, privateKey: schnorrKey.dataRepresentation,
publicKey: xOnlyPubkey, publicKey: xOnlyPubkey, // Store x-only public key
npub: npub, npub: npub,
createdAt: Date() createdAt: Date()
) )
} }
/// Initialize from existing private key data /// Initialize from existing private key data
public init(privateKeyData: Data) throws { init(privateKeyData: Data) throws {
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyData) let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyData)
let xOnlyPubkey = Data(schnorrKey.xonly.bytes) let xOnlyPubkey = Data(schnorrKey.xonly.bytes)
@@ -41,17 +43,18 @@ public struct NostrIdentity: Codable, Sendable {
} }
/// Get signing key for event signatures /// Get signing key for event signatures
public func signingKey() throws -> P256K.Signing.PrivateKey { func signingKey() throws -> P256K.Signing.PrivateKey {
try P256K.Signing.PrivateKey(dataRepresentation: privateKey) try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
} }
/// Get Schnorr signing key for Nostr event signatures /// Get Schnorr signing key for Nostr event signatures
public func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey { func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey {
try P256K.Schnorr.PrivateKey(dataRepresentation: privateKey) try P256K.Schnorr.PrivateKey(dataRepresentation: privateKey)
} }
/// Get hex-encoded public key (for Nostr events) /// Get hex-encoded public key (for Nostr events)
public var publicKeyHex: String { var publicKeyHex: String {
publicKey.hexEncodedString() // Public key is already stored as x-only (32 bytes)
return publicKey.hexEncodedString()
} }
} }
@@ -1,38 +1,35 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
/// Minimal keychain access required by NostrIdentityBridge.
public protocol NostrKeychainStoring: Sendable {
func save(key: String, data: Data, service: String, accessible: CFString?)
func load(key: String, service: String) -> Data?
}
/// Bridge between Noise and Nostr identities /// Bridge between Noise and Nostr identities
public final class NostrIdentityBridge { final class NostrIdentityBridge {
private let keychainService = "chat.bitchat.nostr" private let keychainService = "chat.bitchat.nostr"
private let currentIdentityKey = "nostr-current-identity" private let currentIdentityKey = "nostr-current-identity"
private let deviceSeedKey = "nostr-device-seed" private let deviceSeedKey = "nostr-device-seed"
private let deviceSeedCache: NSLock = NSLock() // In-memory cache to avoid transient keychain access issues
private var _deviceSeedCacheValue: Data? private var deviceSeedCache: Data?
// Cache derived identities to avoid repeated crypto during view rendering // Cache derived identities to avoid repeated crypto during view rendering
private var _derivedIdentityCache: [String: NostrIdentity] = [:] private var derivedIdentityCache: [String: NostrIdentity] = [:]
private let cacheLock = NSLock() private let cacheLock = NSLock()
private let keychain: any NostrKeychainStoring private let keychain: KeychainManagerProtocol
public init(keychain: any NostrKeychainStoring) { init(keychain: KeychainManagerProtocol = KeychainManager()) {
self.keychain = keychain self.keychain = keychain
} }
/// Get or create the current Nostr identity /// Get or create the current Nostr identity
public func getCurrentNostrIdentity() throws -> NostrIdentity? { func getCurrentNostrIdentity() throws -> NostrIdentity? {
// Check if we already have a Nostr identity
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService), if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) { let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
return identity return identity
} }
// Generate new Nostr identity
let nostrIdentity = try NostrIdentity.generate() let nostrIdentity = try NostrIdentity.generate()
// Store it
let data = try JSONEncoder().encode(nostrIdentity) let data = try JSONEncoder().encode(nostrIdentity)
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil) keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
@@ -40,7 +37,7 @@ public final class NostrIdentityBridge {
} }
/// Associate a Nostr identity with a Noise public key (for favorites) /// Associate a Nostr identity with a Noise public key (for favorites)
public func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) { func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) { if let data = nostrPubkey.data(using: .utf8) {
keychain.save(key: key, data: data, service: keychainService, accessible: nil) keychain.save(key: key, data: data, service: keychainService, accessible: nil)
@@ -48,7 +45,7 @@ public final class NostrIdentityBridge {
} }
/// Get Nostr public key associated with a Noise public key /// Get Nostr public key associated with a Noise public key
public func getNostrPublicKey(for noisePublicKey: Data) -> String? { func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
guard let data = keychain.load(key: key, service: keychainService), guard let data = keychain.load(key: key, service: keychainService),
let pubkey = String(data: data, encoding: .utf8) else { let pubkey = String(data: data, encoding: .utf8) else {
@@ -58,7 +55,7 @@ public final class NostrIdentityBridge {
} }
/// Clear all Nostr identity associations and current identity /// Clear all Nostr identity associations and current identity
public func clearAllAssociations() { func clearAllAssociations() {
let query: [String: Any] = [ let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService, kSecAttrService as String: keychainService,
@@ -79,45 +76,42 @@ public final class NostrIdentityBridge {
} }
SecItemDelete(deleteQuery as CFDictionary) SecItemDelete(deleteQuery as CFDictionary)
} }
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
} }
deviceSeedCache.lock() deviceSeedCache = nil
_deviceSeedCacheValue = nil
deviceSeedCache.unlock()
} }
// MARK: - Per-Geohash Identities (Location Channels) // MARK: - Per-Geohash Identities (Location Channels)
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain.
private func getOrCreateDeviceSeed() -> Data { private func getOrCreateDeviceSeed() -> Data {
deviceSeedCache.lock() if let cached = deviceSeedCache { return cached }
if let cached = _deviceSeedCacheValue {
deviceSeedCache.unlock()
return cached
}
deviceSeedCache.unlock()
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) { if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache.lock() deviceSeedCache = existing
_deviceSeedCacheValue = existing
deviceSeedCache.unlock()
return existing return existing
} }
var seed = Data(count: 32) var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in _ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
} }
// Ensure availability after first unlock to prevent unintended rotation when locked
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache.lock() deviceSeedCache = seed
_deviceSeedCacheValue = seed
deviceSeedCache.unlock()
return seed return seed
} }
/// Derive a deterministic, unlinkable Nostr identity for a given geohash. /// Derive a deterministic, unlinkable Nostr identity for a given geohash.
public func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity { /// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
cacheLock.lock() cacheLock.lock()
if let cached = _derivedIdentityCache[geohash] { if let cached = derivedIdentityCache[geohash] {
cacheLock.unlock() cacheLock.unlock()
return cached return cached
} }
@@ -138,21 +132,24 @@ public final class NostrIdentityBridge {
return Data(code) return Data(code)
} }
// Try a few iterations to ensure a valid key can be formed
for i in 0..<10 { for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i)) let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) { if let identity = try? NostrIdentity(privateKeyData: keyData) {
// Cache the result
cacheLock.lock() cacheLock.lock()
_derivedIdentityCache[geohash] = identity derivedIdentityCache[geohash] = identity
cacheLock.unlock() cacheLock.unlock()
return identity return identity
} }
} }
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash() let fallback = (seed + msg).sha256Hash()
let identity = try NostrIdentity(privateKeyData: fallback) let identity = try NostrIdentity(privateKeyData: fallback)
// Cache the result
cacheLock.lock() cacheLock.lock()
_derivedIdentityCache[geohash] = identity derivedIdentityCache[geohash] = identity
cacheLock.unlock() cacheLock.unlock()
return identity return identity
-118
View File
@@ -1,118 +0,0 @@
import Nostr
import Combine
import Foundation
import Tor
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
// MARK: - GeoRelayDirectory Live Dependencies
extension GeoRelayDirectoryDependencies {
@MainActor
static func live() -> Self {
#if os(iOS)
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
#elseif os(macOS)
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
#else
let activeNotificationName: Notification.Name? = nil
#endif
return Self(
userDefaults: .standard,
notificationCenter: .default,
now: Date.init,
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
awaitTorReady: { await TorManager.shared.awaitReady() },
makeFetchData: {
let session = TorURLSession.shared.session
return { request in
let (data, _) = try await session.data(for: request)
return data
}
},
readData: { try? Data(contentsOf: $0) },
writeData: { data, url in
try data.write(to: url, options: .atomic)
},
cacheURL: {
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("georelays_cache.csv")
} catch {
return nil
}
},
bundledCSVURLs: {
[
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
},
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
retrySleep: { delay in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
},
torReadyNotificationName: .TorDidBecomeReady,
activeNotificationName: activeNotificationName,
autoStart: true
)
}
}
// MARK: - NostrRelayManager Live Dependencies
extension NostrRelayManagerDependencies {
@MainActor
static func live() -> Self {
Self(
activationAllowed: { NetworkActivationService.shared.activationAllowed },
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
locationPermissionPublisher: LocationChannelManager.shared.$permissionState
.map { state -> LocationPermissionState in
switch state {
case .notDetermined:.notDetermined
case .authorized: .authorized
case .denied: .denied
case .restricted: .denied
}
}
.eraseToAnyPublisher(),
torEnforced: { TorManager.shared.torEnforced },
torIsReady: { TorManager.shared.isReady },
torIsForeground: { TorManager.shared.isForeground() },
awaitTorReady: { completion in
Task.detached {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
completion(ready)
}
}
},
makeSession: { NostrRelayManager.makeURLSession(TorURLSession.shared.session) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
now: Date.init
)
}
}
@@ -1,15 +1,17 @@
import BitFoundation
import BitLogger import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import P256K import P256K
import Security import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target
/// NIP-17 Protocol Implementation for Private Direct Messages /// NIP-17 Protocol Implementation for Private Direct Messages
public struct NostrProtocol { struct NostrProtocol {
/// Nostr event kinds /// Nostr event kinds
public enum EventKind: Int, Sendable { enum EventKind: Int {
case metadata = 0 case metadata = 0
case textNote = 1 case textNote = 1
case dm = 14 // NIP-17 DM rumor kind case dm = 14 // NIP-17 DM rumor kind
@@ -20,59 +22,76 @@ public struct NostrProtocol {
} }
/// Create a NIP-17 private message /// Create a NIP-17 private message
public static func createPrivateMessage( static func createPrivateMessage(
content: String, content: String,
recipientPubkey: String, recipientPubkey: String,
senderIdentity: NostrIdentity senderIdentity: NostrIdentity
) throws -> NostrEvent { ) throws -> NostrEvent {
// Creating private message
// 1. Create the rumor (unsigned event)
let rumor = NostrEvent( let rumor = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .dm, kind: .dm, // NIP-17: DM rumor kind 14
tags: [], tags: [],
content: content content: content
) )
// 2. Create ephemeral key for this message
let ephemeralKey = try P256K.Schnorr.PrivateKey() let ephemeralKey = try P256K.Schnorr.PrivateKey()
// Created ephemeral key for seal
// 3. Seal the rumor (encrypt to recipient)
let sealedEvent = try createSeal( let sealedEvent = try createSeal(
rumor: rumor, rumor: rumor,
recipientPubkey: recipientPubkey, recipientPubkey: recipientPubkey,
senderKey: ephemeralKey senderKey: ephemeralKey
) )
// 4. Gift wrap the sealed event (encrypt to recipient again)
let giftWrap = try createGiftWrap( let giftWrap = try createGiftWrap(
seal: sealedEvent, seal: sealedEvent,
recipientPubkey: recipientPubkey, recipientPubkey: recipientPubkey,
senderKey: ephemeralKey senderKey: ephemeralKey
) )
// Created gift wrap
return giftWrap return giftWrap
} }
/// Decrypt a received NIP-17 message /// Decrypt a received NIP-17 message
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp) /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
public static func decryptPrivateMessage( static func decryptPrivateMessage(
giftWrap: NostrEvent, giftWrap: NostrEvent,
recipientIdentity: NostrIdentity recipientIdentity: NostrIdentity
) throws -> (content: String, senderPubkey: String, timestamp: Int) { ) throws -> (content: String, senderPubkey: String, timestamp: Int) {
// Starting decryption
// 1. Unwrap the gift wrap
let seal: NostrEvent let seal: NostrEvent
do { do {
seal = try unwrapGiftWrap( seal = try unwrapGiftWrap(
giftWrap: giftWrap, giftWrap: giftWrap,
recipientKey: recipientIdentity.schnorrSigningKey() recipientKey: recipientIdentity.schnorrSigningKey()
) )
// Successfully unwrapped gift wrap
} catch { } catch {
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session) SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
throw error throw error
} }
// 2. Open the seal
let rumor: NostrEvent let rumor: NostrEvent
do { do {
rumor = try openSeal( rumor = try openSeal(
seal: seal, seal: seal,
recipientKey: recipientIdentity.schnorrSigningKey() recipientKey: recipientIdentity.schnorrSigningKey()
) )
// Successfully opened seal
} catch { } catch {
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session) SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
throw error throw error
@@ -82,7 +101,7 @@ public struct NostrProtocol {
} }
/// Create a geohash-scoped ephemeral public message (kind 20000) /// Create a geohash-scoped ephemeral public message (kind 20000)
public static func createEphemeralGeohashEvent( static func createEphemeralGeohashEvent(
content: String, content: String,
geohash: String, geohash: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity,
@@ -109,7 +128,7 @@ public struct NostrProtocol {
/// Create a geohash presence heartbeat (kind 20001) /// Create a geohash presence heartbeat (kind 20001)
/// Must contain empty content and NO nickname tag /// Must contain empty content and NO nickname tag
public static func createGeohashPresenceEvent( static func createGeohashPresenceEvent(
geohash: String, geohash: String,
senderIdentity: NostrIdentity senderIdentity: NostrIdentity
) throws -> NostrEvent { ) throws -> NostrEvent {
@@ -126,7 +145,7 @@ public struct NostrProtocol {
} }
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
public static func createGeohashTextNote( static func createGeohashTextNote(
content: String, content: String,
geohash: String, geohash: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity,
@@ -154,6 +173,7 @@ public struct NostrProtocol {
recipientPubkey: String, recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey senderKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent { ) throws -> NostrEvent {
let rumorJSON = try rumor.jsonString() let rumorJSON = try rumor.jsonString()
let encrypted = try encrypt( let encrypted = try encrypt(
plaintext: rumorJSON, plaintext: rumorJSON,
@@ -169,31 +189,38 @@ public struct NostrProtocol {
content: encrypted content: encrypted
) )
// Sign the seal with the sender's Schnorr private key
return try seal.sign(with: senderKey) return try seal.sign(with: senderKey)
} }
private static func createGiftWrap( private static func createGiftWrap(
seal: NostrEvent, seal: NostrEvent,
recipientPubkey: String, recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
) throws -> NostrEvent { ) throws -> NostrEvent {
let sealJSON = try seal.jsonString()
let wrapKey = try P256K.Schnorr.PrivateKey()
let sealJSON = try seal.jsonString()
// Create new ephemeral key for gift wrap
let wrapKey = try P256K.Schnorr.PrivateKey()
// Creating gift wrap with ephemeral key
// Encrypt the seal with the new ephemeral key (not the seal's key)
let encrypted = try encrypt( let encrypted = try encrypt(
plaintext: sealJSON, plaintext: sealJSON,
recipientPubkey: recipientPubkey, recipientPubkey: recipientPubkey,
senderKey: wrapKey senderKey: wrapKey // Use the gift wrap ephemeral key
) )
let giftWrap = NostrEvent( let giftWrap = NostrEvent(
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(), pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedTimestamp(), createdAt: randomizedTimestamp(),
kind: .giftWrap, kind: .giftWrap,
tags: [["p", recipientPubkey]], tags: [["p", recipientPubkey]], // Tag recipient
content: encrypted content: encrypted
) )
// Sign the gift wrap with the wrap Schnorr private key
return try giftWrap.sign(with: wrapKey) return try giftWrap.sign(with: wrapKey)
} }
@@ -201,6 +228,9 @@ public struct NostrProtocol {
giftWrap: NostrEvent, giftWrap: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent { ) throws -> NostrEvent {
// Unwrapping gift wrap
let decrypted = try decrypt( let decrypted = try decrypt(
ciphertext: giftWrap.content, ciphertext: giftWrap.content,
senderPubkey: giftWrap.pubkey, senderPubkey: giftWrap.pubkey,
@@ -212,13 +242,17 @@ public struct NostrProtocol {
throw NostrError.invalidEvent throw NostrError.invalidEvent
} }
return try NostrEvent(from: sealDict) let seal = try NostrEvent(from: sealDict)
// Unwrapped seal
return seal
} }
private static func openSeal( private static func openSeal(
seal: NostrEvent, seal: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent { ) throws -> NostrEvent {
let decrypted = try decrypt( let decrypted = try decrypt(
ciphertext: seal.content, ciphertext: seal.content,
senderPubkey: seal.pubkey, senderPubkey: seal.pubkey,
@@ -240,16 +274,22 @@ public struct NostrProtocol {
recipientPubkey: String, recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey senderKey: P256K.Schnorr.PrivateKey
) throws -> String { ) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else { guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey throw NostrError.invalidPublicKey
} }
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret
let sharedSecret = try deriveSharedSecret( let sharedSecret = try deriveSharedSecret(
privateKey: senderKey, privateKey: senderKey,
publicKey: recipientPubkeyData publicKey: recipientPubkeyData
) )
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
let key = try deriveNIP44V2Key(from: sharedSecret) let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24) var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in _ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
@@ -258,6 +298,7 @@ public struct NostrProtocol {
let pt = Data(plaintext.utf8) let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
// v2: base64url(nonce24 || ciphertext || tag)
var combined = Data() var combined = Data()
combined.append(nonce24) combined.append(nonce24)
combined.append(sealed.ciphertext) combined.append(sealed.ciphertext)
@@ -270,6 +311,7 @@ public struct NostrProtocol {
senderPubkey: String, senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey recipientKey: P256K.Schnorr.PrivateKey
) throws -> String { ) throws -> String {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3)) let encoded = String(ciphertext.dropFirst(3))
guard let data = base64URLDecode(encoded), guard let data = base64URLDecode(encoded),
@@ -283,6 +325,7 @@ public struct NostrProtocol {
let tag = rest.suffix(16) let tag = rest.suffix(16)
let ct = rest.dropLast(16) let ct = rest.dropLast(16)
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
func attemptDecrypt(using pubKeyData: Data) throws -> Data { func attemptDecrypt(using pubKeyData: Data) throws -> Data {
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData) let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
let key = try deriveNIP44V2Key(from: ss) let key = try deriveNIP44V2Key(from: ss)
@@ -294,6 +337,7 @@ public struct NostrProtocol {
) )
} }
// If 32 bytes (x-only) try both parities, otherwise single try
if senderPubkeyData.count == 32 { if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) { if let pt = try? attemptDecrypt(using: even) {
@@ -312,18 +356,27 @@ public struct NostrProtocol {
privateKey: P256K.Schnorr.PrivateKey, privateKey: P256K.Schnorr.PrivateKey,
publicKey: Data publicKey: Data
) throws -> Data { ) throws -> Data {
// Deriving shared secret
// Convert Schnorr private key to KeyAgreement private key
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey( let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
dataRepresentation: privateKey.dataRepresentation dataRepresentation: privateKey.dataRepresentation
) )
// Create KeyAgreement public key from the public key data
// For ECDH, we need the full 33-byte compressed public key (with 0x02 or 0x03 prefix)
var fullPublicKey = Data() var fullPublicKey = Data()
if publicKey.count == 32 { if publicKey.count == 32 { // X-only key, need to add prefix
// For x-only keys in Nostr/Bitcoin, we need to try both possible Y coordinates
// First try with even Y (0x02 prefix)
fullPublicKey.append(0x02) fullPublicKey.append(0x02)
fullPublicKey.append(publicKey) fullPublicKey.append(publicKey)
// Trying with even Y coordinate
} else { } else {
fullPublicKey = publicKey fullPublicKey = publicKey
} }
// Try to create public key, if it fails with even Y, try odd Y
let keyAgreementPublicKey: P256K.KeyAgreement.PublicKey let keyAgreementPublicKey: P256K.KeyAgreement.PublicKey
do { do {
keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
@@ -332,6 +385,8 @@ public struct NostrProtocol {
) )
} catch { } catch {
if publicKey.count == 32 { if publicKey.count == 32 {
// Try with odd Y (0x03 prefix)
// Even Y failed, trying odd Y
fullPublicKey = Data() fullPublicKey = Data()
fullPublicKey.append(0x03) fullPublicKey.append(0x03)
fullPublicKey.append(publicKey) fullPublicKey.append(publicKey)
@@ -344,31 +399,84 @@ public struct NostrProtocol {
} }
} }
// Perform ECDH
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
with: keyAgreementPublicKey, with: keyAgreementPublicKey,
format: .compressed format: .compressed
) )
return sharedSecret.withUnsafeBytes { Data($0) } // Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData
}
// Direct version that doesn't try to add prefixes
private static func deriveSharedSecretDirect(
privateKey: P256K.Schnorr.PrivateKey,
publicKey: Data
) throws -> Data {
// Direct shared secret calculation
// Convert Schnorr private key to KeyAgreement private key
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
dataRepresentation: privateKey.dataRepresentation
)
// Use the public key as-is (should already have prefix)
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
dataRepresentation: publicKey,
format: .compressed
)
// Perform ECDH
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
with: keyAgreementPublicKey,
format: .compressed
)
// Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData
} }
private static func randomizedTimestamp() -> Date { private static func randomizedTimestamp() -> Date {
let offset = TimeInterval.random(in: -900...900) // Add random offset to current time for privacy
return Date().addingTimeInterval(offset) // This prevents timing correlation attacks while the actual message timestamp
// is preserved in the encrypted rumor
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes
let now = Date()
let randomized = now.addingTimeInterval(offset)
// Log with explicit UTC and local time for debugging
let formatter = DateFormatter()
//
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.timeZone = TimeZone.current
// Timestamp randomized for privacy
return randomized
} }
} }
/// Nostr Event structure /// Nostr Event structure
public struct NostrEvent: Codable, Sendable { struct NostrEvent: Codable {
public var id: String var id: String
public let pubkey: String let pubkey: String
public let created_at: Int let created_at: Int
public let kind: Int let kind: Int
public let tags: [[String]] let tags: [[String]]
public let content: String let content: String
public var sig: String? var sig: String?
public init( init(
pubkey: String, pubkey: String,
createdAt: Date, createdAt: Date,
kind: NostrProtocol.EventKind, kind: NostrProtocol.EventKind,
@@ -381,10 +489,10 @@ public struct NostrEvent: Codable, Sendable {
self.tags = tags self.tags = tags
self.content = content self.content = content
self.sig = nil self.sig = nil
self.id = "" self.id = "" // Will be set during signing
} }
public init(from dict: [String: Any]) throws { init(from dict: [String: Any]) throws {
guard let pubkey = dict["pubkey"] as? String, guard let pubkey = dict["pubkey"] as? String,
let createdAt = dict["created_at"] as? Int, let createdAt = dict["created_at"] as? Int,
let kind = dict["kind"] as? Int, let kind = dict["kind"] as? Int,
@@ -402,9 +510,10 @@ public struct NostrEvent: Codable, Sendable {
self.sig = dict["sig"] as? String self.sig = dict["sig"] as? String
} }
public func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent { func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId() let (eventId, eventIdHash) = try calculateEventId()
// Sign with Schnorr (BIP-340)
var messageBytes = [UInt8](eventIdHash) var messageBytes = [UInt8](eventIdHash)
var auxRand = [UInt8](repeating: 0, count: 32) var auxRand = [UInt8](repeating: 0, count: 32)
_ = auxRand.withUnsafeMutableBytes { ptr in _ = auxRand.withUnsafeMutableBytes { ptr in
@@ -422,7 +531,7 @@ public struct NostrEvent: Codable, Sendable {
/// Validate that the event ID and Schnorr signature match the content and pubkey. /// Validate that the event ID and Schnorr signature match the content and pubkey.
/// Returns false when the signature is missing, malformed, or does not verify. /// Returns false when the signature is missing, malformed, or does not verify.
public func isValidSignature() -> Bool { func isValidSignature() -> Bool {
guard let sig = sig, guard let sig = sig,
let sigData = Data(hexString: sig), let sigData = Data(hexString: sig),
let pubData = Data(hexString: pubkey), let pubData = Data(hexString: pubkey),
@@ -454,7 +563,7 @@ public struct NostrEvent: Codable, Sendable {
return (data.sha256Fingerprint(), data.sha256Hash()) return (data.sha256Fingerprint(), data.sha256Hash())
} }
public func jsonString() throws -> String { func jsonString() throws -> String {
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes] encoder.outputFormatting = [.withoutEscapingSlashes]
let data = try encoder.encode(self) let data = try encoder.encode(self)
@@ -462,7 +571,7 @@ public struct NostrEvent: Codable, Sendable {
} }
} }
public enum NostrError: Error, Sendable { enum NostrError: Error {
case invalidPublicKey case invalidPublicKey
case invalidPrivateKey case invalidPrivateKey
case invalidEvent case invalidEvent
@@ -2,8 +2,9 @@ import BitLogger
import Foundation import Foundation
import Network import Network
import Combine import Combine
import Tor
public protocol NostrRelayConnectionProtocol: AnyObject { protocol NostrRelayConnectionProtocol: AnyObject {
func resume() func resume()
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)
@@ -11,7 +12,7 @@ public protocol NostrRelayConnectionProtocol: AnyObject {
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
} }
public protocol NostrRelaySessionProtocol { protocol NostrRelaySessionProtocol {
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
} }
@@ -51,89 +52,73 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
} }
} }
public enum LocationPermissionState: Equatable, Sendable { struct NostrRelayManagerDependencies {
case notDetermined var activationAllowed: () -> Bool
case authorized var userTorEnabled: () -> Bool
case denied var hasMutualFavorites: () -> Bool
var hasLocationPermission: () -> Bool
var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
var torEnforced: () -> Bool
var torIsReady: () -> Bool
var torIsForeground: () -> Bool
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
var makeSession: () -> NostrRelaySessionProtocol
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
var now: () -> Date
} }
public struct NostrRelayManagerDependencies { private extension NostrRelayManagerDependencies {
public var activationAllowed: () -> Bool @MainActor
public var userTorEnabled: () -> Bool static func live() -> Self {
public var hasMutualFavorites: () -> Bool Self(
public var hasLocationPermission: () -> Bool activationAllowed: { NetworkActivationService.shared.activationAllowed },
public var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never> userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
public var locationPermissionPublisher: AnyPublisher<LocationPermissionState, Never> hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
public var torEnforced: () -> Bool hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
public var torIsReady: () -> Bool mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
public var torIsForeground: () -> Bool locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
public var awaitTorReady: (@escaping (Bool) -> Void) -> Void torEnforced: { TorManager.shared.torEnforced },
public var makeSession: () -> NostrRelaySessionProtocol torIsReady: { TorManager.shared.isReady },
public var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void torIsForeground: { TorManager.shared.isForeground() },
public var now: () -> Date awaitTorReady: { completion in
Task.detached {
public init( let ready = await TorManager.shared.awaitReady()
activationAllowed: @escaping () -> Bool, await MainActor.run {
userTorEnabled: @escaping () -> Bool, completion(ready)
hasMutualFavorites: @escaping () -> Bool, }
hasLocationPermission: @escaping () -> Bool, }
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>, },
locationPermissionPublisher: AnyPublisher<LocationPermissionState, Never>, makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },
torEnforced: @escaping () -> Bool, scheduleAfter: { delay, action in
torIsReady: @escaping () -> Bool, DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
torIsForeground: @escaping () -> Bool, },
awaitTorReady: @escaping (@escaping (Bool) -> Void) -> Void, now: Date.init
makeSession: @escaping () -> NostrRelaySessionProtocol, )
scheduleAfter: @Sendable @escaping (TimeInterval, @escaping @Sendable () -> Void) -> Void,
now: @escaping () -> Date
) {
self.activationAllowed = activationAllowed
self.userTorEnabled = userTorEnabled
self.hasMutualFavorites = hasMutualFavorites
self.hasLocationPermission = hasLocationPermission
self.mutualFavoritesPublisher = mutualFavoritesPublisher
self.locationPermissionPublisher = locationPermissionPublisher
self.torEnforced = torEnforced
self.torIsReady = torIsReady
self.torIsForeground = torIsForeground
self.awaitTorReady = awaitTorReady
self.makeSession = makeSession
self.scheduleAfter = scheduleAfter
self.now = now
} }
} }
/// Manages WebSocket connections to Nostr relays /// Manages WebSocket connections to Nostr relays
@MainActor @MainActor
public final class NostrRelayManager: ObservableObject { final class NostrRelayManager: ObservableObject {
nonisolated(unsafe) public static var shared: NostrRelayManager! static let shared = NostrRelayManager()
public static func setupShared(dependencies: NostrRelayManagerDependencies) {
shared = NostrRelayManager(dependencies: dependencies)
}
/// Wraps a URLSession into a NostrRelaySessionProtocol for use in live dependencies.
public static func makeURLSession(_ session: URLSession) -> NostrRelaySessionProtocol {
URLSessionAdapter(base: session)
}
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) public static var pendingGiftWrapIDs = Set<String>() private(set) static var pendingGiftWrapIDs = Set<String>()
public static func registerPendingGiftWrap(id: String) { static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id) pendingGiftWrapIDs.insert(id)
} }
public struct Relay: Identifiable { struct Relay: Identifiable {
public let id = UUID() let id = UUID()
public let url: String let url: String
public var isConnected: Bool = false var isConnected: Bool = false
public var lastError: Error? var lastError: Error?
public var lastConnectedAt: Date? var lastConnectedAt: Date?
public var messagesSent: Int = 0 var messagesSent: Int = 0
public var messagesReceived: Int = 0 var messagesReceived: Int = 0
public var reconnectAttempts: Int = 0 var reconnectAttempts: Int = 0
public var lastDisconnectedAt: Date? var lastDisconnectedAt: Date?
public var nextReconnectTime: Date? var nextReconnectTime: Date?
} }
// Default relay list (can be customized) // Default relay list (can be customized)
@@ -143,23 +128,26 @@ public final class NostrRelayManager: ObservableObject {
"wss://relay.primal.net", "wss://relay.primal.net",
"wss://offchain.pub", "wss://offchain.pub",
"wss://nostr21.com" "wss://nostr21.com"
// For local testing, you can add: "ws://localhost:8080"
] ]
private static let defaultRelaySet = Set(defaultRelays) private static let defaultRelaySet = Set(defaultRelays)
@Published private(set) public var relays: [Relay] = [] @Published private(set) var relays: [Relay] = []
@Published private(set) public var isConnected = false @Published private(set) var isConnected = false
private let dependencies: NostrRelayManagerDependencies private let dependencies: NostrRelayManagerDependencies
private var allowDefaultRelays: Bool = false private var allowDefaultRelays: Bool = false
private var hasMutualFavorites: Bool = false private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false private var hasLocationPermission: Bool = false
private var connections: [String: NostrRelayConnectionProtocol] = [:] private var connections: [String: NostrRelayConnectionProtocol] = [:]
private var subscriptions: [String: Set<String>] = [:] private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
private var messageHandlers: [String: (NostrEvent) -> Void] = [:] private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
// Coalesce duplicate subscribe requests for the same id within a short window
private var subscribeCoalesce: [String: Date] = [:] private var subscribeCoalesce: [String: Date] = [:]
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker { private struct EOSETracker {
var pendingRelays: Set<String> var pendingRelays: Set<String>
var callback: () -> Void var callback: () -> Void
@@ -167,6 +155,8 @@ public final class NostrRelayManager: ObservableObject {
} }
private var eoseTrackers: [String: EOSETracker] = [:] private var eoseTrackers: [String: EOSETracker] = [:]
// Message queue for reliability
// Pending sends held only for relays that are not yet connected.
private struct PendingSend { private struct PendingSend {
var event: NostrEvent var event: NostrEvent
var pendingRelays: Set<String> var pendingRelays: Set<String>
@@ -176,19 +166,48 @@ public final class NostrRelayManager: ObservableObject {
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private var shouldUseTor: Bool { dependencies.userTorEnabled() } private var shouldUseTor: Bool { dependencies.userTorEnabled() }
// Exponential backoff configuration (matches TransportConfig values) // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = 1.0 private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
private let maxBackoffInterval: TimeInterval = 300.0 private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
private let backoffMultiplier: Double = 2.0 private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
private let maxReconnectAttempts = 10 private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0 private var connectionGeneration: Int = 0
public init(dependencies: NostrRelayManagerDependencies) { init() {
self.dependencies = .live()
hasMutualFavorites = dependencies.hasMutualFavorites()
hasLocationPermission = dependencies.hasLocationPermission()
applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
dependencies.mutualFavoritesPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
guard let self = self else { return }
self.hasMutualFavorites = !favorites.isEmpty
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
dependencies.locationPermissionPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
let authorized = (state == .authorized)
if authorized == self.hasLocationPermission { return }
self.hasLocationPermission = authorized
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
}
internal init(dependencies: NostrRelayManagerDependencies) {
self.dependencies = dependencies self.dependencies = dependencies
hasMutualFavorites = dependencies.hasMutualFavorites() hasMutualFavorites = dependencies.hasMutualFavorites()
hasLocationPermission = dependencies.hasLocationPermission() hasLocationPermission = dependencies.hasLocationPermission()
applyDefaultRelayPolicy(force: true) applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys self.encoder.outputFormatting = .sortedKeys
dependencies.mutualFavoritesPublisher dependencies.mutualFavoritesPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@@ -211,9 +230,11 @@ public final class NostrRelayManager: ObservableObject {
} }
/// Connect to all configured relays /// Connect to all configured relays
public func connect() { func connect() {
// Global network policy gate
guard dependencies.activationAllowed() else { return } guard dependencies.activationAllowed() else { return }
if shouldUseTor { if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
dependencies.awaitTorReady { [weak self] ready in dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return } guard let self = self else { return }
if !ready { if !ready {
@@ -234,24 +255,26 @@ public final class NostrRelayManager: ObservableObject {
} }
/// Disconnect from all relays /// Disconnect from all relays
public func disconnect() { func disconnect() {
connectionGeneration &+= 1 connectionGeneration &+= 1
for (_, task) in connections { for (_, task) in connections {
task.cancel(with: .goingAway, reason: nil) task.cancel(with: .goingAway, reason: nil)
} }
connections.removeAll() connections.removeAll()
// Clear known subscriptions and any queued subs since connections are gone
subscriptions.removeAll() subscriptions.removeAll()
pendingSubscriptions.removeAll() pendingSubscriptions.removeAll()
updateConnectionStatus() updateConnectionStatus()
} }
/// Ensure connections exist to the given relay URLs (idempotent). /// Ensure connections exist to the given relay URLs (idempotent).
public func ensureConnections(to relayUrls: [String]) { func ensureConnections(to relayUrls: [String]) {
// Global network policy gate
guard dependencies.activationAllowed() else { return } guard dependencies.activationAllowed() else { return }
let targets = allowedRelayList(from: relayUrls) let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return } guard !targets.isEmpty else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer until Tor is fully ready; avoid queuing connection attempts early
dependencies.awaitTorReady { [weak self] ready in dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return } guard let self = self else { return }
if ready { self.ensureConnections(to: relayUrls) } if ready { self.ensureConnections(to: relayUrls) }
@@ -269,9 +292,11 @@ public final class NostrRelayManager: ObservableObject {
} }
/// Send an event to specified relays (or all if none specified) /// Send an event to specified relays (or all if none specified)
public func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate
guard dependencies.activationAllowed() else { return } guard dependencies.activationAllowed() else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer sends until Tor is ready to avoid premature queueing
dependencies.awaitTorReady { [weak self] ready in dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return } guard let self = self else { return }
if ready { self.sendEvent(event, to: relayUrls) } if ready { self.sendEvent(event, to: relayUrls) }
@@ -283,6 +308,7 @@ public final class NostrRelayManager: ObservableObject {
guard !targetRelays.isEmpty else { return } guard !targetRelays.isEmpty else { return }
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
// Attempt immediate send to relays with active connections; queue the rest
var stillPending = Set<String>() var stillPending = Set<String>()
for relayUrl in targetRelays { for relayUrl in targetRelays {
if let connection = connections[relayUrl] { if let connection = connections[relayUrl] {
@@ -298,11 +324,13 @@ public final class NostrRelayManager: ObservableObject {
} }
} }
/// Try to flush any queued messages for relays that are now connected.
private func flushMessageQueue(for relayUrl: String? = nil) { private func flushMessageQueue(for relayUrl: String? = nil) {
messageQueueLock.lock() messageQueueLock.lock()
defer { messageQueueLock.unlock() } defer { messageQueueLock.unlock() }
guard !messageQueue.isEmpty else { return } guard !messageQueue.isEmpty else { return }
if let target = relayUrl { if let target = relayUrl {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() { for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i] var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connections[target] { if item.pendingRelays.contains(target), let conn = connections[target] {
@@ -316,6 +344,7 @@ public final class NostrRelayManager: ObservableObject {
} }
} }
} else { } else {
// Flush for any relays that now have connections
for i in (0..<messageQueue.count).reversed() { for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i] var item = messageQueue[i]
for url in item.pendingRelays { for url in item.pendingRelays {
@@ -334,14 +363,16 @@ public final class NostrRelayManager: ObservableObject {
} }
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays. /// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
public func subscribe( func subscribe(
filter: NostrFilter, filter: NostrFilter,
id: String = UUID().uuidString, id: String = UUID().uuidString,
relayUrls: [String]? = nil, relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void, handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil onEOSE: (() -> Void)? = nil
) { ) {
// Global network policy gate
guard dependencies.activationAllowed() else { return } guard dependencies.activationAllowed() else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = dependencies.now() let now = dependencies.now()
if messageHandlers[id] != nil { if messageHandlers[id] != nil {
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 { if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
@@ -350,6 +381,7 @@ public final class NostrRelayManager: ObservableObject {
} }
subscribeCoalesce[id] = now subscribeCoalesce[id] = now
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer subscription setup until Tor is ready; avoid queuing subs early
dependencies.awaitTorReady { [weak self] ready in dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return } guard let self = self else { return }
if ready { if ready {
@@ -369,9 +401,13 @@ public final class NostrRelayManager: ObservableObject {
return return
} }
// SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
// 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 candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
let urls = allowedRelayList(from: candidateUrls) let urls = allowedRelayList(from: candidateUrls)
// 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))
@@ -381,11 +417,13 @@ public final class NostrRelayManager: ObservableObject {
map[id] = messageString map[id] = messageString
self.pendingSubscriptions[url] = map self.pendingSubscriptions[url] = map
} }
// Initialize EOSE tracking if requested
if let onEOSE = onEOSE { if let onEOSE = onEOSE {
if urls.isEmpty { if urls.isEmpty {
onEOSE() onEOSE()
} else { } 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
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in Task { @MainActor in
guard let self = self else { return } guard let self = self else { return }
@@ -400,7 +438,9 @@ public final class NostrRelayManager: ObservableObject {
} }
} }
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
ensureConnections(to: urls) ensureConnections(to: urls)
// If some targets are already connected, flush immediately for them
for url in urls { for url in urls {
if let r = relays.first(where: { $0.url == url }), r.isConnected { if let r = relays.first(where: { $0.url == url }), r.isConnected {
flushPendingSubscriptions(for: url) flushPendingSubscriptions(for: url)
@@ -462,8 +502,9 @@ public final class NostrRelayManager: ObservableObject {
} }
/// Unsubscribe from a subscription /// Unsubscribe from a subscription
public func unsubscribe(id: String) { func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id) subscribeCoalesce.removeValue(forKey: id)
let req = NostrRequest.close(id: id) let req = NostrRequest.close(id: id)
@@ -472,10 +513,13 @@ public final class NostrRelayManager: ObservableObject {
guard let messageData = message, guard let messageData = message,
let messageString = String(data: messageData, encoding: .utf8) else { return } let messageString = String(data: messageData, encoding: .utf8) else { return }
// Send unsubscribe to all relays
for (relayUrl, connection) in connections { for (relayUrl, connection) in connections {
if subscriptions[relayUrl]?.contains(id) == true { if subscriptions[relayUrl]?.contains(id) == true {
subscriptions[relayUrl]?.remove(id) subscriptions[relayUrl]?.remove(id)
connection.send(.string(messageString)) { _ in } connection.send(.string(messageString)) { _ in
// Local state is cleared before sending so callers can re-subscribe immediately.
}
} }
} }
} }
@@ -483,16 +527,19 @@ public 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 dependencies.activationAllowed() else { return } guard dependencies.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
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
return return
} }
// Skip if we already have a connection object
if connections[urlString] != nil { if connections[urlString] != nil {
return return
} }
@@ -500,6 +547,9 @@ public final class NostrRelayManager: ObservableObject {
return return
} }
// Attempting to connect to Nostr relay via the proxied session
// If Tor is enforced but not ready, delay connection until it is.
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
dependencies.awaitTorReady { [weak self] ready in dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return } guard let self = self else { return }
@@ -515,23 +565,28 @@ public final class NostrRelayManager: ObservableObject {
connections[urlString] = task connections[urlString] = task
task.resume() task.resume()
// Start receiving messages
receiveMessage(from: task, relayUrl: urlString) receiveMessage(from: task, relayUrl: urlString)
// Send initial ping to verify connection
task.sendPing { [weak self] error in task.sendPing { [weak self] error in
DispatchQueue.main.async { DispatchQueue.main.async {
if error == nil { if error == nil {
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session) SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
self?.updateRelayStatus(urlString, isConnected: true) self?.updateRelayStatus(urlString, isConnected: true)
// Flush any pending subscriptions for this relay
self?.flushPendingSubscriptions(for: urlString) self?.flushPendingSubscriptions(for: urlString)
} else { } else {
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session) SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
self?.updateRelayStatus(urlString, isConnected: false, error: error) self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil)) self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
} }
} }
} }
} }
/// Send any queued subscriptions for a relay that just connected.
private func flushPendingSubscriptions(for relayUrl: String) { private func flushPendingSubscriptions(for relayUrl: String) {
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return } guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
guard let connection = connections[relayUrl] else { return } guard let connection = connections[relayUrl] else { return }
@@ -558,6 +613,7 @@ public final class NostrRelayManager: ObservableObject {
switch result { switch result {
case .success(let message): case .success(let message):
// Parse off-main to reduce UI jank, then hop back for state updates
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return } guard let parsed = ParsedInbound(message) else { return }
await MainActor.run { await MainActor.run {
@@ -565,6 +621,7 @@ public final class NostrRelayManager: ObservableObject {
} }
} }
// Continue receiving
Task { @MainActor in Task { @MainActor in
self.receiveMessage(from: task, relayUrl: relayUrl) self.receiveMessage(from: task, relayUrl: relayUrl)
} }
@@ -577,6 +634,11 @@ public final class NostrRelayManager: ObservableObject {
} }
} }
// Parsed inbound message type (off-main)
// Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor.
// Handle parsed message on MainActor (state updates and handlers)
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed { switch parsed {
case .event(let subId, let event): case .event(let subId, let event):
@@ -633,6 +695,8 @@ public final class NostrRelayManager: ObservableObject {
if let error = error { if let error = error {
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session) SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
} else { } else {
// SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1 self?.relays[index].messagesSent += 1
} }
@@ -650,13 +714,14 @@ public final class NostrRelayManager: ObservableObject {
relays[index].lastError = error relays[index].lastError = error
if isConnected { if isConnected {
relays[index].lastConnectedAt = dependencies.now() relays[index].lastConnectedAt = dependencies.now()
relays[index].reconnectAttempts = 0 relays[index].reconnectAttempts = 0 // Reset on successful connection
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
} else { } else {
relays[index].lastDisconnectedAt = dependencies.now() relays[index].lastDisconnectedAt = dependencies.now()
} }
} }
updateConnectionStatus() updateConnectionStatus()
// If we just connected to this relay, flush any queued sends targeting it
if isConnected { if isConnected {
flushMessageQueue(for: url) flushMessageQueue(for: url)
} }
@@ -667,6 +732,7 @@ public 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 !dependencies.activationAllowed() { if !dependencies.activationAllowed() {
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl)
@@ -677,6 +743,7 @@ public final class NostrRelayManager: ObservableObject {
subscriptions.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error) updateRelayStatus(relayUrl, isConnected: false, error: error)
// Check if this is a DNS or handshake error; treat as permanent
let errorDescription = error.localizedDescription.lowercased() let errorDescription = error.localizedDescription.lowercased()
let ns = error as NSError let ns = error as NSError
if errorDescription.contains("hostname could not be found") || if errorDescription.contains("hostname could not be found") ||
@@ -694,15 +761,18 @@ public final class NostrRelayManager: ObservableObject {
return return
} }
// Implement exponential backoff for non-DNS errors
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return } guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
relays[index].reconnectAttempts += 1 relays[index].reconnectAttempts += 1
// Stop attempting after max attempts
if relays[index].reconnectAttempts >= maxReconnectAttempts { if relays[index].reconnectAttempts >= maxReconnectAttempts {
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session) SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
return return
} }
// Calculate backoff interval
let backoffInterval = min( let backoffInterval = min(
initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)), initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)),
maxBackoffInterval maxBackoffInterval
@@ -711,11 +781,15 @@ public final class NostrRelayManager: ObservableObject {
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval) let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
relays[index].nextReconnectTime = nextReconnectTime relays[index].nextReconnectTime = nextReconnectTime
// Schedule reconnection with exponential backoff
let gen = connectionGeneration let gen = connectionGeneration
dependencies.scheduleAfter(backoffInterval) { [weak self] in dependencies.scheduleAfter(backoffInterval) { [weak self] in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self = self else { return } guard let self = self else { return }
// Ignore stale scheduled reconnects from a previous generation
guard gen == self.connectionGeneration else { return } guard gen == self.connectionGeneration else { return }
// Check if we should still reconnect (relay might have been removed)
if self.relays.contains(where: { $0.url == relayUrl }) { if self.relays.contains(where: { $0.url == relayUrl }) {
self.connectToRelay(relayUrl) self.connectToRelay(relayUrl)
} }
@@ -725,22 +799,27 @@ public final class NostrRelayManager: ObservableObject {
// MARK: - Public Utility Methods // MARK: - Public Utility Methods
public func retryConnection(to relayUrl: String) { /// Manually retry connection to a specific relay
func retryConnection(to relayUrl: String) {
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return } guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
// Reset reconnection attempts
relays[index].reconnectAttempts = 0 relays[index].reconnectAttempts = 0
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
relays[index].lastError = nil relays[index].lastError = nil
// Disconnect if connected
if let connection = connections[relayUrl] { if let connection = connections[relayUrl] {
connection.cancel(with: .goingAway, reason: nil) connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: relayUrl)
} }
// Attempt immediate reconnection
connectToRelay(relayUrl) connectToRelay(relayUrl)
} }
public func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] { /// Get detailed status for all relays
func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] {
return relays.map { relay in return relays.map { relay in
(url: relay.url, (url: relay.url,
isConnected: relay.isConnected, isConnected: relay.isConnected,
@@ -749,34 +828,38 @@ public final class NostrRelayManager: ObservableObject {
} }
} }
public var debugPendingMessageQueueCount: Int { var debugPendingMessageQueueCount: Int {
messageQueueLock.lock() messageQueueLock.lock()
defer { messageQueueLock.unlock() } defer { messageQueueLock.unlock() }
return messageQueue.count return messageQueue.count
} }
public func debugPendingSubscriptionCount(for relayUrl: String) -> Int { func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
pendingSubscriptions[relayUrl]?.count ?? 0 pendingSubscriptions[relayUrl]?.count ?? 0
} }
public func debugFlushMessageQueue() { func debugFlushMessageQueue() {
flushMessageQueue(for: nil) flushMessageQueue(for: nil)
} }
/// Reset all relay connections /// Reset all relay connections
public func resetAllConnections() { func resetAllConnections() {
disconnect() disconnect()
// New generation begins now
connectionGeneration &+= 1 connectionGeneration &+= 1
// Reset all relay states
for index in relays.indices { for index in relays.indices {
relays[index].reconnectAttempts = 0 relays[index].reconnectAttempts = 0
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
relays[index].lastError = nil relays[index].lastError = nil
} }
// Reconnect
connect() connect()
} }
// MARK: - Failure classification
private func isPermanentlyFailed(_ url: String) -> Bool { private func isPermanentlyFailed(_ url: String) -> Bool {
guard let r = relays.first(where: { $0.url == url }) else { return false } guard let r = relays.first(where: { $0.url == url }) else { return false }
if r.reconnectAttempts >= maxReconnectAttempts { return true } if r.reconnectAttempts >= maxReconnectAttempts { return true }
@@ -855,12 +938,12 @@ private extension URLSessionWebSocketTask.Message {
// MARK: - Nostr Protocol Types // MARK: - Nostr Protocol Types
public enum NostrRequest: Encodable { enum NostrRequest: Encodable {
case event(NostrEvent) case event(NostrEvent)
case subscribe(id: String, filters: [NostrFilter]) case subscribe(id: String, filters: [NostrFilter])
case close(id: String) case close(id: String)
public func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer() var container = encoder.unkeyedContainer()
switch self { switch self {
@@ -882,25 +965,30 @@ public enum NostrRequest: Encodable {
} }
} }
public struct NostrFilter: Encodable { struct NostrFilter: Encodable {
public var ids: [String]? var ids: [String]?
public var authors: [String]? var authors: [String]?
public var kinds: [Int]? var kinds: [Int]?
public var since: Int? var since: Int?
public var until: Int? var until: Int?
public var limit: Int? var limit: Int?
// Tag filters - stored internally but encoded specially
fileprivate var tagFilters: [String: [String]]? fileprivate var tagFilters: [String: [String]]?
public init() {} init() {
// Default initializer
}
// Custom encoding to handle tag filters properly
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case ids, authors, kinds, since, until, limit case ids, authors, kinds, since, until, limit
} }
public func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: DynamicCodingKey.self) var container = encoder.container(keyedBy: DynamicCodingKey.self)
// Encode standard fields
if let ids = ids { try container.encode(ids, forKey: DynamicCodingKey(stringValue: "ids")) } if let ids = ids { try container.encode(ids, forKey: DynamicCodingKey(stringValue: "ids")) }
if let authors = authors { try container.encode(authors, forKey: DynamicCodingKey(stringValue: "authors")) } if let authors = authors { try container.encode(authors, forKey: DynamicCodingKey(stringValue: "authors")) }
if let kinds = kinds { try container.encode(kinds, forKey: DynamicCodingKey(stringValue: "kinds")) } if let kinds = kinds { try container.encode(kinds, forKey: DynamicCodingKey(stringValue: "kinds")) }
@@ -908,6 +996,7 @@ public struct NostrFilter: Encodable {
if let until = until { try container.encode(until, forKey: DynamicCodingKey(stringValue: "until")) } if let until = until { try container.encode(until, forKey: DynamicCodingKey(stringValue: "until")) }
if let limit = limit { try container.encode(limit, forKey: DynamicCodingKey(stringValue: "limit")) } if let limit = limit { try container.encode(limit, forKey: DynamicCodingKey(stringValue: "limit")) }
// Encode tag filters with # prefix
if let tagFilters = tagFilters { if let tagFilters = tagFilters {
for (tag, values) in tagFilters { for (tag, values) in tagFilters {
try container.encode(values, forKey: DynamicCodingKey(stringValue: "#\(tag)")) try container.encode(values, forKey: DynamicCodingKey(stringValue: "#\(tag)"))
@@ -915,18 +1004,18 @@ public struct NostrFilter: Encodable {
} }
} }
// For NIP-17 gift wraps (limit matches TransportConfig.nostrRelayDefaultFetchLimit) // For NIP-17 gift wraps
public static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter { static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
var filter = NostrFilter() var filter = NostrFilter()
filter.kinds = [1059] filter.kinds = [1059] // Gift wrap kind
filter.since = since?.timeIntervalSince1970.toInt() filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["p": [pubkey]] filter.tagFilters = ["p": [pubkey]]
filter.limit = 100 filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
return filter return filter
} }
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001) // For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
public static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter { static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
var filter = NostrFilter() var filter = NostrFilter()
filter.kinds = [20000, 20001] filter.kinds = [20000, 20001]
filter.since = since?.timeIntervalSince1970.toInt() filter.since = since?.timeIntervalSince1970.toInt()
@@ -936,7 +1025,7 @@ public struct NostrFilter: Encodable {
} }
// For location notes: persistent text notes (kind 1) tagged with geohash // For location notes: persistent text notes (kind 1) tagged with geohash
public static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter { static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter() var filter = NostrFilter()
filter.kinds = [1] filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt() filter.since = since?.timeIntervalSince1970.toInt()
@@ -946,7 +1035,7 @@ public struct NostrFilter: Encodable {
} }
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors) // For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
public static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter { static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter() var filter = NostrFilter()
filter.kinds = [1] filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt() filter.since = since?.timeIntervalSince1970.toInt()
@@ -956,6 +1045,7 @@ public struct NostrFilter: Encodable {
} }
} }
// Dynamic coding key for tag filters
private struct DynamicCodingKey: CodingKey { private struct DynamicCodingKey: CodingKey {
var stringValue: String var stringValue: String
var intValue: Int? { nil } var intValue: Int? { nil }
@@ -6,6 +6,7 @@ import CryptoKit
/// as per XChaCha20 construction. /// as per XChaCha20 construction.
enum XChaCha20Poly1305Compat { enum XChaCha20Poly1305Compat {
/// Errors that can occur during XChaCha20-Poly1305 operations
enum Error: Swift.Error { enum Error: Swift.Error {
case invalidKeyLength(expected: Int, got: Int) case invalidKeyLength(expected: Int, got: Int)
case invalidNonceLength(expected: Int, got: Int) case invalidNonceLength(expected: Int, got: Int)
@@ -58,6 +59,7 @@ enum XChaCha20Poly1305Compat {
} }
private static func hchacha20(key: Data, nonce16: Data) throws -> Data { private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
guard key.count == 32 else { guard key.count == 32 else {
throw Error.invalidKeyLength(expected: 32, got: key.count) throw Error.invalidKeyLength(expected: 32, got: key.count)
} }
@@ -68,22 +70,28 @@ enum XChaCha20Poly1305Compat {
// Constants "expand 32-byte k" // Constants "expand 32-byte k"
var state: [UInt32] = [ var state: [UInt32] = [
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574, 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
// key (8 words)
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12), key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28), key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
// nonce (4 words)
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12) nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
] ]
// 20 rounds (10 double rounds)
for _ in 0..<10 { for _ in 0..<10 {
// Column rounds
quarterRound(&state, 0, 4, 8, 12) quarterRound(&state, 0, 4, 8, 12)
quarterRound(&state, 1, 5, 9, 13) quarterRound(&state, 1, 5, 9, 13)
quarterRound(&state, 2, 6, 10, 14) quarterRound(&state, 2, 6, 10, 14)
quarterRound(&state, 3, 7, 11, 15) quarterRound(&state, 3, 7, 11, 15)
// Diagonal rounds
quarterRound(&state, 0, 5, 10, 15) quarterRound(&state, 0, 5, 10, 15)
quarterRound(&state, 1, 6, 11, 12) quarterRound(&state, 1, 6, 11, 12)
quarterRound(&state, 2, 7, 8, 13) quarterRound(&state, 2, 7, 8, 13)
quarterRound(&state, 3, 4, 9, 14) quarterRound(&state, 3, 4, 9, 14)
} }
// Output subkey: state[0..3] and state[12..15]
var out = Data(count: 32) var out = Data(count: 32)
out.storeLEWord(state[0], at: 0) out.storeLEWord(state[0], at: 0)
out.storeLEWord(state[1], at: 4) out.storeLEWord(state[1], at: 4)
@@ -124,3 +132,4 @@ private extension Data {
replaceSubrange(offset..<(offset+4), with: bytes) replaceSubrange(offset..<(offset+4), with: bytes)
} }
} }
+56 -1
View File
@@ -6,7 +6,62 @@
// //
import Foundation import Foundation
import BitFoundation import CryptoKit
// MARK: - Hex Encoding/Decoding
extension Data {
func hexEncodedString() -> String {
if self.isEmpty {
return ""
}
return self.map { String(format: "%02x", $0) }.joined()
}
func sha256Hex() -> String {
let digest = SHA256.hash(data: self)
return digest.map { String(format: "%02x", $0) }.joined()
}
/// Initialize Data from a hex string.
/// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X".
/// Whitespace is trimmed. Must have even length after prefix removal.
/// - Returns: nil if the string has odd length or contains invalid hex characters.
init?(hexString: String) {
var hex = hexString.trimmed
// Remove optional 0x prefix
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
hex = String(hex.dropFirst(2))
}
// Reject odd-length strings
guard hex.count % 2 == 0 else {
return nil
}
// Reject empty strings
guard !hex.isEmpty else {
self = Data()
return
}
let len = hex.count / 2
var data = Data(capacity: len)
var index = hex.startIndex
for _ in 0..<len {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
return nil
}
data.append(byte)
index = nextIndex
}
self = data
}
}
// MARK: - Binary Encoding Utilities // MARK: - Binary Encoding Utilities
-1
View File
@@ -60,7 +60,6 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
// MARK: - Message Types // MARK: - Message Types
-2
View File
@@ -1,6 +1,4 @@
import BitLogger import BitLogger
import BitFoundation
import Nostr
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import Combine import Combine
-2
View File
@@ -6,9 +6,7 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import Nostr
import Foundation import Foundation
import BitFoundation
/// Result of command processing /// Result of command processing
enum CommandResult { enum CommandResult {
@@ -1,5 +1,4 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Combine import Combine
@@ -8,7 +8,6 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import Nostr
import Foundation import Foundation
import Combine import Combine
import BitLogger import BitLogger
@@ -75,7 +74,7 @@ final class GeohashPresenceService: ObservableObject {
] ]
private init() { private init() {
let idBridge = NostrIdentityBridge(keychain: KeychainManager()) let idBridge = NostrIdentityBridge()
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels } self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher() self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady) self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
+1 -3
View File
@@ -7,8 +7,6 @@
// //
import BitLogger import BitLogger
import protocol Nostr.NostrKeychainStoring
import protocol Noise.SecureMemoryCleaner
import Foundation import Foundation
import Security import Security
@@ -53,7 +51,7 @@ enum KeychainSaveResult {
} }
} }
protocol KeychainManagerProtocol: SecureMemoryCleaner, NostrKeychainStoring { protocol KeychainManagerProtocol {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data? func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool func deleteIdentityKey(forKey key: String) -> Bool
+1 -2
View File
@@ -1,5 +1,4 @@
import BitLogger import BitLogger
import Nostr
import Foundation import Foundation
/// Dependencies for location notes, allowing tests to stub relay/identity behavior. /// Dependencies for location notes, allowing tests to stub relay/identity behavior.
@@ -16,7 +15,7 @@ struct LocationNotesDependencies {
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date var now: () -> Date
private static let idBridge = NostrIdentityBridge(keychain: KeychainManager()) private static let idBridge = NostrIdentityBridge()
static let live = LocationNotesDependencies( static let live = LocationNotesDependencies(
relayLookup: { geohash, count in relayLookup: { geohash, count in
@@ -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 BitFoundation
import Foundation import Foundation
import SwiftUI import SwiftUI
-1
View File
@@ -1,5 +1,4 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
/// Routes messages using available transports (Mesh, Nostr, etc.) /// Routes messages using available transports (Mesh, Nostr, etc.)
@@ -1,4 +1,3 @@
import Nostr
import Foundation import Foundation
import BitLogger import BitLogger
import Combine import Combine
@@ -83,8 +83,6 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Noise
import Foundation import Foundation
import CryptoKit import CryptoKit
-2
View File
@@ -1,6 +1,4 @@
import BitLogger import BitLogger
import BitFoundation
import Nostr
import Foundation import Foundation
import Combine import Combine
@@ -6,7 +6,6 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
import UserNotifications import UserNotifications
#if os(iOS) #if os(iOS)
@@ -7,7 +7,6 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import SwiftUI import SwiftUI
-1
View File
@@ -1,4 +1,3 @@
import BitFoundation
import Foundation import Foundation
import Combine import Combine
@@ -7,8 +7,6 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Nostr
import Foundation import Foundation
import Combine import Combine
import SwiftUI import SwiftUI
-1
View File
@@ -1,6 +1,5 @@
import Foundation import Foundation
import BitLogger import BitLogger
import BitFoundation
// Gossip-based sync manager using on-demand GCS filters // Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager { final class GossipSyncManager {
-1
View File
@@ -8,7 +8,6 @@
import Foundation import Foundation
import BitLogger import BitLogger
import BitFoundation
/// Manages outgoing sync requests and validates incoming responses. /// Manages outgoing sync requests and validates incoming responses.
/// ///
+22
View File
@@ -0,0 +1,22 @@
//
// Data+SHA256.swift
// bitchat
//
// Created by Islam on 26/09/2025.
//
import struct Foundation.Data
import struct CryptoKit.SHA256
extension Data {
/// Returns the hex representation of SHA256 hash
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
sha256Hash().hexEncodedString()
}
/// Returns the SHA256 hash wrapped in Data
func sha256Hash() -> Data {
Data(SHA256.hash(data: self))
}
}
@@ -1,5 +1,4 @@
import Foundation import Foundation
import BitFoundation
/// Resolves a stable display name for peers, adding a short suffix when collisions exist. /// Resolves a stable display name for peers, adding a short suffix when collisions exist.
struct PeerDisplayNameResolver { struct PeerDisplayNameResolver {
@@ -1,12 +1,12 @@
// //
// String+Ext.swift // String+Ext.swift
// BitFoundation // 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>
// //
public extension StringProtocol { extension StringProtocol {
var trimmed: String { var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines) trimmingCharacters(in: .whitespacesAndNewlines)
} }
+1 -3
View File
@@ -78,8 +78,6 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Nostr
import Foundation import Foundation
import SwiftUI import SwiftUI
import Combine import Combine
@@ -1968,7 +1966,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
// Reinitialize Nostr relay manager with new identity // Reinitialize Nostr relay manager with new identity
nostrRelayManager = NostrRelayManager(dependencies: .live()) nostrRelayManager = NostrRelayManager()
setupNostrMessageHandling() setupNostrMessageHandling()
nostrRelayManager?.connect() nostrRelayManager?.connect()
} }
@@ -5,11 +5,9 @@
// Geohash and Nostr logic for ChatViewModel // Geohash and Nostr logic for ChatViewModel
// //
import Nostr
import Foundation import Foundation
import Combine import Combine
import BitLogger import BitLogger
import BitFoundation
import SwiftUI import SwiftUI
import Tor import Tor
@@ -5,11 +5,9 @@
// Private chat and media transfer logic for ChatViewModel // Private chat and media transfer logic for ChatViewModel
// //
import Nostr
import Foundation import Foundation
import Combine import Combine
import BitLogger import BitLogger
import BitFoundation
import SwiftUI import SwiftUI
extension ChatViewModel { extension ChatViewModel {
@@ -5,7 +5,6 @@
// Created by Islam on 29/10/2025. // Created by Islam on 29/10/2025.
// //
import Nostr
import SwiftUI import SwiftUI
struct CommandSuggestionsView: View { struct CommandSuggestionsView: View {
@@ -77,7 +76,7 @@ struct CommandSuggestionsView: View {
let keychain = KeychainManager() let keychain = KeychainManager()
let viewModel = ChatViewModel( let viewModel = ChatViewModel(
keychain: keychain, keychain: keychain,
idBridge: NostrIdentityBridge(keychain: keychain), idBridge: NostrIdentityBridge(),
identityManager: SecureIdentityStateManager(keychain) identityManager: SecureIdentityStateManager(keychain)
) )
@@ -6,7 +6,6 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Nostr
import SwiftUI import SwiftUI
struct TextMessageView: View { struct TextMessageView: View {
@@ -90,7 +89,7 @@ struct TextMessageView: View {
.environmentObject( .environmentObject(
ChatViewModel( ChatViewModel(
keychain: keychain, keychain: keychain,
idBridge: NostrIdentityBridge(keychain: keychain), idBridge: NostrIdentityBridge(),
identityManager: SecureIdentityStateManager(keychain) identityManager: SecureIdentityStateManager(keychain)
) )
) )
-1
View File
@@ -15,7 +15,6 @@ import AppKit
#endif #endif
import UniformTypeIdentifiers import UniformTypeIdentifiers
import BitLogger import BitLogger
import BitFoundation
/// On macOS 14+, disables the default system focus ring on TextFields. /// On macOS 14+, disables the default system focus ring on TextFields.
/// On earlier macOS versions and on iOS this is a no-op. /// On earlier macOS versions and on iOS this is a no-op.
-1
View File
@@ -7,7 +7,6 @@
// //
import SwiftUI import SwiftUI
import BitFoundation
struct FingerprintView: View { struct FingerprintView: View {
@ObservedObject var viewModel: ChatViewModel @ObservedObject var viewModel: ChatViewModel
-1
View File
@@ -1,5 +1,4 @@
import SwiftUI import SwiftUI
import BitFoundation
struct MeshPeerList: View { struct MeshPeerList: View {
@ObservedObject var viewModel: ChatViewModel @ObservedObject var viewModel: ChatViewModel
-1
View File
@@ -5,7 +5,6 @@
// Created by Islam on 30/03/2026. // Created by Islam on 30/03/2026.
// //
import BitFoundation
import SwiftUI import SwiftUI
private struct MessageDisplayItem: Identifiable { private struct MessageDisplayItem: Identifiable {
-1
View File
@@ -1,4 +1,3 @@
import Nostr
import SwiftUI import SwiftUI
import CoreImage import CoreImage
import CoreImage.CIFilterBuiltins import CoreImage.CIFilterBuiltins
-2
View File
@@ -6,10 +6,8 @@
// //
import Testing import Testing
import Nostr
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
struct BLEServiceCoreTests { struct BLEServiceCoreTests {
-1
View File
@@ -8,7 +8,6 @@
import Testing import Testing
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
struct BLEServiceTests { struct BLEServiceTests {
-1
View File
@@ -1,6 +1,5 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite("BitchatPeer Tests") @Suite("BitchatPeer Tests")
@@ -6,9 +6,7 @@
// //
import Testing import Testing
import Nostr
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
// MARK: - Test Helpers // MARK: - Test Helpers
@@ -6,7 +6,6 @@
// //
import Testing import Testing
import Nostr
import Foundation import Foundation
import Combine import Combine
#if os(iOS) #if os(iOS)
@@ -14,7 +13,6 @@ import UIKit
#else #else
import AppKit import AppKit
#endif #endif
import BitFoundation
@testable import bitchat @testable import bitchat
// MARK: - Test Helpers // MARK: - Test Helpers
@@ -7,9 +7,7 @@
// //
import Testing import Testing
import Nostr
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct ChatViewModelRefactoringTests { struct ChatViewModelRefactoringTests {
-2
View File
@@ -7,9 +7,7 @@
// //
import Testing import Testing
import Nostr
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
// MARK: - Test Helpers // MARK: - Test Helpers
-1
View File
@@ -6,7 +6,6 @@
// //
import Testing import Testing
import Nostr
import Foundation import Foundation
@testable import bitchat @testable import bitchat
-2
View File
@@ -1,7 +1,5 @@
import Foundation import Foundation
import Nostr
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite(.serialized) @Suite(.serialized)
@@ -9,8 +9,6 @@
import Testing import Testing
import CryptoKit import CryptoKit
import struct Foundation.UUID import struct Foundation.UUID
import BitFoundation
import Noise
@testable import bitchat @testable import bitchat
struct PrivateChatE2ETests { struct PrivateChatE2ETests {
@@ -8,7 +8,6 @@
import Testing import Testing
import struct Foundation.UUID import struct Foundation.UUID
import BitFoundation
@testable import bitchat @testable import bitchat
struct PublicChatE2ETests { struct PublicChatE2ETests {
@@ -7,10 +7,8 @@
// //
import Testing import Testing
import Nostr
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
struct FragmentationTests { struct FragmentationTests {
-1
View File
@@ -7,7 +7,6 @@
// //
import Testing import Testing
import Nostr
import Foundation import Foundation
import Combine import Combine
@testable import bitchat @testable import bitchat
@@ -1,6 +1,5 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
struct GossipSyncManagerTests { struct GossipSyncManagerTests {
@@ -9,8 +9,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import Testing import Testing
import BitFoundation
import Noise
@testable import bitchat @testable import bitchat
struct IntegrationTests { struct IntegrationTests {
@@ -8,8 +8,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import BitFoundation
import Noise
@testable import bitchat @testable import bitchat
final class TestNetworkHelper { final class TestNetworkHelper {
-1
View File
@@ -1,5 +1,4 @@
import Testing import Testing
import Nostr
import Foundation import Foundation
@testable import bitchat @testable import bitchat
@@ -1,5 +1,4 @@
import Testing import Testing
import Nostr
import Foundation import Foundation
@testable import bitchat @testable import bitchat
@@ -9,7 +9,6 @@
import Testing import Testing
import Foundation import Foundation
import SwiftUI import SwiftUI
import BitFoundation
@testable import bitchat @testable import bitchat
struct MessageFormattingEngineTests { struct MessageFormattingEngineTests {
-1
View File
@@ -7,7 +7,6 @@
// //
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
final class MockBLEBus { final class MockBLEBus {
-1
View File
@@ -8,7 +8,6 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
/// In-memory BLE test harness used by E2E/Integration tests. /// In-memory BLE test harness used by E2E/Integration tests.
@@ -7,7 +7,6 @@
// //
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
final class MockIdentityManager: SecureIdentityStateManagerProtocol { final class MockIdentityManager: SecureIdentityStateManagerProtocol {
-1
View File
@@ -9,7 +9,6 @@
import Foundation import Foundation
import Combine import Combine
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
/// Mock Transport implementation for testing ChatViewModel in isolation. /// Mock Transport implementation for testing ChatViewModel in isolation.
@@ -1,8 +1,8 @@
import CryptoKit import CryptoKit
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import Noise @testable import bitchat
@Suite("Noise Coverage Tests") @Suite("Noise Coverage Tests")
struct NoiseCoverageTests { struct NoiseCoverageTests {
@@ -9,8 +9,8 @@
import CryptoKit import CryptoKit
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import Noise @testable import bitchat
// MARK: - Test Vector Support // MARK: - Test Vector Support
@@ -613,7 +613,9 @@ struct NoiseProtocolTests {
private func loadTestVectors() throws -> [NoiseTestVector] { private func loadTestVectors() throws -> [NoiseTestVector] {
// Try to load from test bundle // Try to load from test bundle
guard let url = Bundle.module.url(forResource: "NoiseTestVectors", withExtension: "json") else { let testBundle = Bundle(for: MockKeychain.self)
guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json")
else {
throw NSError( throw NSError(
domain: "NoiseTests", code: 1, domain: "NoiseTests", code: 1,
userInfo: [ userInfo: [
@@ -792,7 +794,7 @@ struct NoiseProtocolTests {
@Test func secureClearCalledDuringHandshake() throws { @Test func secureClearCalledDuringHandshake() throws {
// Use TrackingMockKeychain to verify secureClear is called // Use TrackingMockKeychain to verify secureClear is called
let trackingKeychain = MockKeychain() let trackingKeychain = TrackingMockKeychain()
let aliceKey = Curve25519.KeyAgreement.PrivateKey() let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey()
@@ -841,7 +843,7 @@ struct NoiseProtocolTests {
@Test func encryptionWorksAfterSecureClear() throws { @Test func encryptionWorksAfterSecureClear() throws {
// Verify that encryption/decryption still works correctly after adding secureClear // Verify that encryption/decryption still works correctly after adding secureClear
let trackingKeychain = MockKeychain() let trackingKeychain = TrackingMockKeychain()
let aliceKey = Curve25519.KeyAgreement.PrivateKey() let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey()
@@ -898,8 +900,8 @@ struct NoiseProtocolTests {
// Verify secureClear is called in both writeMessage and readMessage paths // Verify secureClear is called in both writeMessage and readMessage paths
// We do this by checking the count increases at each step // We do this by checking the count increases at each step
let aliceKeychain = MockKeychain() let aliceKeychain = TrackingMockKeychain()
let bobKeychain = MockKeychain() let bobKeychain = TrackingMockKeychain()
let aliceKey = Curve25519.KeyAgreement.PrivateKey() let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey()
@@ -1,6 +1,5 @@
import XCTest import XCTest
import BitFoundation @testable import bitchat
@testable import Noise
final class NoiseRateLimiterTests: XCTestCase { final class NoiseRateLimiterTests: XCTestCase {
func test_allowHandshake_blocksAfterPerPeerLimit() { func test_allowHandshake_blocksAfterPerPeerLimit() {
@@ -1,5 +1,4 @@
import Foundation import Foundation
import Nostr
import Tor import Tor
import XCTest import XCTest
@testable import bitchat @testable import bitchat
@@ -304,7 +303,6 @@ final class GeoRelayDirectoryTests: XCTestCase {
retrySleep: { delay in retrySleep: { delay in
await retryRecorder.record(delay) await retryRecorder.record(delay)
}, },
torReadyNotificationName: .TorDidBecomeReady,
activeNotificationName: activeNotificationName, activeNotificationName: activeNotificationName,
autoStart: autoStart autoStart: autoStart
) )
-2
View File
@@ -5,11 +5,9 @@
// Tests for NIP-17 gift-wrapped private messages // Tests for NIP-17 gift-wrapped private messages
// //
import Nostr
import Testing import Testing
import CryptoKit import CryptoKit
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct NostrProtocolTests { struct NostrProtocolTests {
@@ -8,7 +8,6 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct BinaryProtocolTests { struct BinaryProtocolTests {
-1
View File
@@ -2,7 +2,6 @@ import Testing
import Foundation import Foundation
import Combine import Combine
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
private final class DefaultDelegateProbe: BitchatDelegate { private final class DefaultDelegateProbe: BitchatDelegate {
-1
View File
@@ -1,6 +1,5 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite("ReadReceipt Tests") @Suite("ReadReceipt Tests")
@@ -1,5 +1,4 @@
import XCTest import XCTest
import BitFoundation
@testable import bitchat @testable import bitchat
@MainActor @MainActor
@@ -1,5 +1,4 @@
import Combine import Combine
import Nostr
import XCTest import XCTest
@testable import bitchat @testable import bitchat
@@ -7,7 +7,6 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct MessageRouterTests { struct MessageRouterTests {
@@ -1,6 +1,5 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite("NoiseEncryptionService Tests") @Suite("NoiseEncryptionService Tests")
@@ -1,4 +1,3 @@
import Nostr
import Combine import Combine
import XCTest import XCTest
@testable import bitchat @testable import bitchat
@@ -698,7 +697,7 @@ final class NostrRelayManagerTests: XCTestCase {
} }
private func makeContext( private func makeContext(
permission: LocationPermissionState, permission: LocationChannelManager.PermissionState,
favorites: Set<Data> = [], favorites: Set<Data> = [],
activationAllowed: Bool = true, activationAllowed: Bool = true,
userTorEnabled: Bool = false, userTorEnabled: Bool = false,
@@ -706,7 +705,7 @@ final class NostrRelayManagerTests: XCTestCase {
torIsReady: Bool = true, torIsReady: Bool = true,
torIsForeground: Bool = true torIsForeground: Bool = true
) -> RelayManagerTestContext { ) -> RelayManagerTestContext {
let permissionSubject = CurrentValueSubject<LocationPermissionState, Never>(permission) let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites) let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
let sessionFactory = MockRelaySessionFactory() let sessionFactory = MockRelaySessionFactory()
let scheduler = MockRelayScheduler() let scheduler = MockRelayScheduler()
@@ -783,7 +782,7 @@ final class NostrRelayManagerTests: XCTestCase {
@MainActor @MainActor
private struct RelayManagerTestContext { private struct RelayManagerTestContext {
let manager: NostrRelayManager let manager: NostrRelayManager
let permissionSubject: CurrentValueSubject<LocationPermissionState, Never> let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>
let favoritesSubject: CurrentValueSubject<Set<Data>, Never> let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
let sessionFactory: MockRelaySessionFactory let sessionFactory: MockRelaySessionFactory
let scheduler: MockRelayScheduler let scheduler: MockRelayScheduler
@@ -7,9 +7,7 @@
// //
import Foundation import Foundation
import Nostr
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite("NostrTransport Tests") @Suite("NostrTransport Tests")

Some files were not shown because too many files have changed in this diff Show More