mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:45:20 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa45ee3aea | ||
|
|
e8fb4a6333 |
@@ -21,6 +21,10 @@ jobs:
|
|||||||
path: localPackages/BitLogger
|
path: localPackages/BitLogger
|
||||||
- name: BitFoundation
|
- name: BitFoundation
|
||||||
path: localPackages/BitFoundation
|
path: localPackages/BitFoundation
|
||||||
|
- name: Noise
|
||||||
|
path: localPackages/Noise
|
||||||
|
- name: Nostr
|
||||||
|
path: localPackages/Nostr
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
+7
-3
@@ -17,8 +17,10 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
.package(path: "localPackages/Arti"),
|
.package(path: "localPackages/Arti"),
|
||||||
|
.package(path: "localPackages/Noise"),
|
||||||
.package(path: "localPackages/BitFoundation"),
|
.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,6 +30,8 @@ let package = Package(
|
|||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
.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",
|
||||||
@@ -48,7 +52,8 @@ let package = Package(
|
|||||||
name: "bitchatTests",
|
name: "bitchatTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"bitchat",
|
"bitchat",
|
||||||
.product(name: "BitFoundation", package: "BitFoundation")
|
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||||
|
.product(name: "Nostr", package: "Nostr")
|
||||||
],
|
],
|
||||||
path: "bitchatTests",
|
path: "bitchatTests",
|
||||||
exclude: [
|
exclude: [
|
||||||
@@ -56,8 +61,7 @@ let package = Package(
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localization"),
|
.process("Localization")
|
||||||
.process("Noise")
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+40
@@ -10,6 +10,10 @@
|
|||||||
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 */; };
|
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
|
||||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* 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 */; };
|
||||||
@@ -156,7 +160,9 @@
|
|||||||
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 */,
|
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
||||||
);
|
);
|
||||||
@@ -165,7 +171,9 @@
|
|||||||
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 */,
|
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
||||||
);
|
);
|
||||||
@@ -228,6 +236,8 @@
|
|||||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||||
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
||||||
|
A63163B72F80CB2D00B8B128 /* Noise */,
|
||||||
|
A63180842F8103B600B8B128 /* Nostr */,
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -309,6 +319,8 @@
|
|||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
||||||
|
A63163B52F80CB2500B8B128 /* Noise */,
|
||||||
|
A63180822F8103AB00B8B128 /* Nostr */,
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -351,6 +363,8 @@
|
|||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
||||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
||||||
|
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
|
||||||
|
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -916,6 +930,14 @@
|
|||||||
/* 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" */ = {
|
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/BitFoundation;
|
relativePath = localPackages/BitFoundation;
|
||||||
@@ -947,6 +969,24 @@
|
|||||||
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 */ = {
|
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = BitFoundation;
|
productName = BitFoundation;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// 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 BitFoundation
|
||||||
@@ -27,9 +28,11 @@ struct BitchatApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let idBridge = NostrIdentityBridge()
|
private let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Nostr
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Tor
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - GeoRelayDirectory Live Dependencies
|
||||||
|
|
||||||
|
extension GeoRelayDirectoryDependencies {
|
||||||
|
@MainActor
|
||||||
|
static func live() -> Self {
|
||||||
|
#if os(iOS)
|
||||||
|
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
||||||
|
#elseif os(macOS)
|
||||||
|
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
||||||
|
#else
|
||||||
|
let activeNotificationName: Notification.Name? = nil
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return Self(
|
||||||
|
userDefaults: .standard,
|
||||||
|
notificationCenter: .default,
|
||||||
|
now: Date.init,
|
||||||
|
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||||
|
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||||
|
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||||
|
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||||
|
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||||
|
awaitTorReady: { await TorManager.shared.awaitReady() },
|
||||||
|
makeFetchData: {
|
||||||
|
let session = TorURLSession.shared.session
|
||||||
|
return { request in
|
||||||
|
let (data, _) = try await session.data(for: request)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readData: { try? Data(contentsOf: $0) },
|
||||||
|
writeData: { data, url in
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
},
|
||||||
|
cacheURL: {
|
||||||
|
do {
|
||||||
|
let base = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir.appendingPathComponent("georelays_cache.csv")
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bundledCSVURLs: {
|
||||||
|
[
|
||||||
|
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
|
].compactMap { $0 }
|
||||||
|
},
|
||||||
|
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
|
||||||
|
retrySleep: { delay in
|
||||||
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||||
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
},
|
||||||
|
torReadyNotificationName: .TorDidBecomeReady,
|
||||||
|
activeNotificationName: activeNotificationName,
|
||||||
|
autoStart: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NostrRelayManager Live Dependencies
|
||||||
|
|
||||||
|
extension NostrRelayManagerDependencies {
|
||||||
|
@MainActor
|
||||||
|
static func live() -> Self {
|
||||||
|
Self(
|
||||||
|
activationAllowed: { NetworkActivationService.shared.activationAllowed },
|
||||||
|
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
|
||||||
|
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
|
||||||
|
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
|
||||||
|
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
|
||||||
|
locationPermissionPublisher: LocationChannelManager.shared.$permissionState
|
||||||
|
.map { state -> LocationPermissionState in
|
||||||
|
switch state {
|
||||||
|
case .notDetermined:.notDetermined
|
||||||
|
case .authorized: .authorized
|
||||||
|
case .denied: .denied
|
||||||
|
case .restricted: .denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher(),
|
||||||
|
torEnforced: { TorManager.shared.torEnforced },
|
||||||
|
torIsReady: { TorManager.shared.isReady },
|
||||||
|
torIsForeground: { TorManager.shared.isForeground() },
|
||||||
|
awaitTorReady: { completion in
|
||||||
|
Task.detached {
|
||||||
|
let ready = await TorManager.shared.awaitReady()
|
||||||
|
await MainActor.run {
|
||||||
|
completion(ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
makeSession: { NostrRelayManager.makeURLSession(TorURLSession.shared.session) },
|
||||||
|
scheduleAfter: { delay, action in
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||||
|
},
|
||||||
|
now: Date.init
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import Combine
|
import Combine
|
||||||
|
|||||||
@@ -6,6 +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
|
import BitFoundation
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,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 Combine
|
import Combine
|
||||||
import BitLogger
|
import BitLogger
|
||||||
@@ -74,7 +75,7 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
]
|
]
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
let idBridge = NostrIdentityBridge()
|
let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||||
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,6 +7,8 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import protocol Nostr.NostrKeychainStoring
|
||||||
|
import protocol Noise.SecureMemoryCleaner
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ enum KeychainSaveResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol KeychainManagerProtocol {
|
protocol KeychainManagerProtocol: SecureMemoryCleaner, NostrKeychainStoring {
|
||||||
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,4 +1,5 @@
|
|||||||
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.
|
||||||
@@ -15,7 +16,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()
|
private static let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||||
|
|
||||||
static let live = LocationNotesDependencies(
|
static let live = LocationNotesDependencies(
|
||||||
relayLookup: { geohash, count in
|
relayLookup: { geohash, count in
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import Combine
|
import Combine
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Noise
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
@@ -1967,7 +1968,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()
|
nostrRelayManager = NostrRelayManager(dependencies: .live())
|
||||||
setupNostrMessageHandling()
|
setupNostrMessageHandling()
|
||||||
nostrRelayManager?.connect()
|
nostrRelayManager?.connect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// 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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// 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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// 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 {
|
||||||
@@ -76,7 +77,7 @@ struct CommandSuggestionsView: View {
|
|||||||
let keychain = KeychainManager()
|
let keychain = KeychainManager()
|
||||||
let viewModel = ChatViewModel(
|
let viewModel = ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: NostrIdentityBridge(),
|
idBridge: NostrIdentityBridge(keychain: keychain),
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// 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 {
|
||||||
@@ -89,7 +90,7 @@ struct TextMessageView: View {
|
|||||||
.environmentObject(
|
.environmentObject(
|
||||||
ChatViewModel(
|
ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: NostrIdentityBridge(),
|
idBridge: NostrIdentityBridge(keychain: keychain),
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Nostr
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreImage
|
import CoreImage
|
||||||
import CoreImage.CIFilterBuiltins
|
import CoreImage.CIFilterBuiltins
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Nostr
|
||||||
import Testing
|
import Testing
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Testing
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import struct Foundation.UUID
|
import struct Foundation.UUID
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Noise
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct PrivateChatE2ETests {
|
struct PrivateChatE2ETests {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Testing
|
import Testing
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Noise
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct IntegrationTests {
|
struct IntegrationTests {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Noise
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class TestNetworkHelper {
|
final class TestNetworkHelper {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Nostr
|
||||||
import Tor
|
import Tor
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
@@ -303,6 +304,7 @@ 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,6 +5,7 @@
|
|||||||
// 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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Combine
|
import Combine
|
||||||
|
import Nostr
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Nostr
|
||||||
import Combine
|
import Combine
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
@@ -697,7 +698,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeContext(
|
private func makeContext(
|
||||||
permission: LocationChannelManager.PermissionState,
|
permission: LocationPermissionState,
|
||||||
favorites: Set<Data> = [],
|
favorites: Set<Data> = [],
|
||||||
activationAllowed: Bool = true,
|
activationAllowed: Bool = true,
|
||||||
userTorEnabled: Bool = false,
|
userTorEnabled: Bool = false,
|
||||||
@@ -705,7 +706,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
torIsReady: Bool = true,
|
torIsReady: Bool = true,
|
||||||
torIsForeground: Bool = true
|
torIsForeground: Bool = true
|
||||||
) -> RelayManagerTestContext {
|
) -> RelayManagerTestContext {
|
||||||
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
let permissionSubject = CurrentValueSubject<LocationPermissionState, 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()
|
||||||
@@ -782,7 +783,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private struct RelayManagerTestContext {
|
private struct RelayManagerTestContext {
|
||||||
let manager: NostrRelayManager
|
let manager: NostrRelayManager
|
||||||
let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>
|
let permissionSubject: CurrentValueSubject<LocationPermissionState, 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,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Nostr
|
||||||
import Testing
|
import Testing
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Noise",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v16),
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "Noise",
|
||||||
|
targets: ["Noise"]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: "../BitLogger"),
|
||||||
|
.package(path: "../BitFoundation"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "Noise",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
|
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||||
|
],
|
||||||
|
path: "Sources"
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "NoiseTests",
|
||||||
|
dependencies: ["Noise"],
|
||||||
|
resources: [
|
||||||
|
.process("NoiseTestVectors.json")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
+4
-3
@@ -78,6 +78,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -532,7 +533,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: KeychainManagerProtocol
|
private let keychain: SecureMemoryCleaner
|
||||||
private var symmetricState: NoiseSymmetricState
|
private var symmetricState: NoiseSymmetricState
|
||||||
|
|
||||||
// Keys
|
// Keys
|
||||||
@@ -555,7 +556,7 @@ final class NoiseHandshakeState {
|
|||||||
init(
|
init(
|
||||||
role: NoiseRole,
|
role: NoiseRole,
|
||||||
pattern: NoisePattern,
|
pattern: NoisePattern,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: SecureMemoryCleaner,
|
||||||
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(),
|
||||||
@@ -923,7 +924,7 @@ extension NoisePattern {
|
|||||||
|
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
|
|
||||||
enum NoiseError: Error {
|
public enum NoiseError: Error {
|
||||||
case uninitializedCipher
|
case uninitializedCipher
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
case handshakeComplete
|
case handshakeComplete
|
||||||
+7
-5
@@ -10,7 +10,7 @@ import BitLogger
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
public final class NoiseRateLimiter {
|
||||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
|
||||||
@@ -19,8 +19,10 @@ final class NoiseRateLimiter {
|
|||||||
private var globalMessageTimestamps: [Date] = []
|
private var globalMessageTimestamps: [Date] = []
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||||
|
|
||||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
public init() {}
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -49,7 +51,7 @@ final class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func allowMessage(from peerID: PeerID) -> Bool {
|
public 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)
|
||||||
@@ -85,7 +87,7 @@ final class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetAll() {
|
public 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
|
||||||
|
|
||||||
enum NoiseSecurityConstants {
|
public 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 @@ enum NoiseSecurityConstants {
|
|||||||
static let maxSessionsPerPeer = 3
|
static let maxSessionsPerPeer = 3
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
static let maxHandshakesPerMinute = 10
|
public static let maxHandshakesPerMinute = 10
|
||||||
static let maxMessagesPerSecond = 100
|
public static let maxMessagesPerSecond = 100
|
||||||
|
|
||||||
// Global rate limiting (across all peers)
|
// Global rate limiting (across all peers)
|
||||||
static let maxGlobalHandshakesPerMinute = 30
|
public static let maxGlobalHandshakesPerMinute = 30
|
||||||
static let maxGlobalMessagesPerSecond = 500
|
public static let maxGlobalMessagesPerSecond = 500
|
||||||
}
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum NoiseSecurityError: Error {
|
public 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
|
||||||
|
|
||||||
struct NoiseSecurityValidator {
|
public struct NoiseSecurityValidator {
|
||||||
|
|
||||||
/// Validate message size
|
/// Validate message size
|
||||||
static func validateMessageSize(_ data: Data) -> Bool {
|
public static func validateMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate handshake message size
|
/// Validate handshake message size
|
||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
public static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
-6
@@ -11,10 +11,10 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
class NoiseSession {
|
public class NoiseSession {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
let role: NoiseRole
|
let role: NoiseRole
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: SecureMemoryCleaner
|
||||||
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 +34,7 @@ class NoiseSession {
|
|||||||
init(
|
init(
|
||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
role: NoiseRole,
|
role: NoiseRole,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: SecureMemoryCleaner,
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
||||||
) {
|
) {
|
||||||
@@ -182,7 +182,7 @@ class NoiseSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEstablished() -> Bool {
|
public func isEstablished() -> Bool {
|
||||||
return sessionQueue.sync {
|
return sessionQueue.sync {
|
||||||
if case .established = state {
|
if case .established = state {
|
||||||
return true
|
return true
|
||||||
@@ -217,8 +217,8 @@ class NoiseSession {
|
|||||||
sentHandshakeMessages.removeAll()
|
sentHandshakeMessages.removeAll()
|
||||||
|
|
||||||
// Clear handshake hash
|
// Clear handshake hash
|
||||||
if var hash = handshakeHash {
|
if handshakeHash != nil {
|
||||||
keychain.secureClear(&hash)
|
keychain.secureClear(&handshakeHash!)
|
||||||
}
|
}
|
||||||
handshakeHash = nil
|
handshakeHash = nil
|
||||||
|
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
enum NoiseSessionError: Error, Equatable {
|
public enum NoiseSessionError: Error, Equatable {
|
||||||
case invalidState
|
case invalidState
|
||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
+15
-15
@@ -11,18 +11,18 @@ import CryptoKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
final class NoiseSessionManager {
|
public 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: KeychainManagerProtocol
|
private let keychain: SecureMemoryCleaner
|
||||||
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
|
||||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
public var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
public init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: SecureMemoryCleaner) {
|
||||||
self.localStaticKey = localStaticKey
|
self.localStaticKey = localStaticKey
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.sessionFactory = { peerID, role in
|
self.sessionFactory = { peerID, role in
|
||||||
@@ -38,7 +38,7 @@ final class NoiseSessionManager {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
init(
|
init(
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: SecureMemoryCleaner,
|
||||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
||||||
) {
|
) {
|
||||||
self.localStaticKey = localStaticKey
|
self.localStaticKey = localStaticKey
|
||||||
@@ -49,13 +49,13 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Session Management
|
// MARK: - Session Management
|
||||||
|
|
||||||
func getSession(for peerID: PeerID) -> NoiseSession? {
|
public func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||||
return managerQueue.sync {
|
return managerQueue.sync {
|
||||||
return sessions[peerID]
|
return sessions[peerID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeSession(for peerID: PeerID) {
|
public 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 +63,7 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeAllSessions() {
|
public func removeAllSessions() {
|
||||||
managerQueue.sync(flags: .barrier) {
|
managerQueue.sync(flags: .barrier) {
|
||||||
for (_, session) in sessions {
|
for (_, session) in sessions {
|
||||||
session.reset()
|
session.reset()
|
||||||
@@ -74,7 +74,7 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Handshake Helpers
|
// MARK: - Handshake Helpers
|
||||||
|
|
||||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
public 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 +103,7 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
public 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 +173,7 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
public 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 +181,7 @@ final class NoiseSessionManager {
|
|||||||
return try session.encrypt(plaintext)
|
return try session.encrypt(plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
public 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 +191,13 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Key Management
|
// MARK: - Key Management
|
||||||
|
|
||||||
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
public func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Rekeying
|
// MARK: - Session Rekeying
|
||||||
|
|
||||||
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
public 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 +213,7 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func initiateRekey(for peerID: PeerID) throws {
|
public func initiateRekey(for peerID: PeerID) throws {
|
||||||
// Remove old session
|
// Remove old session
|
||||||
removeSession(for: peerID)
|
removeSession(for: peerID)
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// SecureMemoryCleaner.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import struct Foundation.Data
|
||||||
|
|
||||||
|
public protocol SecureMemoryCleaner {
|
||||||
|
func secureClear(_ data: inout Data)
|
||||||
|
func secureClear(_ string: inout String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//
|
||||||
|
// MockKeychain.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import Noise
|
||||||
|
|
||||||
|
// TODO: Combine with the one from the main target
|
||||||
|
final class MockKeychain: SecureMemoryCleaner {
|
||||||
|
/// Thread-safe counter for secureClear calls
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _secureClearDataCallCount = 0
|
||||||
|
|
||||||
|
var secureClearDataCallCount: Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return _secureClearDataCallCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func secureClear(_ data: inout Data) {
|
||||||
|
lock.lock()
|
||||||
|
_secureClearDataCallCount += 1
|
||||||
|
lock.unlock()
|
||||||
|
data = Data()
|
||||||
|
}
|
||||||
|
|
||||||
|
func secureClear(_ string: inout String) {
|
||||||
|
string = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -2,8 +2,7 @@ import CryptoKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
@testable import Noise
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
@Suite("Noise Coverage Tests")
|
@Suite("Noise Coverage Tests")
|
||||||
struct NoiseCoverageTests {
|
struct NoiseCoverageTests {
|
||||||
+6
-8
@@ -10,7 +10,7 @@ import CryptoKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import Noise
|
||||||
|
|
||||||
// MARK: - Test Vector Support
|
// MARK: - Test Vector Support
|
||||||
|
|
||||||
@@ -613,9 +613,7 @@ struct NoiseProtocolTests {
|
|||||||
|
|
||||||
private func loadTestVectors() throws -> [NoiseTestVector] {
|
private func loadTestVectors() throws -> [NoiseTestVector] {
|
||||||
// Try to load from test bundle
|
// Try to load from test bundle
|
||||||
let testBundle = Bundle(for: MockKeychain.self)
|
guard let url = Bundle.module.url(forResource: "NoiseTestVectors", withExtension: "json") else {
|
||||||
guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json")
|
|
||||||
else {
|
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "NoiseTests", code: 1,
|
domain: "NoiseTests", code: 1,
|
||||||
userInfo: [
|
userInfo: [
|
||||||
@@ -794,7 +792,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 = TrackingMockKeychain()
|
let trackingKeychain = MockKeychain()
|
||||||
|
|
||||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
@@ -843,7 +841,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 = TrackingMockKeychain()
|
let trackingKeychain = MockKeychain()
|
||||||
|
|
||||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
@@ -900,8 +898,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 = TrackingMockKeychain()
|
let aliceKeychain = MockKeychain()
|
||||||
let bobKeychain = TrackingMockKeychain()
|
let bobKeychain = MockKeychain()
|
||||||
|
|
||||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import Noise
|
||||||
|
|
||||||
final class NoiseRateLimiterTests: XCTestCase {
|
final class NoiseRateLimiterTests: XCTestCase {
|
||||||
func test_allowHandshake_blocksAfterPerPeerLimit() {
|
func test_allowHandshake_blocksAfterPerPeerLimit() {
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//
|
||||||
|
// TestHelpers.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
|
// TODO: Combine with the one from the main target
|
||||||
|
final class TestHelpers {
|
||||||
|
static func generateRandomData(length: Int) -> Data {
|
||||||
|
var data = Data(count: length)
|
||||||
|
_ = data.withUnsafeMutableBytes { bytes in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, length, bytes.baseAddress!)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func waitUntil(
|
||||||
|
_ condition: @escaping () -> Bool,
|
||||||
|
timeout: TimeInterval = 5,
|
||||||
|
pollInterval: TimeInterval = 0.01
|
||||||
|
) async -> Bool {
|
||||||
|
let start = Date()
|
||||||
|
while !condition() {
|
||||||
|
if Date().timeIntervalSince(start) > timeout {
|
||||||
|
return condition()
|
||||||
|
}
|
||||||
|
try? await sleep(pollInterval)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleep(_ seconds: TimeInterval) async throws {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||||
|
}
|
||||||
Generated
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "6a29e071e2546e255f894c733cad2a53ab20e153bf12e813d16830b195a5970d",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "swift-secp256k1",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/21-DOT-DEV/swift-secp256k1",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2",
|
||||||
|
"version" : "0.21.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Nostr",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v16),
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "Nostr",
|
||||||
|
targets: ["Nostr"]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: "../BitLogger"),
|
||||||
|
.package(path: "../BitFoundation"),
|
||||||
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "Nostr",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
|
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||||
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
|
],
|
||||||
|
path: "Sources"
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "NostrTests",
|
||||||
|
dependencies: ["Nostr"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -1,38 +1,37 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Bech32 encoding for Nostr (minimal implementation)
|
/// Bech32 encoding for Nostr (minimal implementation)
|
||||||
enum Bech32 {
|
public 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]
|
||||||
|
|
||||||
static func encode(hrp: String, data: Data) throws -> String {
|
public 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
|
||||||
|
|
||||||
return hrp + "1" + combined.map {
|
return hrp + "1" + combined.map {
|
||||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||||
return String(charset[index])
|
return String(charset[index])
|
||||||
}.joined()
|
}.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
public 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
|
||||||
}
|
}
|
||||||
|
|
||||||
let hrp = String(bech32String[..<separatorIndex])
|
let hrp = String(bech32String[..<separatorIndex])
|
||||||
|
|
||||||
// Validate HRP contains only ASCII characters
|
// Validate HRP contains only ASCII characters
|
||||||
for char in hrp {
|
for char in hrp {
|
||||||
guard char.asciiValue != nil else {
|
guard char.asciiValue != nil else {
|
||||||
throw Bech32Error.invalidCharacter
|
throw Bech32Error.invalidCharacter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||||
|
|
||||||
// Convert characters to values
|
// Convert characters to values
|
||||||
var values = [UInt8]()
|
var values = [UInt8]()
|
||||||
for char in dataString {
|
for char in dataString {
|
||||||
@@ -41,84 +40,84 @@ enum Bech32 {
|
|||||||
}
|
}
|
||||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify checksum
|
// Verify checksum
|
||||||
guard values.count >= 6 else {
|
guard values.count >= 6 else {
|
||||||
throw Bech32Error.invalidChecksum
|
throw Bech32Error.invalidChecksum
|
||||||
}
|
}
|
||||||
|
|
||||||
let payloadValues = Array(values.dropLast(6))
|
let payloadValues = Array(values.dropLast(6))
|
||||||
let checksum = Array(values.suffix(6))
|
let checksum = Array(values.suffix(6))
|
||||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||||
|
|
||||||
guard checksum == expectedChecksum else {
|
guard checksum == expectedChecksum else {
|
||||||
throw Bech32Error.invalidChecksum
|
throw Bech32Error.invalidChecksum
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert back to bytes
|
// Convert back to bytes
|
||||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||||
return (hrp: hrp, data: Data(bytes))
|
return (hrp: hrp, data: Data(bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Bech32Error: Error {
|
enum Bech32Error: Error {
|
||||||
case invalidFormat
|
case invalidFormat
|
||||||
case invalidCharacter
|
case invalidCharacter
|
||||||
case invalidChecksum
|
case invalidChecksum
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||||
var acc = 0
|
var acc = 0
|
||||||
var bits = 0
|
var bits = 0
|
||||||
var result = [UInt8]()
|
var result = [UInt8]()
|
||||||
let maxv = (1 << to) - 1
|
let maxv = (1 << to) - 1
|
||||||
|
|
||||||
for value in data {
|
for value in data {
|
||||||
acc = (acc << from) | Int(value)
|
acc = (acc << from) | Int(value)
|
||||||
bits += from
|
bits += from
|
||||||
|
|
||||||
while bits >= to {
|
while bits >= to {
|
||||||
bits -= to
|
bits -= to
|
||||||
result.append(UInt8((acc >> bits) & maxv))
|
result.append(UInt8((acc >> bits) & maxv))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if pad && bits > 0 {
|
if pad && bits > 0 {
|
||||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||||
let polymod = polymod(checksumValues) ^ 1
|
let polymod = polymod(checksumValues) ^ 1
|
||||||
var checksum = [UInt8]()
|
var checksum = [UInt8]()
|
||||||
|
|
||||||
for i in 0..<6 {
|
for i in 0..<6 {
|
||||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||||
}
|
}
|
||||||
|
|
||||||
return checksum
|
return checksum
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||||
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 empty array for invalid input
|
return []
|
||||||
}
|
}
|
||||||
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 empty array for invalid input
|
return []
|
||||||
}
|
}
|
||||||
result.append(UInt8(asciiValue & 31))
|
result.append(UInt8(asciiValue & 31))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func polymod(_ values: [UInt8]) -> Int {
|
private static func polymod(_ values: [UInt8]) -> Int {
|
||||||
var chk = 1
|
var chk = 1
|
||||||
for value in values {
|
for value in values {
|
||||||
+130
-130
@@ -1,101 +1,72 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Tor
|
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
public struct GeoRelayDirectoryDependencies {
|
||||||
struct GeoRelayDirectoryDependencies {
|
public var userDefaults: UserDefaults
|
||||||
var userDefaults: UserDefaults
|
public var notificationCenter: NotificationCenter
|
||||||
var notificationCenter: NotificationCenter
|
public var now: () -> Date
|
||||||
var now: () -> Date
|
public var remoteURL: URL
|
||||||
var remoteURL: URL
|
public var fetchInterval: TimeInterval
|
||||||
var fetchInterval: TimeInterval
|
public var refreshCheckInterval: TimeInterval
|
||||||
var refreshCheckInterval: TimeInterval
|
public var retryInitialSeconds: TimeInterval
|
||||||
var retryInitialSeconds: TimeInterval
|
public var retryMaxSeconds: TimeInterval
|
||||||
var retryMaxSeconds: TimeInterval
|
public var awaitTorReady: @Sendable () async -> Bool
|
||||||
var awaitTorReady: @Sendable () async -> Bool
|
public var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
|
||||||
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
|
public var readData: (URL) -> Data?
|
||||||
var readData: (URL) -> Data?
|
public var writeData: (Data, URL) throws -> Void
|
||||||
var writeData: (Data, URL) throws -> Void
|
public var cacheURL: () -> URL?
|
||||||
var cacheURL: () -> URL?
|
public var bundledCSVURLs: () -> [URL]
|
||||||
var bundledCSVURLs: () -> [URL]
|
public var currentDirectoryPath: () -> String?
|
||||||
var currentDirectoryPath: () -> String?
|
public var retrySleep: (TimeInterval) async -> Void
|
||||||
var retrySleep: (TimeInterval) async -> Void
|
public var torReadyNotificationName: Notification.Name?
|
||||||
var activeNotificationName: Notification.Name?
|
public var activeNotificationName: Notification.Name?
|
||||||
var autoStart: Bool
|
public var autoStart: Bool
|
||||||
}
|
|
||||||
|
|
||||||
private extension GeoRelayDirectoryDependencies {
|
public init(
|
||||||
@MainActor
|
userDefaults: UserDefaults,
|
||||||
static func live() -> Self {
|
notificationCenter: NotificationCenter,
|
||||||
#if os(iOS)
|
now: @escaping () -> Date,
|
||||||
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
remoteURL: URL,
|
||||||
#elseif os(macOS)
|
fetchInterval: TimeInterval,
|
||||||
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
refreshCheckInterval: TimeInterval,
|
||||||
#else
|
retryInitialSeconds: TimeInterval,
|
||||||
let activeNotificationName: Notification.Name? = nil
|
retryMaxSeconds: TimeInterval,
|
||||||
#endif
|
awaitTorReady: @Sendable @escaping () async -> Bool,
|
||||||
|
makeFetchData: @MainActor @Sendable @escaping () -> (@Sendable (URLRequest) async throws -> Data),
|
||||||
return Self(
|
readData: @escaping (URL) -> Data?,
|
||||||
userDefaults: .standard,
|
writeData: @escaping (Data, URL) throws -> Void,
|
||||||
notificationCenter: .default,
|
cacheURL: @escaping () -> URL?,
|
||||||
now: Date.init,
|
bundledCSVURLs: @escaping () -> [URL],
|
||||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
currentDirectoryPath: @escaping () -> String?,
|
||||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
retrySleep: @escaping (TimeInterval) async -> Void,
|
||||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
torReadyNotificationName: Notification.Name?,
|
||||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
activeNotificationName: Notification.Name?,
|
||||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
autoStart: Bool
|
||||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
) {
|
||||||
makeFetchData: {
|
self.userDefaults = userDefaults
|
||||||
let session = TorURLSession.shared.session
|
self.notificationCenter = notificationCenter
|
||||||
return { request in
|
self.now = now
|
||||||
let (data, _) = try await session.data(for: request)
|
self.remoteURL = remoteURL
|
||||||
return data
|
self.fetchInterval = fetchInterval
|
||||||
}
|
self.refreshCheckInterval = refreshCheckInterval
|
||||||
},
|
self.retryInitialSeconds = retryInitialSeconds
|
||||||
readData: { try? Data(contentsOf: $0) },
|
self.retryMaxSeconds = retryMaxSeconds
|
||||||
writeData: { data, url in
|
self.awaitTorReady = awaitTorReady
|
||||||
try data.write(to: url, options: .atomic)
|
self.makeFetchData = makeFetchData
|
||||||
},
|
self.readData = readData
|
||||||
cacheURL: {
|
self.writeData = writeData
|
||||||
do {
|
self.cacheURL = cacheURL
|
||||||
let base = try FileManager.default.url(
|
self.bundledCSVURLs = bundledCSVURLs
|
||||||
for: .applicationSupportDirectory,
|
self.currentDirectoryPath = currentDirectoryPath
|
||||||
in: .userDomainMask,
|
self.retrySleep = retrySleep
|
||||||
appropriateFor: nil,
|
self.torReadyNotificationName = torReadyNotificationName
|
||||||
create: true
|
self.activeNotificationName = activeNotificationName
|
||||||
)
|
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
|
||||||
final class GeoRelayDirectory {
|
public final class GeoRelayDirectory {
|
||||||
private final class CleanupState {
|
private final class CleanupState {
|
||||||
let notificationCenter: NotificationCenter
|
let notificationCenter: NotificationCenter
|
||||||
var observers: [NSObjectProtocol] = []
|
var observers: [NSObjectProtocol] = []
|
||||||
@@ -113,10 +84,16 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Entry: Hashable, Sendable {
|
public struct Entry: Hashable, Sendable {
|
||||||
let host: String
|
public let host: String
|
||||||
let lat: Double
|
public let lat: Double
|
||||||
let lon: Double
|
public 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 {
|
||||||
@@ -126,9 +103,13 @@ final class GeoRelayDirectory {
|
|||||||
case network(String)
|
case network(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
static let shared = GeoRelayDirectory()
|
nonisolated(unsafe) public static var shared: GeoRelayDirectory!
|
||||||
|
|
||||||
private(set) var entries: [Entry] = []
|
public static func setupShared(dependencies: GeoRelayDirectoryDependencies) {
|
||||||
|
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
|
||||||
@@ -136,18 +117,7 @@ final class GeoRelayDirectory {
|
|||||||
private var retryAttempt: Int = 0
|
private var retryAttempt: Int = 0
|
||||||
private var isFetching: Bool = false
|
private var isFetching: Bool = false
|
||||||
|
|
||||||
private init() {
|
public init(dependencies: GeoRelayDirectoryDependencies) {
|
||||||
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()
|
||||||
@@ -159,13 +129,13 @@ 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.
|
||||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
public func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||||
let center = Geohash.decodeCenter(geohash)
|
let center = decodeGeohashCenter(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.
|
||||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
public 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 {
|
||||||
@@ -195,7 +165,7 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
func prefetchIfNeeded(force: Bool = false) {
|
public func prefetchIfNeeded(force: Bool = false) {
|
||||||
guard !isFetching else { return }
|
guard !isFetching else { return }
|
||||||
|
|
||||||
let now = dependencies.now()
|
let now = dependencies.now()
|
||||||
@@ -205,7 +175,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +312,6 @@ 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) {
|
||||||
@@ -351,7 +319,6 @@ 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 {
|
||||||
@@ -362,7 +329,6 @@ 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) {
|
||||||
@@ -373,7 +339,7 @@ final class GeoRelayDirectory {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
nonisolated public 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() {
|
||||||
@@ -397,17 +363,19 @@ final class GeoRelayDirectory {
|
|||||||
private func registerObservers() {
|
private func registerObservers() {
|
||||||
let center = dependencies.notificationCenter
|
let center = dependencies.notificationCenter
|
||||||
|
|
||||||
let torReady = center.addObserver(
|
if let torReadyName = dependencies.torReadyNotificationName {
|
||||||
forName: .TorDidBecomeReady,
|
let torReady = center.addObserver(
|
||||||
object: nil,
|
forName: torReadyName,
|
||||||
queue: .main
|
object: nil,
|
||||||
) { [weak self] _ in
|
queue: .main
|
||||||
guard let self else { return }
|
) { [weak self] _ in
|
||||||
Task { @MainActor in
|
guard let self else { return }
|
||||||
self.prefetchIfNeeded(force: true)
|
Task { @MainActor in
|
||||||
|
self.prefetchIfNeeded(force: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
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(
|
||||||
@@ -439,17 +407,49 @@ final class GeoRelayDirectory {
|
|||||||
RunLoop.main.add(timer, forMode: .common)
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugRetryAttempt: Int { retryAttempt }
|
public var debugRetryAttempt: Int { retryAttempt }
|
||||||
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
public var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
||||||
var debugObserverCount: Int { cleanupState.observers.count }
|
public 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 // Earth radius in km
|
let r = 6371.0
|
||||||
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)
|
||||||
|
}
|
||||||
+21
-24
@@ -2,59 +2,56 @@ 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
|
||||||
struct NostrIdentity: Codable {
|
public struct NostrIdentity: Codable, Sendable {
|
||||||
let privateKey: Data
|
public let privateKey: Data
|
||||||
let publicKey: Data
|
public let publicKey: Data
|
||||||
let npub: String // Bech32-encoded public key
|
public let npub: String // Bech32-encoded public key
|
||||||
let createdAt: Date
|
public let createdAt: Date
|
||||||
|
|
||||||
/// Memberwise initializer
|
public init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
|
||||||
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
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a new Nostr identity
|
/// Generate a new Nostr identity
|
||||||
static func generate() throws -> NostrIdentity {
|
public 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, // Store x-only public key
|
publicKey: xOnlyPubkey,
|
||||||
npub: npub,
|
npub: npub,
|
||||||
createdAt: Date()
|
createdAt: Date()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize from existing private key data
|
/// Initialize from existing private key data
|
||||||
init(privateKeyData: Data) throws {
|
public 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)
|
||||||
|
|
||||||
self.privateKey = privateKeyData
|
self.privateKey = privateKeyData
|
||||||
self.publicKey = xOnlyPubkey
|
self.publicKey = xOnlyPubkey
|
||||||
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
||||||
self.createdAt = Date()
|
self.createdAt = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get signing key for event signatures
|
/// Get signing key for event signatures
|
||||||
func signingKey() throws -> P256K.Signing.PrivateKey {
|
public 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
|
||||||
func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey {
|
public 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)
|
||||||
var publicKeyHex: String {
|
public var publicKeyHex: String {
|
||||||
// Public key is already stored as x-only (32 bytes)
|
publicKey.hexEncodedString()
|
||||||
return publicKey.hexEncodedString()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+44
-41
@@ -1,51 +1,54 @@
|
|||||||
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
|
||||||
final class NostrIdentityBridge {
|
public 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"
|
||||||
// In-memory cache to avoid transient keychain access issues
|
private let deviceSeedCache: NSLock = NSLock()
|
||||||
private var deviceSeedCache: Data?
|
private var _deviceSeedCacheValue: 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: KeychainManagerProtocol
|
private let keychain: any NostrKeychainStoring
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
public init(keychain: any NostrKeychainStoring) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get or create the current Nostr identity
|
/// Get or create the current Nostr identity
|
||||||
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
public 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)
|
||||||
|
|
||||||
return nostrIdentity
|
return nostrIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
public 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Nostr public key associated with a Noise public key
|
/// Get Nostr public key associated with a Noise public key
|
||||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
public 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 {
|
||||||
@@ -53,9 +56,9 @@ final class NostrIdentityBridge {
|
|||||||
}
|
}
|
||||||
return pubkey
|
return pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
/// Clear all Nostr identity associations and current identity
|
||||||
func clearAllAssociations() {
|
public 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,
|
||||||
@@ -76,42 +79,45 @@ final class NostrIdentityBridge {
|
|||||||
}
|
}
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
SecItemDelete(deleteQuery as CFDictionary)
|
||||||
}
|
}
|
||||||
} else if status == errSecItemNotFound {
|
|
||||||
// nothing persisted; no action needed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceSeedCache = nil
|
deviceSeedCache.lock()
|
||||||
|
_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 {
|
||||||
if let cached = deviceSeedCache { return cached }
|
deviceSeedCache.lock()
|
||||||
|
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 = existing
|
deviceSeedCache.lock()
|
||||||
|
_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 = seed
|
deviceSeedCache.lock()
|
||||||
|
_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.
|
||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
public func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||||
/// 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
|
||||||
}
|
}
|
||||||
@@ -132,24 +138,21 @@ 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
|
||||||
+78
-187
@@ -1,17 +1,15 @@
|
|||||||
|
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
|
||||||
struct NostrProtocol {
|
public struct NostrProtocol {
|
||||||
|
|
||||||
/// Nostr event kinds
|
/// Nostr event kinds
|
||||||
enum EventKind: Int {
|
public enum EventKind: Int, Sendable {
|
||||||
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,88 +18,71 @@ struct NostrProtocol {
|
|||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
case geohashPresence = 20001
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
static func createPrivateMessage(
|
public 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, // NIP-17: DM rumor kind 14
|
kind: .dm,
|
||||||
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)
|
||||||
static func decryptPrivateMessage(
|
public 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
|
||||||
}
|
}
|
||||||
|
|
||||||
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||||
static func createEphemeralGeohashEvent(
|
public static func createEphemeralGeohashEvent(
|
||||||
content: String,
|
content: String,
|
||||||
geohash: String,
|
geohash: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
@@ -128,7 +109,7 @@ 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
|
||||||
static func createGeohashPresenceEvent(
|
public static func createGeohashPresenceEvent(
|
||||||
geohash: String,
|
geohash: String,
|
||||||
senderIdentity: NostrIdentity
|
senderIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
@@ -145,7 +126,7 @@ 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.
|
||||||
static func createGeohashTextNote(
|
public static func createGeohashTextNote(
|
||||||
content: String,
|
content: String,
|
||||||
geohash: String,
|
geohash: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
@@ -165,22 +146,21 @@ struct NostrProtocol {
|
|||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private static func createSeal(
|
private static func createSeal(
|
||||||
rumor: NostrEvent,
|
rumor: NostrEvent,
|
||||||
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,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: senderKey
|
senderKey: senderKey
|
||||||
)
|
)
|
||||||
|
|
||||||
let seal = NostrEvent(
|
let seal = NostrEvent(
|
||||||
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
|
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
|
||||||
createdAt: randomizedTimestamp(),
|
createdAt: randomizedTimestamp(),
|
||||||
@@ -188,130 +168,108 @@ struct NostrProtocol {
|
|||||||
tags: [],
|
tags: [],
|
||||||
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 // This is the ephemeral key used for the seal
|
senderKey: P256K.Schnorr.PrivateKey
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
|
||||||
let sealJSON = try seal.jsonString()
|
let sealJSON = try seal.jsonString()
|
||||||
|
|
||||||
// Create new ephemeral key for gift wrap
|
|
||||||
let wrapKey = try P256K.Schnorr.PrivateKey()
|
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 // Use the gift wrap ephemeral key
|
senderKey: wrapKey
|
||||||
)
|
)
|
||||||
|
|
||||||
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]], // Tag recipient
|
tags: [["p", recipientPubkey]],
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapGiftWrap(
|
private static func unwrapGiftWrap(
|
||||||
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,
|
||||||
recipientKey: recipientKey
|
recipientKey: recipientKey
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let data = decrypted.data(using: .utf8),
|
guard let data = decrypted.data(using: .utf8),
|
||||||
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
let seal = try NostrEvent(from: sealDict)
|
return 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,
|
||||||
recipientKey: recipientKey
|
recipientKey: recipientKey
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let data = decrypted.data(using: .utf8),
|
guard let data = decrypted.data(using: .utf8),
|
||||||
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
return try NostrEvent(from: rumorDict)
|
return try NostrEvent(from: rumorDict)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption (NIP-44 v2)
|
// MARK: - Encryption (NIP-44 v2)
|
||||||
|
|
||||||
private static func encrypt(
|
private static func encrypt(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
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!)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
combined.append(sealed.tag)
|
combined.append(sealed.tag)
|
||||||
return "v2:" + base64URLEncode(combined)
|
return "v2:" + base64URLEncode(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
ciphertext: String,
|
ciphertext: String,
|
||||||
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),
|
||||||
@@ -325,7 +283,6 @@ 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)
|
||||||
@@ -337,7 +294,6 @@ 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) {
|
||||||
@@ -351,32 +307,23 @@ struct NostrProtocol {
|
|||||||
return String(data: pt, encoding: .utf8) ?? ""
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func deriveSharedSecret(
|
private static func deriveSharedSecret(
|
||||||
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 { // X-only key, need to add prefix
|
if publicKey.count == 32 {
|
||||||
// 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(
|
||||||
@@ -385,8 +332,6 @@ 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)
|
||||||
@@ -398,85 +343,32 @@ struct NostrProtocol {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform ECDH
|
|
||||||
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
|
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
|
||||||
with: keyAgreementPublicKey,
|
with: keyAgreementPublicKey,
|
||||||
format: .compressed
|
format: .compressed
|
||||||
)
|
)
|
||||||
|
|
||||||
// Convert SharedSecret to Data
|
return sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
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 {
|
||||||
// Add random offset to current time for privacy
|
let offset = TimeInterval.random(in: -900...900)
|
||||||
// This prevents timing correlation attacks while the actual message timestamp
|
return Date().addingTimeInterval(offset)
|
||||||
// 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
|
||||||
struct NostrEvent: Codable {
|
public struct NostrEvent: Codable, Sendable {
|
||||||
var id: String
|
public var id: String
|
||||||
let pubkey: String
|
public let pubkey: String
|
||||||
let created_at: Int
|
public let created_at: Int
|
||||||
let kind: Int
|
public let kind: Int
|
||||||
let tags: [[String]]
|
public let tags: [[String]]
|
||||||
let content: String
|
public let content: String
|
||||||
var sig: String?
|
public var sig: String?
|
||||||
|
|
||||||
init(
|
public init(
|
||||||
pubkey: String,
|
pubkey: String,
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
kind: NostrProtocol.EventKind,
|
kind: NostrProtocol.EventKind,
|
||||||
@@ -489,10 +381,10 @@ struct NostrEvent: Codable {
|
|||||||
self.tags = tags
|
self.tags = tags
|
||||||
self.content = content
|
self.content = content
|
||||||
self.sig = nil
|
self.sig = nil
|
||||||
self.id = "" // Will be set during signing
|
self.id = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from dict: [String: Any]) throws {
|
public 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,
|
||||||
@@ -500,7 +392,7 @@ struct NostrEvent: Codable {
|
|||||||
let content = dict["content"] as? String else {
|
let content = dict["content"] as? String else {
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
self.id = dict["id"] as? String ?? ""
|
self.id = dict["id"] as? String ?? ""
|
||||||
self.pubkey = pubkey
|
self.pubkey = pubkey
|
||||||
self.created_at = createdAt
|
self.created_at = createdAt
|
||||||
@@ -509,20 +401,19 @@ struct NostrEvent: Codable {
|
|||||||
self.content = content
|
self.content = content
|
||||||
self.sig = dict["sig"] as? String
|
self.sig = dict["sig"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
public 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
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
}
|
}
|
||||||
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||||
|
|
||||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||||
|
|
||||||
var signed = self
|
var signed = self
|
||||||
signed.id = eventId
|
signed.id = eventId
|
||||||
signed.sig = signatureHex
|
signed.sig = signatureHex
|
||||||
@@ -531,7 +422,7 @@ struct NostrEvent: Codable {
|
|||||||
|
|
||||||
/// 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.
|
||||||
func isValidSignature() -> Bool {
|
public 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),
|
||||||
@@ -548,7 +439,7 @@ struct NostrEvent: Codable {
|
|||||||
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
|
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
|
||||||
return xonly.isValid(signature, for: &messageBytes)
|
return xonly.isValid(signature, for: &messageBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateEventId() throws -> (String, Data) {
|
private func calculateEventId() throws -> (String, Data) {
|
||||||
let serialized = [
|
let serialized = [
|
||||||
0,
|
0,
|
||||||
@@ -558,12 +449,12 @@ struct NostrEvent: Codable {
|
|||||||
tags,
|
tags,
|
||||||
content
|
content
|
||||||
] as [Any]
|
] as [Any]
|
||||||
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||||
return (data.sha256Fingerprint(), data.sha256Hash())
|
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonString() throws -> String {
|
public 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)
|
||||||
@@ -571,7 +462,7 @@ struct NostrEvent: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NostrError: Error {
|
public enum NostrError: Error, Sendable {
|
||||||
case invalidPublicKey
|
case invalidPublicKey
|
||||||
case invalidPrivateKey
|
case invalidPrivateKey
|
||||||
case invalidEvent
|
case invalidEvent
|
||||||
+192
-282
@@ -2,9 +2,8 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Combine
|
import Combine
|
||||||
import Tor
|
|
||||||
|
|
||||||
protocol NostrRelayConnectionProtocol: AnyObject {
|
public 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)
|
||||||
@@ -12,7 +11,7 @@ protocol NostrRelayConnectionProtocol: AnyObject {
|
|||||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
|
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol NostrRelaySessionProtocol {
|
public protocol NostrRelaySessionProtocol {
|
||||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
|
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,75 +51,91 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NostrRelayManagerDependencies {
|
public enum LocationPermissionState: Equatable, Sendable {
|
||||||
var activationAllowed: () -> Bool
|
case notDetermined
|
||||||
var userTorEnabled: () -> Bool
|
case authorized
|
||||||
var hasMutualFavorites: () -> Bool
|
case denied
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension NostrRelayManagerDependencies {
|
public struct NostrRelayManagerDependencies {
|
||||||
@MainActor
|
public var activationAllowed: () -> Bool
|
||||||
static func live() -> Self {
|
public var userTorEnabled: () -> Bool
|
||||||
Self(
|
public var hasMutualFavorites: () -> Bool
|
||||||
activationAllowed: { NetworkActivationService.shared.activationAllowed },
|
public var hasLocationPermission: () -> Bool
|
||||||
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
|
public var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||||
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
|
public var locationPermissionPublisher: AnyPublisher<LocationPermissionState, Never>
|
||||||
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
|
public var torEnforced: () -> Bool
|
||||||
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
|
public var torIsReady: () -> Bool
|
||||||
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
public var torIsForeground: () -> Bool
|
||||||
torEnforced: { TorManager.shared.torEnforced },
|
public var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
||||||
torIsReady: { TorManager.shared.isReady },
|
public var makeSession: () -> NostrRelaySessionProtocol
|
||||||
torIsForeground: { TorManager.shared.isForeground() },
|
public var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||||
awaitTorReady: { completion in
|
public var now: () -> Date
|
||||||
Task.detached {
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
public init(
|
||||||
await MainActor.run {
|
activationAllowed: @escaping () -> Bool,
|
||||||
completion(ready)
|
userTorEnabled: @escaping () -> Bool,
|
||||||
}
|
hasMutualFavorites: @escaping () -> Bool,
|
||||||
}
|
hasLocationPermission: @escaping () -> Bool,
|
||||||
},
|
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
|
||||||
makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },
|
locationPermissionPublisher: AnyPublisher<LocationPermissionState, Never>,
|
||||||
scheduleAfter: { delay, action in
|
torEnforced: @escaping () -> Bool,
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
torIsReady: @escaping () -> Bool,
|
||||||
},
|
torIsForeground: @escaping () -> Bool,
|
||||||
now: Date.init
|
awaitTorReady: @escaping (@escaping (Bool) -> Void) -> Void,
|
||||||
)
|
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
|
||||||
final class NostrRelayManager: ObservableObject {
|
public final class NostrRelayManager: ObservableObject {
|
||||||
static let shared = NostrRelayManager()
|
nonisolated(unsafe) public static var 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) static var pendingGiftWrapIDs = Set<String>()
|
private(set) public static var pendingGiftWrapIDs = Set<String>()
|
||||||
static func registerPendingGiftWrap(id: String) {
|
public static func registerPendingGiftWrap(id: String) {
|
||||||
pendingGiftWrapIDs.insert(id)
|
pendingGiftWrapIDs.insert(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Relay: Identifiable {
|
public struct Relay: Identifiable {
|
||||||
let id = UUID()
|
public let id = UUID()
|
||||||
let url: String
|
public let url: String
|
||||||
var isConnected: Bool = false
|
public var isConnected: Bool = false
|
||||||
var lastError: Error?
|
public var lastError: Error?
|
||||||
var lastConnectedAt: Date?
|
public var lastConnectedAt: Date?
|
||||||
var messagesSent: Int = 0
|
public var messagesSent: Int = 0
|
||||||
var messagesReceived: Int = 0
|
public var messagesReceived: Int = 0
|
||||||
var reconnectAttempts: Int = 0
|
public var reconnectAttempts: Int = 0
|
||||||
var lastDisconnectedAt: Date?
|
public var lastDisconnectedAt: Date?
|
||||||
var nextReconnectTime: Date?
|
public var nextReconnectTime: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default relay list (can be customized)
|
// Default relay list (can be customized)
|
||||||
private static let defaultRelays = [
|
private static let defaultRelays = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
@@ -128,35 +143,30 @@ 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) var relays: [Relay] = []
|
@Published private(set) public var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) public 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>] = [:] // relay URL -> active subscription IDs
|
private var subscriptions: [String: Set<String>] = [:]
|
||||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
private var pendingSubscriptions: [String: [String: String]] = [:]
|
||||||
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
|
||||||
var timer: Timer?
|
var timer: Timer?
|
||||||
}
|
}
|
||||||
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>
|
||||||
@@ -165,22 +175,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
||||||
|
|
||||||
// Exponential backoff configuration
|
// Exponential backoff configuration (matches TransportConfig values)
|
||||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
private let initialBackoffInterval: TimeInterval = 1.0
|
||||||
private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
|
private let maxBackoffInterval: TimeInterval = 300.0
|
||||||
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
private let backoffMultiplier: Double = 2.0
|
||||||
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
private let maxReconnectAttempts = 10
|
||||||
|
|
||||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
|
||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
init() {
|
public init(dependencies: NostrRelayManagerDependencies) {
|
||||||
self.dependencies = .live()
|
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)
|
||||||
@@ -202,39 +210,10 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal init(dependencies: NostrRelayManagerDependencies) {
|
|
||||||
self.dependencies = dependencies
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
func connect() {
|
public 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 {
|
||||||
@@ -253,28 +232,26 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect from all relays
|
/// Disconnect from all relays
|
||||||
func disconnect() {
|
public 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).
|
||||||
func ensureConnections(to relayUrls: [String]) {
|
public 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) }
|
||||||
@@ -292,11 +269,9 @@ 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)
|
||||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
public 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) }
|
||||||
@@ -308,7 +283,6 @@ 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] {
|
||||||
@@ -324,13 +298,11 @@ 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] {
|
||||||
@@ -344,7 +316,6 @@ 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 {
|
||||||
@@ -361,18 +332,16 @@ 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.
|
||||||
func subscribe(
|
public 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 {
|
||||||
@@ -381,7 +350,6 @@ 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 {
|
||||||
@@ -391,23 +359,19 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
messageHandlers[id] = handler
|
messageHandlers[id] = handler
|
||||||
|
|
||||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let message = try encoder.encode(req)
|
let message = try encoder.encode(req)
|
||||||
guard let messageString = String(data: message, encoding: .utf8) else {
|
guard let messageString = String(data: message, encoding: .utf8) else {
|
||||||
SecureLogger.error("❌ Failed to encode subscription request", category: .session)
|
SecureLogger.error("❌ Failed to encode subscription request", category: .session)
|
||||||
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))
|
||||||
@@ -417,13 +381,11 @@ 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 }
|
||||||
@@ -438,9 +400,7 @@ 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)
|
||||||
@@ -500,56 +460,46 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unsubscribe from a subscription
|
/// Unsubscribe from a subscription
|
||||||
func unsubscribe(id: String) {
|
public 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)
|
||||||
let message = try? encoder.encode(req)
|
let message = try? encoder.encode(req)
|
||||||
|
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
if isPermanentlyFailed(urlString) {
|
if isPermanentlyFailed(urlString) {
|
||||||
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 }
|
||||||
@@ -558,35 +508,30 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let session = dependencies.makeSession()
|
let session = dependencies.makeSession()
|
||||||
let task = session.webSocketTask(with: url)
|
let task = session.webSocketTask(with: url)
|
||||||
|
|
||||||
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 }
|
||||||
@@ -606,26 +551,24 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
pendingSubscriptions[relayUrl] = nil
|
pendingSubscriptions[relayUrl] = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
|
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||||
task.receive { [weak self] result in
|
task.receive { [weak self] result in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
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 {
|
||||||
self.handleParsedMessage(parsed, from: relayUrl)
|
self.handleParsedMessage(parsed, from: relayUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue receiving
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.receiveMessage(from: task, relayUrl: relayUrl)
|
self.receiveMessage(from: task, relayUrl: relayUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.handleDisconnection(relayUrl: relayUrl, error: error)
|
self.handleDisconnection(relayUrl: relayUrl, error: error)
|
||||||
@@ -633,12 +576,7 @@ 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):
|
||||||
@@ -680,23 +618,21 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
|
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||||
let req = NostrRequest.event(event)
|
let req = NostrRequest.event(event)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try encoder.encode(req)
|
let data = try encoder.encode(req)
|
||||||
let message = String(data: data, encoding: .utf8) ?? ""
|
let message = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||||
|
|
||||||
connection.send(.string(message)) { [weak self] error in
|
connection.send(.string(message)) { [weak self] error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -707,32 +643,30 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
SecureLogger.error("Failed to encode event: \(error)", category: .session)
|
SecureLogger.error("Failed to encode event: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateRelayStatus(_ url: String, isConnected: Bool, error: Error? = nil) {
|
private func updateRelayStatus(_ url: String, isConnected: Bool, error: Error? = nil) {
|
||||||
if let index = relays.firstIndex(where: { $0.url == url }) {
|
if let index = relays.firstIndex(where: { $0.url == url }) {
|
||||||
relays[index].isConnected = isConnected
|
relays[index].isConnected = isConnected
|
||||||
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 // Reset on successful connection
|
relays[index].reconnectAttempts = 0
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateConnectionStatus() {
|
private func updateConnectionStatus() {
|
||||||
isConnected = relays.contains { $0.isConnected }
|
isConnected = relays.contains { $0.isConnected }
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -742,11 +676,10 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
|
|
||||||
// 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") ||
|
||||||
errorDescription.contains("dns") ||
|
errorDescription.contains("dns") ||
|
||||||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
|
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
|
||||||
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
|
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
|
||||||
@@ -760,106 +693,90 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
pendingSubscriptions[relayUrl] = nil
|
pendingSubscriptions[relayUrl] = nil
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Utility Methods
|
// MARK: - Public Utility Methods
|
||||||
|
|
||||||
/// Manually retry connection to a specific relay
|
public func retryConnection(to relayUrl: String) {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get detailed status for all relays
|
public func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] {
|
||||||
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,
|
||||||
reconnectAttempts: relay.reconnectAttempts,
|
reconnectAttempts: relay.reconnectAttempts,
|
||||||
nextReconnectTime: relay.nextReconnectTime)
|
nextReconnectTime: relay.nextReconnectTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugPendingMessageQueueCount: Int {
|
public var debugPendingMessageQueueCount: Int {
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
defer { messageQueueLock.unlock() }
|
defer { messageQueueLock.unlock() }
|
||||||
return messageQueue.count
|
return messageQueue.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
public func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
||||||
pendingSubscriptions[relayUrl]?.count ?? 0
|
pendingSubscriptions[relayUrl]?.count ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func debugFlushMessageQueue() {
|
public func debugFlushMessageQueue() {
|
||||||
flushMessageQueue(for: nil)
|
flushMessageQueue(for: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset all relay connections
|
/// Reset all relay connections
|
||||||
func resetAllConnections() {
|
public 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 }
|
||||||
@@ -879,7 +796,7 @@ private enum ParsedInbound {
|
|||||||
case ok(eventId: String, success: Bool, reason: String)
|
case ok(eventId: String, success: Bool, reason: String)
|
||||||
case eose(subscriptionId: String)
|
case eose(subscriptionId: String)
|
||||||
case notice(String)
|
case notice(String)
|
||||||
|
|
||||||
init?(_ message: URLSessionWebSocketTask.Message) {
|
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||||
guard let data = message.data,
|
guard let data = message.data,
|
||||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||||
@@ -938,26 +855,26 @@ private extension URLSessionWebSocketTask.Message {
|
|||||||
|
|
||||||
// MARK: - Nostr Protocol Types
|
// MARK: - Nostr Protocol Types
|
||||||
|
|
||||||
enum NostrRequest: Encodable {
|
public 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)
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
public func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.unkeyedContainer()
|
var container = encoder.unkeyedContainer()
|
||||||
|
|
||||||
switch self {
|
switch self {
|
||||||
case .event(let event):
|
case .event(let event):
|
||||||
try container.encode("EVENT")
|
try container.encode("EVENT")
|
||||||
try container.encode(event)
|
try container.encode(event)
|
||||||
|
|
||||||
case .subscribe(let id, let filters):
|
case .subscribe(let id, let filters):
|
||||||
try container.encode("REQ")
|
try container.encode("REQ")
|
||||||
try container.encode(id)
|
try container.encode(id)
|
||||||
for filter in filters {
|
for filter in filters {
|
||||||
try container.encode(filter)
|
try container.encode(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .close(let id):
|
case .close(let id):
|
||||||
try container.encode("CLOSE")
|
try container.encode("CLOSE")
|
||||||
try container.encode(id)
|
try container.encode(id)
|
||||||
@@ -965,57 +882,51 @@ enum NostrRequest: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NostrFilter: Encodable {
|
public struct NostrFilter: Encodable {
|
||||||
var ids: [String]?
|
public var ids: [String]?
|
||||||
var authors: [String]?
|
public var authors: [String]?
|
||||||
var kinds: [Int]?
|
public var kinds: [Int]?
|
||||||
var since: Int?
|
public var since: Int?
|
||||||
var until: Int?
|
public var until: Int?
|
||||||
var limit: Int?
|
public var limit: Int?
|
||||||
|
|
||||||
// Tag filters - stored internally but encoded specially
|
|
||||||
fileprivate var tagFilters: [String: [String]]?
|
fileprivate var tagFilters: [String: [String]]?
|
||||||
|
|
||||||
init() {
|
public 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
public 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")) }
|
||||||
if let since = since { try container.encode(since, forKey: DynamicCodingKey(stringValue: "since")) }
|
if let since = since { try container.encode(since, forKey: DynamicCodingKey(stringValue: "since")) }
|
||||||
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)"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For NIP-17 gift wraps
|
// For NIP-17 gift wraps (limit matches TransportConfig.nostrRelayDefaultFetchLimit)
|
||||||
static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
|
public static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
|
||||||
var filter = NostrFilter()
|
var filter = NostrFilter()
|
||||||
filter.kinds = [1059] // Gift wrap kind
|
filter.kinds = [1059]
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
filter.tagFilters = ["p": [pubkey]]
|
filter.tagFilters = ["p": [pubkey]]
|
||||||
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
|
filter.limit = 100
|
||||||
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)
|
||||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
|
public 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()
|
||||||
@@ -1025,7 +936,7 @@ 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
|
||||||
static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
public 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()
|
||||||
@@ -1035,7 +946,7 @@ 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)
|
||||||
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
public 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()
|
||||||
@@ -1045,15 +956,14 @@ 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 }
|
||||||
|
|
||||||
init(stringValue: String) {
|
init(stringValue: String) {
|
||||||
self.stringValue = stringValue
|
self.stringValue = stringValue
|
||||||
}
|
}
|
||||||
|
|
||||||
init?(intValue: Int) {
|
init?(intValue: Int) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
-9
@@ -6,7 +6,6 @@ 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)
|
||||||
@@ -59,7 +58,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -70,28 +68,22 @@ 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)
|
||||||
@@ -132,4 +124,3 @@ private extension Data {
|
|||||||
replaceSubrange(offset..<(offset+4), with: bytes)
|
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import Nostr
|
||||||
|
|
||||||
|
@Suite("Bech32")
|
||||||
|
struct Bech32Tests {
|
||||||
|
@Test("Round-trip encode/decode")
|
||||||
|
func roundTrip() throws {
|
||||||
|
let original = Data([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe])
|
||||||
|
let encoded = try Bech32.encode(hrp: "test", data: original)
|
||||||
|
let decoded = try Bech32.decode(encoded)
|
||||||
|
#expect(decoded.hrp == "test")
|
||||||
|
#expect(decoded.data == original)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("HRP is preserved")
|
||||||
|
func hrpPreserved() throws {
|
||||||
|
let data = Data(repeating: 0x42, count: 32)
|
||||||
|
let encoded = try Bech32.encode(hrp: "npub", data: data)
|
||||||
|
#expect(encoded.hasPrefix("npub1"))
|
||||||
|
let decoded = try Bech32.decode(encoded)
|
||||||
|
#expect(decoded.hrp == "npub")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decoding invalid checksum throws")
|
||||||
|
func invalidChecksum() throws {
|
||||||
|
var encoded = try Bech32.encode(hrp: "test", data: Data([1, 2, 3]))
|
||||||
|
// Corrupt the last character
|
||||||
|
encoded = String(encoded.dropLast()) + (encoded.last == "q" ? "p" : "q")
|
||||||
|
#expect(throws: Bech32.Bech32Error.self) {
|
||||||
|
try Bech32.decode(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decoding string without separator throws")
|
||||||
|
func missingSeparator() {
|
||||||
|
#expect(throws: Bech32.Bech32Error.self) {
|
||||||
|
try Bech32.decode("noseparatorhere")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
import struct Foundation.Data
|
import struct Foundation.Data
|
||||||
@testable import bitchat
|
@testable import Nostr
|
||||||
|
|
||||||
struct XChaCha20Poly1305CompatTests {
|
struct XChaCha20Poly1305CompatTests {
|
||||||
|
|
||||||
Reference in New Issue
Block a user