mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-15 07:06:11 +00:00
feat: add pairwise Nostr double ratchet
This commit is contained in:
parent
99b0beaf1a
commit
1e766b8d4f
2
.github/workflows/swift-tests.yml
vendored
2
.github/workflows/swift-tests.yml
vendored
@ -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
2
.gitignore
vendored
@ -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
6
.gitmodules
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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 {
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -1 +1 @@
|
||||
095e70489345df4d92dded686902f3dccb54cc45
|
||||
0fe8caf2d4e24e2030ffae195597a2764613a659
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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
vendor/iris-chat-rs
vendored
@ -1 +0,0 @@
|
||||
Subproject commit 095e70489345df4d92dded686902f3dccb54cc45
|
||||
1
vendor/nostr-double-ratchet
vendored
Submodule
1
vendor/nostr-double-ratchet
vendored
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 0fe8caf2d4e24e2030ffae195597a2764613a659
|
||||
Loading…
x
Reference in New Issue
Block a user