mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 05:45:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f20e65b0d8 |
@@ -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
@@ -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")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
-60
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
+23
-30
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
struct ReadReceipt: Codable {
|
struct ReadReceipt: Codable {
|
||||||
let originalMessageID: String
|
let originalMessageID: String
|
||||||
|
|||||||
+3
-4
@@ -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
|
||||||
+4
-7
@@ -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()
|
||||||
+5
-5
@@ -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
|
||||||
}
|
}
|
||||||
+1
-1
@@ -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
|
||||||
+3
-3
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
-7
@@ -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
|
||||||
|
|
||||||
+1
-1
@@ -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
|
||||||
+15
-16
@@ -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))
|
||||||
}
|
}
|
||||||
+122
-122
@@ -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,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
// MARK: - BitChat-over-Nostr Adapter
|
// MARK: - BitChat-over-Nostr Adapter
|
||||||
|
|
||||||
|
|||||||
+16
-13
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+34
-37
@@ -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
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+143
-34
@@ -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
|
||||||
+207
-117
@@ -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 }
|
||||||
+9
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,6 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
// MARK: - Message Types
|
// MARK: - Message Types
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Nostr
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import Combine
|
import Combine
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+2
-2
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,5 +1,4 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
struct MeshPeerList: View {
|
struct MeshPeerList: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
|
|||||||
@@ -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,4 +1,3 @@
|
|||||||
import Nostr
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreImage
|
import CoreImage
|
||||||
import CoreImage.CIFilterBuiltins
|
import CoreImage.CIFilterBuiltins
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
import Nostr
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class MockBLEBus {
|
final class MockBLEBus {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+2
-2
@@ -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
-7
@@ -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
-2
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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
Reference in New Issue
Block a user