feat: add pairwise Nostr double ratchet

This commit is contained in:
Dev 2026-07-27 20:17:16 +03:00
parent 99b0beaf1a
commit 1e766b8d4f
41 changed files with 7931 additions and 2435 deletions

View File

@ -37,7 +37,7 @@ jobs:
~/.cargo/registry
~/.cargo/git
.cache/ndr-ffi/apple/target
key: ndr-ffi-apple-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('localPackages/NdrFfi/RUST_TOOLCHAIN', 'localPackages/NdrFfi/SOURCE_REVISION', 'localPackages/NdrFfi/build-apple.sh', 'vendor/iris-chat-rs/protocol-ffi/Cargo.lock') }}
key: ndr-ffi-apple-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('localPackages/NdrFfi/RUST_TOOLCHAIN', 'localPackages/NdrFfi/SOURCE_REVISION', 'localPackages/NdrFfi/build-apple.sh', 'vendor/nostr-double-ratchet/rust/Cargo.lock') }}
- name: Build Apple FFI from source
run: ./localPackages/NdrFfi/build-apple.sh

2
.gitignore vendored
View File

@ -82,5 +82,5 @@ build.log
Local.xcconfig
*.profraw
# Built from the pinned iris-chat-rs submodule; never commit native crypto.
# Built from the pinned nostr-double-ratchet submodule; never commit native crypto.
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/

6
.gitmodules vendored
View File

@ -1,4 +1,4 @@
[submodule "vendor/iris-chat-rs"]
path = vendor/iris-chat-rs
url = https://github.com/irislib/iris-chat-rs.git
[submodule "vendor/nostr-double-ratchet"]
path = vendor/nostr-double-ratchet
url = https://github.com/irislib/nostr-double-ratchet.git
shallow = true

View File

@ -107,7 +107,7 @@ Initialize the pinned Rust source and build its generated Apple framework once
before opening the project or running `swift test` directly:
```bash
git submodule update --init --checkout vendor/iris-chat-rs
git submodule update --init --checkout vendor/nostr-double-ratchet
rustup toolchain install "$(cat localPackages/NdrFfi/RUST_TOOLCHAIN)" --profile minimal
rustup target add \
aarch64-apple-darwin \
@ -119,9 +119,9 @@ rustup target add \
```
The generated XCFramework is ignored; every checkout builds it from the exact
`iris-chat-rs` source revision and locked Rust dependency graph recorded in the
repository. `just build`, `just run`, and `just test` perform the build step
automatically.
`nostr-double-ratchet` source revision and locked Rust dependency graph recorded
in the repository. `just build`, `just run`, and `just test` perform the build
step automatically.
### Option 1: Using Xcode

View File

@ -221,9 +221,6 @@ final class NostrRelayManager: ObservableObject {
private var duplicateInboundEventDropCount = 0
private var duplicateInboundEventDropCountBySubscription: [String: Int] = [:]
private var inboundEventLogCount = 0
// Coalesce duplicate subscribe requests for the same id within a short window.
private let subscribeCoalesceInterval: TimeInterval = 1.0
private var subscribeCoalesce: [String: Date] = [:]
private var pendingTorConnectionURLs = Set<String>()
private var awaitingTorForConnections = false
private var torReadyWaitAttempts = 0
@ -489,7 +486,6 @@ final class NostrRelayManager: ObservableObject {
pendingSubscriptions.removeAll()
messageHandlers.removeAll()
subscriptionRequestState.removeAll()
subscribeCoalesce.removeAll()
eoseTrackers.removeAll()
pendingEOSECallbacks.removeAll()
pendingTorConnectionURLs.removeAll()
@ -735,23 +731,19 @@ final class NostrRelayManager: ObservableObject {
return connection
}
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
/// Subscribe to events matching a filter. If `relayUrls` provided, targets
/// only those relays. Returns true only after the replayable subscription
/// intent has been registered, even when every target is still offline.
@discardableResult
func subscribe(
filter: NostrFilter,
id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil
) {
) -> Bool {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
let now = dependencies.now()
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
return
}
subscribeCoalesce[id] = now
messageHandlers[id] = handler
guard dependencies.activationAllowed() else { return false }
let req = NostrRequest.subscribe(id: id, filters: [filter])
@ -759,7 +751,7 @@ final class NostrRelayManager: ObservableObject {
let message = try encoder.encode(req)
guard let messageString = String(data: message, encoding: .utf8) else {
SecureLogger.error("❌ Failed to encode subscription request", category: .session)
return
return false
}
// SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
@ -767,10 +759,16 @@ final class NostrRelayManager: ObservableObject {
// Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? defaultRelays
let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
return
guard !urls.isEmpty else {
onEOSE?()
return false
}
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
messageHandlers[id] = handler
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
return true
}
subscriptionRequestState[id] = requestState
// Always queue subscriptions; sending happens when a relay reports connected
@ -801,8 +799,13 @@ final class NostrRelayManager: ObservableObject {
flushPendingSubscriptions(for: url)
}
}
// `subscriptionRequestState` is the canonical replay intent.
// Pending REQs are bounded/expiring accelerators and may be
// evicted; reconnect rehydrates them from this exact state.
return subscriptionRequestState[id] == requestState
} catch {
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
return false
}
}
@ -873,8 +876,6 @@ final class NostrRelayManager: ObservableObject {
messageHandlers.removeValue(forKey: id)
removeRecentInboundEvents(forSubscriptionID: id)
duplicateInboundEventDropCountBySubscription.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id)
subscriptionRequestState.removeValue(forKey: id)
pendingEOSECallbacks.removeValue(forKey: id)
eoseTrackers.removeValue(forKey: id)
@ -1344,10 +1345,20 @@ final class NostrRelayManager: ObservableObject {
}
}
case .ok(let eventId, let success, let reason):
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
if success {
let durablyAccepted =
success
|| (!success && reason.hasPrefix("duplicate:"))
resolveConfirmedSend(
eventID: eventId,
relayURL: relayUrl,
accepted: durablyAccepted
)
if durablyAccepted {
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
SecureLogger.debug(
"✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
category: .session
)
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
if isGiftWrap {

View File

@ -9,6 +9,8 @@ struct BLEOutboundFragmentTransferRequest {
let transferId: String?
let requireDirectPeerLink: Bool
let requireNoiseAuthenticatedPeerLink: Bool
let requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState?
init(
packet: BitchatPacket,
@ -17,7 +19,9 @@ struct BLEOutboundFragmentTransferRequest {
directedPeer: PeerID?,
transferId: String?,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
requireNoiseAuthenticatedPeerLink: Bool = false,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState? = nil
) {
self.packet = packet
self.pad = pad
@ -26,6 +30,8 @@ struct BLEOutboundFragmentTransferRequest {
self.transferId = transferId
self.requireDirectPeerLink = requireDirectPeerLink
self.requireNoiseAuthenticatedPeerLink = requireNoiseAuthenticatedPeerLink
self.requiredAuthenticatedTransportState =
requiredAuthenticatedTransportState
}
var resolvedTransferId: String? {
@ -43,6 +49,22 @@ struct BLEOutboundFragmentTransferRequest {
}
}
enum BLEAuthenticatedTransportAdmission {
static func isCurrent(
expected: AuthenticatedPeerTransportState,
current: AuthenticatedPeerTransportState?
) -> Bool {
current == expected
}
static func writePriority(
ordinaryPriority: BLEOutboundWritePriority,
requiresExactGeneration: Bool
) -> BLEOutboundWritePriority {
requiresExactGeneration ? .high : ordinaryPriority
}
}
/// Transactional admission for strict fragment trains. Durable callers may
/// commit only when every fragment was accepted; the first rejection stops
/// the train and reports failure so the original remains retryable.

View File

@ -2132,12 +2132,17 @@ final class BLEService: NSObject {
private func broadcastPacket(
_ packet: BitchatPacket,
transferId: String? = nil,
requiresPrivateMediaAdmission: Bool = false
requiresPrivateMediaAdmission: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState? = nil,
admissionCompletion: ((Bool) -> Void)? = nil
) {
guard !isPanicSuspended else {
if requiresPrivateMediaAdmission, let transferId {
privateMediaTransferAdmissions.finish(transferId)
}
admissionCompletion?(false)
return
}
if requiresPrivateMediaAdmission {
@ -2146,12 +2151,33 @@ final class BLEService: NSObject {
if let transferId {
privateMediaTransferAdmissions.finish(transferId)
}
admissionCompletion?(false)
return
}
}
if let requiredAuthenticatedTransportState {
guard requireNoiseAuthenticatedPeerLink,
let recipientPeerID =
PeerID(hexData: packet.recipientID),
BLEAuthenticatedTransportAdmission.isCurrent(
expected: requiredAuthenticatedTransportState,
current: authenticatedPeerTransportState(
recipientPeerID
)
)
else {
admissionCompletion?(false)
return
}
}
// Apply route if recipient exists (centralized route application)
let packetToSend: BitchatPacket
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
if requireNoiseAuthenticatedPeerLink {
// Durable NDR handoff must remain on the exact authenticated
// direct link. Routing or process-local spooling would make a
// positive admission result ambiguous.
packetToSend = packet
} else if let recipientPeerID = PeerID(hexData: packet.recipientID) {
packetToSend = applyRouteIfAvailable(packet, to: recipientPeerID)
} else {
packetToSend = packet
@ -2207,6 +2233,7 @@ final class BLEService: NSObject {
if requiresPrivateMediaAdmission {
privateMediaTransferAdmissions.finish(transferId)
}
admissionCompletion?(false)
return
}
}
@ -2220,6 +2247,7 @@ final class BLEService: NSObject {
if let transferId {
privateMediaTransferAdmissions.finish(transferId)
}
admissionCompletion?(false)
return
}
}
@ -2237,6 +2265,7 @@ final class BLEService: NSObject {
transferId: transferId,
requiresPrivateMediaAdmission: requiresPrivateMediaAdmission
)
admissionCompletion?(true)
return
}
// App-initiated private media is already one opaque Noise ciphertext.
@ -2253,6 +2282,7 @@ final class BLEService: NSObject {
transferId: transferId,
requiresPrivateMediaAdmission: requiresPrivateMediaAdmission
)
admissionCompletion?(true)
return
}
if requiresPrivateMediaAdmission {
@ -2263,24 +2293,49 @@ final class BLEService: NSObject {
"Private media admission reached an unsupported non-directed packet shape",
category: .security
)
admissionCompletion?(false)
return
}
guard let data = packetToSend.toBinaryData(padding: padForBLE) else {
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
admissionCompletion?(false)
return
}
if requireNoiseAuthenticatedPeerLink {
guard packetToSend.type == MessageType.noiseEncrypted.rawValue,
let recipientPeerID =
PeerID(hexData: packetToSend.recipientID)
else {
admissionCompletion?(false)
return
}
let accepted = sendOnAllLinks(
packet: packetToSend,
data: data,
pad: padForBLE,
directedOnlyPeer: recipientPeerID,
requireNoiseAuthenticatedPeerLink: true,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
)
admissionCompletion?(accepted)
return
}
if packetToSend.type == MessageType.noiseEncrypted.rawValue {
sendEncrypted(packetToSend, data: data, pad: padForBLE)
admissionCompletion?(true)
return
}
sendGenericBroadcast(packetToSend, data: data, pad: padForBLE)
admissionCompletion?(true)
}
private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) {
guard let recipientPeerID = PeerID(hexData: packet.recipientID) else { return }
var sentEncrypted = false
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
let outboundPriority =
BLEOutboundPacketPolicy.priority(for: packet, data: data)
// Per-link limits for the specific peer
let directPeripheralState = snapshotDirectPeripheralState(for: recipientPeerID)
@ -2389,11 +2444,21 @@ final class BLEService: NSObject {
centrals: [CBCentral],
characteristic: CBMutableCharacteristic,
context: String,
requiredAuthenticatedPeer: PeerID?
requiredAuthenticatedPeer: PeerID?,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState?
) -> Bool {
let accept = { [self] in
let eligible: [CBCentral]
if let peerID = requiredAuthenticatedPeer {
if let requiredAuthenticatedTransportState {
guard BLEAuthenticatedTransportAdmission.isCurrent(
expected: requiredAuthenticatedTransportState,
current: authenticatedPeerTransportState(peerID)
) else {
return false
}
}
eligible = centrals.filter { central in
let link = BLEIngressLinkID.central(central.identifier.uuidString)
return noiseAuthenticatedLinkOwners[link] == peerID
@ -2430,9 +2495,24 @@ final class BLEService: NSObject {
pad: Bool,
directedOnlyPeer: PeerID?,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
requireNoiseAuthenticatedPeerLink: Bool = false,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState? = nil
) -> Bool {
guard !isPanicSuspended else { return false }
if let requiredAuthenticatedTransportState {
guard requireNoiseAuthenticatedPeerLink,
let directedOnlyPeer,
BLEAuthenticatedTransportAdmission.isCurrent(
expected: requiredAuthenticatedTransportState,
current: authenticatedPeerTransportState(
directedOnlyPeer
)
)
else {
return false
}
}
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
var excludedPeerLinks = links(to: ingressRecord?.peerID)
if requireNoiseAuthenticatedPeerLink {
@ -2442,7 +2522,20 @@ final class BLEService: NSObject {
guard !authenticatedLinks.isEmpty else { return false }
excludedPeerLinks.formUnion(boundLinks.subtracting(authenticatedLinks))
}
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
// Once an authenticated OOB fragment is admitted, the native action
// may be durably acknowledged. Keep those frames at FIFO-high
// priority so later traffic rejects itself instead of evicting an
// already-committed fragment from the bounded write queue.
let outboundPriority =
BLEAuthenticatedTransportAdmission.writePriority(
ordinaryPriority:
BLEOutboundPacketPolicy.priority(
for: packet,
data: data
),
requiresExactGeneration:
requireNoiseAuthenticatedPeerLink
)
let states = snapshotPeripheralStates()
// A link without a discovered characteristic cannot be written to
@ -2487,12 +2580,16 @@ final class BLEService: NSObject {
maxChunk: chunk,
directedOnlyPeer: directedOnlyPeer,
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
)
}
// If directed and we currently have no links to forward on, spool for a short window
if let only = plan.directedPeerHint,
if !requireDirectPeerLink,
!requireNoiseAuthenticatedPeerLink,
let only = plan.directedPeerHint,
plan.shouldSpoolDirectedPacket {
spoolDirectedPacket(packet, recipientPeerID: only)
}
@ -2510,7 +2607,9 @@ final class BLEService: NSObject {
to: s.peripheral,
characteristic: ch,
priority: outboundPriority,
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
) || acceptedByPhysicalLink
} else {
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
@ -2527,7 +2626,9 @@ final class BLEService: NSObject {
centrals: targets,
characteristic: ch,
context: "directed",
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
) || acceptedByPhysicalLink
} else {
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
@ -2550,7 +2651,9 @@ final class BLEService: NSObject {
_ packet: BitchatPacket,
to peerID: PeerID,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
requireNoiseAuthenticatedPeerLink: Bool = false,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState? = nil
) -> Bool {
#if DEBUG
_test_onOutboundPacket?(packet)
@ -2562,7 +2665,9 @@ final class BLEService: NSObject {
pad: false,
directedOnlyPeer: peerID,
requireDirectPeerLink: requireDirectPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
)
}
@ -3052,18 +3157,23 @@ final class BLEService: NSObject {
func sendNdrEvent(
to peerID: PeerID,
eventJson: String,
expectedTransportState: AuthenticatedPeerTransportState
expectedTransportState: AuthenticatedPeerTransportState,
completion: @escaping @MainActor (Bool) -> Void
) {
guard let data = eventJson.data(using: .utf8),
!data.isEmpty,
data.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes
else {
Task { @MainActor in completion(false) }
return
}
let normalizedPeerID = peerID.toShort()
messageQueue.async { [weak self] in
guard let self,
self.doubleRatchetEnabled,
guard let self else {
Task { @MainActor in completion(false) }
return
}
guard self.doubleRatchetEnabled,
let authenticated = self.authenticatedPeerTransportState(normalizedPeerID),
authenticated == expectedTransportState,
authenticated.capabilities.contains(.doubleRatchet)
@ -3072,6 +3182,7 @@ final class BLEService: NSObject {
"NDR: dropping OOB send without the expected authenticated capability proof",
category: .security
)
Task { @MainActor in completion(false) }
return
}
let typedPayload = NoisePayload(type: .ndrEvent, data: data).encode()
@ -3081,12 +3192,21 @@ final class BLEService: NSObject {
to: normalizedPeerID,
expectedSessionGeneration: authenticated.sessionGeneration
)
self.broadcastPacket(packet)
self.broadcastPacket(
packet,
requireNoiseAuthenticatedPeerLink: true,
requiredAuthenticatedTransportState:
expectedTransportState,
admissionCompletion: { accepted in
Task { @MainActor in completion(accepted) }
}
)
} catch {
SecureLogger.warning(
"NDR: dropping OOB send because the authenticated Noise generation changed",
category: .security
)
Task { @MainActor in completion(false) }
}
}
}
@ -6372,7 +6492,9 @@ extension BLEService {
to peripheral: CBPeripheral,
characteristic: CBCharacteristic,
priority: BLEOutboundWritePriority,
requiredAuthenticatedPeer: PeerID?
requiredAuthenticatedPeer: PeerID?,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState?
) -> Bool {
let accept = { [self] in
let uuid = peripheral.identifier.uuidString
@ -6382,6 +6504,14 @@ extension BLEService {
return false
}
if let peerID = requiredAuthenticatedPeer {
if let requiredAuthenticatedTransportState {
guard BLEAuthenticatedTransportAdmission.isCurrent(
expected: requiredAuthenticatedTransportState,
current: authenticatedPeerTransportState(peerID)
) else {
return false
}
}
let link = BLEIngressLinkID.peripheral(uuid)
guard state.peerID == peerID,
noiseAuthenticatedLinkOwners[link] == peerID else {
@ -6780,6 +6910,8 @@ extension BLEService {
transferId: String? = nil,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState? = nil,
requiresPrivateMediaAdmission: Bool = false
) -> Bool {
let request = BLEOutboundFragmentTransferRequest(
@ -6789,7 +6921,9 @@ extension BLEService {
directedPeer: directedOnlyPeer,
transferId: transferId,
requireDirectPeerLink: requireDirectPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
)
let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = collectionsQueue.sync(flags: .barrier) {
@ -6916,12 +7050,30 @@ extension BLEService {
let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in
guard let self else { return false }
if let expected =
request.requiredAuthenticatedTransportState
{
guard let directedPeer = request.directedPeer,
BLEAuthenticatedTransportAdmission.isCurrent(
expected: expected,
current:
self.authenticatedPeerTransportState(
directedPeer
)
)
else {
return false
}
}
if request.requireDirectPeerLink, let directedPeer = request.directedPeer {
return self.sendPacketDirected(
fragmentPacket,
to: directedPeer,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: request.requireNoiseAuthenticatedPeerLink
requireNoiseAuthenticatedPeerLink:
request.requireNoiseAuthenticatedPeerLink,
requiredAuthenticatedTransportState:
request.requiredAuthenticatedTransportState
)
}
self.broadcastPacket(fragmentPacket)

View File

@ -2,9 +2,9 @@ import Foundation
/// Rollout gate for the cross-platform double-ratchet transport.
///
/// Keep this disabled until the coordinated iOS/Android kind-1402 migration
/// tracked by PR #1437 is complete. Source builds and tests can exercise the
/// implementation without advertising or routing production traffic.
/// Keep this disabled until the pairwise NDR implementations are reviewed and
/// ready to be enabled together on iOS and Android. Source builds and tests can
/// exercise it without advertising or routing production traffic.
enum DoubleRatchetFeature {
#if BITCHAT_ENABLE_NDR
static let isEnabled = true

View File

@ -7,7 +7,7 @@ import Combine
@MainActor
final class FavoritesPersistenceService: ObservableObject {
struct FavoriteRelationship: Codable {
struct FavoriteRelationship: Codable, Equatable {
let peerNoisePublicKey: Data
let peerNostrPublicKey: String?
let peerNickname: String
@ -27,15 +27,43 @@ final class FavoritesPersistenceService: ObservableObject {
private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites"
private static let pendingNostrIdentityRebindKey =
"chat.bitchat.favorites.ndr-rebind-journal"
private static let ndrRequiredNoiseKeysKey =
"chat.bitchat.favorites.ndr-required-noise-keys"
private struct PendingNostrIdentityRebind: Codable, Equatable {
let peerNoisePublicKey: Data
let oldNostrPublicKey: String
let targetRelationship: FavoriteRelationship
}
private struct NostrIdentityAssignment {
let requiresVerifiedCommit: Bool
}
private let keychain: KeychainManagerProtocol
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = []
private var nostrIdentityRebindAuthorizationOwner: UUID?
private var nostrIdentityRebindAuthorizationRequired =
DoubleRatchetFeature.isEnabled
private var authorizeNostrIdentityRebind:
((Data, String?, String) -> Bool)?
private var commitNostrIdentityRebind:
((Data, String, String) -> Bool)?
private var pendingNostrIdentityRebind:
PendingNostrIdentityRebind?
private var ndrRequiredNoiseKeys = Set<Data>()
private var ndrBindingStorageUnreadable = false
static let shared = FavoritesPersistenceService()
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
self.keychain = keychain
loadNdrRequiredNoiseKeys()
loadPendingNostrIdentityRebind()
loadFavorites()
// Update mutual favorites when favorites change
@ -45,6 +73,33 @@ final class FavoritesPersistenceService: ObservableObject {
}
.assign(to: &$mutualFavorites)
}
/// Installs the single app-lifetime NDR rebind authority. Ownership keeps
/// a retiring view model from clearing a newer view model's guard.
func installNostrIdentityRebindAuthorization(
owner: UUID,
required: Bool,
authorize: @escaping (Data, String?, String) -> Bool,
commit: @escaping (Data, String, String) -> Bool
) {
nostrIdentityRebindAuthorizationOwner = owner
nostrIdentityRebindAuthorizationRequired =
required || DoubleRatchetFeature.isEnabled
authorizeNostrIdentityRebind = authorize
commitNostrIdentityRebind = commit
recoverPendingNostrIdentityRebindIfPossible()
}
func removeNostrIdentityRebindAuthorization(owner: UUID) {
guard nostrIdentityRebindAuthorizationOwner == owner else {
return
}
nostrIdentityRebindAuthorizationOwner = nil
authorizeNostrIdentityRebind = nil
commitNostrIdentityRebind = nil
nostrIdentityRebindAuthorizationRequired =
DoubleRatchetFeature.isEnabled
}
/// Add or update a favorite
func addFavorite(
@ -52,27 +107,74 @@ final class FavoritesPersistenceService: ObservableObject {
peerNostrPublicKey: String? = nil,
peerNickname: String
) {
guard pendingNostrIdentityRebind?.peerNoisePublicKey
!= peerNoisePublicKey
else {
SecureLogger.error(
"Favorite mutation blocked by pending NDR rebind journal",
category: .security
)
return
}
SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
let existing = favorites[peerNoisePublicKey]
let effectiveNostrPublicKey = Self.preservingEquivalentNostrKey(
existing: existing?.peerNostrPublicKey,
requested: peerNostrPublicKey
)
let relationship = FavoriteRelationship(
peerNoisePublicKey: peerNoisePublicKey,
peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey,
peerNostrPublicKey:
effectiveNostrPublicKey ?? existing?.peerNostrPublicKey,
peerNickname: peerNickname,
isFavorite: true,
theyFavoritedUs: existing?.theyFavoritedUs ?? false,
favoritedAt: existing?.favoritedAt ?? Date(),
lastUpdated: Date()
)
let assignment: NostrIdentityAssignment
if let effectiveNostrPublicKey,
existing?.peerNostrPublicKey != effectiveNostrPublicKey
{
guard let authorized = beginNostrIdentityAssignment(
peerNoisePublicKey: peerNoisePublicKey,
oldNostrPublicKey: existing?.peerNostrPublicKey,
newNostrPublicKey: effectiveNostrPublicKey,
targetRelationship: relationship
) else {
SecureLogger.error(
"Refusing unauthorized favorite Nostr identity assignment",
category: .security
)
return
}
assignment = authorized
} else {
assignment = NostrIdentityAssignment(
requiresVerifiedCommit: false
)
}
// Log if this creates a mutual favorite
if relationship.isMutual {
SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session)
}
favorites[peerNoisePublicKey] = relationship
saveFavorites()
var updatedFavorites = favorites
updatedFavorites[peerNoisePublicKey] = relationship
if assignment.requiresVerifiedCommit {
guard persistFavorites(updatedFavorites) else {
return
}
favorites = updatedFavorites
finishPendingNostrIdentityRebind()
} else {
favorites = updatedFavorites
saveFavorites()
}
// Notify observers
NotificationCenter.default.post(
@ -84,6 +186,15 @@ final class FavoritesPersistenceService: ObservableObject {
/// Remove a favorite
func removeFavorite(peerNoisePublicKey: Data) {
guard pendingNostrIdentityRebind?.peerNoisePublicKey
!= peerNoisePublicKey
else {
SecureLogger.error(
"Favorite removal blocked by pending NDR rebind journal",
category: .security
)
return
}
guard let existing = favorites[peerNoisePublicKey] else { return }
SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
@ -124,6 +235,15 @@ final class FavoritesPersistenceService: ObservableObject {
peerNickname: String? = nil,
peerNostrPublicKey: String? = nil
) {
guard pendingNostrIdentityRebind?.peerNoisePublicKey
!= peerNoisePublicKey
else {
SecureLogger.error(
"Favorite mutation blocked by pending NDR rebind journal",
category: .security
)
return
}
let existing = favorites[peerNoisePublicKey]
// Callers that can't resolve the live nickname pass the "Unknown"
// placeholder (e.g. a notification arriving before the announce);
@ -135,22 +255,52 @@ final class FavoritesPersistenceService: ObservableObject {
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
let effectiveNostrPublicKey = Self.preservingEquivalentNostrKey(
existing: existing?.peerNostrPublicKey,
requested: peerNostrPublicKey
)
let relationship = FavoriteRelationship(
peerNoisePublicKey: peerNoisePublicKey,
peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey,
peerNostrPublicKey:
effectiveNostrPublicKey ?? existing?.peerNostrPublicKey,
peerNickname: displayName,
isFavorite: existing?.isFavorite ?? false,
theyFavoritedUs: favorited,
favoritedAt: existing?.favoritedAt ?? Date(),
lastUpdated: Date()
)
let assignment: NostrIdentityAssignment
if let effectiveNostrPublicKey,
existing?.peerNostrPublicKey != effectiveNostrPublicKey
{
guard let authorized = beginNostrIdentityAssignment(
peerNoisePublicKey: peerNoisePublicKey,
oldNostrPublicKey: existing?.peerNostrPublicKey,
newNostrPublicKey: effectiveNostrPublicKey,
targetRelationship: relationship
) else {
SecureLogger.error(
"Refusing unauthorized favorite Nostr identity assignment",
category: .security
)
return
}
assignment = authorized
} else {
assignment = NostrIdentityAssignment(
requiresVerifiedCommit: false
)
}
var updatedFavorites = favorites
if !relationship.isFavorite && !relationship.theyFavoritedUs {
// Neither side favorites, remove completely
favorites.removeValue(forKey: peerNoisePublicKey)
updatedFavorites.removeValue(forKey: peerNoisePublicKey)
// Removed - neither side favorites anymore
} else {
favorites[peerNoisePublicKey] = relationship
updatedFavorites[peerNoisePublicKey] = relationship
// Check if this creates a mutual favorite
if relationship.isMutual {
@ -158,7 +308,16 @@ final class FavoritesPersistenceService: ObservableObject {
}
}
saveFavorites()
if assignment.requiresVerifiedCommit {
guard persistFavorites(updatedFavorites) else {
return
}
favorites = updatedFavorites
finishPendingNostrIdentityRebind()
} else {
favorites = updatedFavorites
saveFavorites()
}
// Notify observers
NotificationCenter.default.post(
@ -193,6 +352,281 @@ final class FavoritesPersistenceService: ObservableObject {
}
return nil
}
func peerNostrPublicKeys(
excludingNoisePublicKey excludedNoisePublicKey: Data
) -> [String] {
favorites.compactMap { noisePublicKey, relationship in
guard noisePublicKey != excludedNoisePublicKey else {
return nil
}
return relationship.peerNostrPublicKey
}
}
/// Permanently requires pairwise NDR for this Noise identity. The pin is
/// intentionally independent of the Nostr identity so an identity rebind
/// can never reopen legacy kind-1059 fallback. Panic reset is the only
/// normal path that clears it.
@discardableResult
func markNdrRequired(for peerNoisePublicKey: Data) -> Bool {
guard !ndrBindingStorageUnreadable else { return false }
guard !ndrRequiredNoiseKeys.contains(peerNoisePublicKey) else {
return true
}
var updated = ndrRequiredNoiseKeys
updated.insert(peerNoisePublicKey)
guard persistNdrRequiredNoiseKeys(updated) else {
ndrBindingStorageUnreadable = true
SecureLogger.error(
"Could not durably pin favorite to double-ratchet transport",
category: .security
)
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil
)
return false
}
ndrRequiredNoiseKeys = updated
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil
)
return true
}
func isNdrRequired(for peerNoisePublicKey: Data) -> Bool {
ndrBindingStorageUnreadable
|| ndrRequiredNoiseKeys.contains(peerNoisePublicKey)
}
func isNdrRequired(for peerID: PeerID) -> Bool {
if ndrBindingStorageUnreadable {
return true
}
return ndrRequiredNoiseKeys.contains {
Self.peerID(peerID, matchesNoisePublicKey: $0)
}
}
/// A journal always suppresses legacy fallback, including after a build
/// turns the rollout gate back off. A permanent pin does the same after
/// the journal has been completed.
func isNdrFallbackBlocked(for peerID: PeerID) -> Bool {
if isNdrRequired(for: peerID) {
return true
}
guard let pendingNostrIdentityRebind else { return false }
return Self.peerID(
peerID,
matchesNoisePublicKey:
pendingNostrIdentityRebind.peerNoisePublicKey
)
}
/// Binding-dependent work is allowed only for an unambiguous durable
/// binding. If the target favorite was committed and only journal cleanup
/// failed, target OOB remains usable while legacy fallback stays blocked.
func canUseNdrBinding(
peerNoisePublicKey: Data,
peerNostrPublicKey: String
) -> Bool {
guard !ndrBindingStorageUnreadable else { return false }
guard let pendingNostrIdentityRebind else { return true }
guard pendingNostrIdentityRebind.peerNoisePublicKey
== peerNoisePublicKey
else {
return true
}
return pendingNostrIdentityRebind
.targetRelationship.peerNostrPublicKey
== peerNostrPublicKey
&& favorites[peerNoisePublicKey]?.peerNostrPublicKey
== peerNostrPublicKey
}
func canUseNdrBinding(for peerID: PeerID) -> Bool {
guard !ndrBindingStorageUnreadable else { return false }
guard let pendingNostrIdentityRebind,
Self.peerID(
peerID,
matchesNoisePublicKey:
pendingNostrIdentityRebind.peerNoisePublicKey
)
else {
return true
}
let targetNostrPublicKey = pendingNostrIdentityRebind
.targetRelationship.peerNostrPublicKey
return targetNostrPublicKey != nil
&& favorites[pendingNostrIdentityRebind.peerNoisePublicKey]?
.peerNostrPublicKey == targetNostrPublicKey
}
/// Account-mailbox kind-1059 is a legacy transport. Once a favorite has
/// durable pairwise state, or while its identity is being rebound, an
/// inbound legacy envelope from either identity is a downgrade and must
/// not be delivered under a virtual Nostr peer.
func canAcceptLegacyNostrDM(from peerNostrPublicKey: String) -> Bool {
guard !ndrBindingStorageUnreadable,
let normalizedPeer =
Self.normalizedNostrPublicKey(peerNostrPublicKey)
else {
return false
}
if let journal = pendingNostrIdentityRebind {
if Self.normalizedNostrPublicKey(
journal.oldNostrPublicKey
) == normalizedPeer
|| journal.targetRelationship.peerNostrPublicKey.flatMap(
Self.normalizedNostrPublicKey
) == normalizedPeer
{
return false
}
}
for (noisePublicKey, relationship) in favorites {
guard ndrRequiredNoiseKeys.contains(noisePublicKey),
relationship.peerNostrPublicKey.flatMap(
Self.normalizedNostrPublicKey
) == normalizedPeer
else {
continue
}
return false
}
return true
}
var canActivateDoubleRatchetRelay: Bool {
guard !ndrBindingStorageUnreadable else { return false }
guard let pendingNostrIdentityRebind else { return true }
return favorites[pendingNostrIdentityRebind.peerNoisePublicKey]?
.peerNostrPublicKey
== pendingNostrIdentityRebind
.targetRelationship.peerNostrPublicKey
}
private func beginNostrIdentityAssignment(
peerNoisePublicKey: Data,
oldNostrPublicKey: String?,
newNostrPublicKey: String,
targetRelationship: FavoriteRelationship
) -> NostrIdentityAssignment? {
// A pending transaction reserves its target identity globally. Letting
// another favorite claim it can make crash recovery collision-fail
// forever.
guard pendingNostrIdentityRebind == nil else {
return nil
}
if let authorizeNostrIdentityRebind {
guard authorizeNostrIdentityRebind(
peerNoisePublicKey,
oldNostrPublicKey,
newNostrPublicKey
) else {
return nil
}
} else if nostrIdentityRebindAuthorizationRequired {
return nil
}
guard let oldNostrPublicKey,
ndrRequiredNoiseKeys.contains(peerNoisePublicKey)
else {
// Ordinary pre-NDR favorite identity changes remain legacy-capable.
// Only explicit durable session evidence creates the permanent pin
// and therefore requires destructive-retirement journaling.
return NostrIdentityAssignment(
requiresVerifiedCommit: false
)
}
// Durable pins outlive rollout switches. A pinned binding therefore
// always needs the same journaled retirement transaction, even in a
// build where new NDR sessions are dark.
guard !ndrBindingStorageUnreadable,
let commitNostrIdentityRebind
else {
return nil
}
let journal = PendingNostrIdentityRebind(
peerNoisePublicKey: peerNoisePublicKey,
oldNostrPublicKey: oldNostrPublicKey,
targetRelationship: targetRelationship
)
guard persistPendingNostrIdentityRebind(journal) else {
ndrBindingStorageUnreadable = true
return nil
}
pendingNostrIdentityRebind = journal
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil
)
guard commitNostrIdentityRebind(
peerNoisePublicKey,
oldNostrPublicKey,
newNostrPublicKey
) else {
// The journal is deliberately retained. Native retirement may
// have partially succeeded, so clearing it could reopen legacy
// fallback against an ambiguous binding.
return nil
}
return NostrIdentityAssignment(requiresVerifiedCommit: true)
}
private static func peerID(
_ peerID: PeerID,
matchesNoisePublicKey noisePublicKey: Data
) -> Bool {
if let fullKey = Data(hexString: peerID.id),
fullKey == noisePublicKey
{
return true
}
return peerID.toShort()
== PeerID(publicKey: noisePublicKey).toShort()
}
private static func preservingEquivalentNostrKey(
existing: String?,
requested: String?
) -> String? {
guard let requested else { return nil }
guard let existing,
normalizedNostrPublicKey(existing)
== normalizedNostrPublicKey(requested),
normalizedNostrPublicKey(existing) != nil
else {
return requested
}
return existing
}
private static func normalizedNostrPublicKey(_ value: String) -> Data? {
let lowered = value.lowercased()
if lowered.hasPrefix("npub") {
guard let (hrp, data) = try? Bech32.decode(lowered),
hrp == "npub",
data.count == 32
else {
return nil
}
return data
}
guard lowered.count == 64,
lowered.allSatisfy(\.isHexDigit)
else {
return nil
}
return Data(hexString: lowered)
}
/// Clear all favorites - used for panic mode
func clearAllFavorites() {
@ -206,6 +640,17 @@ final class FavoritesPersistenceService: ObservableObject {
key: Self.storageKey,
service: Self.keychainService
)
keychain.delete(
key: Self.pendingNostrIdentityRebindKey,
service: Self.keychainService
)
keychain.delete(
key: Self.ndrRequiredNoiseKeysKey,
service: Self.keychainService
)
pendingNostrIdentityRebind = nil
ndrRequiredNoiseKeys.removeAll()
ndrBindingStorageUnreadable = false
// Post notification for UI update
NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil)
@ -213,36 +658,250 @@ final class FavoritesPersistenceService: ObservableObject {
// MARK: - Persistence
private func saveFavorites() {
let relationships = Array(favorites.values)
// Saving favorite relationships to keychain
@discardableResult
private func saveFavorites() -> Bool {
persistFavorites(favorites)
}
private func persistFavorites(
_ relationshipsByNoiseKey: [Data: FavoriteRelationship]
) -> Bool {
do {
let encoder = JSONEncoder()
let data = try encoder.encode(relationships)
// Store in keychain for security
keychain.save(
let relationships = relationshipsByNoiseKey.values.sorted {
$0.peerNoisePublicKey.hexEncodedString()
< $1.peerNoisePublicKey.hexEncodedString()
}
let data = try JSONEncoder().encode(relationships)
guard persistVerified(
key: Self.storageKey,
data: data,
service: Self.keychainService,
accessible: nil
)
// Successfully saved favorites
service: Self.keychainService
) else {
SecureLogger.error(
"Failed to verify persisted favorites",
category: .security
)
return false
}
return true
} catch {
SecureLogger.error("Failed to save favorites: \(error)", category: .session)
return false
}
}
private func persistNdrRequiredNoiseKeys(
_ noiseKeys: Set<Data>
) -> Bool {
do {
let sorted = noiseKeys.sorted {
$0.hexEncodedString() < $1.hexEncodedString()
}
let data = try JSONEncoder().encode(sorted)
return persistVerified(
key: Self.ndrRequiredNoiseKeysKey,
data: data,
service: Self.keychainService
)
} catch {
return false
}
}
private func persistPendingNostrIdentityRebind(
_ journal: PendingNostrIdentityRebind
) -> Bool {
do {
let data = try JSONEncoder().encode(journal)
return persistVerified(
key: Self.pendingNostrIdentityRebindKey,
data: data,
service: Self.keychainService
)
} catch {
SecureLogger.error(
"Failed to encode favorite NDR rebind journal",
category: .security
)
return false
}
}
private func persistVerified(
key: String,
data: Data,
service: String
) -> Bool {
keychain.save(
key: key,
data: data,
service: service,
accessible: nil
)
guard case .success(let stored) = keychain.loadWithResult(
key: key,
service: service
) else {
return false
}
return stored == data
}
private func finishPendingNostrIdentityRebind() {
guard pendingNostrIdentityRebind != nil else { return }
keychain.delete(
key: Self.pendingNostrIdentityRebindKey,
service: Self.keychainService
)
switch keychain.loadWithResult(
key: Self.pendingNostrIdentityRebindKey,
service: Self.keychainService
) {
case .itemNotFound:
pendingNostrIdentityRebind = nil
case .success:
SecureLogger.error(
"Favorite NDR rebind journal could not be cleared",
category: .security
)
case .accessDenied, .deviceLocked, .authenticationFailed,
.otherError:
ndrBindingStorageUnreadable = true
SecureLogger.error(
"Favorite NDR rebind journal clear could not be verified",
category: .security
)
}
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil
)
}
private func loadNdrRequiredNoiseKeys() {
switch keychain.loadWithResult(
key: Self.ndrRequiredNoiseKeysKey,
service: Self.keychainService
) {
case .itemNotFound:
return
case .success(let data):
guard let values = try? JSONDecoder().decode(
[Data].self,
from: data
),
values.allSatisfy({ $0.count == 32 })
else {
ndrBindingStorageUnreadable = true
return
}
ndrRequiredNoiseKeys = Set(values)
case .accessDenied, .deviceLocked, .authenticationFailed,
.otherError:
ndrBindingStorageUnreadable = true
}
}
private func loadPendingNostrIdentityRebind() {
switch keychain.loadWithResult(
key: Self.pendingNostrIdentityRebindKey,
service: Self.keychainService
) {
case .itemNotFound:
return
case .success(let data):
guard let journal = try? JSONDecoder().decode(
PendingNostrIdentityRebind.self,
from: data
),
journal.peerNoisePublicKey.count == 32,
journal.targetRelationship.peerNoisePublicKey
== journal.peerNoisePublicKey,
journal.targetRelationship.peerNostrPublicKey != nil,
journal.targetRelationship.peerNostrPublicKey
!= journal.oldNostrPublicKey
else {
ndrBindingStorageUnreadable = true
return
}
pendingNostrIdentityRebind = journal
case .accessDenied, .deviceLocked, .authenticationFailed,
.otherError:
ndrBindingStorageUnreadable = true
}
}
private func recoverPendingNostrIdentityRebindIfPossible() {
guard !ndrBindingStorageUnreadable,
let journal = pendingNostrIdentityRebind,
let targetNostrPublicKey =
journal.targetRelationship.peerNostrPublicKey,
let commitNostrIdentityRebind
else {
return
}
let currentNostrPublicKey =
favorites[journal.peerNoisePublicKey]?.peerNostrPublicKey
let normalizedCurrentNostrPublicKey =
currentNostrPublicKey.flatMap(Self.normalizedNostrPublicKey)
guard currentNostrPublicKey == nil
|| normalizedCurrentNostrPublicKey
== Self.normalizedNostrPublicKey(
journal.oldNostrPublicKey
)
|| normalizedCurrentNostrPublicKey
== Self.normalizedNostrPublicKey(
targetNostrPublicKey
),
authorizeNostrIdentityRebind?(
journal.peerNoisePublicKey,
journal.oldNostrPublicKey,
targetNostrPublicKey
) == true,
markNdrRequired(for: journal.peerNoisePublicKey),
commitNostrIdentityRebind(
journal.peerNoisePublicKey,
journal.oldNostrPublicKey,
targetNostrPublicKey
)
else {
return
}
var recovered = favorites
recovered[journal.peerNoisePublicKey] =
journal.targetRelationship
guard persistFavorites(recovered) else {
return
}
favorites = recovered
finishPendingNostrIdentityRebind()
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil,
userInfo: [
"peerPublicKey": journal.peerNoisePublicKey
]
)
}
private func loadFavorites() {
// Loading favorites from keychain
guard let data = keychain.load(
let data: Data
switch keychain.loadWithResult(
key: Self.storageKey,
service: Self.keychainService
) else {
return
) {
case .itemNotFound:
return
case .success(let stored):
data = stored
case .accessDenied, .deviceLocked, .authenticationFailed,
.otherError:
ndrBindingStorageUnreadable = true
return
}
do {
@ -307,6 +966,7 @@ final class FavoritesPersistenceService: ObservableObject {
// Loaded relationships successfully
} catch {
SecureLogger.error("Failed to load favorites: \(error)", category: .session)
ndrBindingStorageUnreadable = true
}
}
}

View File

@ -97,6 +97,7 @@ final class KeychainManager: KeychainManagerProtocol {
/// migrations and panic deletion cannot silently miss them.
private static let additionalApplicationOwnedServices = [
"chat.bitchat.nostr",
"chat.bitchat.ndr.session-markers",
"chat.bitchat.favorites",
"chat.bitchat.outbox",
"com.bitchat.passwords",
@ -874,14 +875,25 @@ final class KeychainManager: KeychainManagerProtocol {
kSecAttrSynchronizable as String: false
]) { _, new in new }
// Delete by the item's primary key only. Value/accessibility fields
// are add attributes, not valid selectors for replacing an existing
// item; including them can leave the old item in place and make the
// subsequent add fail as a duplicate.
let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
// Update in place so a failed replacement never destroys the last
// durable value. Rebind journals rely on the old favorites snapshot
// remaining readable when a write is rejected (locked device,
// entitlement failure, storage pressure, and similar errors).
let updateAttributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String:
accessible ?? Self.itemAccessibility
]
let updateStatus = SecItemUpdate(
primaryKeyQuery as CFDictionary,
updateAttributes as CFDictionary
)
if updateStatus == errSecSuccess {
return
}
guard updateStatus == errSecItemNotFound else {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(deleteStatus)),
NSError(domain: "Keychain", code: Int(updateStatus)),
context: "Unable to replace custom-service keychain item",
category: .keychain
)

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
case missingSenderIdentity
case failedToEncodePacket
case failedToBuildFallbackEvent
case ndrSessionFailure
case expiringMessageRequiresNdrSession
var errorDescription: String? {
switch self {
@ -29,15 +31,28 @@ final class NostrTransport: Transport, @unchecked Sendable {
return "Failed to encode embedded private-message packet"
case .failedToBuildFallbackEvent:
return "Failed to build fallback private-message event"
case .ndrSessionFailure:
return "The active double-ratchet session could not send"
case .expiringMessageRequiresNdrSession:
return "Disappearing messages require an active double-ratchet session"
}
}
}
private enum WrappedMessageOutcome {
case sent(OutboundPrivateMessageTransport)
case ndrFailed
case ndrRequired
case fallbackBuildFailed
}
struct Dependencies {
let notificationCenter: NotificationCenter
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
let canUseNdrBindingForPeerID: @MainActor (PeerID) -> Bool
let isNdrFallbackBlockedForPeerID: @MainActor (PeerID) -> Bool
let currentIdentity: @MainActor () throws -> NostrIdentity?
let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void
@ -55,6 +70,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
canUseNdrBindingForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in true },
isNdrFallbackBlockedForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in false },
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
@ -66,6 +83,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
self.loadFavorites = loadFavorites
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
self.favoriteStatusForPeerID = favoriteStatusForPeerID
self.canUseNdrBindingForPeerID =
canUseNdrBindingForPeerID
self.isNdrFallbackBlockedForPeerID =
isNdrFallbackBlockedForPeerID
self.currentIdentity = currentIdentity
self.registerPendingGiftWrap = registerPendingGiftWrap
self.sendEvent = sendEvent
@ -83,6 +104,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
loadFavorites: { FavoritesPersistenceService.shared.favorites },
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
canUseNdrBindingForPeerID: {
FavoritesPersistenceService.shared
.canUseNdrBinding(for: $0)
},
isNdrFallbackBlockedForPeerID: {
FavoritesPersistenceService.shared
.isNdrFallbackBlocked(for: $0)
},
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
@ -183,7 +212,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
// Synchronously warm the cache to avoid startup race
let favorites = self.dependencies.loadFavorites()
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.filter {
$0.peerNostrPublicKey != nil
&& self.dependencies.canUseNdrBindingForPeerID(
PeerID(publicKey: $0.peerNoisePublicKey)
)
}
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
queue.sync(flags: .barrier) {
@ -217,7 +251,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
Task { @MainActor in
let favorites = dependencies.loadFavorites()
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.filter {
$0.peerNostrPublicKey != nil
&& dependencies.canUseNdrBindingForPeerID(
PeerID(publicKey: $0.peerNoisePublicKey)
)
}
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
self.queue.async(flags: .barrier) { [weak self] in
@ -297,13 +336,44 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
}
func sendPrivateMessage(
_ content: String,
to peerID: PeerID,
recipientNickname: String,
messageID: String,
expiresAtSeconds: UInt64
) {
Task { @MainActor in
do {
_ = try sendPrivateMessageAndReturnTransport(
content,
to: peerID,
recipientNickname: recipientNickname,
messageID: messageID,
expiresAtSeconds: expiresAtSeconds
)
} catch {
SecureLogger.error(
"NostrTransport: failed to send disappearing PM: \(error)",
category: .session
)
}
}
}
@MainActor
func sendPrivateMessageAndReturnTransport(
_ content: String,
to peerID: PeerID,
recipientNickname _: String,
messageID: String
messageID: String,
expiresAtSeconds: UInt64? = nil
) throws -> OutboundPrivateMessageTransport {
guard dependencies.canUseNdrBindingForPeerID(peerID) else {
throw OutboundPrivateMessageError.ndrSessionFailure
}
let requiresNdr =
dependencies.isNdrFallbackBlockedForPeerID(peerID)
guard let recipientNpub = resolveRecipientNpub(for: peerID) else {
throw OutboundPrivateMessageError.missingRecipientNpub(peerID.id)
}
@ -325,14 +395,26 @@ final class NostrTransport: Transport, @unchecked Sendable {
) else {
throw OutboundPrivateMessageError.failedToEncodePacket
}
guard let transport = sendWrappedMessage(
switch sendWrappedMessage(
content: embedded,
recipientHex: recipientHex,
senderIdentity: senderIdentity
) else {
senderIdentity: senderIdentity,
requiresNdr: requiresNdr,
expiresAtSeconds: expiresAtSeconds
) {
case .sent(let transport):
return transport
case .ndrFailed:
throw OutboundPrivateMessageError.ndrSessionFailure
case .ndrRequired:
if expiresAtSeconds != nil {
throw OutboundPrivateMessageError
.expiringMessageRequiresNdrSession
}
throw OutboundPrivateMessageError.ndrSessionFailure
case .fallbackBuildFailed:
throw OutboundPrivateMessageError.failedToBuildFallbackEvent
}
return transport
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
@ -357,7 +439,13 @@ 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,
requiresNdr:
dependencies.isNdrFallbackBlockedForPeerID(peerID)
)
}
}
@ -424,27 +512,51 @@ extension NostrTransport {
recipientHex: String,
senderIdentity: NostrIdentity,
registerPending: Bool = false,
allowNdr: Bool = true
) -> OutboundPrivateMessageTransport? {
allowNdr: Bool = true,
requiresNdr: Bool = false,
expiresAtSeconds: UInt64? = nil
) -> WrappedMessageOutcome {
if allowNdr {
// Invites/responses travel only over an authenticated BLE Noise
// session; relay transport is used only after both clients have
// established the same ratchet session out of band.
ndrService.configureIfNeeded(identity: senderIdentity)
if ndrService.sendIfPossible(content, to: recipientHex) {
return .ndr
switch ndrService.send(
content,
to: recipientHex,
expiresAtSeconds: expiresAtSeconds
) {
case .sent:
return .sent(.ndr)
case .noSession:
if expiresAtSeconds != nil || requiresNdr {
SecureLogger.warning(
"NostrTransport: refusing legacy downgrade where NDR is required",
category: .security
)
return .ndrRequired
}
break
case .failed:
SecureLogger.error(
"NostrTransport: active NDR session failed; refusing legacy downgrade",
category: .security
)
return .ndrFailed
}
} else if expiresAtSeconds != nil || requiresNdr {
return .ndrRequired
}
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
return nil
return .fallbackBuildFailed
}
if registerPending {
dependencies.registerPendingGiftWrap(event.id)
}
dependencies.sendEvent(event)
return .legacy1059
return .sent(.legacy1059)
}
@ -461,7 +573,13 @@ 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,
requiresNdr:
dependencies.isNdrFallbackBlockedForPeerID(peerID)
)
case .deliveredDirect(let messageID, let peerID):
guard let recipientNpub = resolveRecipientNpub(for: peerID),
@ -472,7 +590,13 @@ extension NostrTransport {
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,
requiresNdr:
dependencies.isNdrFallbackBlockedForPeerID(peerID)
)
case .deliveredGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
@ -501,6 +625,9 @@ extension NostrTransport {
@MainActor
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
guard dependencies.canUseNdrBindingForPeerID(peerID) else {
return nil
}
if let noiseKey = Data(hexString: peerID.id),
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
let npub = fav.peerNostrPublicKey {

View File

@ -276,7 +276,8 @@ protocol Transport: AnyObject {
func sendNdrEvent(
to peerID: PeerID,
eventJson: String,
expectedTransportState: AuthenticatedPeerTransportState
expectedTransportState: AuthenticatedPeerTransportState,
completion: @escaping @MainActor (Bool) -> Void
)
// Vouching / transitive verification (optional for transports)
@ -370,8 +371,13 @@ extension Transport {
func sendNdrEvent(
to peerID: PeerID,
eventJson: String,
expectedTransportState: AuthenticatedPeerTransportState
) {}
expectedTransportState: AuthenticatedPeerTransportState,
completion: @escaping @MainActor (Bool) -> Void
) {
Task { @MainActor in
completion(false)
}
}
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {}

View File

@ -565,49 +565,6 @@ final class ChatPrivateConversationCoordinator {
context.notifyUIChanged()
}
/// Ingests a message authored on another device belonging to our account.
/// It is a sent message in the remote peer's thread: do not acknowledge it,
/// mark it unread, or notify as though it came from the remote peer.
func handleLocalSiblingPrivateMessage(
_ payload: NoisePayload,
conversationPubkey: String,
convKey: PeerID,
messageTimestamp: Date
) {
guard let pm = PrivateMessagePacket.decode(from: payload.data),
!context.isNostrBlocked(pubkeyHexLowercased: conversationPubkey),
!context.privateChatsContainMessage(withID: pm.messageID)
else {
return
}
let conversationPeerID = consolidateAccountConversationAliases(for: convKey)
let recipientName: String = {
if let noiseKey = conversationPeerID.noiseKey,
let favoriteNickname =
context.favoriteRelationship(forNoiseKey: noiseKey)?.peerNickname,
!favoriteNickname.isEmpty {
return favoriteNickname
}
return context.displayNameForNostrPubkey(conversationPubkey)
}()
let message = BitchatMessage(
id: pm.messageID,
sender: context.nickname,
content: pm.content,
timestamp: messageTimestamp,
isRelay: false,
isPrivate: true,
recipientNickname: recipientName,
senderPeerID: context.myPeerID,
deliveryStatus: .sent
)
guard context.appendPrivateMessage(message, to: conversationPeerID) else {
return
}
context.notifyUIChanged()
}
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }

View File

@ -181,32 +181,80 @@ extension ChatViewModel: ChatTransportEventContext {
let authenticated =
meshService.authenticatedPeerTransportState(peerID),
authenticated.capabilities.contains(.doubleRatchet),
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(
let relationship = favoritesService.getFavoriteStatus(
for: authenticated.noisePublicKey
),
relationship.isMutual,
let peerNostrKey = relationship.peerNostrPublicKey,
let peerPubkeyHex = Self.ndrNostrPubkeyHex(from: peerNostrKey),
favoritesService.canUseNdrBinding(
peerNoisePublicKey: authenticated.noisePublicKey,
peerNostrPublicKey: peerNostrKey
),
let currentIdentity = try? idBridge.getCurrentNostrIdentity()
else {
return
}
ndrService.configureIfNeeded(identity: currentIdentity)
guard !ndrService.hasActiveSession(with: peerPubkeyHex),
let invite = ndrService.currentInviteEventJson()
else {
ndrService.configureIfNeeded(
identity: currentIdentity,
processPendingActions: false
)
guard prepareDoubleRatchetPeerBinding(
peerID: peerID,
noisePublicKey: authenticated.noisePublicKey,
peerPubkeyHex: peerPubkeyHex,
currentIdentityPubkeyHex: currentIdentity.publicKeyHex
) else {
return
}
if ndrService.hasPairwiseSession(with: peerPubkeyHex) {
guard favoritesService.markNdrRequired(
for: authenticated.noisePublicKey
) else {
return
}
ndrService.configureIfNeeded(identity: currentIdentity)
}
let shouldReleaseDeferredOutOfBand =
ndrOutOfBandGenerationByPeer[peerID]
!= authenticated.sessionGeneration
ndrOutOfBandGenerationByPeer[peerID] =
authenticated.sessionGeneration
sendNdrOutOfBandActions(
ndrService.pendingOutOfBandActions(
forAuthenticatedPeerPubkeyHex: peerPubkeyHex,
releaseDeferred: shouldReleaseDeferredOutOfBand
),
to: peerID,
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: authenticated
)
if ndrService.hasPairwiseSession(with: peerPubkeyHex) {
ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID)
return
}
guard let invite = ndrService.currentInviteAction() else { return }
let inviteAttemptToken = [
authenticated.sessionGeneration.uuidString,
peerPubkeyHex,
invite.eventID
].joined(separator: "|")
guard ndrInviteAttemptTokenByPeer[peerID] != inviteAttemptToken else {
return
}
ndrInviteAttemptTokenByPeer[peerID] = inviteAttemptToken
SecureLogger.debug(
"NDR: OOB invite -> \(peerID.id.prefix(8))… peer=\(peerPubkeyHex.prefix(8))",
category: .session
)
meshService.sendNdrEvent(
sendNdrInvite(
invite,
to: peerID,
eventJson: invite,
expectedTransportState: authenticated
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: authenticated,
inviteAttemptToken: inviteAttemptToken
)
}
@ -217,12 +265,16 @@ extension ChatViewModel: ChatTransportEventContext {
let authenticated =
meshService.authenticatedPeerTransportState(peerID),
authenticated.capabilities.contains(.doubleRatchet),
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(
let relationship = favoritesService.getFavoriteStatus(
for: authenticated.noisePublicKey
),
relationship.isMutual,
let peerNostrKey = relationship.peerNostrPublicKey,
let peerPubkeyHex = Self.ndrNostrPubkeyHex(from: peerNostrKey),
favoritesService.canUseNdrBinding(
peerNoisePublicKey: authenticated.noisePublicKey,
peerNostrPublicKey: peerNostrKey
),
let currentIdentity = try? idBridge.getCurrentNostrIdentity()
else {
return
@ -235,30 +287,172 @@ extension ChatViewModel: ChatTransportEventContext {
expectedPeerPubkeyHex: peerPubkeyHex
) == true
}
let sendIfExpectedBindingCurrent: (String) -> Void = { [weak self] response in
guard let self,
isExpectedBindingCurrent()
else {
return
}
self.meshService.sendNdrEvent(
to: peerID,
eventJson: response,
expectedTransportState: authenticated
)
ndrService.configureIfNeeded(
identity: currentIdentity,
processPendingActions: false
)
guard prepareDoubleRatchetPeerBinding(
peerID: peerID,
noisePublicKey: authenticated.noisePublicKey,
peerPubkeyHex: peerPubkeyHex,
currentIdentityPubkeyHex: currentIdentity.publicKeyHex
) else {
return
}
ndrService.configureIfNeeded(identity: currentIdentity)
for response in ndrService.processOutOfBandEventJson(
let actions = ndrService.processOutOfBandEventJson(
eventJson,
expectedPeerPubkeyHex: peerPubkeyHex,
authorization: isExpectedBindingCurrent,
deferredResponseHandler: sendIfExpectedBindingCurrent
) {
sendIfExpectedBindingCurrent(response)
persistEstablishedBinding: { [weak self] in
self?.favoritesService.markNdrRequired(
for: authenticated.noisePublicKey
) == true
}
)
sendNdrOutOfBandActions(
actions,
to: peerID,
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: authenticated
)
}
private func sendNdrOutOfBandActions(
_ actions: [NdrOutOfBandAction],
to peerID: PeerID,
peerPubkeyHex: String,
expectedTransportState: AuthenticatedPeerTransportState
) {
for action in actions {
sendNdrOutOfBandAction(
action,
to: peerID,
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: expectedTransportState,
retryAttempt: 0
)
}
}
private func sendNdrInvite(
_ invite: NdrInviteAction,
to peerID: PeerID,
peerPubkeyHex: String,
expectedTransportState: AuthenticatedPeerTransportState,
inviteAttemptToken: String,
retryAttempt: Int = 0
) {
guard ndrInviteAttemptTokenByPeer[peerID] == inviteAttemptToken,
!ndrService.hasPairwiseSession(with: peerPubkeyHex),
ndrService.isCurrentInviteAction(invite),
isCurrentDoubleRatchetBinding(
peerID: peerID,
expectedTransportState: expectedTransportState,
expectedPeerPubkeyHex: peerPubkeyHex
)
else {
if ndrInviteAttemptTokenByPeer[peerID] == inviteAttemptToken {
ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID)
}
return
}
meshService.sendNdrEvent(
to: peerID,
eventJson: invite.eventJson,
expectedTransportState: expectedTransportState,
completion: { [weak self] succeeded in
guard !succeeded, let self else { return }
guard self.ndrInviteAttemptTokenByPeer[peerID]
== inviteAttemptToken
else {
return
}
self.ndrService.scheduleHostTransientRetry(
after: retryAttempt
) {
[weak self] in
self?.sendNdrInvite(
invite,
to: peerID,
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: expectedTransportState,
inviteAttemptToken: inviteAttemptToken,
retryAttempt: retryAttempt + 1
)
}
}
)
}
private func sendNdrOutOfBandAction(
_ action: NdrOutOfBandAction,
to peerID: PeerID,
peerPubkeyHex: String,
expectedTransportState: AuthenticatedPeerTransportState,
retryAttempt: Int
) {
guard action.peerPubkeyHex == peerPubkeyHex,
isCurrentDoubleRatchetBinding(
peerID: peerID,
expectedTransportState: expectedTransportState,
expectedPeerPubkeyHex: peerPubkeyHex
)
else {
ndrService.completeOutOfBandAction(
action,
succeeded: false
)
return
}
let service = ndrService
meshService.sendNdrEvent(
to: peerID,
eventJson: action.eventJson,
expectedTransportState: expectedTransportState,
completion: { [weak self] succeeded in
service.completeOutOfBandAction(
action,
succeeded: succeeded
)
guard !succeeded, let self else { return }
self.ndrService.scheduleHostTransientRetry(
after: retryAttempt
) {
[weak self] in
guard let self else { return }
guard self.isCurrentDoubleRatchetBinding(
peerID: peerID,
expectedTransportState: expectedTransportState,
expectedPeerPubkeyHex: peerPubkeyHex
)
else {
if self.ndrOutOfBandGenerationByPeer[peerID]
== expectedTransportState.sessionGeneration
{
self.ndrOutOfBandGenerationByPeer
.removeValue(forKey: peerID)
}
return
}
guard
service.prepareOutOfBandActionForRetry(action)
else {
return
}
self.sendNdrOutOfBandAction(
action,
to: peerID,
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: expectedTransportState,
retryAttempt: retryAttempt + 1
)
}
}
)
}
private func isCurrentDoubleRatchetBinding(
peerID: PeerID,
expectedTransportState: AuthenticatedPeerTransportState,
@ -266,21 +460,184 @@ extension ChatViewModel: ChatTransportEventContext {
) -> Bool {
guard meshService.authenticatedPeerTransportState(peerID) == expectedTransportState,
expectedTransportState.capabilities.contains(.doubleRatchet),
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(
let relationship = favoritesService.getFavoriteStatus(
for: expectedTransportState.noisePublicKey
),
relationship.isMutual,
let peerNostrKey = relationship.peerNostrPublicKey,
Self.ndrNostrPubkeyHex(from: peerNostrKey) == expectedPeerPubkeyHex
Self.ndrNostrPubkeyHex(from: peerNostrKey)
== expectedPeerPubkeyHex,
favoritesService.canUseNdrBinding(
peerNoisePublicKey:
expectedTransportState.noisePublicKey,
peerNostrPublicKey: peerNostrKey
)
else {
return false
}
return true
}
private static func ndrNostrPubkeyHex(from npubOrHex: String) -> String? {
if npubOrHex.hasPrefix("npub") {
guard let (hrp, data) = try? Bech32.decode(npubOrHex),
private func prepareDoubleRatchetPeerBinding(
peerID: PeerID,
noisePublicKey: Data,
peerPubkeyHex: String,
currentIdentityPubkeyHex: String
) -> Bool {
let identityPubkeyHex = currentIdentityPubkeyHex.lowercased()
if ndrBindingIdentityPubkeyHex != identityPubkeyHex {
ndrInviteAttemptTokenByPeer.removeAll()
ndrOutOfBandGenerationByPeer.removeAll()
ndrPeerPubkeyByNoiseKey.removeAll()
ndrBindingIdentityPubkeyHex = identityPubkeyHex
}
if let previousPeerPubkeyHex =
ndrPeerPubkeyByNoiseKey[noisePublicKey],
previousPeerPubkeyHex != peerPubkeyHex
{
guard ndrService.retirePeer(previousPeerPubkeyHex) else {
return false
}
ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID)
ndrOutOfBandGenerationByPeer.removeValue(forKey: peerID)
}
ndrPeerPubkeyByNoiseKey[noisePublicKey] = peerPubkeyHex
return true
}
func authorizeDoubleRatchetFavoriteRebind(
noisePublicKey: Data,
oldNostrPublicKey: String?,
newNostrPublicKey: String
) -> Bool {
guard let newPeerPubkeyHex =
Self.ndrNostrPubkeyHex(from: newNostrPublicKey)
else {
// A malformed destination cannot be collision-checked.
return false
}
let oldPeerPubkeyHex: String?
if let oldNostrPublicKey {
guard let normalized =
Self.ndrNostrPubkeyHex(from: oldNostrPublicKey)
else {
// A malformed existing binding cannot be safely retired.
return false
}
oldPeerPubkeyHex = normalized
} else {
oldPeerPubkeyHex = nil
}
guard oldPeerPubkeyHex != newPeerPubkeyHex else { return true }
let otherFavoritePubkeys =
favoritesService.peerNostrPublicKeys(
excludingNoisePublicKey: noisePublicKey
)
.compactMap { Self.ndrNostrPubkeyHex(from: $0) }
guard !otherFavoritePubkeys.contains(newPeerPubkeyHex) else {
// A Nostr identity may have only one stable Noise binding. Without
// this, two radio identities could both authorize the same ratchet.
return false
}
guard let oldPeerPubkeyHex else {
// Initial and nil-to-value assignments have nothing to retire.
return true
}
guard ndrService.isRolloutEnabled else {
// FavoritesPersistenceService still journals and commits a
// previously pinned binding while rollout is dark. There is no
// new session to discover or pin on this path.
return true
}
guard let currentIdentity =
try? idBridge.getCurrentNostrIdentity()
else {
return false
}
ndrService.configureIfNeeded(
identity: currentIdentity,
processPendingActions: false
)
guard ndrService.isConfigured else {
return false
}
if ndrService.hasPairwiseSession(with: oldPeerPubkeyHex) {
return favoritesService.markNdrRequired(
for: noisePublicKey
)
}
return true
}
func commitDoubleRatchetFavoriteRebind(
noisePublicKey: Data,
oldNostrPublicKey: String,
newNostrPublicKey: String
) -> Bool {
guard let oldPeerPubkeyHex =
Self.ndrNostrPubkeyHex(from: oldNostrPublicKey),
let newPeerPubkeyHex =
Self.ndrNostrPubkeyHex(from: newNostrPublicKey),
let currentIdentity =
try? idBridge.getCurrentNostrIdentity()
else {
return false
}
// Representation-only changes (hex npub or case) carry no
// retirement intent. Returning success also recovers journals written
// by an older build before equivalent keys were normalized.
guard oldPeerPubkeyHex != newPeerPubkeyHex else {
return true
}
let otherFavoritePubkeys =
favoritesService.peerNostrPublicKeys(
excludingNoisePublicKey: noisePublicKey
)
.compactMap { Self.ndrNostrPubkeyHex(from: $0) }
guard !otherFavoritePubkeys.contains(newPeerPubkeyHex) else {
return false
}
// Configuration and retirement are intentionally action-silent here:
// the durable rebind journal exists, but the target favorite has not
// been committed yet. Relay work resumes through the normal setup/send
// path only after FavoritesPersistenceService verifies that commit.
guard ndrService.configureIfNeeded(
identity: currentIdentity,
processPendingActions: false,
allowDisabledMaintenance: true
) else {
return false
}
if !otherFavoritePubkeys.contains(oldPeerPubkeyHex),
!ndrService.retirePeer(
oldPeerPubkeyHex,
processPendingActions: false,
allowDisabledMaintenance: true
)
{
return false
}
let reboundPeerIDs = ndrOutOfBandGenerationByPeer.keys.filter {
meshService.authenticatedPeerTransportState($0)?
.noisePublicKey == noisePublicKey
}
for peerID in reboundPeerIDs {
ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID)
ndrOutOfBandGenerationByPeer.removeValue(forKey: peerID)
}
ndrPeerPubkeyByNoiseKey[noisePublicKey] = newPeerPubkeyHex
ndrBindingIdentityPubkeyHex =
currentIdentity.publicKeyHex.lowercased()
return true
}
static func ndrNostrPubkeyHex(from npubOrHex: String) -> String? {
let lowered = npubOrHex.lowercased()
if lowered.hasPrefix("npub") {
guard let (hrp, data) = try? Bech32.decode(lowered),
hrp == "npub",
data.count == 32
else {
@ -289,7 +646,6 @@ extension ChatViewModel: ChatTransportEventContext {
return data.hexEncodedString()
}
let lowered = npubOrHex.lowercased()
guard lowered.count == 64,
lowered.allSatisfy(\.isHexDigit)
else {

View File

@ -317,6 +317,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
let idBridge: NostrIdentityBridge
let identityManager: SecureIdentityStateManagerProtocol
let ndrService: NdrNostrService
let favoritesService: FavoritesPersistenceService
/// Bounds NDR bootstrap retries to one chain for an exact authenticated
/// Noise generation. A changed generation or invite replaces the token.
var ndrInviteAttemptTokenByPeer: [PeerID: String] = [:]
/// Tracks which Noise generation last claimed durable OOB responses so
/// repeated bootstrap triggers cannot reset their retry budget.
var ndrOutOfBandGenerationByPeer: [PeerID: UUID] = [:]
/// A favorite's authenticated Noise key is the stable binding. If its
/// associated Nostr identity changes, retire only that old pairwise peer.
var ndrPeerPubkeyByNoiseKey: [Data: String] = [:]
var ndrBindingIdentityPubkeyHex: String?
private let ndrFavoriteRebindAuthorizationOwner = UUID()
/// Single source of truth for conversation message state and selection
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed
/// through.
@ -1104,7 +1116,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared,
ndrService: NdrNostrService? = nil
ndrService: NdrNostrService? = nil,
favoritesService: FavoritesPersistenceService? = nil
) {
let livePanicRecoveryOperations = PanicRecoveryOperations.live()
let startSuspendedForRecovery: Bool
@ -1141,6 +1154,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
identityManager: identityManager,
transport: meshService,
ndrService: ndrService,
favoritesService: favoritesService,
conversations: conversations,
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
@ -1161,6 +1175,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
identityManager: SecureIdentityStateManagerProtocol,
transport: Transport,
ndrService: NdrNostrService? = nil,
favoritesService: FavoritesPersistenceService? = nil,
conversations: ConversationStore? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
@ -1176,6 +1191,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore()
let resolvedNdrService = ndrService ?? .shared
let resolvedFavoritesService =
favoritesService ?? FavoritesPersistenceService.shared
let services = ChatViewModelServiceBundle(
keychain: keychain,
idBridge: idBridge,
@ -1194,6 +1211,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
self.idBridge = idBridge
self.identityManager = identityManager
self.ndrService = resolvedNdrService
self.favoritesService = resolvedFavoritesService
self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
@ -1248,6 +1266,32 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
_ = panicClearAllData(restartServices: false)
}
resolvedFavoritesService
.installNostrIdentityRebindAuthorization(
owner: ndrFavoriteRebindAuthorizationOwner,
required: resolvedNdrService.isRolloutEnabled,
authorize: { [weak self]
noisePublicKey,
oldNostrPublicKey,
newNostrPublicKey in
self?.authorizeDoubleRatchetFavoriteRebind(
noisePublicKey: noisePublicKey,
oldNostrPublicKey: oldNostrPublicKey,
newNostrPublicKey: newNostrPublicKey
) ?? false
},
commit: { [weak self]
noisePublicKey,
oldNostrPublicKey,
newNostrPublicKey in
self?.commitDoubleRatchetFavoriteRebind(
noisePublicKey: noisePublicKey,
oldNostrPublicKey: oldNostrPublicKey,
newNostrPublicKey: newNostrPublicKey
) ?? false
}
)
if networkActivationAllowed {
ChatViewModelBootstrapper(viewModel: self).configure()
}
@ -1256,6 +1300,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// MARK: - Deinitialization
deinit {
let owner = ndrFavoriteRebindAuthorizationOwner
let favoritesService = favoritesService
Task { @MainActor in
favoritesService
.removeNostrIdentityRebindAuthorization(owner: owner)
}
// No need to force UserDefaults synchronization
}
@ -1596,6 +1646,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
queuedPrivateChatClears.removeAll(keepingCapacity: false)
privateChatClearInFlight = false
ndrInviteAttemptTokenByPeer.removeAll()
ndrOutOfBandGenerationByPeer.removeAll()
ndrPeerPubkeyByNoiseKey.removeAll()
ndrBindingIdentityPubkeyHex = nil
let ndrWipeCompleted: Bool
do {
try ndrService.resetForPanic()
@ -1648,7 +1702,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
publicRateLimiter.reset()
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
favoritesService.clearAllFavorites()
// Drop courier mail carried for third parties (memory and disk),
// our own queued outbox, the carried public history, and the

View File

@ -84,6 +84,7 @@ final class ChatViewModelBootstrapper {
configureGateway()
configureBridge()
configureBridgeCourier()
bindNdrRelayRetry()
bindTeleportState()
requestNotifications()
registerObservers()
@ -639,6 +640,17 @@ private extension ChatViewModelBootstrapper {
courier.refresh()
}
func bindNdrRelayRetry() {
NostrRelayManager.shared.$isDMRelayConnected
.removeDuplicates()
.filter { $0 }
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
viewModel?.ndrService.retryRelayActions()
}
.store(in: &viewModel.cancellables)
}
private static let bridgeSubscriptionID = "bridge-rendezvous"
private static let courierDropSubscriptionID = "bridge-courier-drops"

View File

@ -62,11 +62,55 @@ extension ChatViewModel {
@MainActor
func setupNostrMessageHandling() {
if let currentIdentity = try? idBridge.getCurrentNostrIdentity() {
ndrService.onDecryptedMessage = { [weak self] message in
self?.nostrCoordinator.inbound.handleNdrDecryptedMessage(message)
if favoritesService.canActivateDoubleRatchetRelay,
let currentIdentity = try? idBridge.getCurrentNostrIdentity()
{
ndrService.configureIfNeeded(
identity: currentIdentity,
processPendingActions: false
)
if ndrService.isRolloutEnabled {
for relationship in favoritesService.favorites.values {
guard let peerNostrPublicKey =
relationship.peerNostrPublicKey,
let peerPubkeyHex =
Self.ndrNostrPubkeyHex(
from: peerNostrPublicKey
),
ndrService.hasPairwiseSession(
with: peerPubkeyHex
)
else {
continue
}
guard favoritesService.markNdrRequired(
for: relationship.peerNoisePublicKey
) else {
ndrService.onDecryptedMessage = nil
nostrCoordinator.subscriptions
.setupNostrMessageHandling()
return
}
}
}
guard favoritesService.canActivateDoubleRatchetRelay else {
ndrService.onDecryptedMessage = nil
nostrCoordinator.subscriptions.setupNostrMessageHandling()
return
}
ndrService.onDecryptedMessage = { [weak self] message, completion in
guard let self else {
completion(.retry)
return
}
self.nostrCoordinator.inbound.handleNdrDecryptedMessage(
message,
completion: completion
)
}
ndrService.configureIfNeeded(identity: currentIdentity)
} else {
ndrService.onDecryptedMessage = nil
}
nostrCoordinator.subscriptions.setupNostrMessageHandling()
}

View File

@ -45,21 +45,6 @@ extension ChatViewModel {
)
}
@MainActor
func handleLocalSiblingPrivateMessage(
_ payload: NoisePayload,
conversationPubkey: String,
convKey: PeerID,
messageTimestamp: Date
) {
privateConversationCoordinator.handleLocalSiblingPrivateMessage(
payload,
conversationPubkey: conversationPubkey,
convKey: convKey,
messageTimestamp: messageTimestamp
)
}
@MainActor
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
privateConversationCoordinator.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)

View File

@ -24,6 +24,11 @@ protocol NostrInboundPipelineContext: AnyObject {
/// All favorite relationships, used to bridge a Nostr pubkey back to a
/// Noise key on the inbound DM path.
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship]
func canUseNdrBinding(
peerNoisePublicKey: Data,
peerNostrPublicKey: String
) -> Bool
func canAcceptLegacyNostrDM(from peerNostrPublicKey: String) -> Bool
// MARK: Presence & key mapping
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String)
@ -48,25 +53,21 @@ protocol NostrInboundPipelineContext: AnyObject {
id: NostrIdentity,
messageTimestamp: Date
)
func handleLocalSiblingPrivateMessage(
_ payload: NoisePayload,
conversationPubkey: String,
convKey: PeerID,
messageTimestamp: Date
)
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
}
extension NostrInboundPipelineContext {
/// Contexts that do not persist private conversations (for example
/// performance harnesses) safely drop local-sibling synchronization.
func handleLocalSiblingPrivateMessage(
_ payload: NoisePayload,
conversationPubkey: String,
convKey: PeerID,
messageTimestamp: Date
) {}
func canUseNdrBinding(
peerNoisePublicKey _: Data,
peerNostrPublicKey _: String
) -> Bool {
true
}
func canAcceptLegacyNostrDM(from _: String) -> Bool {
true
}
}
extension ChatViewModel: NostrInboundPipelineContext {
@ -79,7 +80,23 @@ extension ChatViewModel: NostrInboundPipelineContext {
}
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] {
Array(FavoritesPersistenceService.shared.favorites.values)
Array(favoritesService.favorites.values)
}
func canUseNdrBinding(
peerNoisePublicKey: Data,
peerNostrPublicKey: String
) -> Bool {
favoritesService.canUseNdrBinding(
peerNoisePublicKey: peerNoisePublicKey,
peerNostrPublicKey: peerNostrPublicKey
)
}
func canAcceptLegacyNostrDM(from peerNostrPublicKey: String) -> Bool {
favoritesService.canAcceptLegacyNostrDM(
from: peerNostrPublicKey
)
}
func recordProcessedNostrEvent(_ eventID: String) {
@ -109,6 +126,7 @@ extension ChatViewModel: NostrInboundPipelineContext {
final class NostrInboundPipeline {
private weak var context: (any NostrInboundPipelineContext)?
private let presence: GeoPresenceTracker
private let now: @MainActor () -> Date
private var geoEventLogCount = 0
/// Monotonic panic-wipe generation for this pipeline. A panic wipe clears
@ -127,9 +145,14 @@ final class NostrInboundPipeline {
wipeGeneration &+= 1
}
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
init(
context: any NostrInboundPipelineContext,
presence: GeoPresenceTracker,
now: @escaping @MainActor () -> Date = Date.init
) {
self.context = context
self.presence = presence
self.now = now
}
@MainActor
@ -439,24 +462,47 @@ final class NostrInboundPipeline {
}
@MainActor
func handleNdrDecryptedMessage(_ message: NdrDecryptedMessage) {
guard let context else { return }
func handleNdrDecryptedMessage(
_ message: NdrDecryptedMessage,
completion: @escaping NdrDeliveryCompletion
) {
guard let context else {
completion(.retry)
return
}
let innerEvent = message.event
guard !context.hasProcessedNostrEvent(innerEvent.id) else { return }
context.recordProcessedNostrEvent(innerEvent.id)
guard !context.hasProcessedNostrEvent(innerEvent.id) else {
completion(.consumed)
return
}
let wipeGeneration = self.wipeGeneration
guard let currentIdentity = context.currentNostrIdentity() else { return }
guard let currentIdentity = context.currentNostrIdentity() else {
completion(.retry)
return
}
Task { [weak self] in
await self?.processDecryptedNostrDMContent(
guard let self else {
completion(.retry)
return
}
let disposition = await self.processDecryptedNostrDMContent(
innerEvent.content,
senderPubkey: message.senderPubkeyHex,
conversationPubkey: message.conversationPubkeyHex,
isLocalSiblingCopy: message.isLocalSiblingCopy,
rumorTimestamp: innerEvent.created_at,
currentIdentity: currentIdentity,
wipeGeneration: wipeGeneration
wipeGeneration: wipeGeneration,
expiresAtSeconds: message.expiresAtSeconds,
requiresFavoriteBinding: true
)
guard self.wipeGeneration == wipeGeneration else {
completion(.retry)
return
}
if disposition == .consumed {
context.recordProcessedNostrEvent(innerEvent.id)
}
completion(disposition)
}
}
@ -485,6 +531,18 @@ final class NostrInboundPipeline {
giftWrap: giftWrap,
recipientIdentity: currentIdentity
)
let acceptsLegacyDM = await MainActor.run {
context.canAcceptLegacyNostrDM(
from: senderPubkey
)
}
guard acceptsLegacyDM else {
SecureLogger.warning(
"Rejected legacy account DM for pairwise-only binding",
category: .security
)
return
}
await processDecryptedNostrDMContent(
content,
senderPubkey: senderPubkey,
@ -497,23 +555,24 @@ final class NostrInboundPipeline {
}
}
@discardableResult
private func processDecryptedNostrDMContent(
_ content: String,
senderPubkey: String,
conversationPubkey: String? = nil,
isLocalSiblingCopy: Bool = false,
rumorTimestamp: Int,
currentIdentity: NostrIdentity,
wipeGeneration: UInt64
) async {
guard let context else { return }
wipeGeneration: UInt64,
expiresAtSeconds: UInt64? = nil,
requiresFavoriteBinding: Bool = false
) async -> NdrDeliveryDisposition {
guard let context else { return .retry }
if content.hasPrefix("verify:") {
return
return .consumed
}
guard content.hasPrefix("bitchat1:") else {
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
return
return .consumed
}
let packet: BitchatPacket? = await MainActor.run {
@ -521,61 +580,66 @@ final class NostrInboundPipeline {
}
guard let packet else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return
return .consumed
}
let routingPubkey = conversationPubkey ?? senderPubkey
let routingPubkey = senderPubkey
let actualSenderNoiseKey: Data? = await MainActor.run {
self.findNoiseKey(for: routingPubkey)
}
if requiresFavoriteBinding, actualSenderNoiseKey == nil {
// Keep the native delivery durable until the favorite binding
// journal is recovered. Falling through to a virtual Nostr peer
// would bypass the fail-closed pairwise identity binding.
return .retry
}
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString())
?? PeerID(nostr_: routingPubkey)
guard packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
return .consumed
}
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run {
guard self.wipeGeneration == wipeGeneration else { return }
return await MainActor.run {
guard self.wipeGeneration == wipeGeneration else {
return .retry
}
if let expiresAtSeconds,
self.now().timeIntervalSince1970
>= TimeInterval(expiresAtSeconds)
{
// The native delivery remains durable while decoding hops
// actors. Recheck immediately before every app mutation so a
// message that expires during that work drains without ever
// being mapped, persisted, or notified.
return .consumed
}
context.registerNostrKeyMapping(routingPubkey, for: targetPeerID)
switch payload.type {
case .privateMessage:
if isLocalSiblingCopy {
context.handleLocalSiblingPrivateMessage(
payload,
conversationPubkey: routingPubkey,
convKey: targetPeerID,
messageTimestamp: messageTimestamp
)
} else {
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID,
id: currentIdentity,
messageTimestamp: messageTimestamp
)
}
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID,
id: currentIdentity,
messageTimestamp: messageTimestamp
)
case .delivered:
if !isLocalSiblingCopy {
context.handleDelivered(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID
)
}
context.handleDelivered(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID
)
case .readReceipt:
if !isLocalSiblingCopy {
context.handleReadReceipt(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID
)
}
context.handleReadReceipt(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID
)
// These payloads are mesh-only and must never be tunneled through
// either private-relay envelope.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate,
@ -583,6 +647,7 @@ final class NostrInboundPipeline {
.authenticatedPeerState:
break
}
return .consumed
}
}
@ -608,6 +673,13 @@ final class NostrInboundPipeline {
for relationship in favorites {
if let storedNostrKey = relationship.peerNostrPublicKey {
guard context.canUseNdrBinding(
peerNoisePublicKey:
relationship.peerNoisePublicKey,
peerNostrPublicKey: storedNostrKey
) else {
continue
}
if storedNostrKey == npubToMatch {
return relationship.peerNoisePublicKey
}

View File

@ -213,6 +213,7 @@ private final class MockChatNostrContext: ChatNostrContext {
// Favorites & notifications
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
var acceptsLegacyNostrDM = true
private(set) var geohashActivityNotifications: [(geohash: String, bodyPreview: String)] = []
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
@ -223,6 +224,10 @@ private final class MockChatNostrContext: ChatNostrContext {
Array(favoriteRelationshipsByNoiseKey.values)
}
func canAcceptLegacyNostrDM(from _: String) -> Bool {
acceptsLegacyNostrDM
}
func notifyGeohashActivity(geohash: String, bodyPreview: String) {
geohashActivityNotifications.append((geohash, bodyPreview))
}
@ -446,6 +451,45 @@ struct ChatNostrCoordinatorContextTests {
#expect(context.recordedNostrEventIDs == [giftWrap.id])
}
@Test @MainActor
func processNostrMessage_rejectsLegacyDowngradeBeforeDelivery()
async throws
{
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
context.nostrIdentity = recipient
context.acceptsLegacyNostrDM = false
let embedded = try #require(
NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "must not downgrade",
messageID: "legacy-blocked",
senderPeerID: PeerID(str: "aabbccddeeff0011")
)
)
let blockedGiftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
await coordinator.inbound.processNostrMessage(blockedGiftWrap)
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.recordedNostrEventIDs == [blockedGiftWrap.id])
context.acceptsLegacyNostrDM = true
let acceptedGiftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
await coordinator.inbound.processNostrMessage(acceptedGiftWrap)
#expect(context.handledPrivateMessages.count == 1)
}
@Test @MainActor
func switchLocationChannel_toMesh_tearsDownGeohashState() async {
let context = MockChatNostrContext()
@ -681,4 +725,60 @@ struct GeoPresenceTrackerTests {
)
}
@Test @MainActor
func ndrDelivery_expiredAtFinalMutationDrainsWithoutSideEffects() async throws {
let context = MockChatNostrContext()
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
context.nostrIdentity = recipient
let senderPeerID = PeerID(str: "0011223344556677")
let embedded = try #require(
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
type: .delivered,
messageID: "expired-ndr",
senderPeerID: senderPeerID
)
)
let unsigned = NostrEvent(
pubkey: sender.publicKeyHex,
createdAt: Date(timeIntervalSince1970: 99),
kind: .dm,
tags: [],
content: embedded
)
var rumor = try unsigned.sign(
with: sender.schnorrSigningKey()
)
rumor.sig = nil
let presence = GeoPresenceTracker(context: context)
let pipeline = NostrInboundPipeline(
context: context,
presence: presence,
now: { Date(timeIntervalSince1970: 100) }
)
var disposition: NdrDeliveryDisposition?
pipeline.handleNdrDecryptedMessage(
NdrDecryptedMessage(
event: rumor,
senderPubkeyHex: sender.publicKeyHex,
outerEventID: String(repeating: "a", count: 64),
expiresAtSeconds: 100
),
completion: { disposition = $0 }
)
let completed = await TestHelpers.waitUntil(
{ disposition != nil },
timeout: TestConstants.settleTimeout
)
#expect(completed)
#expect(disposition == .consumed)
#expect(context.nostrKeyMapping.isEmpty)
#expect(context.handledDelivered.isEmpty)
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.handledReadReceipts.isEmpty)
#expect(context.recordedNostrEventIDs == [rumor.id])
}
}

View File

@ -491,48 +491,6 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(context.privateChats[convKey]?.count == 1)
}
@Test @MainActor
func localSiblingCopy_isStoredAsSentInRemoteConversation() async throws {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xD1, count: 32)
let peerID = PeerID(hexData: noiseKey)
let remotePubkey = String(repeating: "b", count: 64)
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: remotePubkey,
nickname: "bob",
isFavorite: true,
theyFavoritedUs: true
)
let payload = NoisePayload(
type: .privateMessage,
data: try #require(
PrivateMessagePacket(
messageID: "local-sibling-1",
content: "sent on my other device"
).encode()
)
)
coordinator.handleLocalSiblingPrivateMessage(
payload,
conversationPubkey: remotePubkey,
convKey: peerID,
messageTimestamp: Date(timeIntervalSince1970: 123)
)
let message = try #require(context.privateChats[peerID]?.first)
#expect(message.id == "local-sibling-1")
#expect(message.sender == context.nickname)
#expect(message.senderPeerID == context.myPeerID)
#expect(message.recipientNickname == "bob")
#expect(message.deliveryStatus == .sent)
#expect(context.sentGeoDeliveryAcks.isEmpty)
#expect(context.unreadPrivateMessages.isEmpty)
#expect(context.notifyUIChangedCount == 1)
}
@Test @MainActor
func accountDM_handsOpenShortIDConversationToStableWhenOffline() async {
let context = MockChatPrivateConversationContext()

View File

@ -17,6 +17,7 @@ import BitFoundation
@MainActor
private func makeTestableViewModel(
keychain injectedKeychain: MockKeychain? = nil,
ndrService: NdrNostrService? = nil,
panicMediaWipe: (() throws -> Void)? = nil,
panicRecoveryOperations: PanicRecoveryOperations? = nil,
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
@ -32,6 +33,7 @@ private func makeTestableViewModel(
idBridge: idBridge,
identityManager: identityManager,
transport: transport,
ndrService: ndrService,
panicMediaWipe: panicMediaWipe,
panicRecoveryOperations: panicRecoveryOperations,
panicNetworkLifecycle: panicNetworkLifecycle
@ -2228,6 +2230,121 @@ struct ChatViewModelPanicTests {
#expect(!viewModel.networkActivationAllowed)
}
@Test @MainActor
func failedNdrPanicWipeStaysLatchedAcrossViewModelRestart() throws {
enum StorageFailure: Error {
case unavailable
}
let storage = FileManager.default.temporaryDirectory
.appendingPathComponent(
"bitchat-tests-panic-ndr-\(UUID().uuidString)",
isDirectory: true
)
var storageAvailable = true
var recoveryPending = false
var beginCount = 0
var completeCount = 0
let recoveryOperations = PanicRecoveryOperations(
isPending: { recoveryPending },
begin: {
recoveryPending = true
beginCount += 1
return PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: true
)
},
wipeMedia: { _ in },
complete: {
recoveryPending = false
completeCount += 1
}
)
let markerStore = InMemoryNdrSessionMarkerStore()
let firstNdrService = NdrNostrService(
relayManager: FakeRelayManager(),
rolloutEnabled: true,
storageDirectoryProvider: {
guard storageAvailable else {
throw StorageFailure.unavailable
}
return storage
},
sessionMarkerStore: markerStore
)
firstNdrService.configureIfNeeded(
identity: try NostrIdentity.generate()
)
#expect(
FileManager.default.fileExists(atPath: storage.path)
)
let (firstViewModel, firstTransport) =
makeTestableViewModel(
ndrService: firstNdrService,
panicRecoveryOperations: recoveryOperations
)
let startsBeforePanic = firstTransport.startServicesCallCount
storageAvailable = false
#expect(!firstViewModel.panicClearAllData())
#expect(recoveryPending)
#expect(completeCount == 0)
#expect(
firstTransport.startServicesCallCount == startsBeforePanic
)
#expect(!firstViewModel.networkActivationAllowed)
#expect(
FileManager.default.fileExists(atPath: storage.path)
)
let secondNdrService = NdrNostrService(
relayManager: FakeRelayManager(),
rolloutEnabled: true,
storageDirectoryProvider: {
guard storageAvailable else {
throw StorageFailure.unavailable
}
return storage
},
sessionMarkerStore: markerStore
)
let (secondViewModel, secondTransport) =
makeTestableViewModel(
ndrService: secondNdrService,
panicRecoveryOperations: recoveryOperations
)
#expect(recoveryPending)
#expect(beginCount == 2)
#expect(completeCount == 0)
#expect(secondTransport.startServicesCallCount == 0)
#expect(!secondViewModel.networkActivationAllowed)
storageAvailable = true
let thirdNdrService = NdrNostrService(
relayManager: FakeRelayManager(),
rolloutEnabled: true,
storageDirectoryProvider: { storage },
sessionMarkerStore: markerStore
)
let (thirdViewModel, thirdTransport) =
makeTestableViewModel(
ndrService: thirdNdrService,
panicRecoveryOperations: recoveryOperations
)
#expect(!recoveryPending)
#expect(beginCount == 3)
#expect(completeCount == 1)
#expect(thirdTransport.startServicesCallCount == 1)
#expect(thirdViewModel.networkActivationAllowed)
#expect(
!FileManager.default.fileExists(atPath: storage.path)
)
}
@Test @MainActor
func panicClearAllData_delegatesToTransport() async {
let (viewModel, transport) = makeTestableViewModel()

File diff suppressed because it is too large Load Diff

View File

@ -403,14 +403,22 @@ struct PrivateMediaEndToEndTests {
await alice._test_drainNoiseMessagePipeline()
let tap = PacketTap()
let admission = ReceiptCapabilityRecorder()
alice._test_onOutboundPacket = tap.record
alice.sendNdrEvent(
to: bob.myPeerID,
eventJson: #"{"kind":1059}"#,
expectedTransportState: authenticatedState
expectedTransportState: authenticatedState,
completion: admission.record
)
await alice._test_drainNoiseMessagePipeline()
#expect(
await TestHelpers.waitUntil(
{ admission.snapshot() == [false] },
timeout: TestConstants.longTimeout
)
)
#expect(
tap.snapshot().allSatisfy {
$0.type != MessageType.noiseEncrypted.rawValue
@ -453,11 +461,13 @@ struct PrivateMediaEndToEndTests {
#expect(authenticatedState.noisePublicKey == bob.noiseStaticPublicKeyData())
let tap = PacketTap()
let admission = ReceiptCapabilityRecorder()
alice._test_onOutboundPacket = tap.record
alice.sendNdrEvent(
to: bob.myPeerID,
eventJson: #"{"kind":1059}"#,
expectedTransportState: authenticatedState
expectedTransportState: authenticatedState,
completion: admission.record
)
let sent = await TestHelpers.waitUntil(
{
@ -470,6 +480,12 @@ struct PrivateMediaEndToEndTests {
)
#expect(sent)
#expect(
await TestHelpers.waitUntil(
{ admission.snapshot().count == 1 },
timeout: TestConstants.longTimeout
)
)
#expect(
tap.snapshot().filter {
$0.type == MessageType.noiseEncrypted.rawValue
@ -568,14 +584,22 @@ struct PrivateMediaEndToEndTests {
await alice._test_drainNoiseMessagePipeline()
let tap = PacketTap()
let admission = ReceiptCapabilityRecorder()
alice._test_onOutboundPacket = tap.record
alice.sendNdrEvent(
to: bob.myPeerID,
eventJson: #"{"kind":1059}"#,
expectedTransportState: oldState
expectedTransportState: oldState,
completion: admission.record
)
await alice._test_drainNoiseMessagePipeline()
#expect(
await TestHelpers.waitUntil(
{ admission.snapshot() == [false] },
timeout: TestConstants.longTimeout
)
)
#expect(
tap.snapshot().allSatisfy {
$0.type != MessageType.noiseEncrypted.rawValue

View File

@ -18,6 +18,8 @@ final class MockKeychain: KeychainManagerProtocol {
var simulatedReadError: KeychainReadResult?
var simulatedSaveError: KeychainSaveResult?
var simulatedGenericReadError: KeychainReadResult?
var simulatedGenericSaveFailureKeys = Set<String>()
var simulatedGenericDeleteFailureKeys = Set<String>()
var simulatedDeleteAllResult = true
private(set) var deleteAllCallCount = 0
@ -77,6 +79,9 @@ final class MockKeychain: KeychainManagerProtocol {
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
func save(key: String, data: Data, service: String, accessible: CFString?) {
guard !simulatedGenericSaveFailureKeys.contains(key) else {
return
}
if serviceStorage[service] == nil {
serviceStorage[service] = [:]
}
@ -98,6 +103,9 @@ final class MockKeychain: KeychainManagerProtocol {
}
func delete(key: String, service: String) {
guard !simulatedGenericDeleteFailureKeys.contains(key) else {
return
}
serviceStorage[service]?.removeValue(forKey: key)
}

View File

@ -45,6 +45,13 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
private(set) var protectedPrivateMediaRelativePaths: [Set<String>] = []
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentNdrEvents: [
(
peerID: PeerID,
eventJson: String,
expectedTransportState: AuthenticatedPeerTransportState
)
] = []
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
private(set) var startServicesCallCount = 0
private(set) var stopServicesCallCount = 0
@ -66,6 +73,10 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
var privateMediaReceiptSessionGenerations: [PeerID: UUID] = [:]
var authenticatedPeerTransportStates: [
PeerID: AuthenticatedPeerTransportState
] = [:]
var ndrSendResults: [Bool] = []
var persistDeletedPrivateMediaResult = true
var deferDeletedPrivateMediaPersistence = false
private var pendingDeletedPrivateMediaCompletions: [
@ -132,6 +143,32 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
triggeredHandshakes.append(peerID)
}
func authenticatedPeerTransportState(
_ peerID: PeerID
) -> AuthenticatedPeerTransportState? {
authenticatedPeerTransportStates[peerID]
}
func sendNdrEvent(
to peerID: PeerID,
eventJson: String,
expectedTransportState: AuthenticatedPeerTransportState,
completion: @escaping @MainActor (Bool) -> Void
) {
sentNdrEvents.append(
(
peerID: peerID,
eventJson: eventJson,
expectedTransportState: expectedTransportState
)
)
let succeeded =
ndrSendResults.isEmpty ? false : ndrSendResults.removeFirst()
Task { @MainActor in
completion(succeeded)
}
}
func purgeArchivedPublicMessages(from peerID: PeerID) {
purgedArchivePeers.append(peerID)
}

View File

@ -5,6 +5,24 @@ import Testing
@Suite("BLE outbound fragment planner tests")
struct BLEOutboundFragmentPlannerTests {
@Test("exact-generation admission promotes fragments to FIFO-high priority")
func exactGenerationAdmissionUsesHighPriority() {
let ordinary = BLEOutboundWritePriority.fragment(totalFragments: 4)
#expect(
BLEAuthenticatedTransportAdmission.writePriority(
ordinaryPriority: ordinary,
requiresExactGeneration: true
) == .high
)
#expect(
BLEAuthenticatedTransportAdmission.writePriority(
ordinaryPriority: ordinary,
requiresExactGeneration: false
) == ordinary
)
}
@Test("planner splits packets and preserves reassembled payload")
func plannerSplitsAndReassemblesPacket() throws {
let packet = makePacket(payload: makePayload(count: 384))
@ -160,6 +178,68 @@ struct BLEOutboundFragmentPlannerTests {
#expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257))
}
@Test("Noise rotation stops exact-generation fragment admission")
func noiseRotationStopsStrictNdrFragmentTrain() {
let peer = PeerID(str: "8877665544332211")
let expected = AuthenticatedPeerTransportState(
capabilities: [.doubleRatchet],
sessionGeneration: UUID(),
noisePublicKey: Data(repeating: 0x42, count: 32)
)
let rotated = AuthenticatedPeerTransportState(
capabilities: [.doubleRatchet],
sessionGeneration: UUID(),
noisePublicKey: expected.noisePublicKey
)
let request = BLEOutboundFragmentTransferRequest(
packet: BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: "0011223344556677")
?? Data(),
recipientID: Data(hexString: peer.id),
timestamp: 0x0102030405,
payload: Data(repeating: 0x55, count: 384),
signature: nil,
ttl: 3
),
pad: false,
maxChunk: 128,
directedPeer: peer,
transferId: nil,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: true,
requiredAuthenticatedTransportState: expected
)
var current: AuthenticatedPeerTransportState? = expected
var admitted: [Int] = []
let fullyAdmitted = BLEStrictFragmentAdmission.admitAll(
[0, 1, 2]
) { index in
guard let carried =
request.requiredAuthenticatedTransportState,
BLEAuthenticatedTransportAdmission.isCurrent(
expected: carried,
current: current
)
else {
return false
}
admitted.append(index)
current = rotated
return true
}
#expect(!fullyAdmitted)
#expect(admitted == [0])
#expect(
!BLEAuthenticatedTransportAdmission.isCurrent(
expected: expected,
current: rotated
)
)
}
private func makePacket(
payload: Data,
route: [Data]? = nil,

View File

@ -95,6 +95,46 @@ struct BLEOutboundWriteBufferTests {
#expect(buffer.takeAll(for: peerID).compactMap(\.data.first) == [0x01])
}
@Test
func admittedStrictFramesSurviveLaterHighPriorityTraffic() {
var buffer = BLEOutboundWriteBuffer()
let peerID = "peer-1"
let first = buffer.enqueueReportingAcceptance(
data: Data(repeating: 0x01, count: 8),
for: peerID,
priority: .high,
capBytes: 16
)
let second = buffer.enqueueReportingAcceptance(
data: Data(repeating: 0x02, count: 8),
for: peerID,
priority: .high,
capBytes: 16
)
let laterNormal = buffer.enqueueReportingAcceptance(
data: Data(repeating: 0x03, count: 8),
for: peerID,
priority: .fragment(totalFragments: 2),
capBytes: 16
)
let laterHigh = buffer.enqueueReportingAcceptance(
data: Data(repeating: 0x04, count: 8),
for: peerID,
priority: .high,
capBytes: 16
)
#expect(first.accepted)
#expect(second.accepted)
#expect(!laterNormal.accepted)
#expect(!laterHigh.accepted)
#expect(
buffer.takeAll(for: peerID).compactMap(\.data.first)
== [0x01, 0x02]
)
}
@Test
func disconnectDiscardRemovesOnlyThatPeripheralQueue() {
var buffer = BLEOutboundWriteBuffer()

View File

@ -6,6 +6,10 @@ import BitFoundation
final class FavoritesPersistenceServiceTests: XCTestCase {
private let storageKey = "chat.bitchat.favorites"
private let serviceKey = "chat.bitchat.favorites"
private let rebindJournalKey =
"chat.bitchat.favorites.ndr-rebind-journal"
private let ndrRequiredKey =
"chat.bitchat.favorites.ndr-required-noise-keys"
func test_addFavorite_persistsAndPostsNotification() throws {
let keychain = MockKeychain()
@ -110,4 +114,530 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
let decoded = try JSONDecoder().decode([FavoritesPersistenceService.FavoriteRelationship].self, from: cleaned)
XCTAssertEqual(decoded.count, 1)
}
func test_preNdrIdentityRebindDoesNotPinOrRetire() {
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x41, count: 32)
let owner = UUID()
var commitCalled = false
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: "old",
peerNickname: "Pre-NDR"
)
service.installNostrIdentityRebindAuthorization(
owner: owner,
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in
commitCalled = true
return true
}
)
service.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: "new"
)
XCTAssertEqual(
service.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
"new"
)
XCTAssertFalse(commitCalled)
XCTAssertFalse(service.isNdrRequired(for: peerKey))
XCTAssertFalse(
service.isNdrFallbackBlocked(
for: PeerID(publicKey: peerKey)
)
)
}
func test_postSessionRebindJournalsBeforeRetireAndPreservesPin()
throws
{
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x42, count: 32)
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: "old",
peerNickname: "Pinned"
)
XCTAssertTrue(service.markNdrRequired(for: peerKey))
var commitCalled = false
service.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, old, new in
commitCalled = true
XCTAssertEqual(old, "old")
XCTAssertEqual(new, "new")
XCTAssertNotNil(
keychain.load(
key: self.rebindJournalKey,
service: self.serviceKey
)
)
let stored = try? JSONDecoder().decode(
[FavoritesPersistenceService.FavoriteRelationship].self,
from: keychain.load(
key: self.storageKey,
service: self.serviceKey
) ?? Data()
)
XCTAssertEqual(
stored?.first?.peerNostrPublicKey,
"old"
)
return true
}
)
service.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: "new"
)
XCTAssertTrue(commitCalled)
XCTAssertEqual(
service.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
"new"
)
XCTAssertTrue(service.isNdrRequired(for: peerKey))
XCTAssertNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
}
func test_failedFavoriteCommitKeepsJournalAndRecoversOnRestart() {
let keychain = MockKeychain()
let peerKey = Data(repeating: 0x43, count: 32)
let first = FavoritesPersistenceService(keychain: keychain)
first.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: "old",
peerNickname: "Recoverable"
)
XCTAssertTrue(first.markNdrRequired(for: peerKey))
first.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
keychain.simulatedGenericSaveFailureKeys.insert(storageKey)
first.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: "new"
)
XCTAssertEqual(
first.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
"old"
)
XCTAssertNotNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
XCTAssertTrue(
first.isNdrFallbackBlocked(
for: PeerID(publicKey: peerKey)
)
)
let restarted = FavoritesPersistenceService(keychain: keychain)
XCTAssertFalse(
restarted.canUseNdrBinding(
for: PeerID(publicKey: peerKey)
)
)
XCTAssertTrue(
restarted.isNdrFallbackBlocked(
for: PeerID(publicKey: peerKey)
)
)
keychain.simulatedGenericSaveFailureKeys.remove(storageKey)
restarted.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
XCTAssertEqual(
restarted.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
"new"
)
XCTAssertTrue(restarted.isNdrRequired(for: peerKey))
XCTAssertNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
}
func test_recoveryTreatsEquivalentHexAndNpubJournalIdentityAsSame()
throws
{
let keychain = MockKeychain()
let peerKey = Data(repeating: 0x4a, count: 32)
let oldIdentity = try NostrIdentity.generate()
let targetIdentity = try NostrIdentity.generate()
let first = FavoritesPersistenceService(keychain: keychain)
first.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: oldIdentity.npub,
peerNickname: "Equivalent recovery"
)
XCTAssertTrue(first.markNdrRequired(for: peerKey))
first.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
keychain.simulatedGenericSaveFailureKeys.insert(storageKey)
first.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: targetIdentity.npub
)
keychain.simulatedGenericSaveFailureKeys.remove(storageKey)
let storedData = try XCTUnwrap(
keychain.load(key: storageKey, service: serviceKey)
)
let storedRelationships = try JSONDecoder().decode(
[FavoritesPersistenceService.FavoriteRelationship].self,
from: storedData
)
let stored = try XCTUnwrap(storedRelationships.first)
let equivalentOld = FavoritesPersistenceService
.FavoriteRelationship(
peerNoisePublicKey: stored.peerNoisePublicKey,
peerNostrPublicKey:
oldIdentity.publicKeyHex.uppercased(),
peerNickname: stored.peerNickname,
isFavorite: stored.isFavorite,
theyFavoritedUs: stored.theyFavoritedUs,
favoritedAt: stored.favoritedAt,
lastUpdated: stored.lastUpdated
)
keychain.save(
key: storageKey,
data: try JSONEncoder().encode([equivalentOld]),
service: serviceKey,
accessible: nil
)
let restarted = FavoritesPersistenceService(keychain: keychain)
restarted.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
XCTAssertEqual(
restarted.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
targetIdentity.npub
)
XCTAssertNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
}
func test_equivalentHexAndNpubIdentityUpdateIsANondestructiveNoop()
throws
{
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x44, count: 32)
let identity = try NostrIdentity.generate()
let storedHex = identity.publicKeyHex.uppercased()
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: storedHex,
peerNickname: "Equivalent"
)
XCTAssertTrue(service.markNdrRequired(for: peerKey))
var authorizeCalled = false
var commitCalled = false
service.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in
authorizeCalled = true
return true
},
commit: { _, _, _ in
commitCalled = true
return true
}
)
service.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: identity.npub
)
XCTAssertEqual(
service.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
storedHex
)
XCTAssertFalse(authorizeCalled)
XCTAssertFalse(commitCalled)
XCTAssertNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
}
func test_pendingJournalReservesTargetAcrossFavoritesAndRestart()
throws
{
let keychain = MockKeychain()
let firstNoiseKey = Data(repeating: 0x45, count: 32)
let secondNoiseKey = Data(repeating: 0x46, count: 32)
let firstOld = try NostrIdentity.generate()
let secondOld = try NostrIdentity.generate()
let target = try NostrIdentity.generate()
let first = FavoritesPersistenceService(keychain: keychain)
first.addFavorite(
peerNoisePublicKey: firstNoiseKey,
peerNostrPublicKey: firstOld.npub,
peerNickname: "First"
)
first.addFavorite(
peerNoisePublicKey: secondNoiseKey,
peerNostrPublicKey: secondOld.npub,
peerNickname: "Second"
)
XCTAssertTrue(first.markNdrRequired(for: firstNoiseKey))
first.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
keychain.simulatedGenericSaveFailureKeys.insert(storageKey)
first.updatePeerFavoritedUs(
peerNoisePublicKey: firstNoiseKey,
favorited: true,
peerNostrPublicKey: target.npub
)
XCTAssertNotNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
keychain.simulatedGenericSaveFailureKeys.remove(storageKey)
first.updatePeerFavoritedUs(
peerNoisePublicKey: secondNoiseKey,
favorited: true,
peerNostrPublicKey: target.npub
)
XCTAssertEqual(
first.getFavoriteStatus(for: secondNoiseKey)?
.peerNostrPublicKey,
secondOld.npub
)
let restarted = FavoritesPersistenceService(keychain: keychain)
restarted.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
XCTAssertEqual(
restarted.getFavoriteStatus(for: firstNoiseKey)?
.peerNostrPublicKey,
target.npub
)
XCTAssertEqual(
restarted.getFavoriteStatus(for: secondNoiseKey)?
.peerNostrPublicKey,
secondOld.npub
)
XCTAssertNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
}
func test_journalClearFailureAllowsCommittedTargetButBlocksFallback()
throws
{
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x47, count: 32)
let oldIdentity = try NostrIdentity.generate()
let targetIdentity = try NostrIdentity.generate()
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: oldIdentity.npub,
peerNickname: "Clear failure"
)
XCTAssertTrue(service.markNdrRequired(for: peerKey))
service.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
keychain.simulatedGenericDeleteFailureKeys.insert(
rebindJournalKey
)
service.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: targetIdentity.npub
)
XCTAssertEqual(
service.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
targetIdentity.npub
)
XCTAssertNotNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
XCTAssertTrue(
service.canUseNdrBinding(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: targetIdentity.npub
)
)
XCTAssertTrue(
service.canUseNdrBinding(
for: PeerID(publicKey: peerKey)
)
)
XCTAssertTrue(service.canActivateDoubleRatchetRelay)
XCTAssertTrue(
service.isNdrFallbackBlocked(
for: PeerID(publicKey: peerKey)
)
)
}
func test_pinWriteFailureFailsClosedForBindingsAndLegacyInbound()
throws
{
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x48, count: 32)
let identity = try NostrIdentity.generate()
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: identity.npub,
peerNickname: "Pin failure"
)
keychain.simulatedGenericSaveFailureKeys.insert(ndrRequiredKey)
XCTAssertFalse(service.markNdrRequired(for: peerKey))
XCTAssertNil(
keychain.load(
key: ndrRequiredKey,
service: serviceKey
)
)
XCTAssertTrue(service.isNdrRequired(for: peerKey))
XCTAssertFalse(
service.canUseNdrBinding(
for: PeerID(publicKey: peerKey)
)
)
XCTAssertFalse(service.canActivateDoubleRatchetRelay)
XCTAssertFalse(
service.canAcceptLegacyNostrDM(
from: identity.publicKeyHex
)
)
}
func test_legacyInboundPolicyRejectsPinnedAndJournalIdentities()
throws
{
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x49, count: 32)
let oldIdentity = try NostrIdentity.generate()
let targetIdentity = try NostrIdentity.generate()
let unrelatedIdentity = try NostrIdentity.generate()
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: oldIdentity.npub,
peerNickname: "Inbound"
)
XCTAssertTrue(service.markNdrRequired(for: peerKey))
XCTAssertFalse(
service.canAcceptLegacyNostrDM(
from: oldIdentity.publicKeyHex
)
)
XCTAssertTrue(
service.canAcceptLegacyNostrDM(
from: unrelatedIdentity.publicKeyHex
)
)
service.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
keychain.simulatedGenericSaveFailureKeys.insert(storageKey)
service.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: targetIdentity.npub
)
XCTAssertFalse(
service.canAcceptLegacyNostrDM(
from: oldIdentity.publicKeyHex
)
)
XCTAssertFalse(
service.canAcceptLegacyNostrDM(
from: targetIdentity.publicKeyHex
)
)
}
}

View File

@ -151,6 +151,117 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertTrue(connected)
}
func test_subscribe_offlineRegistersReplayIntentAndFlushesWhenOnline() async {
let relayURL = "wss://offline-subscribe.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: false
)
let registered = context.manager.subscribe(
filter: makeFilter(),
id: "offline-sub",
relayUrls: [relayURL],
handler: { _ in }
)
XCTAssertTrue(
registered,
"offline success means the replayable request was registered"
)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(true)
let flushed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?
.sentStrings.contains {
$0.contains("offline-sub")
} == true
}
XCTAssertTrue(flushed)
}
func test_ndrHandshakeWhileActivationBlockedRegistersAfterConnectivityWake()
throws
{
let context = makeContext(
permission: .authorized,
activationAllowed: false
)
let localIdentity = try NostrIdentity.generate()
let remoteIdentity = try NostrIdentity.generate()
let localStorage = FileManager.default.temporaryDirectory
.appendingPathComponent(
"ndr-activation-local-\(UUID().uuidString)",
isDirectory: true
)
let remoteStorage = FileManager.default.temporaryDirectory
.appendingPathComponent(
"ndr-activation-remote-\(UUID().uuidString)",
isDirectory: true
)
try FileManager.default.createDirectory(
at: localStorage,
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: remoteStorage,
withIntermediateDirectories: true
)
defer {
try? FileManager.default.removeItem(at: localStorage)
try? FileManager.default.removeItem(at: remoteStorage)
}
var scheduledNdrRetries: [@MainActor () -> Void] = []
let service = NdrNostrService(
relayManager: context.manager,
rolloutEnabled: true,
storageDirectoryProvider: { localStorage },
retryScheduler: { _, operation in
scheduledNdrRetries.append(operation)
}
)
let remote = NdrNostrService(
relayManager: FakeRelayManager(),
rolloutEnabled: true,
storageDirectoryProvider: { remoteStorage }
)
service.configureIfNeeded(identity: localIdentity)
remote.configureIfNeeded(identity: remoteIdentity)
let responseActions = service.processOutOfBandEventJson(
try XCTUnwrap(remote.currentInviteEventJson()),
expectedPeerPubkeyHex: remoteIdentity.publicKeyHex,
persistEstablishedBinding: { true }
)
XCTAssertFalse(responseActions.isEmpty)
for action in responseActions {
service.completeOutOfBandAction(action, succeeded: true)
}
while !scheduledNdrRetries.isEmpty {
scheduledNdrRetries.removeFirst()()
}
XCTAssertEqual(
context.manager.debugSubscriptionRequestCount,
0,
"activation policy must reject registration, not falsely ack it"
)
context.activationAllowed.value = true
service.retryRelayActions()
XCTAssertGreaterThan(
context.manager.debugSubscriptionRequestCount,
0,
"the connectivity wake must register the durable native intent"
)
}
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
let relayURL = "wss://tor-eose-unblock.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@ -469,6 +580,48 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(results, [true])
}
func test_sendEventImmediately_duplicateOKCountsAsDurableAcceptanceOnlyForExactPrefix() async throws {
let relay = "wss://confirmed-duplicate.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relay])
let connected = await waitUntil {
context.manager.relays.first(where: { $0.url == relay })?
.isConnected == true
}
XCTAssertTrue(connected)
let duplicate = try makeSignedEvent(content: "duplicate")
var results: [Bool] = []
context.manager.sendEventImmediately(
duplicate,
to: [relay]
) { results.append($0) }
try context.sessionFactory.latestConnection(for: relay)?.emitOK(
eventID: duplicate.id,
success: false,
reason: "duplicate: already stored"
)
let duplicateSettled = await waitUntil { results.count == 1 }
XCTAssertTrue(duplicateSettled)
XCTAssertEqual(results, [true])
let notMachineReadable = try makeSignedEvent(
content: "not an exact duplicate prefix"
)
context.manager.sendEventImmediately(
notMachineReadable,
to: [relay]
) { results.append($0) }
try context.sessionFactory.latestConnection(for: relay)?.emitOK(
eventID: notMachineReadable.id,
success: false,
reason: " duplicate: leading whitespace"
)
let rejectionSettled = await waitUntil { results.count == 2 }
XCTAssertTrue(rejectionSettled)
XCTAssertEqual(results, [true, false])
}
func test_sendEventImmediately_timeoutFailsAndIgnoresLateWriteAndOK() async throws {
let relay = "wss://confirmed-timeout.example"
let context = makeContext(permission: .denied)

View File

@ -195,19 +195,24 @@ struct NostrTransportTests {
let recipientStorage = try makeTempDir(label: "transport-ndr-recipient")
let senderNdr = NdrNostrService(
relayManager: senderRelay,
deviceId: "transport-ndr-sender",
rolloutEnabled: true,
storageDirectoryProvider: { senderStorage }
)
let recipientNdr = NdrNostrService(
relayManager: recipientRelay,
deviceId: "transport-ndr-recipient",
rolloutEnabled: true,
storageDirectoryProvider: { recipientStorage }
)
senderNdr.configureIfNeeded(identity: sender)
recipientNdr.configureIfNeeded(identity: recipient)
try establishMutualSession(senderNdr, recipientNdr, senderIdentity: sender, recipientIdentity: recipient)
try establishMutualSession(
senderNdr,
recipientNdr,
senderIdentity: sender,
recipientIdentity: recipient,
senderRelay: senderRelay,
recipientRelay: recipientRelay
)
let noiseKey = Data((64..<96).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
@ -239,9 +244,205 @@ struct NostrTransportTests {
#expect(senderRelay.sentEvents.contains(where: { $0.kind == 1060 }))
}
@Test("Private message waits for an owner-verified roster before preferring NDR")
@Test("Disappearing-message expiry reaches the pairwise delivery")
@MainActor
func sendPrivateMessageRequiresVerifiedNdrRoster() throws {
func disappearingMessageForwardsAbsoluteExpiryToNdr() throws {
let keychain = MockKeychain()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let senderRelay = FakeRelayManager()
let recipientRelay = FakeRelayManager()
let senderStorage = try makeTempDir(
label: "transport-expiry-sender"
)
let recipientStorage = try makeTempDir(
label: "transport-expiry-recipient"
)
let senderNdr = NdrNostrService(
relayManager: senderRelay,
rolloutEnabled: true,
storageDirectoryProvider: { senderStorage }
)
let recipientNdr = NdrNostrService(
relayManager: recipientRelay,
rolloutEnabled: true,
storageDirectoryProvider: { recipientStorage }
)
senderNdr.configureIfNeeded(identity: sender)
recipientNdr.configureIfNeeded(identity: recipient)
try establishMutualSession(
senderNdr,
recipientNdr,
senderIdentity: sender,
recipientIdentity: recipient,
senderRelay: senderRelay,
recipientRelay: recipientRelay
)
senderRelay.resetSentEvents()
let noiseKey = Data((72..<104).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Expires"
)
let transport = NostrTransport(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: keychain),
ndrService: senderNdr,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender }
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
let expiration: UInt64 = 4_000_000_000
let used = try transport.sendPrivateMessageAndReturnTransport(
"vanish later",
to: peerID,
recipientNickname: "Expires",
messageID: "pm-expiring",
expiresAtSeconds: expiration
)
let outbound = try #require(
senderRelay.sentEvents.first { $0.kind == 1060 }
)
var deliveredExpiration: UInt64?
recipientNdr.onDecryptedMessage = { message, completion in
deliveredExpiration = message.expiresAtSeconds
completion(.consumed)
}
recipientNdr.processInboundRelayEvent(outbound)
#expect(used == .ndr)
#expect(deliveredExpiration == expiration)
}
@Test("A disappearing message never downgrades to kind 1059")
@MainActor
func disappearingMessageWithoutSessionFailsClosed() throws {
let keychain = MockKeychain()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let relay = FakeRelayManager()
let ndrService = NdrNostrService(
relayManager: relay,
rolloutEnabled: true,
storageDirectoryProvider: {
try makeTempDir(label: "transport-expiry-no-session")
}
)
let noiseKey = Data((104..<136).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "No Session"
)
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: keychain),
ndrService: ndrService,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
sendEvent: probe.record(event:)
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
do {
_ = try transport.sendPrivateMessageAndReturnTransport(
"must not become legacy",
to: peerID,
recipientNickname: "No Session",
messageID: "pm-expiry-no-session",
expiresAtSeconds: 4_000_000_000
)
Issue.record(
"Expected an expiring send without NDR to fail closed"
)
} catch let error as NostrTransport.OutboundPrivateMessageError {
guard case .expiringMessageRequiresNdrSession = error else {
Issue.record("Unexpected transport error: \(error)")
return
}
}
#expect(probe.sentEvents.isEmpty)
#expect(relay.sentEvents.isEmpty)
}
@Test("A durable NDR pin blocks legacy fallback with rollout off")
@MainActor
func durableNdrPinBlocksGateOffFallback() throws {
let keychain = MockKeychain()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let relay = FakeRelayManager()
let ndrService = NdrNostrService(
relayManager: relay,
rolloutEnabled: false,
storageDirectoryProvider: {
try makeTempDir(label: "transport-gate-off-pin")
}
)
let noiseKey = Data(repeating: 0x7d, count: 32)
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Pinned"
)
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: keychain),
ndrService: ndrService,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
canUseNdrBindingForPeerID: { _ in true },
isNdrFallbackBlockedForPeerID: {
$0.toShort() == peerID.toShort()
},
currentIdentity: { sender },
sendEvent: probe.record(event:)
)
)
do {
_ = try transport.sendPrivateMessageAndReturnTransport(
"must remain pairwise",
to: peerID,
recipientNickname: "Pinned",
messageID: "pm-gate-off-pin"
)
Issue.record("Expected a pinned gate-off send to fail closed")
} catch let error as NostrTransport.OutboundPrivateMessageError {
guard case .ndrSessionFailure = error else {
Issue.record("Unexpected transport error: \(error)")
return
}
}
#expect(probe.sentEvents.isEmpty)
#expect(relay.sentEvents.isEmpty)
}
@Test("A pairwise session is send-ready without a device roster")
@MainActor
func sendPrivateMessageDoesNotRequireDeviceRoster() throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
@ -252,41 +453,28 @@ struct NostrTransportTests {
let recipientStorage = try makeTempDir(label: "transport-ndr-queued-recipient")
let senderNdr = NdrNostrService(
relayManager: senderRelay,
deviceId: "transport-ndr-queued-sender",
rolloutEnabled: true,
storageDirectoryProvider: { senderStorage }
)
let recipientNdr = NdrNostrService(
relayManager: recipientRelay,
deviceId: "transport-ndr-queued-recipient",
rolloutEnabled: true,
storageDirectoryProvider: { recipientStorage }
)
senderNdr.configureIfNeeded(identity: sender)
recipientNdr.configureIfNeeded(identity: recipient)
let senderInvite = try #require(senderNdr.currentInviteEventJson())
let recipientPublishes = recipientNdr.processOutOfBandEventJson(
senderInvite,
expectedPeerPubkeyHex: sender.publicKeyHex
try establishMutualSession(
senderNdr,
recipientNdr,
senderIdentity: sender,
recipientIdentity: recipient,
senderRelay: senderRelay,
recipientRelay: recipientRelay
)
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"
)
let recipientAppKeys = try #require(
recipientRelay.sentEvents.first(where: { isAppKeysEvent($0) }),
"Recipient should publish an AppKeys roster event"
)
_ = senderNdr.processOutOfBandEventJson(
recipientResponse,
expectedPeerPubkeyHex: recipient.publicKeyHex
)
#expect(senderNdr.hasActiveSession(with: recipient.publicKeyHex))
#expect(!senderRelay.sentEvents.contains { $0.kind == 37368 })
#expect(!recipientRelay.sentEvents.contains { $0.kind == 37368 })
#expect(!senderRelay.subscriptions.contains { $0.filter.kinds?.contains(37368) == true })
#expect(!recipientRelay.subscriptions.contains { $0.filter.kinds?.contains(37368) == true })
senderRelay.resetSentEvents()
let probe = NostrTransportProbe()
@ -310,33 +498,163 @@ struct NostrTransportTests {
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
let transportBeforeRoster = try transport.sendPrivateMessageAndReturnTransport(
"before verified roster",
let transportUsed = try transport.sendPrivateMessageAndReturnTransport(
"pairwise ndr",
to: fullPeerID,
recipientNickname: "Queued",
messageID: "pm-before-ndr-roster"
messageID: "pm-pairwise-ndr"
)
#expect(transportBeforeRoster == .legacy1059)
#expect(probe.sentEvents.count == 1)
#expect(probe.sentEvents.first?.kind == 1059)
#expect(senderRelay.sentEvents.filter { $0.kind == 1060 }.isEmpty)
#expect(transportUsed == .ndr)
#expect(probe.sentEvents.isEmpty)
#expect(senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 1)
}
senderNdr.processInboundRelayEvent(recipientBootstrap)
#expect(senderRelay.sentEvents.filter { $0.kind == 1060 }.isEmpty)
@Test("An existing ratchet session never downgrades to legacy encryption")
@MainActor
func activeButNotSendReadySessionFailsClosed() 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 senderNdr = NdrNostrService(
relayManager: senderRelay,
rolloutEnabled: true,
storageDirectoryProvider: {
try makeTempDir(label: "fail-closed-sender")
}
)
let recipientNdr = NdrNostrService(
relayManager: recipientRelay,
rolloutEnabled: true,
storageDirectoryProvider: {
try makeTempDir(label: "fail-closed-recipient")
}
)
senderNdr.configureIfNeeded(identity: sender)
recipientNdr.configureIfNeeded(identity: recipient)
senderNdr.processInboundRelayEvent(recipientAppKeys)
let transportAfterRoster = try transport.sendPrivateMessageAndReturnTransport(
"after verified roster",
to: fullPeerID,
recipientNickname: "Queued",
messageID: "pm-after-ndr-roster"
// Processing the response installs a receive path. Until its relay
// bootstrap arrives, this is intentionally not a send-ready session.
let senderInvite = try #require(senderNdr.currentInviteEventJson())
let response = try #require(
recipientNdr.processOutOfBandEventJson(
senderInvite,
expectedPeerPubkeyHex: sender.publicKeyHex,
persistEstablishedBinding: { true }
).first
)
recipientNdr.completeOutOfBandAction(response, succeeded: true)
_ = senderNdr.processOutOfBandEventJson(
response.eventJson,
expectedPeerPubkeyHex: recipient.publicKeyHex,
persistEstablishedBinding: { true }
)
#expect(
senderNdr.hasPairwiseSession(with: recipient.publicKeyHex)
)
#expect(transportAfterRoster == .ndr)
#expect(probe.sentEvents.count == 1)
#expect(senderRelay.sentEvents.contains(where: { $0.kind == 1060 }))
let noiseKey = Data((88..<120).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Fail Closed"
)
let probe = NostrTransportProbe()
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")
do {
_ = try transport.sendPrivateMessageAndReturnTransport(
"must not downgrade",
to: peerID,
recipientNickname: "Fail Closed",
messageID: "pm-fail-closed"
)
Issue.record("Expected the non-send-ready NDR session to fail")
} catch let error as NostrTransport.OutboundPrivateMessageError {
guard case .ndrSessionFailure = error else {
Issue.record("Unexpected transport error: \(error)")
return
}
}
#expect(probe.sentEvents.isEmpty)
#expect(senderRelay.sentEvents.allSatisfy { $0.kind == 1060 })
}
@Test("An NDR storage-open failure never falls back to legacy encryption")
@MainActor
func ndrConfigurationFailureFailsClosed() throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let relay = FakeRelayManager()
let ndrService = NdrNostrService(
relayManager: relay,
rolloutEnabled: true,
storageDirectoryProvider: {
throw NostrTransportTestError.storageUnavailable
}
)
let noiseKey = Data((96..<128).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Corrupt State"
)
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: ndrService,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
sendEvent: probe.record(event:)
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
for _ in 0..<2 {
do {
_ = try transport.sendPrivateMessageAndReturnTransport(
"must remain failed",
to: peerID,
recipientNickname: "Corrupt State",
messageID: UUID().uuidString
)
Issue.record("Expected NDR configuration failure")
} catch let error as NostrTransport.OutboundPrivateMessageError {
guard case .ndrSessionFailure = error else {
Issue.record("Unexpected transport error: \(error)")
return
}
}
}
#expect(probe.sentEvents.isEmpty)
#expect(relay.sentEvents.isEmpty)
}
@Test("Favorite notification embeds current npub")
@ -591,6 +909,8 @@ struct NostrTransportTests {
loadFavorites: @escaping @MainActor () -> [Data: FavoriteRelationship] = { [:] },
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
canUseNdrBindingForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in true },
isNdrFallbackBlockedForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in false },
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
@ -602,6 +922,10 @@ struct NostrTransportTests {
loadFavorites: loadFavorites,
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
favoriteStatusForPeerID: favoriteStatusForPeerID,
canUseNdrBindingForPeerID:
canUseNdrBindingForPeerID,
isNdrFallbackBlockedForPeerID:
isNdrFallbackBlockedForPeerID,
currentIdentity: currentIdentity,
registerPendingGiftWrap: registerPendingGiftWrap,
sendEvent: sendEvent,
@ -615,7 +939,6 @@ struct NostrTransportTests {
let storage = try makeTempDir(label: label)
return NdrNostrService(
relayManager: FakeRelayManager(),
deviceId: "nostr-transport-\(label)",
rolloutEnabled: true,
storageDirectoryProvider: { storage }
)
@ -651,36 +974,76 @@ struct NostrTransportTests {
_ senderService: NdrNostrService,
_ recipientService: NdrNostrService,
senderIdentity: NostrIdentity,
recipientIdentity: NostrIdentity
recipientIdentity: NostrIdentity,
senderRelay: FakeRelayManager,
recipientRelay: FakeRelayManager
) throws {
var toRecipient: [String] = [try #require(senderService.currentInviteEventJson())]
var toSender: [String] = [try #require(recipientService.currentInviteEventJson())]
let senderRelayIndex = senderRelay.sentEvents.count
let recipientRelayIndex = recipientRelay.sentEvents.count
let senderInvite = try #require(
senderService.currentInviteEventJson()
)
let recipientInvite = try #require(
recipientService.currentInviteEventJson()
)
let generatedByRecipient =
recipientService.processOutOfBandEventJson(
senderInvite,
expectedPeerPubkeyHex: senderIdentity.publicKeyHex,
persistEstablishedBinding: { true }
)
let generatedBySender =
senderService.processOutOfBandEventJson(
recipientInvite,
expectedPeerPubkeyHex: recipientIdentity.publicKeyHex,
persistEstablishedBinding: { true }
)
for _ in 0..<10 {
let nextToSender = toRecipient.flatMap {
recipientService.processOutOfBandEventJson(
$0,
expectedPeerPubkeyHex: senderIdentity.publicKeyHex
)
}
let nextToRecipient = toSender.flatMap {
senderService.processOutOfBandEventJson(
$0,
expectedPeerPubkeyHex: recipientIdentity.publicKeyHex
)
}
toRecipient = nextToRecipient
toSender = nextToSender
if senderService.hasActiveSession(with: recipientIdentity.publicKeyHex),
recipientService.hasActiveSession(with: senderIdentity.publicKeyHex) {
return
}
if toRecipient.isEmpty && toSender.isEmpty {
break
}
for response in generatedByRecipient {
recipientService.completeOutOfBandAction(
response,
succeeded: true
)
_ = senderService.processOutOfBandEventJson(
response.eventJson,
expectedPeerPubkeyHex: recipientIdentity.publicKeyHex,
persistEstablishedBinding: { true }
)
}
for response in generatedBySender {
senderService.completeOutOfBandAction(
response,
succeeded: true
)
_ = recipientService.processOutOfBandEventJson(
response.eventJson,
expectedPeerPubkeyHex: senderIdentity.publicKeyHex,
persistEstablishedBinding: { true }
)
}
throw NostrTransportTestError.failedToEstablishNdrSession
for event in senderRelay.sentEvents
.dropFirst(senderRelayIndex)
where event.kind == 1060
{
recipientService.processInboundRelayEvent(event)
}
for event in recipientRelay.sentEvents
.dropFirst(recipientRelayIndex)
where event.kind == 1060
{
senderService.processInboundRelayEvent(event)
}
guard senderService.hasActiveSession(
with: recipientIdentity.publicKeyHex
),
recipientService.hasActiveSession(
with: senderIdentity.publicKeyHex
)
else {
throw NostrTransportTestError.failedToEstablishNdrSession
}
}
private func decodeEmbeddedPayload(
@ -711,18 +1074,6 @@ 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 func isAppKeysEvent(_ event: NostrEvent) -> Bool {
event.kind == 37368 && event.tags.contains { tag in
tag.count >= 2 && tag[0] == "type" && tag[1] == "app_keys_roster_snapshot"
}
}
}
private enum NostrTransportTestError: Error {
@ -730,6 +1081,7 @@ private enum NostrTransportTestError: Error {
case invalidPacket
case invalidPrivateMessage
case failedToEstablishNdrSession
case storageUnavailable
}
private func base64URLDecode(_ string: String) -> Data? {

View File

@ -21,7 +21,7 @@ let package = Package(
dependencies: ["ndr_ffiFFI"],
path: "Sources/NdrFfi"
),
// Dynamic XCFramework built from iris-chat-rs protocol-ffi. Keeping
// Dynamic XCFramework built from nostr-double-ratchet's pairwise FFI. Keeping
// this runtime dynamic avoids linking a second Rust static runtime
// beside Arti's source-built static library.
.binaryTarget(

View File

@ -1,20 +1,21 @@
# NdrFfi
Generated Swift bindings and an ignored dynamic Apple XCFramework for the upstream
`iris-chat-rs` `protocol-ffi` crate. Its locked dependency graph uses
`nostr-double-ratchet` and `nostr-double-ratchet-pairwise-codec` `0.0.164`.
Generated Swift bindings and an ignored dynamic Apple XCFramework for the
single-device pairwise UniFFI crate in `nostr-double-ratchet`. The binary does
not include AppKeys, linked-device, sibling-sync, or group runtime code.
## Source Of Truth
The generated files in this package come from the upstream
`iris-chat-rs` checkout, specifically the Rust `protocol-ffi` crate and its
UniFFI-generated Swift bindings. The built library keeps the compatibility
name `ndr_ffi`.
`nostr-double-ratchet` checkout, specifically the Rust `ndr-pairwise-ffi`
crate and its UniFFI-generated Swift bindings. The built library keeps the
module name `ndr_ffi`.
The exact upstream revision is pinned by the `vendor/iris-chat-rs` submodule
and repeated in `SOURCE_REVISION`. Native libraries are deliberately not
tracked in this repository. Apple deployment targets are fixed in the build
script, so ambient shell settings cannot change the output.
The exact upstream revision is pinned by the
`vendor/nostr-double-ratchet` submodule and repeated in `SOURCE_REVISION`.
Native libraries are deliberately not tracked in this repository. Apple
deployment targets are fixed in the build script, so ambient shell settings
cannot change the output.
## Rebuild From Source
@ -38,7 +39,7 @@ rustup target add \
aarch64-apple-ios \
aarch64-apple-ios-sim \
x86_64-apple-ios
git submodule update --init --checkout vendor/iris-chat-rs
git submodule update --init --checkout vendor/nostr-double-ratchet
./localPackages/NdrFfi/build-apple.sh
```
@ -46,12 +47,12 @@ Or:
```bash
cd localPackages/NdrFfi
IRIS_CHAT_RS_DIR=/path/to/iris-chat-rs ./build-apple.sh
NOSTR_DOUBLE_RATCHET_DIR=/path/to/nostr-double-ratchet ./build-apple.sh
```
The script:
- builds the upstream `protocol-ffi` crate
- builds the upstream `ndr-pairwise-ffi` crate
- reuses an ignored Cargo target cache under `.cache/ndr-ffi/apple`
- regenerates `Sources/NdrFfi/NdrFfi.swift` via UniFFI
- rebuilds the ignored dynamic Apple XCFramework at `Frameworks/NdrFfi.xcframework`

View File

@ -1 +1 @@
095e70489345df4d92dded686902f3dccb54cc45
0fe8caf2d4e24e2030ffae195597a2764613a659

File diff suppressed because it is too large Load Diff

View File

@ -1,225 +1,364 @@
import Foundation
import XCTest
@testable import NdrFfi
final class NdrFfiTests: XCTestCase {
func testVersionAndKeyGeneration() throws {
XCTAssertFalse(NdrFfi.version().isEmpty)
// MARK: - Version Tests
func testVersion() {
let v = NdrFfi.version()
XCTAssertFalse(v.isEmpty, "Version should not be empty")
print("ndr-ffi version: \(v)")
let first = generateKeypair()
let second = generateKeypair()
XCTAssertEqual(first.publicKeyHex.count, 64)
XCTAssertEqual(first.privateKeyHex.count, 64)
XCTAssertNotNil(Data(hexString: first.publicKeyHex))
XCTAssertNotNil(Data(hexString: first.privateKeyHex))
XCTAssertNotEqual(first.publicKeyHex, second.publicKeyHex)
XCTAssertNotEqual(first.privateKeyHex, second.privateKeyHex)
XCTAssertEqual(
try derivePublicKey(privateKeyHex: first.privateKeyHex),
first.publicKeyHex
)
}
// 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 {
func testCurrentInviteIsIdentityBoundKind30078() throws {
let keys = generateKeypair()
let mgr = try SessionManagerHandle(
ourPubkeyHex: keys.publicKeyHex,
ourIdentityPrivkeyHex: keys.privateKeyHex,
deviceId: "test-device",
ownerPubkeyHex: nil
)
try mgr.`init`()
let manager = try makeManager(keys)
let inviteJSON = try manager.currentInviteEventJson()
let invite = try PairwiseInvite.fromEventJson(eventJson: inviteJSON)
let events = try mgr.drainEvents()
let inviteEventJson = try XCTUnwrap(
events.first(where: { isInvitePublish($0) })?.eventJson,
"Expected SessionManager to publish an invite on init"
XCTAssertEqual(try extractNostrKind(json: inviteJSON), 30078)
XCTAssertEqual(invite.getPeerPubkeyHex(), keys.publicKeyHex)
XCTAssertEqual(
try PairwiseInvite.fromUrl(
url: invite.toUrl(root: "https://b")
).getPeerPubkeyHex(),
keys.publicKeyHex
)
XCTAssertEqual(try extractNostrKind(json: inviteEventJson), 30078)
}
func testSessionManagerAcceptInviteFromEventJsonEstablishesSession() throws {
let alice = generateKeypair()
let bob = generateKeypair()
func testAuthenticatedHandshakeBecomesBidirectionallySendReady() throws {
let aliceKeys = generateKeypair()
let bobKeys = generateKeypair()
let alice = try makeManager(aliceKeys)
let bob = try makeManager(bobKeys)
let aliceMgr = try SessionManagerHandle(
ourPubkeyHex: alice.publicKeyHex,
ourIdentityPrivkeyHex: alice.privateKeyHex,
deviceId: "alice-device",
ownerPubkeyHex: nil
let artifacts = try establishSession(
inviter: alice,
inviterKeys: aliceKeys,
acceptor: bob,
acceptorKeys: bobKeys
)
let bobMgr = try SessionManagerHandle(
ourPubkeyHex: bob.publicKeyHex,
ourIdentityPrivkeyHex: bob.privateKeyHex,
deviceId: "bob-device",
ownerPubkeyHex: nil
XCTAssertEqual(artifacts.response.peerPubkeyHex, aliceKeys.publicKeyHex)
XCTAssertEqual(try extractNostrKind(json: artifacts.responseJSON), 1059)
XCTAssertEqual(try extractNostrKind(json: artifacts.bootstrapJSON), 1060)
XCTAssertEqual(
try alice.sessionInfo(peerPubkeyHex: bobKeys.publicKeyHex)?
.sendReady,
true
)
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: { isInvitePublish($0) })?.eventJson,
"Expected Alice to publish an invite on init"
XCTAssertEqual(
try bob.sessionInfo(peerPubkeyHex: aliceKeys.publicKeyHex)?
.sendReady,
true
)
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
func testSendProducesDurableUnsignedDeliveryWithExpiration() throws {
let aliceKeys = generateKeypair()
let bobKeys = generateKeypair()
let alice = try makeManager(aliceKeys)
let bob = try makeManager(bobKeys)
_ = try establishSession(
inviter: alice,
inviterKeys: aliceKeys,
acceptor: bob,
acceptorKeys: bobKeys
)
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: { isInvitePublish($0) })?.eventJson
let expiration = UInt64(Date().timeIntervalSince1970) + 60
let result = try bob.sendText(
peerPubkeyHex: aliceKeys.publicKeyHex,
text: "hello from bob",
expiresAtSeconds: expiration
)
_ = 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
let publish = try requireAction(
in: bob,
kind: "publish",
outerEventID: result.outerEventId
)
try aliceMgr.processEvent(eventJson: bobResponse)
_ = try aliceMgr.drainEvents()
let outerJSON = try XCTUnwrap(publish.eventJson)
try alice.processEvent(eventJson: outerJSON)
_ = 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
let delivery = try requireAction(
in: alice,
kind: "delivery",
innerEventID: result.innerEventId
)
let innerJSON = try XCTUnwrap(delivery.innerEventJson)
let inner = try jsonObject(innerJSON)
XCTAssertEqual(inner["kind"] as? Int, 14)
XCTAssertEqual(inner["pubkey"] as? String, bobKeys.publicKeyHex)
XCTAssertEqual(inner["content"] as? String, "hello from bob")
XCTAssertNil(inner["sig"] as? String)
XCTAssertEqual(delivery.peerPubkeyHex, bobKeys.publicKeyHex)
XCTAssertEqual(delivery.outerEventId, result.outerEventId)
XCTAssertEqual(delivery.expiresAtSeconds, expiration)
try bob.ackActions(actionIds: [publish.actionId])
try alice.ackActions(actionIds: [delivery.actionId])
XCTAssertFalse(
try bob.pendingActions().contains {
$0.actionId == publish.actionId
}
)
XCTAssertFalse(
try alice.pendingActions().contains {
$0.actionId == delivery.actionId
}
)
}
func testSameSecondSendsHaveDistinctIDs() throws {
let aliceKeys = generateKeypair()
let bobKeys = generateKeypair()
let alice = try makeManager(aliceKeys)
let bob = try makeManager(bobKeys)
_ = try establishSession(
inviter: alice,
inviterKeys: aliceKeys,
acceptor: bob,
acceptorKeys: bobKeys
)
let first = try bob.sendText(
peerPubkeyHex: aliceKeys.publicKeyHex,
text: "first",
expiresAtSeconds: nil
)
let second = try bob.sendText(
peerPubkeyHex: aliceKeys.publicKeyHex,
text: "second",
expiresAtSeconds: nil
)
XCTAssertNotEqual(first.innerEventId, second.innerEventId)
XCTAssertNotEqual(first.outerEventId, second.outerEventId)
}
func testPendingPublishAndDeliverySurviveRestart() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
"ndr-ffi-restart-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let alicePath = root.appendingPathComponent("alice").path
let bobPath = root.appendingPathComponent("bob").path
let aliceKeys = generateKeypair()
let bobKeys = generateKeypair()
var outerJSON = ""
var result: PairwiseSendResult?
do {
let alice = try makeManager(aliceKeys, storagePath: alicePath)
let bob = try makeManager(bobKeys, storagePath: bobPath)
_ = try establishSession(
inviter: alice,
inviterKeys: aliceKeys,
acceptor: bob,
acceptorKeys: bobKeys
)
let sent = try bob.sendText(
peerPubkeyHex: aliceKeys.publicKeyHex,
text: "survives restart",
expiresAtSeconds: nil
)
result = sent
outerJSON = try XCTUnwrap(
requireAction(
in: bob,
kind: "publish",
outerEventID: sent.outerEventId
).eventJson
)
}
XCTAssertFalse(bobOutbound.isEmpty, "Expected at least one kind 1060 message to publish")
for eventJson in bobOutbound {
try aliceMgr.processEvent(eventJson: eventJson)
let sent = try XCTUnwrap(result)
do {
let restoredBob = try makeManager(
bobKeys,
storagePath: bobPath
)
XCTAssertNotNil(
try restoredBob.pendingActions().first {
$0.outerEventId == sent.outerEventId
&& $0.kind == "publish"
}
)
}
let aliceEvents = try aliceMgr.drainEvents()
let decryptedInner = try XCTUnwrap(
aliceEvents.first(where: { $0.kind == "decrypted_message" })?.content,
"Expected a decrypted inner event to surface"
do {
let restoredAlice = try makeManager(
aliceKeys,
storagePath: alicePath
)
try restoredAlice.processEvent(eventJson: outerJSON)
}
let restoredAgain = try makeManager(
aliceKeys,
storagePath: alicePath
)
let delivery = try requireAction(
in: restoredAgain,
kind: "delivery",
innerEventID: sent.innerEventId
)
XCTAssertEqual(
try jsonObject(
XCTUnwrap(delivery.innerEventJson)
)["content"] as? String,
"survives restart"
)
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`()
func testInvalidInviteAndAuthenticatedPeerMismatchAreRejected() throws {
let aliceKeys = generateKeypair()
let bobKeys = generateKeypair()
let unexpectedKeys = generateKeypair()
let alice = try makeManager(aliceKeys)
let bob = try makeManager(bobKeys)
let notAnInvite = """
{"kind":1,"id":"test","pubkey":"test","created_at":0,"content":"hello","tags":[],"sig":"test"}
"""
XCTAssertThrowsError(try mgr.acceptInviteFromEventJson(eventJson: notAnInvite, ownerPubkeyHintHex: nil))
XCTAssertThrowsError(
try bob.acceptInviteFromEventJson(
eventJson:
#"{"kind":1,"id":"bad","pubkey":"bad","created_at":0,"content":"","tags":[],"sig":"bad"}"#,
authenticatedPeerPubkeyHex: aliceKeys.publicKeyHex
)
)
XCTAssertThrowsError(
try bob.acceptInviteFromEventJson(
eventJson: alice.currentInviteEventJson(),
authenticatedPeerPubkeyHex: unexpectedKeys.publicKeyHex
)
)
}
private func makeManager(
_ keys: FfiKeyPair,
storagePath: String? = nil
) throws -> PairwiseManager {
let path: String
if let storagePath {
path = storagePath
} else {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(
"ndr-ffi-test-\(UUID().uuidString)",
isDirectory: true
)
path = directory.path
addTeardownBlock {
try? FileManager.default.removeItem(at: directory)
}
}
return try PairwiseManager.newWithStoragePath(
ourPubkeyHex: keys.publicKeyHex,
ourIdentityPrivateKeyHex: keys.privateKeyHex,
storagePath: path
)
}
}
// MARK: - Helper Extensions
private struct HandshakeArtifacts {
let response: PairwiseAction
let responseJSON: String
let bootstrapJSON: String
}
extension Data {
private func establishSession(
inviter: PairwiseManager,
inviterKeys: FfiKeyPair,
acceptor: PairwiseManager,
acceptorKeys: FfiKeyPair
) throws -> HandshakeArtifacts {
let inviteJSON = try inviter.currentInviteEventJson()
let accepted = try acceptor.acceptInviteFromEventJson(
eventJson: inviteJSON,
authenticatedPeerPubkeyHex: inviterKeys.publicKeyHex
)
XCTAssertTrue(accepted.createdNewSession)
let response = try requireAction(in: acceptor, kind: "out_of_band")
let responseJSON = try XCTUnwrap(response.eventJson)
let bootstrap = try requireAction(in: acceptor, kind: "publish")
let bootstrapJSON = try XCTUnwrap(bootstrap.eventJson)
try inviter.processOutOfBandResponse(
eventJson: responseJSON,
authenticatedPeerPubkeyHex: acceptorKeys.publicKeyHex
)
XCTAssertEqual(
try inviter.sessionInfo(
peerPubkeyHex: acceptorKeys.publicKeyHex
)?.sendReady,
false
)
try inviter.processEvent(eventJson: bootstrapJSON)
try acceptor.ackActions(
actionIds: [response.actionId, bootstrap.actionId]
)
return HandshakeArtifacts(
response: response,
responseJSON: responseJSON,
bootstrapJSON: bootstrapJSON
)
}
private func requireAction(
in manager: PairwiseManager,
kind: String,
innerEventID: String? = nil,
outerEventID: String? = nil
) throws -> PairwiseAction {
try XCTUnwrap(
try manager.pendingActions().first { action in
action.kind == kind
&& (innerEventID == nil || action.innerEventId == innerEventID)
&& (outerEventID == nil || action.outerEventId == outerEventID)
},
"Expected pending \(kind) action"
)
}
private func extractNostrKind(json: String) throws -> Int {
try XCTUnwrap(
jsonObject(json)["kind"] as? Int,
"Event should have an integer kind"
)
}
private func jsonObject(_ json: String) throws -> [String: Any] {
try XCTUnwrap(
JSONSerialization.jsonObject(
with: Data(json.utf8),
options: []
) as? [String: Any],
"Expected a JSON object"
)
}
private 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 {
guard hexString.count.isMultiple(of: 2) else { return nil }
var data = Data(capacity: hexString.count / 2)
var index = hexString.startIndex
while index < hexString.endIndex {
let next = hexString.index(index, offsetBy: 2)
guard let byte = UInt8(hexString[index..<next], radix: 16) else {
return nil
}
data.append(byte)
i = j
index = next
}
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 isInvitePublish(_ event: PubSubEvent) -> Bool {
guard event.kind == "publish_signed",
let json = event.eventJson,
(try? extractNostrKind(json: json)) == 30078,
let tags = try? extractNostrTags(json: json) else {
return false
}
return tags.contains { tag in
tag.count >= 2 && tag[0] == "l" && tag[1] == "double-ratchet/invites"
} || tags.contains { tag in
tag.count >= 2 && tag[0] == "d" && tag[1].hasPrefix("double-ratchet/invites/")
}
}
private func extractNostrTags(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: 5) }
return dict["tags"] as? [[String]] ?? []
}
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

@ -2,22 +2,20 @@
`NdrFfi` is built from source using:
- Upstream repository: `https://github.com/irislib/iris-chat-rs`
- Upstream crate: `protocol-ffi` (`iris-chat-protocol-ffi`, library `ndr_ffi`)
- Upstream base: `main` at `33f7732bbd300ed62fdf5bcf9da0a176efa7ff8c`
- Pinned source commit: `095e70489345df4d92dded686902f3dccb54cc45`
- Source branch: `codex/bitchat-ffi-hardening`
- Latest double-ratchet crates: `nostr-double-ratchet` `0.0.164` and
`nostr-double-ratchet-pairwise-codec` `0.0.164`, checksum-pinned by
`protocol-ffi/Cargo.lock`
- Upstream repository: `https://github.com/irislib/nostr-double-ratchet`
- Upstream base: `master` at `c93f76a2b947f4288d2c7bcbecabe70ce197da5f`
- Pinned source commit: `0fe8caf2d4e24e2030ffae195597a2764613a659`
- Crate: `ndr-pairwise-ffi` (library `ndr_ffi`)
- Runtime: durable single-identity pairwise sessions only; no AppKeys,
linked-device, sibling-sync, or group runtime
- Rebuild script: `build-apple.sh`
- Rust compiler: `1.95.0` (pinned by `RUST_TOOLCHAIN`)
- Release Rust flags: `-C panic=abort -C strip=debuginfo`
- Packaging: embedded dynamic XCFramework, isolating its Rust runtime from
Arti's independent static Rust runtime
- Cargo builds use `--locked`.
- The pinned source commit transactionally binds invite responses to an
expected account owner and preserves account/device attribution in UniFFI.
- The pinned source commit provides durable pairwise state/action ordering,
targeted peer retirement, and portable exclusive storage locking.
Generated/build outputs:

View File

@ -4,12 +4,15 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PACKAGE_DIR="$SCRIPT_DIR"
REPOSITORY_DIR="$(cd "$PACKAGE_DIR/../.." && pwd)"
SOURCE_DIR="${1:-${IRIS_CHAT_RS_DIR:-$REPOSITORY_DIR/vendor/iris-chat-rs}}"
CRATE_DIR="$SOURCE_DIR/protocol-ffi"
SOURCE_DIR="${1:-${NOSTR_DOUBLE_RATCHET_DIR:-$REPOSITORY_DIR/vendor/nostr-double-ratchet}}"
CRATE_DIR="$SOURCE_DIR/rust/crates/ndr-pairwise-ffi"
CRATE_MANIFEST="$CRATE_DIR/Cargo.toml"
BINDGEN_MANIFEST="$SOURCE_DIR/core/uniffi-bindgen/Cargo.toml"
BINDGEN_MANIFEST="$CRATE_MANIFEST"
EXPECTED_REVISION="$(tr -d '[:space:]' < "$PACKAGE_DIR/SOURCE_REVISION")"
EXPECTED_RUST="$(tr -d '[:space:]' < "$PACKAGE_DIR/RUST_TOOLCHAIN")"
# A user-level Cargo config may point at a compiler cache unavailable to CI or
# the current sandbox, so this reproducible build does not use an ambient wrapper.
export RUSTC_WRAPPER=""
# These are reproducibility inputs, not ambient build-machine preferences.
# Keep them aligned with the application's documented minimum OS versions.
@ -19,12 +22,12 @@ FRAMEWORK_NAME="ndr_ffiFFI"
INSTALL_NAME="@rpath/$FRAMEWORK_NAME.framework/$FRAMEWORK_NAME"
if [[ ! -f "$CRATE_MANIFEST" ]]; then
echo "error: expected iris-chat-rs protocol-ffi crate at $CRATE_MANIFEST" >&2
echo "error: expected nostr-double-ratchet pairwise FFI crate at $CRATE_MANIFEST" >&2
exit 1
fi
if [[ ! -f "$BINDGEN_MANIFEST" ]]; then
echo "error: expected UniFFI bindgen helper at $BINDGEN_MANIFEST" >&2
echo "error: expected UniFFI bindgen manifest at $BINDGEN_MANIFEST" >&2
exit 1
fi
@ -35,13 +38,25 @@ if [[ "$ACTUAL_RUST" != "$EXPECTED_RUST" ]]; then
exit 1
fi
if [[ -d "$SOURCE_DIR/.git" ]] || git -C "$SOURCE_DIR" rev-parse --git-dir >/dev/null 2>&1; then
ACTUAL_REVISION="$(git -C "$SOURCE_DIR" rev-parse HEAD)"
if [[ "$ACTUAL_REVISION" != "$EXPECTED_REVISION" ]]; then
echo "error: iris-chat-rs is at $ACTUAL_REVISION; expected $EXPECTED_REVISION" >&2
echo "run: git submodule update --init --checkout vendor/iris-chat-rs" >&2
exit 1
fi
if ! git -C "$SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "error: nostr-double-ratchet source must be the pinned Git submodule at $SOURCE_DIR" >&2
exit 1
fi
SOURCE_WORKTREE="$(cd "$SOURCE_DIR" && pwd -P)"
SOURCE_GIT_ROOT="$(git -C "$SOURCE_DIR" rev-parse --show-toplevel)"
if [[ "$SOURCE_GIT_ROOT" != "$SOURCE_WORKTREE" ]]; then
echo "error: nostr-double-ratchet Git root is $SOURCE_GIT_ROOT; expected $SOURCE_WORKTREE" >&2
exit 1
fi
ACTUAL_REVISION="$(git -C "$SOURCE_DIR" rev-parse HEAD)"
if [[ "$ACTUAL_REVISION" != "$EXPECTED_REVISION" ]]; then
echo "error: nostr-double-ratchet is at $ACTUAL_REVISION; expected $EXPECTED_REVISION" >&2
echo "run: git submodule update --init --checkout vendor/nostr-double-ratchet" >&2
exit 1
fi
if [[ -n "$(git -C "$SOURCE_DIR" status --porcelain --untracked-files=all)" ]]; then
echo "error: nostr-double-ratchet source has local changes; refusing an unreproducible build" >&2
exit 1
fi
WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ndrffi-apple.XXXXXX")"
@ -57,7 +72,7 @@ trap cleanup EXIT
mkdir -p "$TARGET_DIR" "$BINDINGS_DIR" "$HEADERS_DIR"
echo "==> Building ndr_ffi compatibility artifacts from $CRATE_DIR"
echo "==> Building the pairwise ndr_ffi artifacts from $CRATE_DIR"
echo " macOS minimum: $MACOS_MIN"
echo " iOS minimum: $IOS_MIN"
@ -70,7 +85,7 @@ env \
env \
CARGO_TARGET_DIR="$TARGET_DIR" \
cargo run --locked --manifest-path "$BINDGEN_MANIFEST" -- \
cargo run --locked --manifest-path "$BINDGEN_MANIFEST" --features bindgen --bin uniffi-bindgen -- \
generate \
--library "$TARGET_DIR/debug/libndr_ffi.dylib" \
--language swift \

1
vendor/iris-chat-rs vendored

@ -1 +0,0 @@
Subproject commit 095e70489345df4d92dded686902f3dccb54cc45

1
vendor/nostr-double-ratchet vendored Submodule

@ -0,0 +1 @@
Subproject commit 0fe8caf2d4e24e2030ffae195597a2764613a659