Add Nostr double-ratchet DMs

This commit is contained in:
Dev 2026-04-25 13:55:22 +03:00
parent bafa461f26
commit 60d03f82eb
30 changed files with 9088 additions and 86 deletions

View File

@ -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: [

View File

@ -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 */;

View File

@ -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)

View File

@ -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"
}
}
}

View File

@ -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)")
}

View 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
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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) {}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)

View 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")
}
}

View File

@ -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? {

View 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>

View File

@ -0,0 +1,4 @@
module ndr_ffiFFI {
header "ndr_ffiFFI.h"
export *
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
module ndr_ffiFFI {
header "ndr_ffiFFI.h"
export *
}

View File

@ -0,0 +1,4 @@
module ndr_ffiFFI {
header "ndr_ffiFFI.h"
export *
}

File diff suppressed because it is too large Load Diff

View 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"
)
]
)

View 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
```

File diff suppressed because it is too large Load Diff

View 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
}

View 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`.

View 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"