Extract Nostr into a dedicated module

This commit is contained in:
islam
2026-04-14 20:23:16 +01:00
parent e8fb4a6333
commit fa45ee3aea
49 changed files with 778 additions and 714 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"originHash" : "6a29e071e2546e255f894c733cad2a53ab20e153bf12e813d16830b195a5970d",
"pins" : [
{
"identity" : "swift-secp256k1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/21-DOT-DEV/swift-secp256k1",
"state" : {
"revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2",
"version" : "0.21.1"
}
}
],
"version" : 3
}
+37
View File
@@ -0,0 +1,37 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Nostr",
platforms: [
.iOS(.v16),
.macOS(.v13)
],
products: [
.library(
name: "Nostr",
targets: ["Nostr"]
),
],
dependencies: [
.package(path: "../BitLogger"),
.package(path: "../BitFoundation"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1"),
],
targets: [
.target(
name: "Nostr",
dependencies: [
.product(name: "BitLogger", package: "BitLogger"),
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "P256K", package: "swift-secp256k1"),
],
path: "Sources"
),
.testTarget(
name: "NostrTests",
dependencies: ["Nostr"]
),
]
)
@@ -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)
}
}