mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +00:00
Extract Nostr into a dedicated module
This commit is contained in:
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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
|
||||
/// Bech32 encoding for Nostr (minimal implementation)
|
||||
public enum Bech32 {
|
||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
|
||||
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 {
|
||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||
return String(charset[index])
|
||||
}.joined()
|
||||
}
|
||||
|
||||
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 {
|
||||
guard let index = charset.firstIndex(of: char) else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
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 []
|
||||
}
|
||||
result.append(UInt8(asciiValue >> 5))
|
||||
}
|
||||
result.append(0)
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return []
|
||||
}
|
||||
result.append(UInt8(asciiValue & 31))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func polymod(_ values: [UInt8]) -> Int {
|
||||
var chk = 1
|
||||
for value in values {
|
||||
let b = chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||
for i in 0..<5 {
|
||||
if (b >> i) & 1 == 1 {
|
||||
chk ^= generator[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
public final class GeoRelayDirectory {
|
||||
private final class CleanupState {
|
||||
let notificationCenter: NotificationCenter
|
||||
var observers: [NSObjectProtocol] = []
|
||||
var refreshTimer: Timer?
|
||||
var retryTask: Task<Void, Never>?
|
||||
|
||||
init(notificationCenter: NotificationCenter) {
|
||||
self.notificationCenter = notificationCenter
|
||||
}
|
||||
|
||||
deinit {
|
||||
observers.forEach { notificationCenter.removeObserver($0) }
|
||||
refreshTimer?.invalidate()
|
||||
retryTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
case success(entries: [Entry], csv: String)
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
}
|
||||
|
||||
nonisolated(unsafe) public static var shared: GeoRelayDirectory!
|
||||
|
||||
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
|
||||
|
||||
private var retryAttempt: Int = 0
|
||||
private var isFetching: Bool = false
|
||||
|
||||
public init(dependencies: GeoRelayDirectoryDependencies) {
|
||||
self.dependencies = dependencies
|
||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||
entries = loadLocalEntries()
|
||||
if dependencies.autoStart {
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||
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.
|
||||
public func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||
guard !entries.isEmpty, count > 0 else { return [] }
|
||||
|
||||
if entries.count <= count {
|
||||
return entries
|
||||
.sorted { a, b in
|
||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||
}
|
||||
.map { "wss://\($0.host)" }
|
||||
}
|
||||
|
||||
var best: [(entry: Entry, distance: Double)] = []
|
||||
best.reserveCapacity(count)
|
||||
|
||||
for entry in entries {
|
||||
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
||||
if best.count < count {
|
||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||
best.insert((entry, distance), at: idx)
|
||||
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||
best.insert((entry, distance), at: idx)
|
||||
best.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
return best.map { "wss://\($0.entry.host)" }
|
||||
}
|
||||
|
||||
// MARK: - Remote Fetch
|
||||
public func prefetchIfNeeded(force: Bool = false) {
|
||||
guard !isFetching else { return }
|
||||
|
||||
let now = dependencies.now()
|
||||
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
|
||||
if !force {
|
||||
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
||||
} else if last != .distantPast,
|
||||
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
||||
return
|
||||
}
|
||||
|
||||
cancelRetry()
|
||||
fetchRemote()
|
||||
}
|
||||
|
||||
private func fetchRemote() {
|
||||
guard !isFetching else { return }
|
||||
isFetching = true
|
||||
|
||||
let request = URLRequest(
|
||||
url: dependencies.remoteURL,
|
||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||
timeoutInterval: 15
|
||||
)
|
||||
let awaitTorReady = dependencies.awaitTorReady
|
||||
let fetchData = dependencies.makeFetchData()
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let outcome = await Self.fetchRemoteOutcome(
|
||||
request: request,
|
||||
awaitTorReady: awaitTorReady,
|
||||
fetchData: fetchData
|
||||
)
|
||||
|
||||
switch outcome {
|
||||
case .success(let parsed, let csv):
|
||||
self.handleFetchSuccess(entries: parsed, csv: csv)
|
||||
case .torNotReady:
|
||||
self.handleFetchFailure(.torNotReady)
|
||||
case .invalidData:
|
||||
self.handleFetchFailure(.invalidData)
|
||||
case .network(let description):
|
||||
self.handleFetchFailure(.network(description))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func fetchRemoteOutcome(
|
||||
request: URLRequest,
|
||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
||||
) async -> DetachedFetchOutcome {
|
||||
await Task.detached(priority: .utility) {
|
||||
let ready = await awaitTorReady()
|
||||
guard ready else { return .torNotReady }
|
||||
|
||||
do {
|
||||
let data = try await fetchData(request)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
let parsed = Self.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
return .success(entries: parsed, csv: text)
|
||||
} catch {
|
||||
return .network(error.localizedDescription)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
private enum FetchFailure {
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
isFetching = false
|
||||
retryAttempt = 0
|
||||
cancelRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
||||
switch reason {
|
||||
case .torNotReady:
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||
case .invalidData:
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||
case .network(let errorDescription):
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
|
||||
}
|
||||
isFetching = false
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleRetry() {
|
||||
retryAttempt = min(retryAttempt + 1, 10)
|
||||
let base = dependencies.retryInitialSeconds
|
||||
let maxDelay = dependencies.retryMaxSeconds
|
||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||
let calculated = base * multiplier
|
||||
let delay = min(maxDelay, max(base, calculated))
|
||||
|
||||
cancelRetry()
|
||||
cleanupState.retryTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.dependencies.retrySleep(delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func cancelRetry() {
|
||||
cleanupState.retryTask?.cancel()
|
||||
cleanupState.retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ text: String) {
|
||||
guard let url = dependencies.cacheURL() else { return }
|
||||
guard let data = text.data(using: .utf8) else { return }
|
||||
do {
|
||||
try dependencies.writeData(data, url)
|
||||
} catch {
|
||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
private func loadLocalEntries() -> [Entry] {
|
||||
if let cache = dependencies.cacheURL(),
|
||||
let data = dependencies.readData(cache),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
let bundleCandidates = dependencies.bundledCSVURLs()
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = dependencies.readData(url),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
|
||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||
return []
|
||||
}
|
||||
|
||||
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() {
|
||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||
guard parts.count >= 3 else { continue }
|
||||
var host = parts[0]
|
||||
host = host.replacingOccurrences(of: "https://", with: "")
|
||||
host = host.replacingOccurrences(of: "http://", with: "")
|
||||
host = host.replacingOccurrences(of: "wss://", with: "")
|
||||
host = host.replacingOccurrences(of: "ws://", with: "")
|
||||
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||
}
|
||||
return Array(result)
|
||||
}
|
||||
|
||||
// MARK: - Observers & Timers
|
||||
private func registerObservers() {
|
||||
let center = dependencies.notificationCenter
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if let activeNotificationName = dependencies.activeNotificationName {
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: activeNotificationName,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
cleanupState.observers.append(didBecomeActive)
|
||||
}
|
||||
}
|
||||
|
||||
private func startRefreshTimer() {
|
||||
cleanupState.refreshTimer?.invalidate()
|
||||
let interval = dependencies.refreshCheckInterval
|
||||
guard interval > 0 else { return }
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
cleanupState.refreshTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import P256K
|
||||
|
||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||
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
|
||||
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,
|
||||
npub: npub,
|
||||
createdAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
/// Initialize from existing private key data
|
||||
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
|
||||
public func signingKey() throws -> P256K.Signing.PrivateKey {
|
||||
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
|
||||
}
|
||||
|
||||
/// Get Schnorr signing key for Nostr event signatures
|
||||
public func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey {
|
||||
try P256K.Schnorr.PrivateKey(dataRepresentation: privateKey)
|
||||
}
|
||||
|
||||
/// Get hex-encoded public key (for Nostr events)
|
||||
public var publicKeyHex: String {
|
||||
publicKey.hexEncodedString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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
|
||||
public final class NostrIdentityBridge {
|
||||
private let keychainService = "chat.bitchat.nostr"
|
||||
private let currentIdentityKey = "nostr-current-identity"
|
||||
private let deviceSeedKey = "nostr-device-seed"
|
||||
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 let cacheLock = NSLock()
|
||||
|
||||
private let keychain: any NostrKeychainStoring
|
||||
|
||||
public init(keychain: any NostrKeychainStoring) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
/// Get or create the current 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
|
||||
}
|
||||
|
||||
let nostrIdentity = try NostrIdentity.generate()
|
||||
|
||||
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)
|
||||
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
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
public func clearAllAssociations() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
deviceSeedCache.lock()
|
||||
_deviceSeedCacheValue = nil
|
||||
deviceSeedCache.unlock()
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
private func getOrCreateDeviceSeed() -> Data {
|
||||
deviceSeedCache.lock()
|
||||
if let cached = _deviceSeedCacheValue {
|
||||
deviceSeedCache.unlock()
|
||||
return cached
|
||||
}
|
||||
deviceSeedCache.unlock()
|
||||
|
||||
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
||||
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache.lock()
|
||||
_deviceSeedCacheValue = existing
|
||||
deviceSeedCache.unlock()
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache.lock()
|
||||
_deviceSeedCacheValue = seed
|
||||
deviceSeedCache.unlock()
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
public func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
cacheLock.lock()
|
||||
if let cached = _derivedIdentityCache[geohash] {
|
||||
cacheLock.unlock()
|
||||
return cached
|
||||
}
|
||||
cacheLock.unlock()
|
||||
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
}
|
||||
|
||||
func candidateKey(iteration: UInt32) -> Data {
|
||||
var input = Data(msg)
|
||||
var iterBE = iteration.bigEndian
|
||||
withUnsafeBytes(of: &iterBE) { bytes in
|
||||
input.append(contentsOf: bytes)
|
||||
}
|
||||
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
cacheLock.lock()
|
||||
_derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
return identity
|
||||
}
|
||||
}
|
||||
|
||||
let fallback = (seed + msg).sha256Hash()
|
||||
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||
|
||||
cacheLock.lock()
|
||||
_derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
|
||||
return identity
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
import Security
|
||||
|
||||
/// NIP-17 Protocol Implementation for Private Direct Messages
|
||||
public struct NostrProtocol {
|
||||
|
||||
/// Nostr event kinds
|
||||
public enum EventKind: Int, Sendable {
|
||||
case metadata = 0
|
||||
case textNote = 1
|
||||
case dm = 14 // NIP-17 DM rumor kind
|
||||
case seal = 13 // NIP-17 sealed event
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
public static func createPrivateMessage(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let rumor = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
|
||||
let ephemeralKey = try P256K.Schnorr.PrivateKey()
|
||||
|
||||
let sealedEvent = try createSeal(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: ephemeralKey
|
||||
)
|
||||
|
||||
let giftWrap = try createGiftWrap(
|
||||
seal: sealedEvent,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: ephemeralKey
|
||||
)
|
||||
|
||||
return giftWrap
|
||||
}
|
||||
|
||||
/// Decrypt a received NIP-17 message
|
||||
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
|
||||
public static func decryptPrivateMessage(
|
||||
giftWrap: NostrEvent,
|
||||
recipientIdentity: NostrIdentity
|
||||
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
||||
let seal: NostrEvent
|
||||
do {
|
||||
seal = try unwrapGiftWrap(
|
||||
giftWrap: giftWrap,
|
||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
let rumor: NostrEvent
|
||||
do {
|
||||
rumor = try openSeal(
|
||||
seal: seal,
|
||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
||||
)
|
||||
} 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)
|
||||
public static func createEphemeralGeohashEvent(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil,
|
||||
teleported: Bool = false
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
if teleported {
|
||||
tags.append(["t", "teleport"])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a geohash presence heartbeat (kind 20001)
|
||||
/// Must contain empty content and NO nickname tag
|
||||
public static func createGeohashPresenceEvent(
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let tags = [["g", geohash]]
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: tags,
|
||||
content: ""
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||
public static func createGeohashTextNote(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
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(),
|
||||
kind: .seal,
|
||||
tags: [],
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
return try seal.sign(with: senderKey)
|
||||
}
|
||||
|
||||
private static func createGiftWrap(
|
||||
seal: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> NostrEvent {
|
||||
let sealJSON = try seal.jsonString()
|
||||
let wrapKey = try P256K.Schnorr.PrivateKey()
|
||||
|
||||
let encrypted = try encrypt(
|
||||
plaintext: sealJSON,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: wrapKey
|
||||
)
|
||||
|
||||
let giftWrap = NostrEvent(
|
||||
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
|
||||
createdAt: randomizedTimestamp(),
|
||||
kind: .giftWrap,
|
||||
tags: [["p", recipientPubkey]],
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
return try giftWrap.sign(with: wrapKey)
|
||||
}
|
||||
|
||||
private static func unwrapGiftWrap(
|
||||
giftWrap: NostrEvent,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> NostrEvent {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let sharedSecret = try deriveSharedSecret(
|
||||
privateKey: senderKey,
|
||||
publicKey: recipientPubkeyData
|
||||
)
|
||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||
|
||||
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)
|
||||
|
||||
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 {
|
||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||
let encoded = String(ciphertext.dropFirst(3))
|
||||
guard let data = base64URLDecode(encoded),
|
||||
data.count > (24 + 16),
|
||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
|
||||
let nonce24 = data.prefix(24)
|
||||
let rest = data.dropFirst(24)
|
||||
let tag = rest.suffix(16)
|
||||
let ct = rest.dropLast(16)
|
||||
|
||||
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
||||
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||
let key = try deriveNIP44V2Key(from: ss)
|
||||
return try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: Data(ct),
|
||||
tag: Data(tag),
|
||||
key: key,
|
||||
nonce24: Data(nonce24)
|
||||
)
|
||||
}
|
||||
|
||||
if senderPubkeyData.count == 32 {
|
||||
let even = Data([0x02]) + senderPubkeyData
|
||||
if let pt = try? attemptDecrypt(using: even) {
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
}
|
||||
let odd = Data([0x03]) + senderPubkeyData
|
||||
let pt = try attemptDecrypt(using: odd)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
} else {
|
||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private static func deriveSharedSecret(
|
||||
privateKey: P256K.Schnorr.PrivateKey,
|
||||
publicKey: Data
|
||||
) throws -> Data {
|
||||
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
|
||||
dataRepresentation: privateKey.dataRepresentation
|
||||
)
|
||||
|
||||
var fullPublicKey = Data()
|
||||
if publicKey.count == 32 {
|
||||
fullPublicKey.append(0x02)
|
||||
fullPublicKey.append(publicKey)
|
||||
} else {
|
||||
fullPublicKey = publicKey
|
||||
}
|
||||
|
||||
let keyAgreementPublicKey: P256K.KeyAgreement.PublicKey
|
||||
do {
|
||||
keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
|
||||
dataRepresentation: fullPublicKey,
|
||||
format: .compressed
|
||||
)
|
||||
} catch {
|
||||
if publicKey.count == 32 {
|
||||
fullPublicKey = Data()
|
||||
fullPublicKey.append(0x03)
|
||||
fullPublicKey.append(publicKey)
|
||||
keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
|
||||
dataRepresentation: fullPublicKey,
|
||||
format: .compressed
|
||||
)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
|
||||
with: keyAgreementPublicKey,
|
||||
format: .compressed
|
||||
)
|
||||
|
||||
return sharedSecret.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
private static func randomizedTimestamp() -> Date {
|
||||
let offset = TimeInterval.random(in: -900...900)
|
||||
return Date().addingTimeInterval(offset)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nostr Event structure
|
||||
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,
|
||||
tags: [[String]],
|
||||
content: String
|
||||
) {
|
||||
self.pubkey = pubkey
|
||||
self.created_at = Int(createdAt.timeIntervalSince1970)
|
||||
self.kind = kind.rawValue
|
||||
self.tags = tags
|
||||
self.content = content
|
||||
self.sig = nil
|
||||
self.id = ""
|
||||
}
|
||||
|
||||
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,
|
||||
let tags = dict["tags"] as? [[String]],
|
||||
let content = dict["content"] as? String else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
self.id = dict["id"] as? String ?? ""
|
||||
self.pubkey = pubkey
|
||||
self.created_at = createdAt
|
||||
self.kind = kind
|
||||
self.tags = tags
|
||||
self.content = content
|
||||
self.sig = dict["sig"] as? String
|
||||
}
|
||||
|
||||
public func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||
let (eventId, eventIdHash) = try calculateEventId()
|
||||
|
||||
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
|
||||
return signed
|
||||
}
|
||||
|
||||
/// 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.
|
||||
public func isValidSignature() -> Bool {
|
||||
guard let sig = sig,
|
||||
let sigData = Data(hexString: sig),
|
||||
let pubData = Data(hexString: pubkey),
|
||||
sigData.count == 64,
|
||||
pubData.count == 32,
|
||||
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
|
||||
let (expectedId, eventHash) = try? calculateEventId(),
|
||||
expectedId == id
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
var messageBytes = [UInt8](eventHash)
|
||||
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
|
||||
return xonly.isValid(signature, for: &messageBytes)
|
||||
}
|
||||
|
||||
private func calculateEventId() throws -> (String, Data) {
|
||||
let serialized = [
|
||||
0,
|
||||
pubkey,
|
||||
created_at,
|
||||
kind,
|
||||
tags,
|
||||
content
|
||||
] as [Any]
|
||||
|
||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||
}
|
||||
|
||||
public func jsonString() throws -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.withoutEscapingSlashes]
|
||||
let data = try encoder.encode(self)
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
public enum NostrError: Error, Sendable {
|
||||
case invalidPublicKey
|
||||
case invalidPrivateKey
|
||||
case invalidEvent
|
||||
case invalidCiphertext
|
||||
case signingFailed
|
||||
case encryptionFailed
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
||||
|
||||
private extension NostrProtocol {
|
||||
static func base64URLEncode(_ data: Data) -> String {
|
||||
return data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s
|
||||
let pad = (4 - (str.count % 4)) % 4
|
||||
if pad > 0 { str += String(repeating: "=", count: pad) }
|
||||
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
|
||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: Data(),
|
||||
info: "nip44-v2".data(using: .utf8)!,
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derivedKey.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,976 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Network
|
||||
import Combine
|
||||
|
||||
public protocol NostrRelayConnectionProtocol: AnyObject {
|
||||
func resume()
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
|
||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)
|
||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
|
||||
}
|
||||
|
||||
public protocol NostrRelaySessionProtocol {
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
|
||||
}
|
||||
|
||||
private final class URLSessionWebSocketTaskAdapter: NostrRelayConnectionProtocol {
|
||||
private let base: URLSessionWebSocketTask
|
||||
|
||||
init(base: URLSessionWebSocketTask) {
|
||||
self.base = base
|
||||
}
|
||||
|
||||
func resume() {
|
||||
base.resume()
|
||||
}
|
||||
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
base.cancel(with: closeCode, reason: reason)
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||
base.send(message, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
base.receive(completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||
base.sendPing(pongReceiveHandler: pongReceiveHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private struct URLSessionAdapter: NostrRelaySessionProtocol {
|
||||
let base: URLSession
|
||||
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
|
||||
}
|
||||
}
|
||||
|
||||
public enum LocationPermissionState: Equatable, Sendable {
|
||||
case notDetermined
|
||||
case authorized
|
||||
case denied
|
||||
}
|
||||
|
||||
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
|
||||
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) public static var pendingGiftWrapIDs = Set<String>()
|
||||
public static func registerPendingGiftWrap(id: String) {
|
||||
pendingGiftWrapIDs.insert(id)
|
||||
}
|
||||
|
||||
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",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
"wss://offchain.pub",
|
||||
"wss://nostr21.com"
|
||||
]
|
||||
private static let defaultRelaySet = Set(defaultRelays)
|
||||
|
||||
@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>] = [:]
|
||||
private var pendingSubscriptions: [String: [String: String]] = [:]
|
||||
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
||||
private var subscribeCoalesce: [String: Date] = [:]
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private struct EOSETracker {
|
||||
var pendingRelays: Set<String>
|
||||
var callback: () -> Void
|
||||
var timer: Timer?
|
||||
}
|
||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||
|
||||
private struct PendingSend {
|
||||
var event: NostrEvent
|
||||
var pendingRelays: Set<String>
|
||||
}
|
||||
private var messageQueue: [PendingSend] = []
|
||||
private let messageQueueLock = NSLock()
|
||||
private let encoder = JSONEncoder()
|
||||
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
||||
|
||||
// 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
|
||||
|
||||
public init(dependencies: NostrRelayManagerDependencies) {
|
||||
self.dependencies = dependencies
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
hasLocationPermission = dependencies.hasLocationPermission()
|
||||
applyDefaultRelayPolicy(force: true)
|
||||
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
|
||||
public func connect() {
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
if shouldUseTor {
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
||||
for relay in self.relays {
|
||||
connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from all relays
|
||||
public func disconnect() {
|
||||
connectionGeneration &+= 1
|
||||
|
||||
for (_, task) in connections {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
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() {
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if ready { self.ensureConnections(to: relayUrls) }
|
||||
}
|
||||
return
|
||||
}
|
||||
var existing = Set(relays.map { $0.url })
|
||||
for url in targets where !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
for url in targets where connections[url] == nil {
|
||||
connectToRelay(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
public func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if ready { self.sendEvent(event, to: relayUrls) }
|
||||
}
|
||||
return
|
||||
}
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
ensureConnections(to: targetRelays)
|
||||
|
||||
var stillPending = Set<String>()
|
||||
for relayUrl in targetRelays {
|
||||
if let connection = connections[relayUrl] {
|
||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||
} else {
|
||||
stillPending.insert(relayUrl)
|
||||
}
|
||||
}
|
||||
if !stillPending.isEmpty {
|
||||
messageQueueLock.lock()
|
||||
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
|
||||
messageQueueLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private func flushMessageQueue(for relayUrl: String? = nil) {
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
guard !messageQueue.isEmpty else { return }
|
||||
if let target = relayUrl {
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[i]
|
||||
if item.pendingRelays.contains(target), let conn = connections[target] {
|
||||
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
||||
item.pendingRelays.remove(target)
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: i)
|
||||
} else {
|
||||
messageQueue[i] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[i]
|
||||
for url in item.pendingRelays {
|
||||
if let conn = connections[url] {
|
||||
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
||||
item.pendingRelays.remove(url)
|
||||
}
|
||||
}
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: i)
|
||||
} else {
|
||||
messageQueue[i] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
||||
public func subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String = UUID().uuidString,
|
||||
relayUrls: [String]? = nil,
|
||||
handler: @escaping (NostrEvent) -> Void,
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
let now = dependencies.now()
|
||||
if messageHandlers[id] != nil {
|
||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
subscribeCoalesce[id] = now
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if ready {
|
||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
SecureLogger.error("❌ Failed to encode subscription request", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||
let urls = allowedRelayList(from: candidateUrls)
|
||||
let existingSet = Set(relays.map { $0.url })
|
||||
for url in urls where !existingSet.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
}
|
||||
for url in urls {
|
||||
var map = self.pendingSubscriptions[url] ?? [:]
|
||||
map[id] = messageString
|
||||
self.pendingSubscriptions[url] = map
|
||||
}
|
||||
if let onEOSE = onEOSE {
|
||||
if urls.isEmpty {
|
||||
onEOSE()
|
||||
} else {
|
||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
if let t = self.eoseTrackers[id] {
|
||||
t.timer?.invalidate()
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
onEOSE()
|
||||
}
|
||||
}
|
||||
}
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
}
|
||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||
ensureConnections(to: urls)
|
||||
for url in urls {
|
||||
if let r = relays.first(where: { $0.url == url }), r.isConnected {
|
||||
flushPendingSubscriptions(for: url)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyDefaultRelayPolicy(force: Bool = false) {
|
||||
let shouldAllow = hasMutualFavorites || hasLocationPermission
|
||||
if !force && shouldAllow == allowDefaultRelays { return }
|
||||
allowDefaultRelays = shouldAllow
|
||||
if shouldAllow {
|
||||
var existing = Set(relays.map { $0.url })
|
||||
for url in Self.defaultRelays where !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
if dependencies.activationAllowed() {
|
||||
ensureConnections(to: Self.defaultRelays)
|
||||
}
|
||||
} else {
|
||||
for url in Self.defaultRelays {
|
||||
if let connection = connections[url] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeValue(forKey: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
pendingSubscriptions.removeValue(forKey: url)
|
||||
}
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[index]
|
||||
item.pendingRelays.subtract(Self.defaultRelaySet)
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
||||
updateConnectionStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for url in urls {
|
||||
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
||||
if seen.insert(url).inserted {
|
||||
result.append(url)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Unsubscribe from a subscription
|
||||
public func unsubscribe(id: String) {
|
||||
messageHandlers.removeValue(forKey: id)
|
||||
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 }
|
||||
|
||||
for (relayUrl, connection) in connections {
|
||||
if subscriptions[relayUrl]?.contains(id) == true {
|
||||
subscriptions[relayUrl]?.remove(id)
|
||||
connection.send(.string(messageString)) { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func connectToRelay(_ urlString: String) {
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
guard let url = URL(string: urlString) else {
|
||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
if connections[urlString] != nil {
|
||||
return
|
||||
}
|
||||
if isPermanentlyFailed(urlString) {
|
||||
return
|
||||
}
|
||||
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if ready { self.connectToRelay(urlString) }
|
||||
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let session = dependencies.makeSession()
|
||||
let task = session.webSocketTask(with: url)
|
||||
|
||||
connections[urlString] = task
|
||||
task.resume()
|
||||
|
||||
receiveMessage(from: task, relayUrl: urlString)
|
||||
|
||||
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)
|
||||
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)
|
||||
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flushPendingSubscriptions(for relayUrl: String) {
|
||||
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
|
||||
guard let connection = connections[relayUrl] else { return }
|
||||
for (id, messageString) in map {
|
||||
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||
connection.send(.string(messageString)) { error in
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
|
||||
} else {
|
||||
Task { @MainActor in
|
||||
var subs = self.subscriptions[relayUrl] ?? Set<String>()
|
||||
subs.insert(id)
|
||||
self.subscriptions[relayUrl] = subs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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):
|
||||
Task.detached(priority: .utility) {
|
||||
guard let parsed = ParsedInbound(message) else { return }
|
||||
await MainActor.run {
|
||||
self.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.receiveMessage(from: task, relayUrl: relayUrl)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
self.handleDisconnection(relayUrl: relayUrl, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||
switch parsed {
|
||||
case .event(let subId, let event):
|
||||
if event.kind != 1059 {
|
||||
SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
}
|
||||
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
self.relays[index].messagesReceived += 1
|
||||
}
|
||||
if let handler = self.messageHandlers[subId] {
|
||||
handler(event)
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
||||
}
|
||||
case .eose(let subId):
|
||||
if var tracker = eoseTrackers[subId] {
|
||||
tracker.pendingRelays.remove(relayUrl)
|
||||
if tracker.pendingRelays.isEmpty {
|
||||
tracker.timer?.invalidate()
|
||||
eoseTrackers.removeValue(forKey: subId)
|
||||
tracker.callback()
|
||||
} else {
|
||||
eoseTrackers[subId] = tracker
|
||||
}
|
||||
}
|
||||
case .ok(let eventId, let success, let reason):
|
||||
if success {
|
||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
} else {
|
||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||
if isGiftWrap {
|
||||
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
|
||||
} else {
|
||||
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
|
||||
}
|
||||
}
|
||||
case .notice:
|
||||
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 {
|
||||
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
self?.relays[index].messagesSent += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
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
|
||||
relays[index].nextReconnectTime = nil
|
||||
} else {
|
||||
relays[index].lastDisconnectedAt = dependencies.now()
|
||||
}
|
||||
}
|
||||
updateConnectionStatus()
|
||||
if isConnected {
|
||||
flushMessageQueue(for: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateConnectionStatus() {
|
||||
isConnected = relays.contains { $0.isConnected }
|
||||
}
|
||||
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
if !dependencies.activationAllowed() {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
return
|
||||
}
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
|
||||
let errorDescription = error.localizedDescription.lowercased()
|
||||
let ns = error as NSError
|
||||
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 {
|
||||
SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
|
||||
}
|
||||
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
relays[index].lastError = error
|
||||
relays[index].reconnectAttempts = maxReconnectAttempts
|
||||
relays[index].nextReconnectTime = nil
|
||||
}
|
||||
pendingSubscriptions[relayUrl] = nil
|
||||
return
|
||||
}
|
||||
|
||||
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
|
||||
|
||||
relays[index].reconnectAttempts += 1
|
||||
|
||||
if relays[index].reconnectAttempts >= maxReconnectAttempts {
|
||||
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
let backoffInterval = min(
|
||||
initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)),
|
||||
maxBackoffInterval
|
||||
)
|
||||
|
||||
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
|
||||
relays[index].nextReconnectTime = nextReconnectTime
|
||||
|
||||
let gen = connectionGeneration
|
||||
dependencies.scheduleAfter(backoffInterval) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard gen == self.connectionGeneration else { return }
|
||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||
self.connectToRelay(relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Utility Methods
|
||||
|
||||
public func retryConnection(to relayUrl: String) {
|
||||
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
|
||||
|
||||
relays[index].reconnectAttempts = 0
|
||||
relays[index].nextReconnectTime = nil
|
||||
relays[index].lastError = nil
|
||||
|
||||
if let connection = connections[relayUrl] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
}
|
||||
|
||||
connectToRelay(relayUrl)
|
||||
}
|
||||
|
||||
public func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] {
|
||||
return relays.map { relay in
|
||||
(url: relay.url,
|
||||
isConnected: relay.isConnected,
|
||||
reconnectAttempts: relay.reconnectAttempts,
|
||||
nextReconnectTime: relay.nextReconnectTime)
|
||||
}
|
||||
}
|
||||
|
||||
public var debugPendingMessageQueueCount: Int {
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
return messageQueue.count
|
||||
}
|
||||
|
||||
public func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
||||
pendingSubscriptions[relayUrl]?.count ?? 0
|
||||
}
|
||||
|
||||
public func debugFlushMessageQueue() {
|
||||
flushMessageQueue(for: nil)
|
||||
}
|
||||
|
||||
/// Reset all relay connections
|
||||
public func resetAllConnections() {
|
||||
disconnect()
|
||||
connectionGeneration &+= 1
|
||||
|
||||
for index in relays.indices {
|
||||
relays[index].reconnectAttempts = 0
|
||||
relays[index].nextReconnectTime = nil
|
||||
relays[index].lastError = nil
|
||||
}
|
||||
|
||||
connect()
|
||||
}
|
||||
|
||||
private func isPermanentlyFailed(_ url: String) -> Bool {
|
||||
guard let r = relays.first(where: { $0.url == url }) else { return false }
|
||||
if r.reconnectAttempts >= maxReconnectAttempts { return true }
|
||||
if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain {
|
||||
if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
|
||||
|
||||
private enum ParsedInbound {
|
||||
case event(subId: String, event: NostrEvent)
|
||||
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],
|
||||
array.count >= 2,
|
||||
let type = array[0] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch type {
|
||||
case "EVENT":
|
||||
if array.count >= 3,
|
||||
let subId = array[1] as? String,
|
||||
let eventDict = array[2] as? [String: Any],
|
||||
let event = try? NostrEvent(from: eventDict),
|
||||
event.isValidSignature() {
|
||||
self = .event(subId: subId, event: event)
|
||||
return
|
||||
}
|
||||
return nil
|
||||
case "EOSE":
|
||||
if let subId = array[1] as? String {
|
||||
self = .eose(subscriptionId: subId)
|
||||
return
|
||||
}
|
||||
return nil
|
||||
case "OK":
|
||||
if array.count >= 3,
|
||||
let eventId = array[1] as? String,
|
||||
let success = array[2] as? Bool {
|
||||
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
|
||||
self = .ok(eventId: eventId, success: success, reason: reason)
|
||||
return
|
||||
}
|
||||
return nil
|
||||
case "NOTICE":
|
||||
if array.count >= 2, let msg = array[1] as? String {
|
||||
self = .notice(msg)
|
||||
return
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension URLSessionWebSocketTask.Message {
|
||||
var data: Data? {
|
||||
switch self {
|
||||
case .string(let text): text.data(using: .utf8)
|
||||
case .data(let data): data
|
||||
@unknown default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nostr Protocol Types
|
||||
|
||||
public enum NostrRequest: Encodable {
|
||||
case event(NostrEvent)
|
||||
case subscribe(id: String, filters: [NostrFilter])
|
||||
case close(id: String)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]]?
|
||||
|
||||
public init() {}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case ids, authors, kinds, since, until, limit
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: DynamicCodingKey.self)
|
||||
|
||||
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")) }
|
||||
|
||||
if let tagFilters = tagFilters {
|
||||
for (tag, values) in tagFilters {
|
||||
try container.encode(values, forKey: DynamicCodingKey(stringValue: "#\(tag)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["p": [pubkey]]
|
||||
filter.limit = 100
|
||||
return filter
|
||||
}
|
||||
|
||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
||||
public static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [20000, 20001]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["g": [geohash]]
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
|
||||
// For location notes: persistent text notes (kind 1) tagged with geohash
|
||||
public static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [1]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["g": [geohash]]
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
|
||||
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
|
||||
public static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [1]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["g": geohashes]
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
private struct DynamicCodingKey: CodingKey {
|
||||
var stringValue: String
|
||||
var intValue: Int? { nil }
|
||||
|
||||
init(stringValue: String) {
|
||||
self.stringValue = stringValue
|
||||
}
|
||||
|
||||
init?(intValue: Int) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension TimeInterval {
|
||||
func toInt() -> Int {
|
||||
return Int(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly.
|
||||
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||
/// as per XChaCha20 construction.
|
||||
enum XChaCha20Poly1305Compat {
|
||||
|
||||
enum Error: Swift.Error {
|
||||
case invalidKeyLength(expected: Int, got: Int)
|
||||
case invalidNonceLength(expected: Int, got: Int)
|
||||
}
|
||||
|
||||
struct SealBox {
|
||||
let ciphertext: Data
|
||||
let tag: Data
|
||||
}
|
||||
|
||||
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce24.count == 24 else {
|
||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
||||
}
|
||||
|
||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||
let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data())
|
||||
return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag)
|
||||
}
|
||||
|
||||
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce24.count == 24 else {
|
||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
||||
}
|
||||
|
||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||
return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data())
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func derive12ByteNonce(from24 nonce24: Data) -> Data {
|
||||
// XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce
|
||||
var out = Data(count: 12)
|
||||
out.replaceSubrange(0..<4, with: [0, 0, 0, 0])
|
||||
out.replaceSubrange(4..<12, with: nonce24.suffix(8))
|
||||
return out
|
||||
}
|
||||
|
||||
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce16.count == 16 else {
|
||||
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
|
||||
}
|
||||
|
||||
// Constants "expand 32-byte k"
|
||||
var state: [UInt32] = [
|
||||
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
|
||||
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
|
||||
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
|
||||
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
|
||||
]
|
||||
|
||||
for _ in 0..<10 {
|
||||
quarterRound(&state, 0, 4, 8, 12)
|
||||
quarterRound(&state, 1, 5, 9, 13)
|
||||
quarterRound(&state, 2, 6, 10, 14)
|
||||
quarterRound(&state, 3, 7, 11, 15)
|
||||
quarterRound(&state, 0, 5, 10, 15)
|
||||
quarterRound(&state, 1, 6, 11, 12)
|
||||
quarterRound(&state, 2, 7, 8, 13)
|
||||
quarterRound(&state, 3, 4, 9, 14)
|
||||
}
|
||||
|
||||
var out = Data(count: 32)
|
||||
out.storeLEWord(state[0], at: 0)
|
||||
out.storeLEWord(state[1], at: 4)
|
||||
out.storeLEWord(state[2], at: 8)
|
||||
out.storeLEWord(state[3], at: 12)
|
||||
out.storeLEWord(state[12], at: 16)
|
||||
out.storeLEWord(state[13], at: 20)
|
||||
out.storeLEWord(state[14], at: 24)
|
||||
out.storeLEWord(state[15], at: 28)
|
||||
return out
|
||||
}
|
||||
|
||||
private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
|
||||
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16)
|
||||
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20)
|
||||
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8) | (s[d] >> 24)
|
||||
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7) | (s[b] >> 25)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
func loadLEWord(_ offset: Int) -> UInt32 {
|
||||
let range = offset..<(offset+4)
|
||||
let bytes = self[range]
|
||||
return bytes.withUnsafeBytes { ptr -> UInt32 in
|
||||
let b = ptr.bindMemory(to: UInt8.self)
|
||||
return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func storeLEWord(_ value: UInt32, at offset: Int) {
|
||||
let bytes: [UInt8] = [
|
||||
UInt8(value & 0xff),
|
||||
UInt8((value >> 8) & 0xff),
|
||||
UInt8((value >> 16) & 0xff),
|
||||
UInt8((value >> 24) & 0xff)
|
||||
]
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//
|
||||
// XChaCha20Poly1305CompatTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for XChaCha20-Poly1305 encryption with proper error handling.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import struct Foundation.Data
|
||||
@testable import Nostr
|
||||
|
||||
struct XChaCha20Poly1305CompatTests {
|
||||
|
||||
@Test func sealAndOpenRoundtrip() throws {
|
||||
let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce)
|
||||
let decrypted = try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: sealed.ciphertext,
|
||||
tag: sealed.tag,
|
||||
key: key,
|
||||
nonce24: nonce
|
||||
)
|
||||
|
||||
#expect(decrypted == plaintext)
|
||||
}
|
||||
|
||||
@Test func sealAndOpenWithAAD() throws {
|
||||
let plaintext = "Secret message".data(using: .utf8)!
|
||||
let key = Data(repeating: 0xAB, count: 32)
|
||||
let nonce = Data(repeating: 0xCD, count: 24)
|
||||
let aad = "additional authenticated data".data(using: .utf8)!
|
||||
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad)
|
||||
let decrypted = try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: sealed.ciphertext,
|
||||
tag: sealed.tag,
|
||||
key: key,
|
||||
nonce24: nonce,
|
||||
aad: aad
|
||||
)
|
||||
|
||||
#expect(decrypted == plaintext)
|
||||
}
|
||||
|
||||
@Test func sealProducesDifferentCiphertextWithDifferentNonces() throws {
|
||||
let plaintext = "Same plaintext".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce1 = Data(repeating: 0x01, count: 24)
|
||||
let nonce2 = Data(repeating: 0x02, count: 24)
|
||||
|
||||
let sealed1 = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce1)
|
||||
let sealed2 = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce2)
|
||||
|
||||
#expect(sealed1.ciphertext != sealed2.ciphertext)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnShortKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let shortKey = Data(repeating: 0x42, count: 16)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: shortKey, nonce24: nonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnLongKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let longKey = Data(repeating: 0x42, count: 64)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: longKey, nonce24: nonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnEmptyKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let emptyKey = Data()
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: emptyKey, nonce24: nonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func openThrowsOnInvalidKeyLength() {
|
||||
let ciphertext = Data(repeating: 0x00, count: 16)
|
||||
let tag = Data(repeating: 0x00, count: 16)
|
||||
let shortKey = Data(repeating: 0x42, count: 31)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.open(ciphertext: ciphertext, tag: tag, key: shortKey, nonce24: nonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnShortNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let shortNonce = Data(repeating: 0x24, count: 12)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: shortNonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnLongNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let longNonce = Data(repeating: 0x24, count: 32)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: longNonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnEmptyNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let emptyNonce = Data()
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: emptyNonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func openThrowsOnInvalidNonceLength() {
|
||||
let ciphertext = Data(repeating: 0x00, count: 16)
|
||||
let tag = Data(repeating: 0x00, count: 16)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let shortNonce = Data(repeating: 0x24, count: 23)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.open(ciphertext: ciphertext, tag: tag, key: key, nonce24: shortNonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func openFailsWithWrongKey() throws {
|
||||
let plaintext = "Secret".data(using: .utf8)!
|
||||
let correctKey = Data(repeating: 0x42, count: 32)
|
||||
let wrongKey = Data(repeating: 0x43, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: correctKey, nonce24: nonce)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: sealed.ciphertext,
|
||||
tag: sealed.tag,
|
||||
key: wrongKey,
|
||||
nonce24: nonce
|
||||
)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func openFailsWithTamperedCiphertext() throws {
|
||||
let plaintext = "Secret".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce)
|
||||
|
||||
// Create tampered ciphertext by changing first byte
|
||||
var tamperedBytes = [UInt8](sealed.ciphertext)
|
||||
tamperedBytes[0] = tamperedBytes[0] ^ 0xFF
|
||||
let tampered = Data(tamperedBytes)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: tampered,
|
||||
tag: sealed.tag,
|
||||
key: key,
|
||||
nonce24: nonce
|
||||
)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user