mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Add Nostr double-ratchet DMs
This commit is contained in:
parent
bafa461f26
commit
60d03f82eb
@ -19,6 +19,7 @@ let package = Package(
|
||||
.package(path: "localPackages/Arti"),
|
||||
.package(path: "localPackages/BitFoundation"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
.package(path: "localPackages/NdrFfi"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||
],
|
||||
targets: [
|
||||
@ -28,7 +29,8 @@ let package = Package(
|
||||
.product(name: "P256K", package: "swift-secp256k1"),
|
||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "Tor", package: "Arti")
|
||||
.product(name: "Tor", package: "Arti"),
|
||||
.product(name: "NdrFfi", package: "NdrFfi")
|
||||
],
|
||||
path: "bitchat",
|
||||
exclude: [
|
||||
@ -48,7 +50,8 @@ let package = Package(
|
||||
name: "bitchatTests",
|
||||
dependencies: [
|
||||
"bitchat",
|
||||
.product(name: "BitFoundation", package: "BitFoundation")
|
||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||
.product(name: "NdrFfi", package: "NdrFfi")
|
||||
],
|
||||
path: "bitchatTests",
|
||||
exclude: [
|
||||
|
||||
15
bitchat.xcodeproj/project.pbxproj
generated
15
bitchat.xcodeproj/project.pbxproj
generated
@ -228,6 +228,7 @@
|
||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
||||
NDRF0002000000000000000000 /* NdrFfi */,
|
||||
);
|
||||
productName = bitchat_macOS;
|
||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||
@ -309,6 +310,7 @@
|
||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
||||
NDRF0003000000000000000000 /* NdrFfi */,
|
||||
);
|
||||
productName = bitchat_iOS;
|
||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||
@ -351,6 +353,7 @@
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
||||
NDRF0001000000000000000000 /* XCLocalSwiftPackageReference "localPackages/NdrFfi" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 90;
|
||||
projectDirPath = "";
|
||||
@ -928,6 +931,10 @@
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Arti;
|
||||
};
|
||||
NDRF0001000000000000000000 /* XCLocalSwiftPackageReference "localPackages/NdrFfi" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/NdrFfi;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
@ -977,6 +984,14 @@
|
||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||
productName = P256K;
|
||||
};
|
||||
NDRF0002000000000000000000 /* NdrFfi */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = NdrFfi;
|
||||
};
|
||||
NDRF0003000000000000000000 /* NdrFfi */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = NdrFfi;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 475D96681D0EA0AE57A4E06E /* Project object */;
|
||||
|
||||
@ -965,7 +965,7 @@ enum NostrRequest: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
struct NostrFilter: Encodable {
|
||||
struct NostrFilter: Codable {
|
||||
var ids: [String]?
|
||||
var authors: [String]?
|
||||
var kinds: [Int]?
|
||||
@ -974,7 +974,7 @@ struct NostrFilter: Encodable {
|
||||
var limit: Int?
|
||||
|
||||
// Tag filters - stored internally but encoded specially
|
||||
fileprivate var tagFilters: [String: [String]]?
|
||||
var tagFilters: [String: [String]]?
|
||||
|
||||
init() {
|
||||
// Default initializer
|
||||
@ -984,6 +984,29 @@ struct NostrFilter: Encodable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case ids, authors, kinds, since, until, limit
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
|
||||
|
||||
self.ids = try container.decodeIfPresent([String].self, forKey: DynamicCodingKey(stringValue: "ids"))
|
||||
self.authors = try container.decodeIfPresent([String].self, forKey: DynamicCodingKey(stringValue: "authors"))
|
||||
self.kinds = try container.decodeIfPresent([Int].self, forKey: DynamicCodingKey(stringValue: "kinds"))
|
||||
self.since = try container.decodeIfPresent(Int.self, forKey: DynamicCodingKey(stringValue: "since"))
|
||||
self.until = try container.decodeIfPresent(Int.self, forKey: DynamicCodingKey(stringValue: "until"))
|
||||
self.limit = try container.decodeIfPresent(Int.self, forKey: DynamicCodingKey(stringValue: "limit"))
|
||||
|
||||
// Decode tag filters (#p, #d, etc) into internal storage without the leading '#'.
|
||||
var decodedTagFilters: [String: [String]] = [:]
|
||||
for key in container.allKeys {
|
||||
let name = key.stringValue
|
||||
guard name.hasPrefix("#") else { continue }
|
||||
let tag = String(name.dropFirst())
|
||||
if let values = try container.decodeIfPresent([String].self, forKey: key) {
|
||||
decodedTagFilters[tag] = values
|
||||
}
|
||||
}
|
||||
self.tagFilters = decodedTagFilters.isEmpty ? nil : decodedTagFilters
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: DynamicCodingKey.self)
|
||||
|
||||
@ -75,6 +75,9 @@ enum NoisePayloadType: UInt8 {
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
// Double Ratchet (nostr-double-ratchet) out-of-band session bootstrap
|
||||
// Nostr invite/response events are exchanged over the BLE Noise channel (not published to Nostr).
|
||||
case ndrEvent = 0x12 // UTF-8 Nostr event JSON (invite/response)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
@ -83,6 +86,7 @@ enum NoisePayloadType: UInt8 {
|
||||
case .delivered: return "delivered"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
case .verifyResponse: return "verifyResponse"
|
||||
case .ndrEvent: return "ndrEvent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -248,12 +248,22 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private static func shouldEnableCoreBluetooth() -> Bool {
|
||||
// SwiftPM's swift-testing/xctest runner is a command-line tool without an Info.plist.
|
||||
// On macOS, touching CoreBluetooth there can crash due to missing usage-description keys (TCC).
|
||||
let processName = ProcessInfo.processInfo.processName.lowercased()
|
||||
if processName.contains("swiftpm-testing-helper") { return false }
|
||||
if processName.contains("xctest") { return false }
|
||||
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
initializeBluetoothManagers: Bool = true
|
||||
initializeBluetoothManagers: Bool = BLEService.shouldEnableCoreBluetooth()
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
@ -312,6 +322,8 @@ final class BLEService: NSObject {
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
||||
#endif
|
||||
} else {
|
||||
SecureLogger.info("CoreBluetooth disabled (test environment)", category: .session)
|
||||
}
|
||||
|
||||
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
|
||||
@ -1578,6 +1590,13 @@ final class BLEService: NSObject {
|
||||
guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
|
||||
sendNoisePayload(payload, to: peerID)
|
||||
}
|
||||
|
||||
func sendNdrEvent(to peerID: PeerID, eventJson: String) {
|
||||
guard let data = eventJson.data(using: .utf8), !data.isEmpty else { return }
|
||||
var payload = Data([NoisePayloadType.ndrEvent.rawValue])
|
||||
payload.append(data)
|
||||
sendNoisePayload(payload, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GossipSyncManager Delegate
|
||||
@ -4205,6 +4224,11 @@ extension BLEService {
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .ndrEvent:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .ndrEvent, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .none:
|
||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||
}
|
||||
|
||||
370
bitchat/Services/NdrNostrService.swift
Normal file
370
bitchat/Services/NdrNostrService.swift
Normal file
@ -0,0 +1,370 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import NdrFfi
|
||||
|
||||
@MainActor
|
||||
protocol NostrRelayManaging: AnyObject {
|
||||
func subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String,
|
||||
relayUrls: [String]?,
|
||||
handler: @escaping (NostrEvent) -> Void,
|
||||
onEOSE: (() -> Void)?
|
||||
)
|
||||
func unsubscribe(id: String)
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]?)
|
||||
}
|
||||
|
||||
extension NostrRelayManager: NostrRelayManaging {}
|
||||
|
||||
/// Bridges `nostr-double-ratchet` (ndr-ffi) `SessionManagerHandle` with `NostrRelayManager`.
|
||||
///
|
||||
/// The ndr session manager emits a stream of pub/sub actions we must execute externally:
|
||||
/// - `subscribe` / `unsubscribe`: Nostr filter subscriptions (for invite responses, sessions, etc)
|
||||
/// - `publish_signed`: signed Nostr events to publish
|
||||
/// - `decrypted_message`: decrypted inner event JSON (kind 14) to surface to the app
|
||||
///
|
||||
/// BitChat policy: do NOT publish double-ratchet invite/response handshake events to Nostr.
|
||||
/// Those are exchanged out-of-band over the BLE Noise channel (see `Transport.sendNdrEvent`).
|
||||
@MainActor
|
||||
final class NdrNostrService {
|
||||
static let shared = NdrNostrService()
|
||||
private static let compactInviteURLRoot = "https://b"
|
||||
|
||||
/// Called when an ndr message is decrypted into an inner Nostr event (kind 14).
|
||||
var onDecryptedMessage: ((NostrEvent) -> Void)?
|
||||
|
||||
private let relayManager: NostrRelayManaging
|
||||
private let storageDirectoryProvider: @MainActor () throws -> URL
|
||||
|
||||
private var sessionManager: SessionManagerHandle?
|
||||
private var activeSubIDs = Set<String>()
|
||||
private var cachedInviteEventJson: String?
|
||||
|
||||
private var configuredForPubkeyHex: String?
|
||||
private let deviceId: String
|
||||
|
||||
private init() {
|
||||
self.relayManager = NostrRelayManager.shared
|
||||
self.deviceId = Self.loadOrCreateDeviceId()
|
||||
self.storageDirectoryProvider = Self.ndrStorageDirectory
|
||||
}
|
||||
|
||||
/// Dependency-injected initializer (primarily for tests).
|
||||
init(
|
||||
relayManager: NostrRelayManaging,
|
||||
deviceId: String,
|
||||
storageDirectoryProvider: @escaping @MainActor () throws -> URL
|
||||
) {
|
||||
self.relayManager = relayManager
|
||||
self.deviceId = deviceId
|
||||
self.storageDirectoryProvider = storageDirectoryProvider
|
||||
}
|
||||
|
||||
var isConfigured: Bool { sessionManager != nil }
|
||||
var configuredPubkeyHex: String? { configuredForPubkeyHex }
|
||||
|
||||
/// Returns our current device invite event JSON (kind 30078), if available.
|
||||
///
|
||||
/// This is exchanged out-of-band with mutual favorites over BLE and is never published to Nostr.
|
||||
func currentInviteEventJson() -> String? {
|
||||
cachedInviteEventJson
|
||||
}
|
||||
|
||||
func configureIfNeeded(identity: NostrIdentity) {
|
||||
let pubkey = identity.publicKeyHex.lowercased()
|
||||
if configuredForPubkeyHex == pubkey, sessionManager != nil { return }
|
||||
|
||||
// Identity changed: tear down subscriptions we created (best-effort).
|
||||
for id in activeSubIDs {
|
||||
relayManager.unsubscribe(id: id)
|
||||
}
|
||||
activeSubIDs.removeAll()
|
||||
sessionManager = nil
|
||||
cachedInviteEventJson = nil
|
||||
configuredForPubkeyHex = pubkey
|
||||
|
||||
do {
|
||||
let storagePath = try storageDirectoryProvider().path
|
||||
let mgr = try SessionManagerHandle.newWithStoragePath(
|
||||
ourPubkeyHex: pubkey,
|
||||
ourIdentityPrivkeyHex: identity.privateKey.hexEncodedString(),
|
||||
deviceId: deviceId,
|
||||
storagePath: storagePath,
|
||||
ownerPubkeyHex: nil
|
||||
)
|
||||
try mgr.`init`()
|
||||
sessionManager = mgr
|
||||
_ = drainAndApplyPubSubEvents()
|
||||
SecureLogger.info("NdrNostrService configured pub=\(pubkey.prefix(8))… device=\(deviceId)", category: .session)
|
||||
} catch {
|
||||
SecureLogger.error("NdrNostrService: failed to configure: \(error)", category: .session)
|
||||
sessionManager = nil
|
||||
}
|
||||
}
|
||||
|
||||
func hasActiveSession(with peerPubkeyHex: String) -> Bool {
|
||||
guard let mgr = sessionManager else { return false }
|
||||
do {
|
||||
return try mgr.getActiveSessionState(peerPubkeyHex: peerPubkeyHex.lowercased()) != nil
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func activeSessionStateJson(with peerPubkeyHex: String) -> String? {
|
||||
guard let mgr = sessionManager else { return nil }
|
||||
return try? mgr.getActiveSessionState(peerPubkeyHex: peerPubkeyHex.lowercased())
|
||||
}
|
||||
|
||||
/// Attempt to send via ndr when a session exists.
|
||||
/// Returns true when the runtime accepted the message, even if it queued it for a later relay publish.
|
||||
func sendIfPossible(_ text: String, to peerPubkeyHex: String) -> Bool {
|
||||
guard let mgr = sessionManager else { return false }
|
||||
guard hasActiveSession(with: peerPubkeyHex) else { return false }
|
||||
do {
|
||||
let outboundEventIDs = try mgr.sendText(
|
||||
recipientPubkeyHex: peerPubkeyHex.lowercased(),
|
||||
text: text,
|
||||
expiresAtSeconds: nil
|
||||
)
|
||||
_ = drainAndApplyPubSubEvents()
|
||||
if outboundEventIDs.isEmpty {
|
||||
SecureLogger.debug(
|
||||
"NdrNostrService: send queued no relay publish for \(peerPubkeyHex.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
SecureLogger.debug("NdrNostrService: send failed (no session yet?): \(error)", category: .session)
|
||||
// Still drain in case the error queued any pubsub actions.
|
||||
_ = drainAndApplyPubSubEvents()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a received invite/response payload (transferred out-of-band over BLE).
|
||||
///
|
||||
/// Returns any outbound handshake payloads (e.g. giftwrap response JSON or compact invite URL)
|
||||
/// that should be returned to the sender over BLE.
|
||||
func processOutOfBandEventJson(_ eventJson: String) -> [String] {
|
||||
guard let mgr = sessionManager else { return [] }
|
||||
let payload = eventJson.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let inboundInvite = parseOutOfBandInvite(payload)
|
||||
do {
|
||||
switch inboundInvite?.transport {
|
||||
case .eventJSON:
|
||||
_ = try mgr.acceptInviteFromEventJson(eventJson: payload, ownerPubkeyHintHex: nil)
|
||||
case .url:
|
||||
_ = try mgr.acceptInviteFromUrl(inviteUrl: payload, ownerPubkeyHintHex: nil)
|
||||
case .none:
|
||||
try mgr.processEvent(eventJson: payload)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.debug("NdrNostrService: processOutOfBandEventJson ignored/rejected: \(error)", category: .session)
|
||||
}
|
||||
let outOfBandPublishes = drainAndApplyPubSubEvents(collectOutOfBandPublishes: true)
|
||||
if let inboundInvite,
|
||||
outOfBandPublishes.isEmpty,
|
||||
hasActiveSession(with: inboundInvite.senderPubkeyHex),
|
||||
let currentInvite = preferredInviteOobPayload() {
|
||||
return outOfBandPublishes + [currentInvite]
|
||||
}
|
||||
return outOfBandPublishes
|
||||
}
|
||||
|
||||
/// Process a Nostr event received from relays (kind 1060 messages, app-keys maintenance, etc).
|
||||
func processInboundRelayEvent(_ event: NostrEvent) {
|
||||
processInboundNostrEvent(event)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func processInboundNostrEvent(_ event: NostrEvent) {
|
||||
guard let mgr = sessionManager else { return }
|
||||
guard let json = try? event.jsonString() else { return }
|
||||
|
||||
do {
|
||||
try mgr.processEvent(eventJson: json)
|
||||
} catch {
|
||||
// ndr will reject most unrelated events; keep log noise low.
|
||||
SecureLogger.debug("NdrNostrService: processEvent ignored/rejected: \(error)", category: .session)
|
||||
}
|
||||
|
||||
_ = drainAndApplyPubSubEvents()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func drainAndApplyPubSubEvents(collectOutOfBandPublishes: Bool = false) -> [String] {
|
||||
guard let mgr = sessionManager else { return [] }
|
||||
var outOfBandPublishes: [String] = []
|
||||
do {
|
||||
let events = try mgr.drainEvents()
|
||||
for e in events {
|
||||
apply(
|
||||
pubsub: e,
|
||||
collectOutOfBandPublish: collectOutOfBandPublishes ? { outOfBandPublishes.append($0) } : nil
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("NdrNostrService: drainEvents failed: \(error)", category: .session)
|
||||
}
|
||||
return outOfBandPublishes
|
||||
}
|
||||
|
||||
private func apply(pubsub e: PubSubEvent, collectOutOfBandPublish: ((String) -> Void)?) {
|
||||
switch e.kind {
|
||||
case "subscribe":
|
||||
guard let subid = e.subid, let filterJson = e.filterJson else { return }
|
||||
|
||||
do {
|
||||
let filter = try JSONDecoder().decode(NostrFilter.self, from: Data(filterJson.utf8))
|
||||
// BitChat policy: don't do Nostr-based DR invite discovery or invite-response listening.
|
||||
if shouldIgnoreNdrSubscription(filter) {
|
||||
return
|
||||
}
|
||||
guard activeSubIDs.insert(subid).inserted else { return } // already subscribed
|
||||
relayManager.subscribe(
|
||||
filter: filter,
|
||||
id: subid,
|
||||
relayUrls: nil,
|
||||
handler: { [weak self] event in
|
||||
self?.processInboundNostrEvent(event)
|
||||
},
|
||||
onEOSE: nil
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error("NdrNostrService: failed to decode subscribe filter: \(error)", category: .session)
|
||||
}
|
||||
|
||||
case "unsubscribe":
|
||||
guard let subid = e.subid else { return }
|
||||
relayManager.unsubscribe(id: subid)
|
||||
activeSubIDs.remove(subid)
|
||||
|
||||
case "publish_signed":
|
||||
guard let eventJson = e.eventJson else { return }
|
||||
do {
|
||||
let event = try JSONDecoder().decode(NostrEvent.self, from: Data(eventJson.utf8))
|
||||
|
||||
if isDoubleRatchetInviteEvent(event) {
|
||||
// Cache the current device invite for out-of-band sharing; never publish to Nostr.
|
||||
cachedInviteEventJson = eventJson
|
||||
collectOutOfBandPublish?(eventJson)
|
||||
return
|
||||
}
|
||||
if event.kind == 1059 {
|
||||
// Giftwrap responses are part of the DR handshake; exchange OOB over BLE.
|
||||
collectOutOfBandPublish?(eventJson)
|
||||
return
|
||||
}
|
||||
|
||||
relayManager.sendEvent(event, to: nil)
|
||||
} catch {
|
||||
SecureLogger.error("NdrNostrService: failed to decode outbound event: \(error)", category: .session)
|
||||
}
|
||||
|
||||
case "decrypted_message":
|
||||
guard let innerJson = e.content else { return }
|
||||
do {
|
||||
let inner = try JSONDecoder().decode(NostrEvent.self, from: Data(innerJson.utf8))
|
||||
onDecryptedMessage?(inner)
|
||||
} catch {
|
||||
SecureLogger.error("NdrNostrService: failed to decode decrypted inner event: \(error)", category: .session)
|
||||
}
|
||||
|
||||
default:
|
||||
// Other events currently ignored (e.g. app-keys maintenance).
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func isDoubleRatchetInviteEvent(_ event: NostrEvent) -> Bool {
|
||||
guard event.kind == 30078 else { return false }
|
||||
for tag in event.tags where tag.count >= 2 {
|
||||
if tag[0] == "l", tag[1] == "double-ratchet/invites" {
|
||||
return true
|
||||
}
|
||||
if tag[0] == "d", tag[1].hasPrefix("double-ratchet/invites/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private enum OutOfBandInviteTransport {
|
||||
case eventJSON
|
||||
case url
|
||||
}
|
||||
|
||||
private struct ParsedOutOfBandInvite {
|
||||
let senderPubkeyHex: String
|
||||
let transport: OutOfBandInviteTransport
|
||||
}
|
||||
|
||||
private func parseOutOfBandInvite(_ payload: String) -> ParsedOutOfBandInvite? {
|
||||
guard !payload.isEmpty else { return nil }
|
||||
if payload.first == "{" {
|
||||
guard let event = try? JSONDecoder().decode(NostrEvent.self, from: Data(payload.utf8)),
|
||||
isDoubleRatchetInviteEvent(event) else {
|
||||
return nil
|
||||
}
|
||||
return ParsedOutOfBandInvite(
|
||||
senderPubkeyHex: event.pubkey.lowercased(),
|
||||
transport: .eventJSON
|
||||
)
|
||||
}
|
||||
|
||||
guard let invite = try? InviteHandle.fromUrl(url: payload) else {
|
||||
return nil
|
||||
}
|
||||
return ParsedOutOfBandInvite(
|
||||
senderPubkeyHex: invite.getInviterPubkeyHex().lowercased(),
|
||||
transport: .url
|
||||
)
|
||||
}
|
||||
|
||||
private func preferredInviteOobPayload() -> String? {
|
||||
guard let eventJson = cachedInviteEventJson else { return nil }
|
||||
return compactInviteURL(from: eventJson) ?? eventJson
|
||||
}
|
||||
|
||||
private func compactInviteURL(from eventJson: String) -> String? {
|
||||
guard let invite = try? InviteHandle.fromEventJson(eventJson: eventJson) else {
|
||||
return nil
|
||||
}
|
||||
return try? invite.toUrl(root: Self.compactInviteURLRoot)
|
||||
}
|
||||
|
||||
private func shouldIgnoreNdrSubscription(_ filter: NostrFilter) -> Bool {
|
||||
// Never use Nostr for invite/response exchange in BitChat.
|
||||
if filter.kinds?.contains(1059) == true {
|
||||
return true
|
||||
}
|
||||
if filter.kinds?.contains(30078) == true,
|
||||
filter.tagFilters?["l"]?.contains("double-ratchet/invites") == true {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func loadOrCreateDeviceId() -> String {
|
||||
let defaults = UserDefaults.standard
|
||||
let key = "ndr.device_id"
|
||||
if let existing = defaults.string(forKey: key), !existing.isEmpty {
|
||||
return existing
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
defaults.set(id, forKey: key)
|
||||
return id
|
||||
}
|
||||
|
||||
private static func ndrStorageDirectory() throws -> URL {
|
||||
let root = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let dir = root.appendingPathComponent("ndr", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
|
||||
return dir
|
||||
}
|
||||
}
|
||||
@ -44,10 +44,14 @@ final class NetworkActivationService: ObservableObject {
|
||||
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
||||
private let mutualFavoritesProvider: () -> Set<Data>
|
||||
private let torController: NetworkActivationTorControlling
|
||||
private let relayController: NetworkActivationRelayControlling
|
||||
private let relayControllerProvider: @MainActor () -> NetworkActivationRelayControlling
|
||||
private let proxyController: NetworkActivationProxyControlling
|
||||
private let notificationCenter: NotificationCenter
|
||||
|
||||
private var relayController: NetworkActivationRelayControlling {
|
||||
relayControllerProvider()
|
||||
}
|
||||
|
||||
private init() {
|
||||
storage = .standard
|
||||
locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()
|
||||
@ -55,7 +59,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
permissionProvider = { LocationChannelManager.shared.permissionState }
|
||||
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
||||
torController = TorManager.shared
|
||||
relayController = NostrRelayManager.shared
|
||||
relayControllerProvider = { NostrRelayManager.shared }
|
||||
proxyController = TorURLSession.shared
|
||||
notificationCenter = .default
|
||||
}
|
||||
@ -77,7 +81,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
self.permissionProvider = permissionProvider
|
||||
self.mutualFavoritesProvider = mutualFavoritesProvider
|
||||
self.torController = torController
|
||||
self.relayController = relayController
|
||||
self.relayControllerProvider = { relayController }
|
||||
self.proxyController = proxyController
|
||||
self.notificationCenter = notificationCenter
|
||||
}
|
||||
|
||||
@ -5,6 +5,34 @@ import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport, @unchecked Sendable {
|
||||
enum OutboundPrivateMessageTransport: String, Codable {
|
||||
case ndr
|
||||
case nip17
|
||||
}
|
||||
|
||||
enum OutboundPrivateMessageError: LocalizedError {
|
||||
case missingRecipientNpub(String)
|
||||
case invalidRecipientNpub(String)
|
||||
case missingSenderIdentity
|
||||
case failedToEncodePacket
|
||||
case failedToBuildNip17Event
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingRecipientNpub(let peerID):
|
||||
return "Missing recipient Nostr public key for peer \(peerID)"
|
||||
case .invalidRecipientNpub(let npub):
|
||||
return "Recipient Nostr public key is invalid: \(npub)"
|
||||
case .missingSenderIdentity:
|
||||
return "Local Nostr identity is unavailable"
|
||||
case .failedToEncodePacket:
|
||||
return "Failed to encode embedded private-message packet"
|
||||
case .failedToBuildNip17Event:
|
||||
return "Failed to build fallback NIP-17 event"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Dependencies {
|
||||
let notificationCenter: NotificationCenter
|
||||
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
|
||||
@ -45,6 +73,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private let dependencies: Dependencies
|
||||
private let ndrService: NdrNostrService
|
||||
private var favoriteStatusObserver: NSObjectProtocol?
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
@ -55,11 +84,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
ndrService: NdrNostrService? = nil,
|
||||
dependencies: Dependencies? = nil
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
||||
self.ndrService = ndrService ?? NdrNostrService.shared
|
||||
|
||||
setupObservers()
|
||||
|
||||
@ -158,18 +189,46 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
return
|
||||
do {
|
||||
_ = try sendPrivateMessageAndReturnTransport(
|
||||
content,
|
||||
to: peerID,
|
||||
recipientNickname: recipientNickname,
|
||||
messageID: messageID
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to send PM: \(error)", category: .session)
|
||||
}
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendPrivateMessageAndReturnTransport(
|
||||
_ content: String,
|
||||
to peerID: PeerID,
|
||||
recipientNickname: String,
|
||||
messageID: String
|
||||
) throws -> OutboundPrivateMessageTransport {
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else {
|
||||
throw OutboundPrivateMessageError.missingRecipientNpub(peerID.id)
|
||||
}
|
||||
guard let recipientHex = npubToHex(recipientNpub) else {
|
||||
throw OutboundPrivateMessageError.invalidRecipientNpub(recipientNpub)
|
||||
}
|
||||
guard let senderIdentity = try dependencies.currentIdentity() else {
|
||||
throw OutboundPrivateMessageError.missingSenderIdentity
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
throw OutboundPrivateMessageError.failedToEncodePacket
|
||||
}
|
||||
guard let transport = sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity) else {
|
||||
throw OutboundPrivateMessageError.failedToBuildNip17Event
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Enqueue and process with throttling to avoid relay rate limits
|
||||
// Use barrier to synchronize access to readQueue
|
||||
@ -190,7 +249,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
_ = sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
@ -205,7 +264,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
_ = sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -219,7 +278,7 @@ extension NostrTransport {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
_ = sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -227,7 +286,7 @@ extension NostrTransport {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
_ = sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,7 +299,7 @@ extension NostrTransport {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
_ = sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -263,15 +322,24 @@ extension NostrTransport {
|
||||
|
||||
/// Creates and sends a gift-wrapped private message event
|
||||
@MainActor
|
||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) -> OutboundPrivateMessageTransport? {
|
||||
// Prefer nostr-double-ratchet when a session exists.
|
||||
// BitChat policy: double-ratchet invite/response handshake is exchanged out-of-band over BLE (mutual favorites),
|
||||
// so we do not attempt Nostr-based invite discovery here.
|
||||
ndrService.configureIfNeeded(identity: senderIdentity)
|
||||
if ndrService.sendIfPossible(content, to: recipientHex) {
|
||||
return .ndr
|
||||
}
|
||||
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if registerPending {
|
||||
dependencies.registerPendingGiftWrap(event.id)
|
||||
}
|
||||
dependencies.sendEvent(event)
|
||||
return .nip17
|
||||
}
|
||||
|
||||
/// Must be called within a barrier on `queue`
|
||||
@ -295,7 +363,7 @@ extension NostrTransport {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
_ = sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -59,6 +59,9 @@ protocol Transport: AnyObject {
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
// Double Ratchet out-of-band bootstrap (optional for transports)
|
||||
func sendNdrEvent(to peerID: PeerID, eventJson: String)
|
||||
|
||||
// Pending file management (BCH-01-002: files held in memory until user accepts)
|
||||
func acceptPendingFile(id: String) -> URL?
|
||||
@ -68,6 +71,7 @@ protocol Transport: AnyObject {
|
||||
extension Transport {
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendNdrEvent(to peerID: PeerID, eventJson: String) {}
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
@ -267,6 +267,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
let meshService: Transport
|
||||
let idBridge: NostrIdentityBridge
|
||||
let identityManager: SecureIdentityStateManagerProtocol
|
||||
let ndrService: NdrNostrService
|
||||
|
||||
var nostrRelayManager: NostrRelayManager?
|
||||
private let userDefaults = UserDefaults.standard
|
||||
@ -395,13 +396,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
convenience init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
ndrService: NdrNostrService? = nil
|
||||
) {
|
||||
self.init(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
|
||||
ndrService: ndrService
|
||||
)
|
||||
}
|
||||
|
||||
@ -412,11 +415,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
transport: Transport
|
||||
transport: Transport,
|
||||
ndrService: NdrNostrService? = nil
|
||||
) {
|
||||
let resolvedNdrService = ndrService ?? NdrNostrService.shared
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
self.identityManager = identityManager
|
||||
self.ndrService = resolvedNdrService
|
||||
self.meshService = transport
|
||||
self.publicMessagePipeline = PublicMessagePipeline()
|
||||
|
||||
@ -433,7 +439,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
self.commandProcessor = CommandProcessor(identityManager: identityManager)
|
||||
self.privateChatManager = PrivateChatManager(meshService: meshService)
|
||||
self.unifiedPeerService = UnifiedPeerService(meshService: meshService, idBridge: idBridge, identityManager: identityManager)
|
||||
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: resolvedNdrService)
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
self.messageRouter = MessageRouter(transports: [meshService, nostrTransport])
|
||||
// Route receipts from PrivateChatManager through MessageRouter
|
||||
@ -1559,8 +1565,60 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
// Update peer manager to refresh UI
|
||||
// UnifiedPeerService updates automatically via subscriptions
|
||||
}
|
||||
|
||||
// If this update produced a mutual favorite relationship with a currently-connected peer,
|
||||
// bootstrap nostr-double-ratchet over the BLE Noise channel (no Nostr invite publishing).
|
||||
if let connected = unifiedPeerService.peers.first(where: { $0.isConnected && $0.noisePublicKey == peerPublicKey }) {
|
||||
maybeBootstrapDoubleRatchetIfNeeded(for: connected.peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func maybeBootstrapDoubleRatchetIfNeeded(for peerID: PeerID) {
|
||||
guard let peer = unifiedPeerService.getPeer(by: peerID) else { return }
|
||||
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey),
|
||||
relationship.isMutual,
|
||||
let peerNostrKey = relationship.peerNostrPublicKey,
|
||||
let peerPubkeyHex = nostrPubkeyHex(from: peerNostrKey),
|
||||
let currentIdentity = try? idBridge.getCurrentNostrIdentity()
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
ndrService.configureIfNeeded(identity: currentIdentity)
|
||||
if ndrService.hasActiveSession(with: peerPubkeyHex) {
|
||||
return
|
||||
}
|
||||
guard let inviteJson = ndrService.currentInviteEventJson() else {
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug(
|
||||
"NDR: OOB invite -> \(peerID.id.prefix(8))… peer=\(peerPubkeyHex.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
meshService.sendNdrEvent(to: peerID, eventJson: inviteJson)
|
||||
}
|
||||
|
||||
private func nostrPubkeyHex(from npubOrHex: String) -> String? {
|
||||
if npubOrHex.hasPrefix("npub") {
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(npubOrHex)
|
||||
guard hrp == "npub" else { return nil }
|
||||
return data.hexEncodedString()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Accept raw hex pubkeys too.
|
||||
let lowered = npubOrHex.lowercased()
|
||||
if lowered.count == 64, lowered.allSatisfy({ $0.isHexDigit }) {
|
||||
return lowered
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - App Lifecycle
|
||||
|
||||
@ -1748,7 +1806,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
for message in messages where message.senderPeerID == peerID && !message.isRelay {
|
||||
if !sentReadReceipts.contains(message.id) {
|
||||
SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…", category: .session)
|
||||
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
@ -3167,6 +3225,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
updateEncryptionStatus(for: peerID)
|
||||
}
|
||||
}
|
||||
case .ndrEvent:
|
||||
// Double Ratchet out-of-band event exchange (invite/response) is allowed only for mutual favorites.
|
||||
guard let eventJson = String(data: payload, encoding: .utf8), !eventJson.isEmpty else { return }
|
||||
guard let peer = unifiedPeerService.getPeer(by: peerID) else { return }
|
||||
guard FavoritesPersistenceService.shared.isMutualFavorite(peer.noisePublicKey) else { return }
|
||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
|
||||
ndrService.configureIfNeeded(identity: currentIdentity)
|
||||
let outbound = ndrService.processOutOfBandEventJson(eventJson)
|
||||
for json in outbound {
|
||||
meshService.sendNdrEvent(to: peerID, eventJson: json)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3258,6 +3328,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
|
||||
// Flush any queued messages for this peer via router
|
||||
messageRouter.flushOutbox(for: peerID)
|
||||
|
||||
// If this is a mutual favorite, bootstrap nostr-double-ratchet out-of-band over BLE.
|
||||
maybeBootstrapDoubleRatchetIfNeeded(for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -171,7 +171,7 @@ extension ChatViewModel {
|
||||
handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
case .verifyChallenge, .verifyResponse, .ndrEvent:
|
||||
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
|
||||
break
|
||||
}
|
||||
@ -404,7 +404,7 @@ extension ChatViewModel {
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
|
||||
// Explicitly list other cases so we get compile-time check if a new case is added in the future
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
case .verifyChallenge, .verifyResponse, .ndrEvent:
|
||||
break
|
||||
}
|
||||
}
|
||||
@ -572,7 +572,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
// MARK: - Nostr DM Handling
|
||||
|
||||
|
||||
@MainActor
|
||||
func setupNostrMessageHandling() {
|
||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else {
|
||||
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
|
||||
@ -580,6 +581,14 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
SecureLogger.debug("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", category: .session)
|
||||
|
||||
// Configure nostr-double-ratchet (ndr-ffi) integration.
|
||||
// BitChat policy: invite/response handshake is exchanged out-of-band over BLE (mutual favorites),
|
||||
// so we only use Nostr here for kind 1060 message exchange and decryption.
|
||||
ndrService.configureIfNeeded(identity: currentIdentity)
|
||||
ndrService.onDecryptedMessage = { [weak self] innerEvent in
|
||||
self?.handleNdrDecryptedMessage(innerEvent)
|
||||
}
|
||||
|
||||
// Subscribe to Nostr messages
|
||||
let filter = NostrFilter.giftWrapsFor(
|
||||
@ -612,55 +621,89 @@ extension ChatViewModel {
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: currentIdentity
|
||||
)
|
||||
|
||||
// Handle verification payloads first
|
||||
if content.hasPrefix("verify:") {
|
||||
// Ignore verification payloads arriving via Nostr path for now
|
||||
// Verification should ideally happen over mesh for security binding
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
|
||||
if content.hasPrefix("bitchat1:") {
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
|
||||
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Map sender by Nostr pubkey to Noise key when possible
|
||||
let actualSenderNoiseKey = findNoiseKey(for: senderPubkey)
|
||||
|
||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
||||
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
||||
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
|
||||
}
|
||||
await processDecryptedNostrDMContent(
|
||||
content,
|
||||
senderPubkey: senderPubkey,
|
||||
rumorTimestamp: rumorTimestamp,
|
||||
currentIdentity: currentIdentity
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func handleNdrDecryptedMessage(_ innerEvent: NostrEvent) {
|
||||
// Deduplicate using the stable inner event id.
|
||||
if deduplicationService.hasProcessedNostrEvent(innerEvent.id) { return }
|
||||
deduplicationService.recordNostrEvent(innerEvent.id)
|
||||
|
||||
let content = innerEvent.content
|
||||
let senderPubkey = innerEvent.pubkey
|
||||
let rumorTimestamp = innerEvent.created_at
|
||||
|
||||
Task(priority: .userInitiated) { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let currentIdentity = try? self.idBridge.getCurrentNostrIdentity() else { return }
|
||||
await self.processDecryptedNostrDMContent(
|
||||
content,
|
||||
senderPubkey: senderPubkey,
|
||||
rumorTimestamp: rumorTimestamp,
|
||||
currentIdentity: currentIdentity
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func processDecryptedNostrDMContent(
|
||||
_ content: String,
|
||||
senderPubkey: String,
|
||||
rumorTimestamp: Int,
|
||||
currentIdentity: NostrIdentity
|
||||
) async {
|
||||
// Handle verification payloads first
|
||||
if content.hasPrefix("verify:") {
|
||||
// Ignore verification payloads arriving via Nostr path for now
|
||||
// Verification should ideally happen over mesh for security binding
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
|
||||
if content.hasPrefix("bitchat1:") {
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
|
||||
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Map sender by Nostr pubkey to Noise key when possible
|
||||
let actualSenderNoiseKey = findNoiseKey(for: senderPubkey)
|
||||
|
||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
||||
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
||||
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse, .ndrEvent:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||
// Check favorites for this Nostr key
|
||||
let favorites = FavoritesPersistenceService.shared.favorites.values
|
||||
@ -703,13 +746,13 @@ extension ChatViewModel {
|
||||
// Ideally we would use MessageRouter here, but for simplicity in this direct callback:
|
||||
// check if we have an identity
|
||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
||||
}
|
||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
// Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
||||
SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
||||
@ -719,12 +762,12 @@ extension ChatViewModel {
|
||||
if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID {
|
||||
if let _ = key {
|
||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
||||
}
|
||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
||||
SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
||||
|
||||
@ -175,7 +175,7 @@ extension ChatViewModel {
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session)
|
||||
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
|
||||
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
@ -288,7 +288,7 @@ extension ChatViewModel {
|
||||
|
||||
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||
guard !sentGeoDeliveryAcks.contains(messageId) else { return }
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id)
|
||||
sentGeoDeliveryAcks.insert(messageId)
|
||||
@ -296,7 +296,7 @@ extension ChatViewModel {
|
||||
|
||||
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||
guard !sentReadReceipts.contains(messageId) else { return }
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id)
|
||||
sentReadReceipts.insert(messageId)
|
||||
@ -846,7 +846,7 @@ extension ChatViewModel {
|
||||
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
|
||||
sentReadReceipts.insert(message.id)
|
||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
|
||||
169
bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift
Normal file
169
bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift
Normal file
@ -0,0 +1,169 @@
|
||||
//
|
||||
// NdrOutOfBandTransportTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import NdrFfi
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
final class FakeRelayManager: NostrRelayManaging {
|
||||
struct Subscription {
|
||||
let id: String
|
||||
let filter: NostrFilter
|
||||
}
|
||||
|
||||
private(set) var subscriptions: [Subscription] = []
|
||||
private(set) var unsubscribedIDs: [String] = []
|
||||
private(set) var sentEvents: [NostrEvent] = []
|
||||
|
||||
func resetSentEvents() {
|
||||
sentEvents.removeAll()
|
||||
}
|
||||
|
||||
func subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String,
|
||||
relayUrls: [String]?,
|
||||
handler: @escaping (NostrEvent) -> Void,
|
||||
onEOSE: (() -> Void)?
|
||||
) {
|
||||
subscriptions.append(Subscription(id: id, filter: filter))
|
||||
}
|
||||
|
||||
func unsubscribe(id: String) {
|
||||
unsubscribedIDs.append(id)
|
||||
}
|
||||
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]?) {
|
||||
sentEvents.append(event)
|
||||
}
|
||||
}
|
||||
|
||||
struct NdrOutOfBandTransportTests {
|
||||
|
||||
@Test("NdrNostrService does not publish invite/response events to Nostr relays")
|
||||
@MainActor
|
||||
func ndrNostrService_doesNotPublishHandshakeEvents() throws {
|
||||
let relay = FakeRelayManager()
|
||||
let storage = try makeTempDir(label: "ndr-no-publish")
|
||||
let identity = try NostrIdentity.generate()
|
||||
let svc = NdrNostrService(
|
||||
relayManager: relay,
|
||||
deviceId: "test-device",
|
||||
storageDirectoryProvider: { storage }
|
||||
)
|
||||
|
||||
svc.configureIfNeeded(identity: identity)
|
||||
|
||||
let inviteJson = try #require(svc.currentInviteEventJson(), "Expected device invite to be cached")
|
||||
#expect(try extractNostrKind(json: inviteJson) == 30078)
|
||||
|
||||
// Service may publish other maintenance events, but not invites or giftwrap responses.
|
||||
#expect(!relay.sentEvents.contains(where: { isDoubleRatchetInviteEvent($0) }))
|
||||
#expect(!relay.sentEvents.contains(where: { $0.kind == 1059 }))
|
||||
|
||||
// Service should not ask relays to subscribe to giftwrap responses (kind 1059) or invite discovery.
|
||||
#expect(!relay.subscriptions.contains(where: { $0.filter.kinds?.contains(1059) == true }))
|
||||
#expect(!relay.subscriptions.contains(where: { sub in
|
||||
(sub.filter.kinds?.contains(30078) == true) &&
|
||||
(sub.filter.tagFilters?["l"]?.contains("double-ratchet/invites") == true)
|
||||
}))
|
||||
}
|
||||
|
||||
@Test("Out-of-band invite/response over BLE can establish a session and decrypt kind 1060 messages")
|
||||
@MainActor
|
||||
func oobHandshake_establishesSession_andDecrypts() throws {
|
||||
let aliceRelay = FakeRelayManager()
|
||||
let bobRelay = FakeRelayManager()
|
||||
let aliceStorage = try makeTempDir(label: "ndr-alice")
|
||||
let bobStorage = try makeTempDir(label: "ndr-bob")
|
||||
|
||||
let aliceKeys = generateKeypair()
|
||||
let bobKeys = generateKeypair()
|
||||
let aliceIdentity = try NostrIdentity(privateKeyData: try #require(Data(hexString: aliceKeys.privateKeyHex)))
|
||||
let bobIdentity = try NostrIdentity(privateKeyData: try #require(Data(hexString: bobKeys.privateKeyHex)))
|
||||
|
||||
let aliceSvc = NdrNostrService(
|
||||
relayManager: aliceRelay,
|
||||
deviceId: "alice-device",
|
||||
storageDirectoryProvider: { aliceStorage }
|
||||
)
|
||||
let bobSvc = NdrNostrService(
|
||||
relayManager: bobRelay,
|
||||
deviceId: "bob-device",
|
||||
storageDirectoryProvider: { bobStorage }
|
||||
)
|
||||
|
||||
aliceSvc.configureIfNeeded(identity: aliceIdentity)
|
||||
bobSvc.configureIfNeeded(identity: bobIdentity)
|
||||
|
||||
// Exchange BOTH device invites out-of-band (mutual favorites) and bounce any resulting
|
||||
// handshake events until both sides are quiescent.
|
||||
let aliceInvite = try #require(aliceSvc.currentInviteEventJson())
|
||||
let bobInvite = try #require(bobSvc.currentInviteEventJson())
|
||||
var aToB: [String] = [aliceInvite]
|
||||
var bToA: [String] = [bobInvite]
|
||||
var sawResponse1059 = false
|
||||
for _ in 0..<10 {
|
||||
let nextBToA = aToB.flatMap { bobSvc.processOutOfBandEventJson($0) } // Bob -> Alice
|
||||
let nextAToB = bToA.flatMap { aliceSvc.processOutOfBandEventJson($0) } // Alice -> Bob
|
||||
if nextBToA.contains(where: { (try? extractNostrKind(json: $0)) == 1059 }) { sawResponse1059 = true }
|
||||
if nextAToB.contains(where: { (try? extractNostrKind(json: $0)) == 1059 }) { sawResponse1059 = true }
|
||||
aToB = nextAToB
|
||||
bToA = nextBToA
|
||||
if aToB.isEmpty && bToA.isEmpty { break }
|
||||
}
|
||||
#expect(sawResponse1059)
|
||||
|
||||
#expect(aliceSvc.hasActiveSession(with: bobIdentity.publicKeyHex))
|
||||
#expect(bobSvc.hasActiveSession(with: aliceIdentity.publicKeyHex))
|
||||
|
||||
// Now Alice can send via DR (kind 1060), which is published to Nostr relays.
|
||||
aliceRelay.resetSentEvents()
|
||||
#expect(aliceSvc.sendIfPossible("bitchat1:hello", to: bobIdentity.publicKeyHex))
|
||||
let outbound = aliceRelay.sentEvents.filter { $0.kind == 1060 }
|
||||
#expect(!outbound.isEmpty)
|
||||
|
||||
var decryptedInner: NostrEvent?
|
||||
bobSvc.onDecryptedMessage = { inner in
|
||||
decryptedInner = inner
|
||||
}
|
||||
|
||||
for event in outbound {
|
||||
bobSvc.processInboundRelayEvent(event)
|
||||
}
|
||||
|
||||
let inner = try #require(decryptedInner, "Expected decrypted inner event to surface from SessionManagerHandle")
|
||||
#expect(inner.pubkey.lowercased() == aliceIdentity.publicKeyHex.lowercased())
|
||||
#expect(inner.content == "bitchat1:hello")
|
||||
}
|
||||
|
||||
private func makeTempDir(label: String) throws -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"bitchat-tests-\(label)-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
|
||||
return dir
|
||||
}
|
||||
|
||||
private func isDoubleRatchetInviteEvent(_ event: NostrEvent) -> Bool {
|
||||
guard event.kind == 30078 else { return false }
|
||||
for tag in event.tags where tag.count >= 2 {
|
||||
if tag[0] == "l", tag[1] == "double-ratchet/invites" { return true }
|
||||
if tag[0] == "d", tag[1].hasPrefix("double-ratchet/invites/") { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func extractNostrKind(json: String) throws -> Int {
|
||||
let data = Data(json.utf8)
|
||||
let obj = try JSONSerialization.jsonObject(with: data, options: [])
|
||||
let dict = try #require(obj as? [String: Any], "Event should be a JSON object")
|
||||
return try #require(dict["kind"] as? Int, "Event should have integer kind")
|
||||
}
|
||||
|
||||
}
|
||||
@ -20,6 +20,7 @@ struct NostrTransportTests {
|
||||
func reachabilityCacheWarmsFromFavorites() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let ndrService = try makeNdrService(label: "reachability-cache")
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
@ -34,6 +35,7 @@ struct NostrTransportTests {
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
ndrService: ndrService,
|
||||
dependencies: makeDependencies(
|
||||
loadFavorites: { favorites },
|
||||
favoriteStatusForNoiseKey: { favorites[$0] },
|
||||
@ -52,6 +54,7 @@ struct NostrTransportTests {
|
||||
func favoriteStatusNotificationRefreshesReachability() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let ndrService = try makeNdrService(label: "favorite-refresh")
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((32..<64).map(UInt8.init))
|
||||
let peerID = PeerID(hexData: noiseKey).toShort()
|
||||
@ -61,6 +64,7 @@ struct NostrTransportTests {
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
ndrService: ndrService,
|
||||
dependencies: makeDependencies(
|
||||
notificationCenter: notificationCenter,
|
||||
loadFavorites: { favorites },
|
||||
@ -88,6 +92,7 @@ struct NostrTransportTests {
|
||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let ndrService = try makeNdrService(label: "private-message")
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((64..<96).map(UInt8.init))
|
||||
@ -101,6 +106,7 @@ struct NostrTransportTests {
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
ndrService: ndrService,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { _ in nil },
|
||||
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
||||
@ -128,11 +134,141 @@ struct NostrTransportTests {
|
||||
#expect(probe.pendingGiftWrapIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Private message prefers NDR when a session already exists")
|
||||
@MainActor
|
||||
func sendPrivateMessagePrefersNdrWhenSessionExists() throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let senderRelay = FakeRelayManager()
|
||||
let recipientRelay = FakeRelayManager()
|
||||
let senderStorage = try makeTempDir(label: "transport-ndr-sender")
|
||||
let recipientStorage = try makeTempDir(label: "transport-ndr-recipient")
|
||||
let senderNdr = NdrNostrService(
|
||||
relayManager: senderRelay,
|
||||
deviceId: "transport-ndr-sender",
|
||||
storageDirectoryProvider: { senderStorage }
|
||||
)
|
||||
let recipientNdr = NdrNostrService(
|
||||
relayManager: recipientRelay,
|
||||
deviceId: "transport-ndr-recipient",
|
||||
storageDirectoryProvider: { recipientStorage }
|
||||
)
|
||||
senderNdr.configureIfNeeded(identity: sender)
|
||||
recipientNdr.configureIfNeeded(identity: recipient)
|
||||
try establishMutualSession(senderNdr, recipientNdr, senderIdentity: sender, recipientIdentity: recipient)
|
||||
|
||||
let noiseKey = Data((64..<96).map(UInt8.init))
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Carol"
|
||||
)
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
ndrService: senderNdr,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender }
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
|
||||
let transportUsed = try transport.sendPrivateMessageAndReturnTransport(
|
||||
"hello via ndr",
|
||||
to: fullPeerID,
|
||||
recipientNickname: "Carol",
|
||||
messageID: "pm-ndr"
|
||||
)
|
||||
|
||||
#expect(transportUsed == .ndr)
|
||||
#expect(senderRelay.sentEvents.contains(where: { $0.kind == 1060 }))
|
||||
}
|
||||
|
||||
@Test("Private message stays on NDR when an active session queues before relay publish")
|
||||
@MainActor
|
||||
func sendPrivateMessageDoesNotFallBackWhenNdrQueues() throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let senderRelay = FakeRelayManager()
|
||||
let recipientRelay = FakeRelayManager()
|
||||
let senderStorage = try makeTempDir(label: "transport-ndr-queued-sender")
|
||||
let recipientStorage = try makeTempDir(label: "transport-ndr-queued-recipient")
|
||||
let senderNdr = NdrNostrService(
|
||||
relayManager: senderRelay,
|
||||
deviceId: "transport-ndr-queued-sender",
|
||||
storageDirectoryProvider: { senderStorage }
|
||||
)
|
||||
let recipientNdr = NdrNostrService(
|
||||
relayManager: recipientRelay,
|
||||
deviceId: "transport-ndr-queued-recipient",
|
||||
storageDirectoryProvider: { recipientStorage }
|
||||
)
|
||||
senderNdr.configureIfNeeded(identity: sender)
|
||||
recipientNdr.configureIfNeeded(identity: recipient)
|
||||
|
||||
let senderInvite = try #require(senderNdr.currentInviteEventJson())
|
||||
let recipientPublishes = recipientNdr.processOutOfBandEventJson(senderInvite)
|
||||
let recipientResponse = try #require(
|
||||
recipientPublishes.first(where: { (try? extractNostrKind(json: $0)) == 1059 }),
|
||||
"Recipient should return a response after processing sender invite"
|
||||
)
|
||||
let recipientBootstrap = try #require(
|
||||
recipientRelay.sentEvents.first(where: { $0.kind == 1060 }),
|
||||
"Recipient should publish a bootstrap message event after accepting sender invite"
|
||||
)
|
||||
_ = senderNdr.processOutOfBandEventJson(recipientResponse)
|
||||
#expect(senderNdr.hasActiveSession(with: recipient.publicKeyHex))
|
||||
|
||||
senderRelay.resetSentEvents()
|
||||
let probe = NostrTransportProbe()
|
||||
let noiseKey = Data((80..<112).map(UInt8.init))
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Queued"
|
||||
)
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
ndrService: senderNdr,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
sendEvent: probe.record(event:)
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
|
||||
let transportUsed = try transport.sendPrivateMessageAndReturnTransport(
|
||||
"queued via ndr",
|
||||
to: fullPeerID,
|
||||
recipientNickname: "Queued",
|
||||
messageID: "pm-ndr-queued"
|
||||
)
|
||||
|
||||
#expect(transportUsed == .ndr)
|
||||
#expect(probe.sentEvents.isEmpty)
|
||||
#expect(senderRelay.sentEvents.filter { $0.kind == 1060 }.isEmpty)
|
||||
|
||||
senderNdr.processInboundRelayEvent(recipientBootstrap)
|
||||
#expect(senderRelay.sentEvents.contains(where: { $0.kind == 1060 }))
|
||||
}
|
||||
|
||||
@Test("Favorite notification embeds current npub")
|
||||
@MainActor
|
||||
func sendFavoriteNotificationEmbedsCurrentIdentity() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let ndrService = try makeNdrService(label: "favorite-notification")
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((96..<128).map(UInt8.init))
|
||||
@ -146,6 +282,7 @@ struct NostrTransportTests {
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
ndrService: ndrService,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
@ -174,6 +311,7 @@ struct NostrTransportTests {
|
||||
func sendDeliveryAckEmitsDeliveredAck() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let ndrService = try makeNdrService(label: "delivery-ack")
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((128..<160).map(UInt8.init))
|
||||
@ -187,6 +325,7 @@ struct NostrTransportTests {
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
ndrService: ndrService,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
@ -216,12 +355,14 @@ struct NostrTransportTests {
|
||||
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let ndrService = try makeNdrService(label: "geohash-pm")
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let probe = NostrTransportProbe()
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
ndrService: ndrService,
|
||||
dependencies: makeDependencies(
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
@ -257,6 +398,7 @@ struct NostrTransportTests {
|
||||
func readReceiptQueueThrottlesSequentially() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let ndrService = try makeNdrService(label: "read-queue")
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((160..<192).map(UInt8.init))
|
||||
@ -270,6 +412,7 @@ struct NostrTransportTests {
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
ndrService: ndrService,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
@ -313,7 +456,8 @@ struct NostrTransportTests {
|
||||
func concurrentReadReceiptEnqueue() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let ndrService = try makeNdrService(label: "concurrent-read")
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
|
||||
let iterations = 100
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
@ -336,7 +480,8 @@ struct NostrTransportTests {
|
||||
func isPeerReachableThreadSafety() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
let ndrService = try makeNdrService(label: "reachable-thread-safety")
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
|
||||
let iterations = 100
|
||||
|
||||
await withTaskGroup(of: Bool.self) { group in
|
||||
@ -376,6 +521,16 @@ struct NostrTransportTests {
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeNdrService(label: String) throws -> NdrNostrService {
|
||||
let storage = try makeTempDir(label: label)
|
||||
return NdrNostrService(
|
||||
relayManager: FakeRelayManager(),
|
||||
deviceId: "nostr-transport-\(label)",
|
||||
storageDirectoryProvider: { storage }
|
||||
)
|
||||
}
|
||||
|
||||
private func makeRelationship(
|
||||
peerNoisePublicKey: Data,
|
||||
peerNostrPublicKey: String?,
|
||||
@ -392,6 +547,42 @@ struct NostrTransportTests {
|
||||
)
|
||||
}
|
||||
|
||||
private func makeTempDir(label: String) throws -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"bitchat-tests-\(label)-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
|
||||
return dir
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func establishMutualSession(
|
||||
_ senderService: NdrNostrService,
|
||||
_ recipientService: NdrNostrService,
|
||||
senderIdentity: NostrIdentity,
|
||||
recipientIdentity: NostrIdentity
|
||||
) throws {
|
||||
var toRecipient: [String] = [try #require(senderService.currentInviteEventJson())]
|
||||
var toSender: [String] = [try #require(recipientService.currentInviteEventJson())]
|
||||
|
||||
for _ in 0..<10 {
|
||||
let nextToSender = toRecipient.flatMap { recipientService.processOutOfBandEventJson($0) }
|
||||
let nextToRecipient = toSender.flatMap { senderService.processOutOfBandEventJson($0) }
|
||||
toRecipient = nextToRecipient
|
||||
toSender = nextToSender
|
||||
if senderService.hasActiveSession(with: recipientIdentity.publicKeyHex),
|
||||
recipientService.hasActiveSession(with: senderIdentity.publicKeyHex) {
|
||||
return
|
||||
}
|
||||
if toRecipient.isEmpty && toSender.isEmpty {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
throw NostrTransportTestError.failedToEstablishNdrSession
|
||||
}
|
||||
|
||||
private func decodeEmbeddedPayload(
|
||||
from event: NostrEvent,
|
||||
recipient: NostrIdentity
|
||||
@ -419,12 +610,20 @@ struct NostrTransportTests {
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
private func extractNostrKind(json: String) throws -> Int {
|
||||
let data = Data(json.utf8)
|
||||
let obj = try JSONSerialization.jsonObject(with: data, options: [])
|
||||
let dict = try #require(obj as? [String: Any], "Nostr event should be a JSON object")
|
||||
return try #require(dict["kind"] as? Int, "Nostr event should include kind")
|
||||
}
|
||||
}
|
||||
|
||||
private enum NostrTransportTestError: Error {
|
||||
case invalidEmbeddedContent
|
||||
case invalidPacket
|
||||
case invalidPrivateMessage
|
||||
case failedToEstablishNdrSession
|
||||
}
|
||||
|
||||
private func base64URLDecode(_ string: String) -> Data? {
|
||||
|
||||
65
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/Info.plist
vendored
Normal file
65
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/Info.plist
vendored
Normal file
@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AvailableLibraries</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>libndr_ffi.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>ios-arm64</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>libndr_ffi.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>ios</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>libndr_ffi_sim.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>ios-arm64_x86_64-simulator</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>libndr_ffi_sim.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
<string>x86_64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>ios</string>
|
||||
<key>SupportedPlatformVariant</key>
|
||||
<string>simulator</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>libndr_ffi_macos.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>macos-arm64_x86_64</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>libndr_ffi_macos.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
<string>x86_64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>macos</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XFWK</string>
|
||||
<key>XCFrameworkFormatVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
4
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ios-arm64/Headers/module.modulemap
vendored
Normal file
4
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ios-arm64/Headers/module.modulemap
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
module ndr_ffiFFI {
|
||||
header "ndr_ffiFFI.h"
|
||||
export *
|
||||
}
|
||||
1243
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ios-arm64/Headers/ndr_ffiFFI.h
vendored
Normal file
1243
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ios-arm64/Headers/ndr_ffiFFI.h
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ios-arm64/libndr_ffi.a
vendored
Normal file
BIN
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ios-arm64/libndr_ffi.a
vendored
Normal file
Binary file not shown.
@ -0,0 +1,4 @@
|
||||
module ndr_ffiFFI {
|
||||
header "ndr_ffiFFI.h"
|
||||
export *
|
||||
}
|
||||
1243
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ios-arm64_x86_64-simulator/Headers/ndr_ffiFFI.h
vendored
Normal file
1243
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ios-arm64_x86_64-simulator/Headers/ndr_ffiFFI.h
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ios-arm64_x86_64-simulator/libndr_ffi_sim.a
vendored
Normal file
BIN
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ios-arm64_x86_64-simulator/libndr_ffi_sim.a
vendored
Normal file
Binary file not shown.
@ -0,0 +1,4 @@
|
||||
module ndr_ffiFFI {
|
||||
header "ndr_ffiFFI.h"
|
||||
export *
|
||||
}
|
||||
1243
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/macos-arm64_x86_64/Headers/ndr_ffiFFI.h
vendored
Normal file
1243
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/macos-arm64_x86_64/Headers/ndr_ffiFFI.h
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/macos-arm64_x86_64/libndr_ffi_macos.a
vendored
Normal file
BIN
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/macos-arm64_x86_64/libndr_ffi_macos.a
vendored
Normal file
Binary file not shown.
36
localPackages/NdrFfi/Package.swift
Normal file
36
localPackages/NdrFfi/Package.swift
Normal file
@ -0,0 +1,36 @@
|
||||
// swift-tools-version: 5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "NdrFfi",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "NdrFfi",
|
||||
targets: ["NdrFfi"]
|
||||
)
|
||||
],
|
||||
targets: [
|
||||
// Swift bindings generated by uniffi-bindgen
|
||||
.target(
|
||||
name: "NdrFfi",
|
||||
dependencies: ["ndr_ffiFFI"],
|
||||
path: "Sources/NdrFfi"
|
||||
),
|
||||
// Binary xcframework built from the ndr-ffi Rust crate
|
||||
.binaryTarget(
|
||||
name: "ndr_ffiFFI",
|
||||
path: "Frameworks/NdrFfi.xcframework"
|
||||
),
|
||||
// Tests
|
||||
.testTarget(
|
||||
name: "NdrFfiTests",
|
||||
dependencies: ["NdrFfi"],
|
||||
path: "Tests"
|
||||
)
|
||||
]
|
||||
)
|
||||
68
localPackages/NdrFfi/README.md
Normal file
68
localPackages/NdrFfi/README.md
Normal file
@ -0,0 +1,68 @@
|
||||
# NdrFfi
|
||||
|
||||
Vendored Swift bindings and Apple XCFramework for the upstream
|
||||
`nostr-double-ratchet` `ndr-ffi` crate.
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
The generated files in this package come from the upstream
|
||||
`nostr-double-ratchet` checkout, specifically the Rust `ndr-ffi` crate and its
|
||||
UniFFI-generated Swift bindings.
|
||||
|
||||
The exact upstream revision used for the currently vendored artifacts is
|
||||
recorded in `VENDORED_FROM.md`.
|
||||
|
||||
Default expected upstream checkout:
|
||||
|
||||
```bash
|
||||
$HOME/src/nostr-double-ratchet
|
||||
```
|
||||
|
||||
You can also point the build at a different checkout by passing a path or by
|
||||
setting `NDR_SOURCE_DIR`.
|
||||
|
||||
## Rebuild From Source
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Xcode and command line tools
|
||||
- Rust toolchain with cargo
|
||||
- Rust targets:
|
||||
- `aarch64-apple-darwin`
|
||||
- `aarch64-apple-ios`
|
||||
- `aarch64-apple-ios-sim`
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
rustup target add aarch64-apple-darwin aarch64-apple-ios aarch64-apple-ios-sim
|
||||
cd localPackages/NdrFfi
|
||||
./build-apple.sh ~/src/nostr-double-ratchet
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```bash
|
||||
cd localPackages/NdrFfi
|
||||
NDR_SOURCE_DIR=/path/to/nostr-double-ratchet ./build-apple.sh
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
- builds the upstream `ndr-ffi` crate
|
||||
- regenerates `Sources/NdrFfi/NdrFfi.swift` via UniFFI
|
||||
- rebuilds the Apple XCFramework at `Frameworks/NdrFfi.xcframework`
|
||||
- bakes in the current Apple deployment targets used by `bitchat`
|
||||
|
||||
## Outputs Updated By The Script
|
||||
|
||||
- `Sources/NdrFfi/NdrFfi.swift`
|
||||
- `Frameworks/NdrFfi.xcframework`
|
||||
|
||||
## Recommended Verification
|
||||
|
||||
```bash
|
||||
swift test --package-path localPackages/NdrFfi
|
||||
swift test --filter NdrOutOfBandTransportTests
|
||||
swift test --filter NostrTransportTests
|
||||
```
|
||||
3750
localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift
Normal file
3750
localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift
Normal file
File diff suppressed because it is too large
Load Diff
204
localPackages/NdrFfi/Tests/NdrFfiTests.swift
Normal file
204
localPackages/NdrFfi/Tests/NdrFfiTests.swift
Normal file
@ -0,0 +1,204 @@
|
||||
import XCTest
|
||||
@testable import NdrFfi
|
||||
|
||||
final class NdrFfiTests: XCTestCase {
|
||||
|
||||
// MARK: - Version Tests
|
||||
|
||||
func testVersion() {
|
||||
let v = NdrFfi.version()
|
||||
XCTAssertFalse(v.isEmpty, "Version should not be empty")
|
||||
print("ndr-ffi version: \(v)")
|
||||
}
|
||||
|
||||
// MARK: - Keypair Tests
|
||||
|
||||
func testKeypairGeneration() {
|
||||
let keypair = generateKeypair()
|
||||
|
||||
XCTAssertEqual(keypair.publicKeyHex.count, 64, "Public key should be 64 hex characters")
|
||||
XCTAssertEqual(keypair.privateKeyHex.count, 64, "Private key should be 64 hex characters")
|
||||
|
||||
// Verify they're valid hex
|
||||
XCTAssertNotNil(Data(hexString: keypair.publicKeyHex), "Public key should be valid hex")
|
||||
XCTAssertNotNil(Data(hexString: keypair.privateKeyHex), "Private key should be valid hex")
|
||||
|
||||
print("Generated keypair - pubkey: \(keypair.publicKeyHex.prefix(16))...")
|
||||
}
|
||||
|
||||
func testMultipleKeypairsAreDifferent() {
|
||||
let kp1 = generateKeypair()
|
||||
let kp2 = generateKeypair()
|
||||
|
||||
XCTAssertNotEqual(kp1.publicKeyHex, kp2.publicKeyHex, "Different keypairs should have different public keys")
|
||||
XCTAssertNotEqual(kp1.privateKeyHex, kp2.privateKeyHex, "Different keypairs should have different private keys")
|
||||
}
|
||||
|
||||
// MARK: - SessionManager Tests
|
||||
|
||||
func testSessionManagerInitEmitsInviteEvent() throws {
|
||||
let keys = generateKeypair()
|
||||
let mgr = try SessionManagerHandle(
|
||||
ourPubkeyHex: keys.publicKeyHex,
|
||||
ourIdentityPrivkeyHex: keys.privateKeyHex,
|
||||
deviceId: "test-device",
|
||||
ownerPubkeyHex: nil
|
||||
)
|
||||
try mgr.`init`()
|
||||
|
||||
let events = try mgr.drainEvents()
|
||||
let inviteEventJson = try XCTUnwrap(
|
||||
events.first(where: { $0.kind == "publish_signed" })?.eventJson,
|
||||
"Expected SessionManager to publish an invite on init"
|
||||
)
|
||||
XCTAssertEqual(try extractNostrKind(json: inviteEventJson), 30078)
|
||||
}
|
||||
|
||||
func testSessionManagerAcceptInviteFromEventJsonEstablishesSession() throws {
|
||||
let alice = generateKeypair()
|
||||
let bob = generateKeypair()
|
||||
|
||||
let aliceMgr = try SessionManagerHandle(
|
||||
ourPubkeyHex: alice.publicKeyHex,
|
||||
ourIdentityPrivkeyHex: alice.privateKeyHex,
|
||||
deviceId: "alice-device",
|
||||
ownerPubkeyHex: nil
|
||||
)
|
||||
let bobMgr = try SessionManagerHandle(
|
||||
ourPubkeyHex: bob.publicKeyHex,
|
||||
ourIdentityPrivkeyHex: bob.privateKeyHex,
|
||||
deviceId: "bob-device",
|
||||
ownerPubkeyHex: nil
|
||||
)
|
||||
try aliceMgr.`init`()
|
||||
try bobMgr.`init`()
|
||||
|
||||
let aliceInitEvents = try aliceMgr.drainEvents()
|
||||
_ = try bobMgr.drainEvents() // discard Bob init invite
|
||||
|
||||
let aliceInviteEventJson = try XCTUnwrap(
|
||||
aliceInitEvents.first(where: { $0.kind == "publish_signed" })?.eventJson,
|
||||
"Expected Alice to publish an invite on init"
|
||||
)
|
||||
XCTAssertEqual(try extractNostrKind(json: aliceInviteEventJson), 30078)
|
||||
|
||||
let accept = try bobMgr.acceptInviteFromEventJson(eventJson: aliceInviteEventJson, ownerPubkeyHintHex: nil)
|
||||
XCTAssertTrue(accept.createdNewSession)
|
||||
|
||||
let bobAfterAccept = try bobMgr.drainEvents()
|
||||
let responseEventJson = try XCTUnwrap(
|
||||
bobAfterAccept.first(where: { $0.kind == "publish_signed" && ((try? extractNostrKind(json: $0.eventJson ?? "")) == 1059) })?.eventJson,
|
||||
"Expected Bob to publish a giftwrap response after accepting invite"
|
||||
)
|
||||
XCTAssertEqual(try extractNostrKind(json: responseEventJson), 1059)
|
||||
|
||||
try aliceMgr.processEvent(eventJson: responseEventJson)
|
||||
_ = try aliceMgr.drainEvents()
|
||||
|
||||
XCTAssertNotNil(try aliceMgr.getActiveSessionState(peerPubkeyHex: bob.publicKeyHex))
|
||||
XCTAssertNotNil(try bobMgr.getActiveSessionState(peerPubkeyHex: alice.publicKeyHex))
|
||||
}
|
||||
|
||||
func testSessionManagerSendTextDecryptsOnOtherSide() throws {
|
||||
let alice = generateKeypair()
|
||||
let bob = generateKeypair()
|
||||
|
||||
let aliceMgr = try SessionManagerHandle(
|
||||
ourPubkeyHex: alice.publicKeyHex,
|
||||
ourIdentityPrivkeyHex: alice.privateKeyHex,
|
||||
deviceId: "alice-device",
|
||||
ownerPubkeyHex: nil
|
||||
)
|
||||
let bobMgr = try SessionManagerHandle(
|
||||
ourPubkeyHex: bob.publicKeyHex,
|
||||
ourIdentityPrivkeyHex: bob.privateKeyHex,
|
||||
deviceId: "bob-device",
|
||||
ownerPubkeyHex: nil
|
||||
)
|
||||
try aliceMgr.`init`()
|
||||
try bobMgr.`init`()
|
||||
|
||||
let aliceInvite = try XCTUnwrap(
|
||||
try aliceMgr.drainEvents().first(where: { $0.kind == "publish_signed" })?.eventJson
|
||||
)
|
||||
_ = try bobMgr.drainEvents() // discard Bob init invite
|
||||
|
||||
_ = try bobMgr.acceptInviteFromEventJson(eventJson: aliceInvite, ownerPubkeyHintHex: nil)
|
||||
let bobAfterAccept = try bobMgr.drainEvents()
|
||||
let bobResponse = try XCTUnwrap(
|
||||
bobAfterAccept.first(where: { $0.kind == "publish_signed" && ((try? extractNostrKind(json: $0.eventJson ?? "")) == 1059) })?.eventJson
|
||||
)
|
||||
try aliceMgr.processEvent(eventJson: bobResponse)
|
||||
_ = try aliceMgr.drainEvents()
|
||||
|
||||
_ = try bobMgr.sendText(recipientPubkeyHex: alice.publicKeyHex, text: "hello from bob", expiresAtSeconds: nil)
|
||||
let bobOutbound = try bobMgr.drainEvents().compactMap { e -> String? in
|
||||
guard e.kind == "publish_signed", let json = e.eventJson else { return nil }
|
||||
return ((try? extractNostrKind(json: json)) == 1060) ? json : nil
|
||||
}
|
||||
XCTAssertFalse(bobOutbound.isEmpty, "Expected at least one kind 1060 message to publish")
|
||||
|
||||
for eventJson in bobOutbound {
|
||||
try aliceMgr.processEvent(eventJson: eventJson)
|
||||
}
|
||||
let aliceEvents = try aliceMgr.drainEvents()
|
||||
let decryptedInner = try XCTUnwrap(
|
||||
aliceEvents.first(where: { $0.kind == "decrypted_message" })?.content,
|
||||
"Expected a decrypted inner event to surface"
|
||||
)
|
||||
XCTAssertEqual(try innerEventContent(json: decryptedInner), "hello from bob")
|
||||
}
|
||||
|
||||
func testSessionManagerRejectsInvalidInviteEventJson() throws {
|
||||
let keys = generateKeypair()
|
||||
let mgr = try SessionManagerHandle(
|
||||
ourPubkeyHex: keys.publicKeyHex,
|
||||
ourIdentityPrivkeyHex: keys.privateKeyHex,
|
||||
deviceId: "test-device",
|
||||
ownerPubkeyHex: nil
|
||||
)
|
||||
try mgr.`init`()
|
||||
|
||||
let notAnInvite = """
|
||||
{"kind":1,"id":"test","pubkey":"test","created_at":0,"content":"hello","tags":[],"sig":"test"}
|
||||
"""
|
||||
XCTAssertThrowsError(try mgr.acceptInviteFromEventJson(eventJson: notAnInvite, ownerPubkeyHintHex: nil))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Extensions
|
||||
|
||||
extension Data {
|
||||
init?(hexString: String) {
|
||||
let len = hexString.count / 2
|
||||
var data = Data(capacity: len)
|
||||
var i = hexString.startIndex
|
||||
for _ in 0..<len {
|
||||
let j = hexString.index(i, offsetBy: 2)
|
||||
guard let byte = UInt8(hexString[i..<j], radix: 16) else {
|
||||
return nil
|
||||
}
|
||||
data.append(byte)
|
||||
i = j
|
||||
}
|
||||
self = data
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
private func extractNostrKind(json: String) throws -> Int {
|
||||
let data = Data(json.utf8)
|
||||
let obj = try JSONSerialization.jsonObject(with: data, options: [])
|
||||
guard let dict = obj as? [String: Any] else { throw NSError(domain: "NdrFfiTests", code: 1) }
|
||||
guard let kind = dict["kind"] as? Int else { throw NSError(domain: "NdrFfiTests", code: 2) }
|
||||
return kind
|
||||
}
|
||||
|
||||
private func innerEventContent(json: String) throws -> String {
|
||||
let data = Data(json.utf8)
|
||||
let obj = try JSONSerialization.jsonObject(with: data, options: [])
|
||||
guard let dict = obj as? [String: Any] else { throw NSError(domain: "NdrFfiTests", code: 3) }
|
||||
guard let content = dict["content"] as? String else { throw NSError(domain: "NdrFfiTests", code: 4) }
|
||||
return content
|
||||
}
|
||||
19
localPackages/NdrFfi/VENDORED_FROM.md
Normal file
19
localPackages/NdrFfi/VENDORED_FROM.md
Normal file
@ -0,0 +1,19 @@
|
||||
# Vendored Provenance
|
||||
|
||||
Current vendored `NdrFfi` artifacts in this package correspond to:
|
||||
|
||||
- Upstream repository: `git@github.com:mmalmi/nostr-double-ratchet.git`
|
||||
- Upstream crate: `rust/crates/ndr-ffi`
|
||||
- Upstream version: `v0.0.97`
|
||||
- Upstream source revision: `v0.0.97-3-g5fa8dbb`
|
||||
- Upstream commit: `5fa8dbb7d4a2ea21e448e5fa220f655030de12f2`
|
||||
- Rebuild script: `build-apple.sh`
|
||||
- Static archive post-processing: `xcrun strip -S` to remove DWARF debug info while preserving link symbols
|
||||
- Rust toolchain used for the vendored refresh: `rustc 1.94.1 (e408947bf 2026-03-25)`
|
||||
|
||||
Vendored outputs updated from that source:
|
||||
|
||||
- `Sources/NdrFfi/NdrFfi.swift`
|
||||
- `Frameworks/NdrFfi.xcframework` (`macos-arm64_x86_64`, `ios-arm64`, `ios-arm64_x86_64-simulator`)
|
||||
|
||||
Recorded on `2026-04-25T09:00:04Z`.
|
||||
120
localPackages/NdrFfi/build-apple.sh
Executable file
120
localPackages/NdrFfi/build-apple.sh
Executable file
@ -0,0 +1,120 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PACKAGE_DIR="$SCRIPT_DIR"
|
||||
SOURCE_DIR="${1:-${NDR_SOURCE_DIR:-$HOME/src/nostr-double-ratchet}}"
|
||||
RUST_ROOT="$SOURCE_DIR/rust"
|
||||
|
||||
MACOS_MIN="${MACOSX_DEPLOYMENT_TARGET:-13.0}"
|
||||
IOS_MIN="${IPHONEOS_DEPLOYMENT_TARGET:-16.0}"
|
||||
|
||||
if [[ ! -d "$RUST_ROOT" ]]; then
|
||||
echo "error: expected nostr-double-ratchet checkout at $SOURCE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ndrffi-apple.XXXXXX")"
|
||||
TARGET_DIR="$WORK_ROOT/target"
|
||||
OUT_DIR="$WORK_ROOT/out"
|
||||
BINDINGS_DIR="$OUT_DIR/bindings"
|
||||
HEADERS_DIR="$OUT_DIR/headers"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BINDINGS_DIR" "$HEADERS_DIR"
|
||||
|
||||
echo "==> Building ndr-ffi from $SOURCE_DIR"
|
||||
echo " macOS minimum: $MACOS_MIN"
|
||||
echo " iOS minimum: $IOS_MIN"
|
||||
|
||||
cd "$RUST_ROOT"
|
||||
|
||||
echo "==> Generating Swift bindings"
|
||||
env \
|
||||
CARGO_TARGET_DIR="$TARGET_DIR" \
|
||||
cargo build -p ndr-ffi --lib
|
||||
|
||||
env \
|
||||
CARGO_TARGET_DIR="$TARGET_DIR" \
|
||||
cargo run -p ndr-ffi --features bindgen-cli --bin uniffi-bindgen -- \
|
||||
generate \
|
||||
--library "$TARGET_DIR/debug/libndr_ffi.dylib" \
|
||||
--language swift \
|
||||
--out-dir "$BINDINGS_DIR"
|
||||
|
||||
cp "$BINDINGS_DIR/ndr_ffiFFI.h" "$HEADERS_DIR/ndr_ffiFFI.h"
|
||||
|
||||
cat > "$HEADERS_DIR/module.modulemap" <<'EOF'
|
||||
module ndr_ffiFFI {
|
||||
header "ndr_ffiFFI.h"
|
||||
export *
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "==> Building macOS slices"
|
||||
for target in aarch64-apple-darwin x86_64-apple-darwin; do
|
||||
env \
|
||||
CARGO_TARGET_DIR="$TARGET_DIR" \
|
||||
MACOSX_DEPLOYMENT_TARGET="$MACOS_MIN" \
|
||||
CFLAGS_aarch64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \
|
||||
CXXFLAGS_aarch64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \
|
||||
CFLAGS_x86_64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \
|
||||
CXXFLAGS_x86_64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \
|
||||
RUSTFLAGS="-C link-arg=-mmacosx-version-min=$MACOS_MIN" \
|
||||
cargo build -p ndr-ffi --lib --release --target "$target"
|
||||
done
|
||||
|
||||
echo "==> Building iOS slices"
|
||||
for target in aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios; do
|
||||
env \
|
||||
CARGO_TARGET_DIR="$TARGET_DIR" \
|
||||
IPHONEOS_DEPLOYMENT_TARGET="$IOS_MIN" \
|
||||
cargo build -p ndr-ffi --lib --release --target "$target"
|
||||
done
|
||||
|
||||
MACOS_ARM64_LIB="$TARGET_DIR/aarch64-apple-darwin/release/libndr_ffi.a"
|
||||
MACOS_X64_LIB="$TARGET_DIR/x86_64-apple-darwin/release/libndr_ffi.a"
|
||||
MACOS_FAT_LIB="$OUT_DIR/libndr_ffi_macos.a"
|
||||
if [[ -f "$MACOS_ARM64_LIB" ]] && [[ -f "$MACOS_X64_LIB" ]]; then
|
||||
lipo -create "$MACOS_ARM64_LIB" "$MACOS_X64_LIB" -output "$MACOS_FAT_LIB"
|
||||
elif [[ -f "$MACOS_ARM64_LIB" ]]; then
|
||||
cp "$MACOS_ARM64_LIB" "$MACOS_FAT_LIB"
|
||||
elif [[ -f "$MACOS_X64_LIB" ]]; then
|
||||
cp "$MACOS_X64_LIB" "$MACOS_FAT_LIB"
|
||||
fi
|
||||
|
||||
SIM_ARM64_LIB="$TARGET_DIR/aarch64-apple-ios-sim/release/libndr_ffi.a"
|
||||
SIM_X64_LIB="$TARGET_DIR/x86_64-apple-ios/release/libndr_ffi.a"
|
||||
SIM_FAT_LIB="$OUT_DIR/libndr_ffi_sim.a"
|
||||
if [[ -f "$SIM_ARM64_LIB" ]] && [[ -f "$SIM_X64_LIB" ]]; then
|
||||
lipo -create "$SIM_ARM64_LIB" "$SIM_X64_LIB" -output "$SIM_FAT_LIB"
|
||||
elif [[ -f "$SIM_ARM64_LIB" ]]; then
|
||||
cp "$SIM_ARM64_LIB" "$SIM_FAT_LIB"
|
||||
elif [[ -f "$SIM_X64_LIB" ]]; then
|
||||
cp "$SIM_X64_LIB" "$SIM_FAT_LIB"
|
||||
fi
|
||||
|
||||
echo "==> Assembling XCFramework"
|
||||
xcodebuild -create-xcframework \
|
||||
-library "$MACOS_FAT_LIB" -headers "$HEADERS_DIR" \
|
||||
-library "$TARGET_DIR/aarch64-apple-ios/release/libndr_ffi.a" -headers "$HEADERS_DIR" \
|
||||
-library "$SIM_FAT_LIB" -headers "$HEADERS_DIR" \
|
||||
-output "$OUT_DIR/NdrFfi.xcframework"
|
||||
|
||||
echo "==> Updating vendored package"
|
||||
cp "$BINDINGS_DIR/ndr_ffi.swift" "$PACKAGE_DIR/Sources/NdrFfi/NdrFfi.swift"
|
||||
rm -rf "$PACKAGE_DIR/Frameworks/NdrFfi.xcframework"
|
||||
cp -R "$OUT_DIR/NdrFfi.xcframework" "$PACKAGE_DIR/Frameworks/NdrFfi.xcframework"
|
||||
|
||||
echo "==> Stripping debug info from vendored static libraries"
|
||||
for lib in "$PACKAGE_DIR"/Frameworks/NdrFfi.xcframework/*/libndr_ffi*.a; do
|
||||
[[ -f "$lib" ]] && xcrun strip -S "$lib"
|
||||
done
|
||||
|
||||
echo "==> Done"
|
||||
echo " Updated $PACKAGE_DIR/Sources/NdrFfi/NdrFfi.swift"
|
||||
echo " Updated $PACKAGE_DIR/Frameworks/NdrFfi.xcframework"
|
||||
Loading…
x
Reference in New Issue
Block a user