Extract Nostr into a dedicated module

This commit is contained in:
islam
2026-04-14 20:23:16 +01:00
parent e8fb4a6333
commit fa45ee3aea
49 changed files with 778 additions and 714 deletions
+2
View File
@@ -23,6 +23,8 @@ jobs:
path: localPackages/BitFoundation
- name: Noise
path: localPackages/Noise
- name: Nostr
path: localPackages/Nostr
steps:
- name: Checkout code
+4 -1
View File
@@ -20,6 +20,7 @@ let package = Package(
.package(path: "localPackages/Noise"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"),
.package(path: "localPackages/Nostr"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
],
targets: [
@@ -30,6 +31,7 @@ let package = Package(
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "BitLogger", package: "BitLogger"),
.product(name: "Noise", package: "Noise"),
.product(name: "Nostr", package: "Nostr"),
.product(name: "Tor", package: "Arti")
],
path: "bitchat",
@@ -50,7 +52,8 @@ let package = Package(
name: "bitchatTests",
dependencies: [
"bitchat",
.product(name: "BitFoundation", package: "BitFoundation")
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "Nostr", package: "Nostr")
],
path: "bitchatTests",
exclude: [
+20
View File
@@ -12,6 +12,8 @@
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; };
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; };
A63180832F8103AB00B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180822F8103AB00B8B128 /* Nostr */; };
A63180852F8103B600B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180842F8103B600B8B128 /* Nostr */; };
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
@@ -160,6 +162,7 @@
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */,
3EE336D150427F736F32B56C /* P256K in Frameworks */,
A63180852F8103B600B8B128 /* Nostr in Frameworks */,
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
);
@@ -170,6 +173,7 @@
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
A63163B62F80CB2500B8B128 /* Noise in Frameworks */,
885BBED78092484A5B069461 /* P256K in Frameworks */,
A63180832F8103AB00B8B128 /* Nostr in Frameworks */,
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
);
@@ -233,6 +237,7 @@
A6E3EA802E7706A80032EA8A /* Tor */,
A6BCF9492F809550001CF9B9 /* BitFoundation */,
A63163B72F80CB2D00B8B128 /* Noise */,
A63180842F8103B600B8B128 /* Nostr */,
);
productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@@ -315,6 +320,7 @@
A6E3EA7E2E7706720032EA8A /* Tor */,
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
A63163B52F80CB2500B8B128 /* Noise */,
A63180822F8103AB00B8B128 /* Nostr */,
);
productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@@ -358,6 +364,7 @@
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */,
);
preferredProjectObjectVersion = 90;
projectDirPath = "";
@@ -927,6 +934,10 @@
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Noise;
};
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Nostr;
};
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitFoundation;
@@ -967,6 +978,15 @@
package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */;
productName = Noise;
};
A63180822F8103AB00B8B128 /* Nostr */ = {
isa = XCSwiftPackageProductDependency;
productName = Nostr;
};
A63180842F8103B600B8B128 /* Nostr */ = {
isa = XCSwiftPackageProductDependency;
package = A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */;
productName = Nostr;
};
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
productName = BitFoundation;
+4 -1
View File
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org>
//
import Nostr
import Tor
import SwiftUI
import BitFoundation
@@ -27,9 +28,11 @@ struct BitchatApp: App {
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif
private let idBridge = NostrIdentityBridge()
private let idBridge = NostrIdentityBridge(keychain: KeychainManager())
init() {
GeoRelayDirectory.setupShared(dependencies: .live())
NostrRelayManager.setupShared(dependencies: .live())
let keychain = KeychainManager()
let idBridge = self.idBridge
_chatViewModel = StateObject(
+118
View File
@@ -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
View File
@@ -1,5 +1,6 @@
import BitLogger
import BitFoundation
import Nostr
import Foundation
import CoreBluetooth
import Combine
+1
View File
@@ -6,6 +6,7 @@
// This is free and unencumbered software released into the public domain.
//
import Nostr
import Foundation
import BitFoundation
@@ -8,6 +8,7 @@
// This is free and unencumbered software released into the public domain.
//
import Nostr
import Foundation
import Combine
import BitLogger
@@ -74,7 +75,7 @@ final class GeohashPresenceService: ObservableObject {
]
private init() {
let idBridge = NostrIdentityBridge()
let idBridge = NostrIdentityBridge(keychain: KeychainManager())
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
+2 -1
View File
@@ -7,6 +7,7 @@
//
import BitLogger
import protocol Nostr.NostrKeychainStoring
import protocol Noise.SecureMemoryCleaner
import Foundation
import Security
@@ -52,7 +53,7 @@ enum KeychainSaveResult {
}
}
protocol KeychainManagerProtocol: SecureMemoryCleaner {
protocol KeychainManagerProtocol: SecureMemoryCleaner, NostrKeychainStoring {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
+2 -1
View File
@@ -1,4 +1,5 @@
import BitLogger
import Nostr
import Foundation
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
@@ -15,7 +16,7 @@ struct LocationNotesDependencies {
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date
private static let idBridge = NostrIdentityBridge()
private static let idBridge = NostrIdentityBridge(keychain: KeychainManager())
static let live = LocationNotesDependencies(
relayLookup: { geohash, count in
@@ -1,3 +1,4 @@
import Nostr
import Foundation
import BitLogger
import Combine
+1
View File
@@ -1,5 +1,6 @@
import BitLogger
import BitFoundation
import Nostr
import Foundation
import Combine
@@ -8,6 +8,7 @@
import BitLogger
import BitFoundation
import Nostr
import Foundation
import Combine
import SwiftUI
+2 -1
View File
@@ -79,6 +79,7 @@
import BitLogger
import BitFoundation
import Nostr
import Foundation
import SwiftUI
import Combine
@@ -1967,7 +1968,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
// Reinitialize Nostr relay manager with new identity
nostrRelayManager = NostrRelayManager()
nostrRelayManager = NostrRelayManager(dependencies: .live())
setupNostrMessageHandling()
nostrRelayManager?.connect()
}
@@ -5,6 +5,7 @@
// Geohash and Nostr logic for ChatViewModel
//
import Nostr
import Foundation
import Combine
import BitLogger
@@ -5,6 +5,7 @@
// Private chat and media transfer logic for ChatViewModel
//
import Nostr
import Foundation
import Combine
import BitLogger
@@ -5,6 +5,7 @@
// Created by Islam on 29/10/2025.
//
import Nostr
import SwiftUI
struct CommandSuggestionsView: View {
@@ -76,7 +77,7 @@ struct CommandSuggestionsView: View {
let keychain = KeychainManager()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: NostrIdentityBridge(),
idBridge: NostrIdentityBridge(keychain: keychain),
identityManager: SecureIdentityStateManager(keychain)
)
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org>
//
import Nostr
import SwiftUI
struct TextMessageView: View {
@@ -89,7 +90,7 @@ struct TextMessageView: View {
.environmentObject(
ChatViewModel(
keychain: keychain,
idBridge: NostrIdentityBridge(),
idBridge: NostrIdentityBridge(keychain: keychain),
identityManager: SecureIdentityStateManager(keychain)
)
)
+1
View File
@@ -1,3 +1,4 @@
import Nostr
import SwiftUI
import CoreImage
import CoreImage.CIFilterBuiltins
+1
View File
@@ -6,6 +6,7 @@
//
import Testing
import Nostr
import Foundation
import CoreBluetooth
import BitFoundation
@@ -6,6 +6,7 @@
//
import Testing
import Nostr
import Foundation
import BitFoundation
@testable import bitchat
@@ -6,6 +6,7 @@
//
import Testing
import Nostr
import Foundation
import Combine
#if os(iOS)
@@ -7,6 +7,7 @@
//
import Testing
import Nostr
import Foundation
import BitFoundation
@testable import bitchat
+1
View File
@@ -7,6 +7,7 @@
//
import Testing
import Nostr
import Foundation
import BitFoundation
@testable import bitchat
+1
View File
@@ -6,6 +6,7 @@
//
import Testing
import Nostr
import Foundation
@testable import bitchat
+1
View File
@@ -1,4 +1,5 @@
import Foundation
import Nostr
import Testing
import BitFoundation
@testable import bitchat
@@ -7,6 +7,7 @@
//
import Testing
import Nostr
import Foundation
import CoreBluetooth
import BitFoundation
+1
View File
@@ -7,6 +7,7 @@
//
import Testing
import Nostr
import Foundation
import Combine
@testable import bitchat
+1
View File
@@ -1,4 +1,5 @@
import Testing
import Nostr
import Foundation
@testable import bitchat
@@ -1,4 +1,5 @@
import Testing
import Nostr
import Foundation
@testable import bitchat
@@ -1,4 +1,5 @@
import Foundation
import Nostr
import Tor
import XCTest
@testable import bitchat
@@ -303,6 +304,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
retrySleep: { delay in
await retryRecorder.record(delay)
},
torReadyNotificationName: .TorDidBecomeReady,
activeNotificationName: activeNotificationName,
autoStart: autoStart
)
+1
View File
@@ -5,6 +5,7 @@
// Tests for NIP-17 gift-wrapped private messages
//
import Nostr
import Testing
import CryptoKit
import Foundation
@@ -1,4 +1,5 @@
import Combine
import Nostr
import XCTest
@testable import bitchat
@@ -1,3 +1,4 @@
import Nostr
import Combine
import XCTest
@testable import bitchat
@@ -697,7 +698,7 @@ final class NostrRelayManagerTests: XCTestCase {
}
private func makeContext(
permission: LocationChannelManager.PermissionState,
permission: LocationPermissionState,
favorites: Set<Data> = [],
activationAllowed: Bool = true,
userTorEnabled: Bool = false,
@@ -705,7 +706,7 @@ final class NostrRelayManagerTests: XCTestCase {
torIsReady: Bool = true,
torIsForeground: Bool = true
) -> RelayManagerTestContext {
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
let permissionSubject = CurrentValueSubject<LocationPermissionState, Never>(permission)
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
let sessionFactory = MockRelaySessionFactory()
let scheduler = MockRelayScheduler()
@@ -782,7 +783,7 @@ final class NostrRelayManagerTests: XCTestCase {
@MainActor
private struct RelayManagerTestContext {
let manager: NostrRelayManager
let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>
let permissionSubject: CurrentValueSubject<LocationPermissionState, Never>
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
let sessionFactory: MockRelaySessionFactory
let scheduler: MockRelayScheduler
@@ -7,6 +7,7 @@
//
import Foundation
import Nostr
import Testing
import BitFoundation
@testable import bitchat
@@ -6,6 +6,7 @@
//
import Testing
import Nostr
import Foundation
import BitFoundation
@testable import bitchat
@@ -6,6 +6,7 @@
//
import Testing
import Nostr
import Foundation
import BitFoundation
@testable import bitchat
+1
View File
@@ -1,4 +1,5 @@
import Testing
import Nostr
import Foundation
import SwiftUI
import CoreGraphics
+15
View File
@@ -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
}
+37
View File
@@ -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
/// Bech32 encoding for Nostr (minimal implementation)
enum Bech32 {
public enum Bech32 {
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
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 checksum = createChecksum(hrp: hrp, values: values)
let combined = values + checksum
return hrp + "1" + combined.map {
return hrp + "1" + combined.map {
let index = charset.index(charset.startIndex, offsetBy: Int($0))
return String(charset[index])
}.joined()
}
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
// Find the last occurrence of '1'
public static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
throw Bech32Error.invalidFormat
}
let hrp = String(bech32String[..<separatorIndex])
// Validate HRP contains only ASCII characters
for char in hrp {
guard char.asciiValue != nil else {
throw Bech32Error.invalidCharacter
}
}
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
// Convert characters to values
var values = [UInt8]()
for char in dataString {
@@ -41,84 +40,84 @@ enum Bech32 {
}
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
}
// Verify checksum
guard values.count >= 6 else {
throw Bech32Error.invalidChecksum
}
let payloadValues = Array(values.dropLast(6))
let checksum = Array(values.suffix(6))
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
guard checksum == expectedChecksum else {
throw Bech32Error.invalidChecksum
}
// Convert back to bytes
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
return (hrp: hrp, data: Data(bytes))
}
enum Bech32Error: Error {
case invalidFormat
case invalidCharacter
case invalidChecksum
}
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
var acc = 0
var bits = 0
var result = [UInt8]()
let maxv = (1 << to) - 1
for value in data {
acc = (acc << from) | Int(value)
bits += from
while bits >= to {
bits -= to
result.append(UInt8((acc >> bits) & maxv))
}
}
if pad && bits > 0 {
result.append(UInt8((acc << (to - bits)) & maxv))
}
return result
}
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
let polymod = polymod(checksumValues) ^ 1
var checksum = [UInt8]()
for i in 0..<6 {
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
}
return checksum
}
private static func hrpExpand(_ hrp: String) -> [UInt8] {
var result = [UInt8]()
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
return []
}
result.append(UInt8(asciiValue >> 5))
}
result.append(0)
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
return []
}
result.append(UInt8(asciiValue & 31))
}
return result
}
private static func polymod(_ values: [UInt8]) -> Int {
var chk = 1
for value in values {
@@ -1,101 +1,72 @@
import BitLogger
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.
struct GeoRelayDirectoryDependencies {
var userDefaults: UserDefaults
var notificationCenter: NotificationCenter
var now: () -> Date
var remoteURL: URL
var fetchInterval: TimeInterval
var refreshCheckInterval: TimeInterval
var retryInitialSeconds: TimeInterval
var retryMaxSeconds: TimeInterval
var awaitTorReady: @Sendable () async -> Bool
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
var readData: (URL) -> Data?
var writeData: (Data, URL) throws -> Void
var cacheURL: () -> URL?
var bundledCSVURLs: () -> [URL]
var currentDirectoryPath: () -> String?
var retrySleep: (TimeInterval) async -> Void
var activeNotificationName: Notification.Name?
var autoStart: Bool
}
public struct GeoRelayDirectoryDependencies {
public var userDefaults: UserDefaults
public var notificationCenter: NotificationCenter
public var now: () -> Date
public var remoteURL: URL
public var fetchInterval: TimeInterval
public var refreshCheckInterval: TimeInterval
public var retryInitialSeconds: TimeInterval
public var retryMaxSeconds: TimeInterval
public var awaitTorReady: @Sendable () async -> Bool
public var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
public var readData: (URL) -> Data?
public var writeData: (Data, URL) throws -> Void
public var cacheURL: () -> URL?
public var bundledCSVURLs: () -> [URL]
public var currentDirectoryPath: () -> String?
public var retrySleep: (TimeInterval) async -> Void
public var torReadyNotificationName: Notification.Name?
public var activeNotificationName: Notification.Name?
public var autoStart: Bool
private 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)
},
activeNotificationName: activeNotificationName,
autoStart: true
)
public init(
userDefaults: UserDefaults,
notificationCenter: NotificationCenter,
now: @escaping () -> Date,
remoteURL: URL,
fetchInterval: TimeInterval,
refreshCheckInterval: TimeInterval,
retryInitialSeconds: TimeInterval,
retryMaxSeconds: TimeInterval,
awaitTorReady: @Sendable @escaping () async -> Bool,
makeFetchData: @MainActor @Sendable @escaping () -> (@Sendable (URLRequest) async throws -> Data),
readData: @escaping (URL) -> Data?,
writeData: @escaping (Data, URL) throws -> Void,
cacheURL: @escaping () -> URL?,
bundledCSVURLs: @escaping () -> [URL],
currentDirectoryPath: @escaping () -> String?,
retrySleep: @escaping (TimeInterval) async -> Void,
torReadyNotificationName: Notification.Name?,
activeNotificationName: Notification.Name?,
autoStart: Bool
) {
self.userDefaults = userDefaults
self.notificationCenter = notificationCenter
self.now = now
self.remoteURL = remoteURL
self.fetchInterval = fetchInterval
self.refreshCheckInterval = refreshCheckInterval
self.retryInitialSeconds = retryInitialSeconds
self.retryMaxSeconds = retryMaxSeconds
self.awaitTorReady = awaitTorReady
self.makeFetchData = makeFetchData
self.readData = readData
self.writeData = writeData
self.cacheURL = cacheURL
self.bundledCSVURLs = bundledCSVURLs
self.currentDirectoryPath = currentDirectoryPath
self.retrySleep = retrySleep
self.torReadyNotificationName = torReadyNotificationName
self.activeNotificationName = activeNotificationName
self.autoStart = autoStart
}
}
@MainActor
final class GeoRelayDirectory {
public final class GeoRelayDirectory {
private final class CleanupState {
let notificationCenter: NotificationCenter
var observers: [NSObjectProtocol] = []
@@ -113,10 +84,16 @@ final class GeoRelayDirectory {
}
}
struct Entry: Hashable, Sendable {
let host: String
let lat: Double
let lon: Double
public struct Entry: Hashable, Sendable {
public let host: String
public let lat: 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 {
@@ -126,9 +103,13 @@ final class GeoRelayDirectory {
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 dependencies: GeoRelayDirectoryDependencies
private let cleanupState: CleanupState
@@ -136,18 +117,7 @@ final class GeoRelayDirectory {
private var retryAttempt: Int = 0
private var isFetching: Bool = false
private init() {
self.dependencies = .live()
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
entries = loadLocalEntries()
if dependencies.autoStart {
registerObservers()
startRefreshTimer()
prefetchIfNeeded()
}
}
internal init(dependencies: GeoRelayDirectoryDependencies) {
public init(dependencies: GeoRelayDirectoryDependencies) {
self.dependencies = dependencies
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
entries = loadLocalEntries()
@@ -159,13 +129,13 @@ final class GeoRelayDirectory {
}
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
let center = Geohash.decodeCenter(geohash)
public func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
let center = decodeGeohashCenter(geohash)
return closestRelays(toLat: center.lat, lon: center.lon, count: count)
}
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
public func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty, count > 0 else { return [] }
if entries.count <= count {
@@ -195,7 +165,7 @@ final class GeoRelayDirectory {
}
// MARK: - Remote Fetch
func prefetchIfNeeded(force: Bool = false) {
public func prefetchIfNeeded(force: Bool = false) {
guard !isFetching else { return }
let now = dependencies.now()
@@ -205,7 +175,6 @@ final class GeoRelayDirectory {
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
} else if last != .distantPast,
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
// Skip forced fetches if we just refreshed moments ago.
return
}
@@ -343,7 +312,6 @@ final class GeoRelayDirectory {
// MARK: - Loading
private func loadLocalEntries() -> [Entry] {
// Prefer cached file if present
if let cache = dependencies.cacheURL(),
let data = dependencies.readData(cache),
let text = String(data: data, encoding: .utf8) {
@@ -351,7 +319,6 @@ final class GeoRelayDirectory {
if !arr.isEmpty { return arr }
}
// Try bundled resource(s)
let bundleCandidates = dependencies.bundledCSVURLs()
for url in bundleCandidates {
@@ -362,7 +329,6 @@ final class GeoRelayDirectory {
}
}
// Try filesystem path (development/test)
if let cwd = dependencies.currentDirectoryPath(),
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let text = String(data: data, encoding: .utf8) {
@@ -373,7 +339,7 @@ final class GeoRelayDirectory {
return []
}
nonisolated static func parseCSV(_ text: String) -> [Entry] {
nonisolated public static func parseCSV(_ text: String) -> [Entry] {
var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline })
for (idx, raw) in lines.enumerated() {
@@ -397,17 +363,19 @@ final class GeoRelayDirectory {
private func registerObservers() {
let center = dependencies.notificationCenter
let torReady = center.addObserver(
forName: .TorDidBecomeReady,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded(force: true)
if let torReadyName = dependencies.torReadyNotificationName {
let torReady = center.addObserver(
forName: torReadyName,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded(force: true)
}
}
cleanupState.observers.append(torReady)
}
cleanupState.observers.append(torReady)
if let activeNotificationName = dependencies.activeNotificationName {
let didBecomeActive = center.addObserver(
@@ -439,17 +407,49 @@ final class GeoRelayDirectory {
RunLoop.main.add(timer, forMode: .common)
}
var debugRetryAttempt: Int { retryAttempt }
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
var debugObserverCount: Int { cleanupState.observers.count }
public var debugRetryAttempt: Int { retryAttempt }
public var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
public var debugObserverCount: Int { cleanupState.observers.count }
}
// MARK: - Distance
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 dLon = (lon2 - lon1) * .pi / 180
let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return r * c
}
// 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)
}
@@ -2,59 +2,56 @@ import Foundation
import P256K
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
struct NostrIdentity: Codable {
let privateKey: Data
let publicKey: Data
let npub: String // Bech32-encoded public key
let createdAt: Date
/// Memberwise initializer
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
public struct NostrIdentity: Codable, Sendable {
public let privateKey: Data
public let publicKey: Data
public let npub: String // Bech32-encoded public key
public let createdAt: Date
public init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
self.privateKey = privateKey
self.publicKey = publicKey
self.npub = npub
self.createdAt = createdAt
}
/// Generate a new Nostr identity
static func generate() throws -> NostrIdentity {
// Generate Schnorr key for Nostr
public static func generate() throws -> NostrIdentity {
let schnorrKey = try P256K.Schnorr.PrivateKey()
let xOnlyPubkey = Data(schnorrKey.xonly.bytes)
let npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
return NostrIdentity(
privateKey: schnorrKey.dataRepresentation,
publicKey: xOnlyPubkey, // Store x-only public key
publicKey: xOnlyPubkey,
npub: npub,
createdAt: Date()
)
}
/// Initialize from existing private key data
init(privateKeyData: Data) throws {
public init(privateKeyData: Data) throws {
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyData)
let xOnlyPubkey = Data(schnorrKey.xonly.bytes)
self.privateKey = privateKeyData
self.publicKey = xOnlyPubkey
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
self.createdAt = Date()
}
/// 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)
}
/// 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)
}
/// Get hex-encoded public key (for Nostr events)
var publicKeyHex: String {
// Public key is already stored as x-only (32 bytes)
return publicKey.hexEncodedString()
public var publicKeyHex: String {
publicKey.hexEncodedString()
}
}
@@ -1,51 +1,54 @@
import Foundation
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
final class NostrIdentityBridge {
public final class NostrIdentityBridge {
private let keychainService = "chat.bitchat.nostr"
private let currentIdentityKey = "nostr-current-identity"
private let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private var deviceSeedCache: Data?
private let deviceSeedCache: NSLock = NSLock()
private var _deviceSeedCacheValue: Data?
// 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 keychain: KeychainManagerProtocol
private let keychain: any NostrKeychainStoring
init(keychain: KeychainManagerProtocol = KeychainManager()) {
public init(keychain: any NostrKeychainStoring) {
self.keychain = keychain
}
/// Get or create the current Nostr identity
func getCurrentNostrIdentity() throws -> NostrIdentity? {
// Check if we already have a Nostr identity
public func getCurrentNostrIdentity() throws -> NostrIdentity? {
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
return identity
}
// Generate new Nostr identity
let nostrIdentity = try NostrIdentity.generate()
// Store it
let data = try JSONEncoder().encode(nostrIdentity)
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
return nostrIdentity
}
/// 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())"
if let data = nostrPubkey.data(using: .utf8) {
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
}
}
/// 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())"
guard let data = keychain.load(key: key, service: keychainService),
let pubkey = String(data: data, encoding: .utf8) else {
@@ -53,9 +56,9 @@ final class NostrIdentityBridge {
}
return pubkey
}
/// Clear all Nostr identity associations and current identity
func clearAllAssociations() {
public func clearAllAssociations() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
@@ -76,42 +79,45 @@ final class NostrIdentityBridge {
}
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)
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain.
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) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = existing
deviceSeedCache.lock()
_deviceSeedCacheValue = existing
deviceSeedCache.unlock()
return existing
}
var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in
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)
deviceSeedCache = seed
deviceSeedCache.lock()
_deviceSeedCacheValue = seed
deviceSeedCache.unlock()
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
public func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
cacheLock.lock()
if let cached = derivedIdentityCache[geohash] {
if let cached = _derivedIdentityCache[geohash] {
cacheLock.unlock()
return cached
}
@@ -132,24 +138,21 @@ final class NostrIdentityBridge {
return Data(code)
}
// Try a few iterations to ensure a valid key can be formed
for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) {
// Cache the result
cacheLock.lock()
derivedIdentityCache[geohash] = identity
_derivedIdentityCache[geohash] = identity
cacheLock.unlock()
return identity
}
}
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash()
let identity = try NostrIdentity(privateKeyData: fallback)
// Cache the result
cacheLock.lock()
derivedIdentityCache[geohash] = identity
_derivedIdentityCache[geohash] = identity
cacheLock.unlock()
return identity
@@ -1,17 +1,15 @@
import BitFoundation
import BitLogger
import Foundation
import CryptoKit
import P256K
import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target
/// NIP-17 Protocol Implementation for Private Direct Messages
struct NostrProtocol {
public struct NostrProtocol {
/// Nostr event kinds
enum EventKind: Int {
public enum EventKind: Int, Sendable {
case metadata = 0
case textNote = 1
case dm = 14 // NIP-17 DM rumor kind
@@ -20,88 +18,71 @@ struct NostrProtocol {
case ephemeralEvent = 20000
case geohashPresence = 20001
}
/// Create a NIP-17 private message
static func createPrivateMessage(
public static func createPrivateMessage(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
// Creating private message
// 1. Create the rumor (unsigned event)
let rumor = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm, // NIP-17: DM rumor kind 14
kind: .dm,
tags: [],
content: content
)
// 2. Create ephemeral key for this message
let ephemeralKey = try P256K.Schnorr.PrivateKey()
// Created ephemeral key for seal
// 3. Seal the rumor (encrypt to recipient)
let sealedEvent = try createSeal(
rumor: rumor,
recipientPubkey: recipientPubkey,
senderKey: ephemeralKey
)
// 4. Gift wrap the sealed event (encrypt to recipient again)
let giftWrap = try createGiftWrap(
seal: sealedEvent,
recipientPubkey: recipientPubkey,
senderKey: ephemeralKey
)
// Created gift wrap
return giftWrap
}
/// Decrypt a received NIP-17 message
/// 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,
recipientIdentity: NostrIdentity
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
// Starting decryption
// 1. Unwrap the gift wrap
let seal: NostrEvent
do {
seal = try unwrapGiftWrap(
giftWrap: giftWrap,
recipientKey: recipientIdentity.schnorrSigningKey()
)
// Successfully unwrapped gift wrap
} catch {
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
throw error
}
// 2. Open the seal
let rumor: NostrEvent
do {
rumor = try openSeal(
seal: seal,
recipientKey: recipientIdentity.schnorrSigningKey()
)
// Successfully opened seal
} catch {
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
throw error
}
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
}
/// Create a geohash-scoped ephemeral public message (kind 20000)
static func createEphemeralGeohashEvent(
public static func createEphemeralGeohashEvent(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
@@ -128,7 +109,7 @@ struct NostrProtocol {
/// Create a geohash presence heartbeat (kind 20001)
/// Must contain empty content and NO nickname tag
static func createGeohashPresenceEvent(
public static func createGeohashPresenceEvent(
geohash: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
@@ -145,7 +126,7 @@ struct NostrProtocol {
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
static func createGeohashTextNote(
public static func createGeohashTextNote(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
@@ -165,22 +146,21 @@ struct NostrProtocol {
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
// MARK: - Private Methods
private static func createSeal(
rumor: NostrEvent,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
let rumorJSON = try rumor.jsonString()
let encrypted = try encrypt(
plaintext: rumorJSON,
recipientPubkey: recipientPubkey,
senderKey: senderKey
)
let seal = NostrEvent(
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedTimestamp(),
@@ -188,130 +168,108 @@ struct NostrProtocol {
tags: [],
content: encrypted
)
// Sign the seal with the sender's Schnorr private key
return try seal.sign(with: senderKey)
}
private static func createGiftWrap(
seal: NostrEvent,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
senderKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
let sealJSON = try seal.jsonString()
// Create new ephemeral key for gift wrap
let wrapKey = try P256K.Schnorr.PrivateKey()
// Creating gift wrap with ephemeral key
// Encrypt the seal with the new ephemeral key (not the seal's key)
let encrypted = try encrypt(
plaintext: sealJSON,
recipientPubkey: recipientPubkey,
senderKey: wrapKey // Use the gift wrap ephemeral key
senderKey: wrapKey
)
let giftWrap = NostrEvent(
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedTimestamp(),
kind: .giftWrap,
tags: [["p", recipientPubkey]], // Tag recipient
tags: [["p", recipientPubkey]],
content: encrypted
)
// Sign the gift wrap with the wrap Schnorr private key
return try giftWrap.sign(with: wrapKey)
}
private static func unwrapGiftWrap(
giftWrap: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
// Unwrapping gift wrap
let decrypted = try decrypt(
ciphertext: giftWrap.content,
senderPubkey: giftWrap.pubkey,
recipientKey: recipientKey
)
guard let data = decrypted.data(using: .utf8),
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NostrError.invalidEvent
}
let seal = try NostrEvent(from: sealDict)
// Unwrapped seal
return seal
return try NostrEvent(from: sealDict)
}
private static func openSeal(
seal: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
let decrypted = try decrypt(
ciphertext: seal.content,
senderPubkey: seal.pubkey,
recipientKey: recipientKey
)
guard let data = decrypted.data(using: .utf8),
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NostrError.invalidEvent
}
return try NostrEvent(from: rumorDict)
}
// MARK: - Encryption (NIP-44 v2)
private static func encrypt(
plaintext: String,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey
) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey
}
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret
let sharedSecret = try deriveSharedSecret(
privateKey: senderKey,
publicKey: recipientPubkeyData
)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
// v2: base64url(nonce24 || ciphertext || tag)
var combined = Data()
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + base64URLEncode(combined)
}
private static func decrypt(
ciphertext: String,
senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> String {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard let data = base64URLDecode(encoded),
@@ -325,7 +283,6 @@ struct NostrProtocol {
let tag = rest.suffix(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 {
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
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 {
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
@@ -351,32 +307,23 @@ struct NostrProtocol {
return String(data: pt, encoding: .utf8) ?? ""
}
}
private static func deriveSharedSecret(
privateKey: P256K.Schnorr.PrivateKey,
publicKey: Data
) throws -> Data {
// Deriving shared secret
// Convert Schnorr private key to KeyAgreement private key
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
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()
if publicKey.count == 32 { // X-only key, need to add prefix
// For x-only keys in Nostr/Bitcoin, we need to try both possible Y coordinates
// First try with even Y (0x02 prefix)
if publicKey.count == 32 {
fullPublicKey.append(0x02)
fullPublicKey.append(publicKey)
// Trying with even Y coordinate
} else {
fullPublicKey = publicKey
}
// Try to create public key, if it fails with even Y, try odd Y
let keyAgreementPublicKey: P256K.KeyAgreement.PublicKey
do {
keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
@@ -385,8 +332,6 @@ struct NostrProtocol {
)
} catch {
if publicKey.count == 32 {
// Try with odd Y (0x03 prefix)
// Even Y failed, trying odd Y
fullPublicKey = Data()
fullPublicKey.append(0x03)
fullPublicKey.append(publicKey)
@@ -398,85 +343,32 @@ struct NostrProtocol {
throw error
}
}
// Perform ECDH
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
with: keyAgreementPublicKey,
format: .compressed
)
// Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData
return sharedSecret.withUnsafeBytes { Data($0) }
}
// 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 {
// Add random offset to current time for privacy
// This prevents timing correlation attacks while the actual message timestamp
// is preserved in the encrypted rumor
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes
let now = Date()
let randomized = now.addingTimeInterval(offset)
// Log with explicit UTC and local time for debugging
let formatter = DateFormatter()
//
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.timeZone = TimeZone.current
// Timestamp randomized for privacy
return randomized
let offset = TimeInterval.random(in: -900...900)
return Date().addingTimeInterval(offset)
}
}
/// Nostr Event structure
struct NostrEvent: Codable {
var id: String
let pubkey: String
let created_at: Int
let kind: Int
let tags: [[String]]
let content: String
var sig: String?
init(
public struct NostrEvent: Codable, Sendable {
public var id: String
public let pubkey: String
public let created_at: Int
public let kind: Int
public let tags: [[String]]
public let content: String
public var sig: String?
public init(
pubkey: String,
createdAt: Date,
kind: NostrProtocol.EventKind,
@@ -489,10 +381,10 @@ struct NostrEvent: Codable {
self.tags = tags
self.content = content
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,
let createdAt = dict["created_at"] as? Int,
let kind = dict["kind"] as? Int,
@@ -500,7 +392,7 @@ struct NostrEvent: Codable {
let content = dict["content"] as? String else {
throw NostrError.invalidEvent
}
self.id = dict["id"] as? String ?? ""
self.pubkey = pubkey
self.created_at = createdAt
@@ -509,20 +401,19 @@ struct NostrEvent: Codable {
self.content = content
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()
// Sign with Schnorr (BIP-340)
var messageBytes = [UInt8](eventIdHash)
var auxRand = [UInt8](repeating: 0, count: 32)
_ = auxRand.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
var signed = self
signed.id = eventId
signed.sig = signatureHex
@@ -531,7 +422,7 @@ struct NostrEvent: Codable {
/// 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.
func isValidSignature() -> Bool {
public func isValidSignature() -> Bool {
guard let sig = sig,
let sigData = Data(hexString: sig),
let pubData = Data(hexString: pubkey),
@@ -548,7 +439,7 @@ struct NostrEvent: Codable {
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
return xonly.isValid(signature, for: &messageBytes)
}
private func calculateEventId() throws -> (String, Data) {
let serialized = [
0,
@@ -558,12 +449,12 @@ struct NostrEvent: Codable {
tags,
content
] as [Any]
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
return (data.sha256Fingerprint(), data.sha256Hash())
}
func jsonString() throws -> String {
public func jsonString() throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
let data = try encoder.encode(self)
@@ -571,7 +462,7 @@ struct NostrEvent: Codable {
}
}
enum NostrError: Error {
public enum NostrError: Error, Sendable {
case invalidPublicKey
case invalidPrivateKey
case invalidEvent
@@ -2,9 +2,8 @@ import BitLogger
import Foundation
import Network
import Combine
import Tor
protocol NostrRelayConnectionProtocol: AnyObject {
public protocol NostrRelayConnectionProtocol: AnyObject {
func resume()
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)
@@ -12,7 +11,7 @@ protocol NostrRelayConnectionProtocol: AnyObject {
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
}
protocol NostrRelaySessionProtocol {
public protocol NostrRelaySessionProtocol {
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
}
@@ -52,75 +51,91 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
}
}
struct NostrRelayManagerDependencies {
var activationAllowed: () -> Bool
var userTorEnabled: () -> Bool
var hasMutualFavorites: () -> Bool
var hasLocationPermission: () -> Bool
var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
var torEnforced: () -> Bool
var torIsReady: () -> Bool
var torIsForeground: () -> Bool
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
var makeSession: () -> NostrRelaySessionProtocol
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
var now: () -> Date
public enum LocationPermissionState: Equatable, Sendable {
case notDetermined
case authorized
case denied
}
private 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.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: { URLSessionAdapter(base: TorURLSession.shared.session) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
now: Date.init
)
public struct NostrRelayManagerDependencies {
public var activationAllowed: () -> Bool
public var userTorEnabled: () -> Bool
public var hasMutualFavorites: () -> Bool
public var hasLocationPermission: () -> Bool
public var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
public var locationPermissionPublisher: AnyPublisher<LocationPermissionState, Never>
public var torEnforced: () -> Bool
public var torIsReady: () -> Bool
public var torIsForeground: () -> Bool
public var awaitTorReady: (@escaping (Bool) -> Void) -> Void
public var makeSession: () -> NostrRelaySessionProtocol
public var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
public var now: () -> Date
public init(
activationAllowed: @escaping () -> Bool,
userTorEnabled: @escaping () -> Bool,
hasMutualFavorites: @escaping () -> Bool,
hasLocationPermission: @escaping () -> Bool,
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
locationPermissionPublisher: AnyPublisher<LocationPermissionState, Never>,
torEnforced: @escaping () -> Bool,
torIsReady: @escaping () -> Bool,
torIsForeground: @escaping () -> Bool,
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
@MainActor
final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager()
public final class NostrRelayManager: ObservableObject {
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
private(set) static var pendingGiftWrapIDs = Set<String>()
static func registerPendingGiftWrap(id: String) {
private(set) public static var pendingGiftWrapIDs = Set<String>()
public static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id)
}
struct Relay: Identifiable {
let id = UUID()
let url: String
var isConnected: Bool = false
var lastError: Error?
var lastConnectedAt: Date?
var messagesSent: Int = 0
var messagesReceived: Int = 0
var reconnectAttempts: Int = 0
var lastDisconnectedAt: Date?
var nextReconnectTime: Date?
public struct Relay: Identifiable {
public let id = UUID()
public let url: String
public var isConnected: Bool = false
public var lastError: Error?
public var lastConnectedAt: Date?
public var messagesSent: Int = 0
public var messagesReceived: Int = 0
public var reconnectAttempts: Int = 0
public var lastDisconnectedAt: Date?
public var nextReconnectTime: Date?
}
// Default relay list (can be customized)
private static let defaultRelays = [
"wss://relay.damus.io",
@@ -128,35 +143,30 @@ final class NostrRelayManager: ObservableObject {
"wss://relay.primal.net",
"wss://offchain.pub",
"wss://nostr21.com"
// For local testing, you can add: "ws://localhost:8080"
]
private static let defaultRelaySet = Set(defaultRelays)
@Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false
@Published private(set) public var relays: [Relay] = []
@Published private(set) public var isConnected = false
private let dependencies: NostrRelayManagerDependencies
private var allowDefaultRelays: Bool = false
private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false
private var connections: [String: NostrRelayConnectionProtocol] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
private var subscriptions: [String: Set<String>] = [:]
private var pendingSubscriptions: [String: [String: String]] = [:]
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 cancellables = Set<AnyCancellable>()
// Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker {
var pendingRelays: Set<String>
var callback: () -> Void
var timer: Timer?
}
private var eoseTrackers: [String: EOSETracker] = [:]
// Message queue for reliability
// Pending sends held only for relays that are not yet connected.
private struct PendingSend {
var event: NostrEvent
var pendingRelays: Set<String>
@@ -165,22 +175,20 @@ final class NostrRelayManager: ObservableObject {
private let messageQueueLock = NSLock()
private let encoder = JSONEncoder()
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
// Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
// Exponential backoff configuration (matches TransportConfig values)
private let initialBackoffInterval: TimeInterval = 1.0
private let maxBackoffInterval: TimeInterval = 300.0
private let backoffMultiplier: Double = 2.0
private let maxReconnectAttempts = 10
private var connectionGeneration: Int = 0
init() {
self.dependencies = .live()
public 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)
@@ -202,39 +210,10 @@ final class NostrRelayManager: ObservableObject {
.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
func connect() {
// Global network policy gate
public func connect() {
guard dependencies.activationAllowed() else { return }
if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if !ready {
@@ -253,28 +232,26 @@ final class NostrRelayManager: ObservableObject {
}
}
}
/// Disconnect from all relays
func disconnect() {
public func disconnect() {
connectionGeneration &+= 1
for (_, task) in connections {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
// Clear known subscriptions and any queued subs since connections are gone
subscriptions.removeAll()
pendingSubscriptions.removeAll()
updateConnectionStatus()
}
/// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) {
// Global network policy gate
public func ensureConnections(to relayUrls: [String]) {
guard dependencies.activationAllowed() else { return }
let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer until Tor is fully ready; avoid queuing connection attempts early
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
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)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate
public func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
guard dependencies.activationAllowed() else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer sends until Tor is ready to avoid premature queueing
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if ready { self.sendEvent(event, to: relayUrls) }
@@ -308,7 +283,6 @@ final class NostrRelayManager: ObservableObject {
guard !targetRelays.isEmpty else { return }
ensureConnections(to: targetRelays)
// Attempt immediate send to relays with active connections; queue the rest
var stillPending = Set<String>()
for relayUrl in targetRelays {
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) {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
guard !messageQueue.isEmpty else { return }
if let target = relayUrl {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connections[target] {
@@ -344,7 +316,6 @@ final class NostrRelayManager: ObservableObject {
}
}
} else {
// Flush for any relays that now have connections
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
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.
func subscribe(
public func subscribe(
filter: NostrFilter,
id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil
) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = dependencies.now()
if messageHandlers[id] != nil {
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
@@ -381,7 +350,6 @@ final class NostrRelayManager: ObservableObject {
}
subscribeCoalesce[id] = now
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer subscription setup until Tor is ready; avoid queuing subs early
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if ready {
@@ -391,23 +359,19 @@ final class NostrRelayManager: ObservableObject {
return
}
messageHandlers[id] = handler
let req = NostrRequest.subscribe(id: id, filters: [filter])
do {
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)
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 candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
let urls = allowedRelayList(from: candidateUrls)
// Always queue subscriptions; sending happens when a relay reports connected
let existingSet = Set(relays.map { $0.url })
for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url))
@@ -417,13 +381,11 @@ final class NostrRelayManager: ObservableObject {
map[id] = messageString
self.pendingSubscriptions[url] = map
}
// Initialize EOSE tracking if requested
if let onEOSE = onEOSE {
if urls.isEmpty {
onEOSE()
} else {
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
Task { @MainActor in
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)
// Ensure we actually have sockets opening to these relays so queued REQs can flush
ensureConnections(to: urls)
// If some targets are already connected, flush immediately for them
for url in urls {
if let r = relays.first(where: { $0.url == url }), r.isConnected {
flushPendingSubscriptions(for: url)
@@ -500,56 +460,46 @@ final class NostrRelayManager: ObservableObject {
}
return result
}
/// Unsubscribe from a subscription
func unsubscribe(id: String) {
public func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id)
let req = NostrRequest.close(id: id)
let message = try? encoder.encode(req)
guard let messageData = message,
let messageString = String(data: messageData, encoding: .utf8) else { return }
// Send unsubscribe to all relays
for (relayUrl, connection) in connections {
if subscriptions[relayUrl]?.contains(id) == true {
subscriptions[relayUrl]?.remove(id)
connection.send(.string(messageString)) { _ in
// Local state is cleared before sending so callers can re-subscribe immediately.
}
connection.send(.string(messageString)) { _ in }
}
}
}
// MARK: - Private Methods
private func connectToRelay(_ urlString: String) {
// Global network policy gate
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)
return
return
}
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
return
}
// Skip if we already have a connection object
if connections[urlString] != nil {
return
}
if isPermanentlyFailed(urlString) {
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() {
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
@@ -558,35 +508,30 @@ final class NostrRelayManager: ObservableObject {
}
return
}
let session = dependencies.makeSession()
let task = session.webSocketTask(with: url)
connections[urlString] = task
task.resume()
// Start receiving messages
receiveMessage(from: task, relayUrl: urlString)
// Send initial ping to verify connection
task.sendPing { [weak self] error in
DispatchQueue.main.async {
if error == nil {
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
self?.updateRelayStatus(urlString, isConnected: true)
// Flush any pending subscriptions for this relay
self?.flushPendingSubscriptions(for: urlString)
} else {
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
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))
}
}
}
}
/// Send any queued subscriptions for a relay that just connected.
private func flushPendingSubscriptions(for relayUrl: String) {
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
guard let connection = connections[relayUrl] else { return }
@@ -606,26 +551,24 @@ final class NostrRelayManager: ObservableObject {
}
pendingSubscriptions[relayUrl] = nil
}
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
task.receive { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let message):
// Parse off-main to reduce UI jank, then hop back for state updates
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
await MainActor.run {
self.handleParsedMessage(parsed, from: relayUrl)
}
}
// Continue receiving
Task { @MainActor in
self.receiveMessage(from: task, relayUrl: relayUrl)
}
case .failure(let error):
DispatchQueue.main.async {
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) {
switch parsed {
case .event(let subId, let event):
@@ -680,23 +618,21 @@ final class NostrRelayManager: ObservableObject {
break
}
}
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
let req = NostrRequest.event(event)
do {
let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? ""
SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
connection.send(.string(message)) { [weak self] error in
DispatchQueue.main.async {
if let error = error {
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
} else {
// SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1
}
@@ -707,32 +643,30 @@ final class NostrRelayManager: ObservableObject {
SecureLogger.error("Failed to encode event: \(error)", category: .session)
}
}
private func updateRelayStatus(_ url: String, isConnected: Bool, error: Error? = nil) {
if let index = relays.firstIndex(where: { $0.url == url }) {
relays[index].isConnected = isConnected
relays[index].lastError = error
if isConnected {
relays[index].lastConnectedAt = dependencies.now()
relays[index].reconnectAttempts = 0 // Reset on successful connection
relays[index].reconnectAttempts = 0
relays[index].nextReconnectTime = nil
} else {
relays[index].lastDisconnectedAt = dependencies.now()
}
}
updateConnectionStatus()
// If we just connected to this relay, flush any queued sends targeting it
if isConnected {
flushMessageQueue(for: url)
}
}
private func updateConnectionStatus() {
isConnected = relays.contains { $0.isConnected }
}
private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection
if !dependencies.activationAllowed() {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
@@ -742,11 +676,10 @@ final class NostrRelayManager: ObservableObject {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
// Check if this is a DNS or handshake error; treat as permanent
let errorDescription = error.localizedDescription.lowercased()
let ns = error as NSError
if errorDescription.contains("hostname could not be found") ||
if errorDescription.contains("hostname could not be found") ||
errorDescription.contains("dns") ||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
@@ -760,106 +693,90 @@ final class NostrRelayManager: ObservableObject {
pendingSubscriptions[relayUrl] = nil
return
}
// Implement exponential backoff for non-DNS errors
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
relays[index].reconnectAttempts += 1
// Stop attempting after max attempts
if relays[index].reconnectAttempts >= maxReconnectAttempts {
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
return
}
// Calculate backoff interval
let backoffInterval = min(
initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)),
maxBackoffInterval
)
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
relays[index].nextReconnectTime = nextReconnectTime
// Schedule reconnection with exponential backoff
let gen = connectionGeneration
dependencies.scheduleAfter(backoffInterval) { [weak self] in
Task { @MainActor [weak self] in
guard let self = self else { return }
// Ignore stale scheduled reconnects from a previous generation
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 }) {
self.connectToRelay(relayUrl)
}
}
}
}
// MARK: - Public Utility Methods
/// Manually retry connection to a specific relay
func retryConnection(to relayUrl: String) {
public func retryConnection(to relayUrl: String) {
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
// Reset reconnection attempts
relays[index].reconnectAttempts = 0
relays[index].nextReconnectTime = nil
relays[index].lastError = nil
// Disconnect if connected
if let connection = connections[relayUrl] {
connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: relayUrl)
}
// Attempt immediate reconnection
connectToRelay(relayUrl)
}
/// Get detailed status for all relays
func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] {
public func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] {
return relays.map { relay in
(url: relay.url,
isConnected: relay.isConnected,
(url: relay.url,
isConnected: relay.isConnected,
reconnectAttempts: relay.reconnectAttempts,
nextReconnectTime: relay.nextReconnectTime)
}
}
var debugPendingMessageQueueCount: Int {
public var debugPendingMessageQueueCount: Int {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
return messageQueue.count
}
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
public func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
pendingSubscriptions[relayUrl]?.count ?? 0
}
func debugFlushMessageQueue() {
public func debugFlushMessageQueue() {
flushMessageQueue(for: nil)
}
/// Reset all relay connections
func resetAllConnections() {
public func resetAllConnections() {
disconnect()
// New generation begins now
connectionGeneration &+= 1
// Reset all relay states
for index in relays.indices {
relays[index].reconnectAttempts = 0
relays[index].nextReconnectTime = nil
relays[index].lastError = nil
}
// Reconnect
connect()
}
// MARK: - Failure classification
private func isPermanentlyFailed(_ url: String) -> Bool {
guard let r = relays.first(where: { $0.url == url }) else { return false }
if r.reconnectAttempts >= maxReconnectAttempts { return true }
@@ -879,7 +796,7 @@ private enum ParsedInbound {
case ok(eventId: String, success: Bool, reason: String)
case eose(subscriptionId: String)
case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.data,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
@@ -938,26 +855,26 @@ private extension URLSessionWebSocketTask.Message {
// MARK: - Nostr Protocol Types
enum NostrRequest: Encodable {
public enum NostrRequest: Encodable {
case event(NostrEvent)
case subscribe(id: String, filters: [NostrFilter])
case close(id: String)
func encode(to encoder: Encoder) throws {
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
switch self {
case .event(let event):
try container.encode("EVENT")
try container.encode(event)
case .subscribe(let id, let filters):
try container.encode("REQ")
try container.encode(id)
for filter in filters {
try container.encode(filter)
}
case .close(let id):
try container.encode("CLOSE")
try container.encode(id)
@@ -965,57 +882,51 @@ enum NostrRequest: Encodable {
}
}
struct NostrFilter: Encodable {
var ids: [String]?
var authors: [String]?
var kinds: [Int]?
var since: Int?
var until: Int?
var limit: Int?
// Tag filters - stored internally but encoded specially
public struct NostrFilter: Encodable {
public var ids: [String]?
public var authors: [String]?
public var kinds: [Int]?
public var since: Int?
public var until: Int?
public var limit: Int?
fileprivate var tagFilters: [String: [String]]?
init() {
// Default initializer
}
// Custom encoding to handle tag filters properly
public init() {}
enum CodingKeys: String, CodingKey {
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)
// Encode standard fields
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 kinds = kinds { try container.encode(kinds, forKey: DynamicCodingKey(stringValue: "kinds")) }
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 limit = limit { try container.encode(limit, forKey: DynamicCodingKey(stringValue: "limit")) }
// Encode tag filters with # prefix
if let tagFilters = tagFilters {
for (tag, values) in tagFilters {
try container.encode(values, forKey: DynamicCodingKey(stringValue: "#\(tag)"))
}
}
}
// For NIP-17 gift wraps
static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
// For NIP-17 gift wraps (limit matches TransportConfig.nostrRelayDefaultFetchLimit)
public static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1059] // Gift wrap kind
filter.kinds = [1059]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["p": [pubkey]]
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
filter.limit = 100
return filter
}
// 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()
filter.kinds = [20000, 20001]
filter.since = since?.timeIntervalSince1970.toInt()
@@ -1025,7 +936,7 @@ struct NostrFilter: Encodable {
}
// 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()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
@@ -1035,7 +946,7 @@ struct NostrFilter: Encodable {
}
// 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()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
@@ -1045,15 +956,14 @@ struct NostrFilter: Encodable {
}
}
// Dynamic coding key for tag filters
private struct DynamicCodingKey: CodingKey {
var stringValue: String
var intValue: Int? { nil }
init(stringValue: String) {
self.stringValue = stringValue
}
init?(intValue: Int) {
return nil
}
@@ -6,7 +6,6 @@ import CryptoKit
/// as per XChaCha20 construction.
enum XChaCha20Poly1305Compat {
/// Errors that can occur during XChaCha20-Poly1305 operations
enum Error: Swift.Error {
case invalidKeyLength(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 {
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
guard key.count == 32 else {
throw Error.invalidKeyLength(expected: 32, got: key.count)
}
@@ -70,28 +68,22 @@ enum XChaCha20Poly1305Compat {
// Constants "expand 32-byte k"
var state: [UInt32] = [
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
// key (8 words)
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
// nonce (4 words)
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
]
// 20 rounds (10 double rounds)
for _ in 0..<10 {
// Column rounds
quarterRound(&state, 0, 4, 8, 12)
quarterRound(&state, 1, 5, 9, 13)
quarterRound(&state, 2, 6, 10, 14)
quarterRound(&state, 3, 7, 11, 15)
// Diagonal rounds
quarterRound(&state, 0, 5, 10, 15)
quarterRound(&state, 1, 6, 11, 12)
quarterRound(&state, 2, 7, 8, 13)
quarterRound(&state, 3, 4, 9, 14)
}
// Output subkey: state[0..3] and state[12..15]
var out = Data(count: 32)
out.storeLEWord(state[0], at: 0)
out.storeLEWord(state[1], at: 4)
@@ -132,4 +124,3 @@ private extension Data {
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")
}
}
}
@@ -8,7 +8,7 @@
import Testing
import struct Foundation.Data
@testable import bitchat
@testable import Nostr
struct XChaCha20Poly1305CompatTests {