mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 21:45:19 +00:00
* Gate connected-link trust on a secure session; deny forged direct announces the connected shortcut
Residual gap after the rotation-heal containment (#1401): "verified
direct" announces prove the signature but not directness — TTL is
unsigned — so a malicious connected peer can replay a victim's fresh
announce with its TTL restored. When the victim has no live link, the
replayer's link rebinds to the victim's ID and reads as "connected",
and MessageRouter's connected fast-path then trusts it outright: every
DM stalls on a Noise handshake the replayer can never complete and is
silently lost while showing "sent".
Router-level trust gate (no wire change):
- Transport gains canDeliverSecurely(to:) — BLE answers with an
established Noise session; Nostr keeps its prompt-delivery predicate;
the protocol default forwards to canDeliverPromptly for transports
without a forgeable link layer.
- MessageRouter.sendPrivate only trusts a connected link outright when
it can deliver securely; otherwise it still sends (kicking the
handshake on a genuine link) but retains a copy and hands a sealed
copy to couriers, like the reachable path. flushOutbox gets the same
gate so a flush over an insecure link resends instead of dropping the
retained copy.
Presence hardening (defense in depth): a "direct" announce arriving on
a link already bound to a different peer no longer shortcuts the
claimed peer into "connected" — only real link state does. Genuine
first-contact and direct announces are unaffected; only the ambiguous
heal path loses the forgeable shortcut.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Harden the insecure-link outbox bound; document the second-replay presence gap and the courier metadata tradeoff
Review follow-ups on the canDeliverSecurely gate:
- flushOutbox no longer counts connected-but-insecure flushes toward the
maxSendAttempts drop: the message was actually transmitted over a live
link, so a peer whose Noise handshake stalls across reconnect flapping
must not burn through the cap and lose the store-and-forward copy the
gate exists to preserve. Retention stays bounded by the 24h outbox TTL
and the per-peer FIFO cap; acks still clear it. Attempt-counting stays
for reachable-only (heuristic) sends. Regression test: >8 connected-
insecure flushes keep the retained copy, drop callback never fires.
- Document the known second-replay presence gap: linkBoundToOtherPeer
reads the binding before rebindLinkAfterVerifiedDirectAnnounce steals
the link, so once a first replay has rebound a link to an absent
victim's ID, a second replay marks the victim connected. Presence
display only — DMs stay on the retain+courier path via the router
gate. Not closed at the announce layer because the post-rebind state
is indistinguishable from a legitimate rotation/reconnect heal (a
supported, field-verified flow). Covered by a two-announce test.
- Note the accepted courier-spray metadata tradeoff at the connected-
insecure send: nearby verified peers receive a sealed copy (they learn
a DM exists, never its content), cleared on ack.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Promote a healed rotation to connected; normalize the secure-delivery probe; retain Codable properties in Periphery
Codex review follow-ups on 5017c232:
- P1: a legitimate rotation announce necessarily arrives on a link still
bound to the OLD peer ID, so the linkBoundToOtherPeer denial stored the
new identity as disconnected — and the rebind only fixed link state,
never the registry. A healed rotation then read as disconnected until
the peer happened to announce again. rebindLinkAfterVerifiedDirect-
Announce now promotes the rebound identity to connected after the
containment checks pass (registry markConnected + topology/snapshot/
peer-list refresh; the .peerConnected UI event already fired from the
announce path). This consciously moves the forged-presence residue
from second-replay to first-successful-rebind — bounded by the rebind
containment (never steals a live identity, one rebind per link per
cooldown) and still display-only: DMs stay gated on canDeliverSecurely.
Comments and the pinned tests updated; new regression test covers
rotate-on-open-link -> rebind -> isPeerConnected(new) == true.
- P2: BLEService.canDeliverSecurely probed the Noise session with the
peer ID as given, but sessions are keyed by the short wire ID — a send
keyed by the full 64-hex Noise key (favorites resolution) misread an
established session as insecure and needlessly retained + couriered
every DM until ack. Normalize with toShort() like isPeerConnected.
Test drives a real XX handshake and asserts both ID forms pass.
- Periphery: the PrekeyBundleStore.StoredBundle.noiseKey "assign-only"
flake fired again and slipped past its baselined USR (read exists in
loadFromDisk; the indexer intermittently misses it). Replace the
baseline entry with retain_codable_properties: true — deterministic,
and a truly-dead Codable field is a persisted-format change anyway.
Local periphery scan --strict: no unused code detected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
411 lines
20 KiB
Swift
411 lines
20 KiB
Swift
import BitLogger
|
|
import BitFoundation
|
|
import Foundation
|
|
import Combine
|
|
|
|
// Minimal Nostr transport conforming to Transport for offline sending
|
|
final class NostrTransport: Transport, @unchecked Sendable {
|
|
struct Dependencies {
|
|
let notificationCenter: NotificationCenter
|
|
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
|
|
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
|
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
|
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
|
let registerPendingGiftWrap: @MainActor (String) -> Void
|
|
let sendEvent: @MainActor (NostrEvent) -> Void
|
|
/// Emits whether a relay that carries private messages is up
|
|
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
|
/// doesn't count: DM sends target the default relay set and would
|
|
/// still queue.
|
|
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
|
|
/// Paces outbound acks. Defaults to an isolated pacer so tests don't
|
|
/// serialize behind each other; `live` passes the process-wide one.
|
|
let ackPacer: AckPacer
|
|
|
|
init(
|
|
notificationCenter: NotificationCenter,
|
|
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
|
|
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
|
|
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
|
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
|
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
|
|
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
|
|
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
|
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
|
ackPacer: AckPacer? = nil
|
|
) {
|
|
self.notificationCenter = notificationCenter
|
|
self.loadFavorites = loadFavorites
|
|
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
|
|
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
|
self.currentIdentity = currentIdentity
|
|
self.registerPendingGiftWrap = registerPendingGiftWrap
|
|
self.sendEvent = sendEvent
|
|
self.relayConnectivity = relayConnectivity
|
|
// Default pacer drives its throttle through the same injected
|
|
// scheduler, so tests that step scheduleAfter manually keep
|
|
// control of the ack cadence.
|
|
self.ackPacer = ackPacer ?? AckPacer(scheduleAfter: scheduleAfter)
|
|
}
|
|
|
|
@MainActor
|
|
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
|
Dependencies(
|
|
notificationCenter: .default,
|
|
loadFavorites: { FavoritesPersistenceService.shared.favorites },
|
|
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
|
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
|
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
|
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
|
|
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
|
scheduleAfter: { delay, action in
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
|
},
|
|
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
|
|
ackPacer: NostrTransport.sharedAckPacer
|
|
)
|
|
}
|
|
}
|
|
|
|
// Provide BLE short peer ID for BitChat embedding
|
|
var senderPeerID = PeerID(str: "")
|
|
|
|
// Throttle outbound acks — READ receipts and DELIVERED acks, direct and
|
|
// geohash — to avoid relay rate limits. Reconnect redelivery produces a
|
|
// burst of acks at once: 8 DELIVERED in under a second tripped damus's
|
|
// "noting too much" during July 2026 device testing.
|
|
private enum QueuedAck {
|
|
case readDirect(ReadReceipt, PeerID)
|
|
case deliveredDirect(messageID: String, peerID: PeerID)
|
|
case deliveredGeohash(messageID: String, recipientHex: String, identity: NostrIdentity)
|
|
case readGeohash(messageID: String, recipientHex: String, identity: NostrIdentity)
|
|
}
|
|
|
|
/// Ack pacing shared across transport instances. Geohash acks are sent
|
|
/// through short-lived transports created per ack
|
|
/// (`makeGeohashNostrTransport()`), so a per-instance queue would only
|
|
/// ever hold one item and never pace a burst (flagged by Codex on
|
|
/// #1398). Production wires `sharedAckPacer` via `Dependencies.live`;
|
|
/// tests get an isolated instance per `Dependencies` by default.
|
|
/// @unchecked Sendable: all mutable state (`pending`, `isSending`) is
|
|
/// confined to the serial `queue`; the class is only touched via
|
|
/// `enqueue` and the scheduler callback, both of which hop onto it.
|
|
final class AckPacer: @unchecked Sendable {
|
|
typealias Scheduler = @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
|
|
|
private let queue = DispatchQueue(label: "chat.bitchat.nostr-ack-pacer")
|
|
private var pending: [() -> Void] = []
|
|
private var isSending = false
|
|
private let interval: TimeInterval = TransportConfig.nostrReadAckInterval
|
|
private let scheduleAfter: Scheduler
|
|
|
|
init(scheduleAfter: @escaping Scheduler = { delay, action in
|
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay, execute: action)
|
|
}) {
|
|
self.scheduleAfter = scheduleAfter
|
|
}
|
|
|
|
func enqueue(_ send: @escaping () -> Void) {
|
|
queue.async {
|
|
self.pending.append(send)
|
|
self.processNext()
|
|
}
|
|
}
|
|
|
|
/// Must be called on `queue`.
|
|
private func processNext() {
|
|
guard !isSending, !pending.isEmpty else { return }
|
|
isSending = true
|
|
let send = pending.removeFirst()
|
|
send()
|
|
scheduleAfter(interval) { [weak self] in
|
|
guard let self else { return }
|
|
self.queue.async {
|
|
self.isSending = false
|
|
self.processNext()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
static let sharedAckPacer = AckPacer()
|
|
private let dependencies: Dependencies
|
|
private var favoriteStatusObserver: NSObjectProtocol?
|
|
|
|
// Reachability Cache (thread-safe)
|
|
private var reachablePeers: Set<PeerID> = []
|
|
// Mirror of the relay manager's connection state, cached here because
|
|
// canDeliverPromptly is called synchronously off the main actor.
|
|
private var relaysConnected = false
|
|
private var relayConnectivityCancellable: AnyCancellable?
|
|
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
|
|
|
@MainActor
|
|
init(
|
|
keychain _: KeychainManagerProtocol,
|
|
idBridge: NostrIdentityBridge,
|
|
dependencies: Dependencies? = nil
|
|
) {
|
|
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
|
|
|
setupObservers()
|
|
|
|
// Synchronously warm the cache to avoid startup race
|
|
let favorites = self.dependencies.loadFavorites()
|
|
let reachable = favorites.values
|
|
.filter { $0.peerNostrPublicKey != nil }
|
|
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
|
|
|
queue.sync(flags: .barrier) {
|
|
self.reachablePeers = Set(reachable)
|
|
}
|
|
|
|
relayConnectivityCancellable = self.dependencies.relayConnectivity()
|
|
.sink { [weak self] connected in
|
|
guard let self else { return }
|
|
self.queue.async(flags: .barrier) { self.relaysConnected = connected }
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
if let favoriteStatusObserver {
|
|
dependencies.notificationCenter.removeObserver(favoriteStatusObserver)
|
|
}
|
|
}
|
|
|
|
private func setupObservers() {
|
|
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
|
forName: .favoriteStatusChanged,
|
|
object: nil,
|
|
queue: nil
|
|
) { [weak self] _ in
|
|
self?.refreshReachablePeers()
|
|
}
|
|
}
|
|
|
|
private func refreshReachablePeers() {
|
|
Task { @MainActor in
|
|
let favorites = dependencies.loadFavorites()
|
|
let reachable = favorites.values
|
|
.filter { $0.peerNostrPublicKey != nil }
|
|
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
|
|
|
self.queue.async(flags: .barrier) { [weak self] in
|
|
self?.reachablePeers = Set(reachable)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Transport Protocol Conformance
|
|
|
|
weak var delegate: BitchatDelegate?
|
|
weak var eventDelegate: TransportEventDelegate?
|
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
|
|
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
|
|
|
var myPeerID: PeerID { senderPeerID }
|
|
var myNickname: String { "" }
|
|
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
|
|
|
func startServices() { /* no-op */ }
|
|
func stopServices() { /* no-op */ }
|
|
func emergencyDisconnectAll() { /* no-op */ }
|
|
|
|
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
|
|
|
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
|
// Callers address peers by either the short 16-hex ID or the full
|
|
// 64-hex noise key (offline favorites), so compare in short form.
|
|
let short = peerID.toShort()
|
|
return queue.sync {
|
|
if reachablePeers.contains(peerID) { return true }
|
|
return reachablePeers.contains(where: { $0.toShort() == short })
|
|
}
|
|
}
|
|
|
|
func canDeliverPromptly(to peerID: PeerID) -> Bool {
|
|
// A known npub makes a peer "reachable", but with no relay
|
|
// connection a send only joins the local queue. Answering honestly
|
|
// here lets the router hand a sealed copy to a courier in parallel
|
|
// instead of waiting for internet that may never come.
|
|
isPeerReachable(peerID) && queue.sync { relaysConnected }
|
|
}
|
|
|
|
func canDeliverSecurely(to peerID: PeerID) -> Bool {
|
|
// Nostr has no link bindings to forge; a known recipient key plus a
|
|
// connected relay is the strongest delivery signal it has. The router
|
|
// already retains + couriers for Nostr sends, so keep that behavior.
|
|
canDeliverPromptly(to: peerID)
|
|
}
|
|
|
|
func peerNickname(peerID: PeerID) -> String? { nil }
|
|
func getPeerNicknames() -> [PeerID: String] { [:] }
|
|
|
|
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
|
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
|
|
|
// Nostr does not use Noise sessions here; the inert Transport defaults
|
|
// for the noise* identity hooks apply.
|
|
|
|
// Public broadcast not supported over Nostr here
|
|
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
|
|
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
|
Task { @MainActor in
|
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
|
let recipientHex = npubToHex(recipientNpub),
|
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
|
return
|
|
}
|
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
|
}
|
|
}
|
|
|
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
|
enqueueAck(.readDirect(receipt, peerID))
|
|
}
|
|
|
|
/// Enqueue an ack for paced sending. Captures self strongly on purpose:
|
|
/// geohash acks ride throwaway transport instances that must stay alive
|
|
/// until their ack leaves the queue.
|
|
private func enqueueAck(_ ack: QueuedAck) {
|
|
dependencies.ackPacer.enqueue { self.sendAckItem(ack) }
|
|
}
|
|
|
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
|
Task { @MainActor in
|
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
|
let recipientHex = npubToHex(recipientNpub),
|
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
|
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
|
return
|
|
}
|
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
|
}
|
|
}
|
|
|
|
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
|
enqueueAck(.deliveredDirect(messageID: messageID, peerID: peerID))
|
|
}
|
|
}
|
|
|
|
// MARK: - Geohash Helpers
|
|
|
|
extension NostrTransport {
|
|
|
|
// MARK: Geohash ACK helpers
|
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
enqueueAck(.deliveredGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
|
|
}
|
|
|
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
enqueueAck(.readGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
|
|
}
|
|
|
|
// MARK: Geohash DMs (per-geohash identity)
|
|
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
|
Task { @MainActor in
|
|
guard !recipientHex.isEmpty else { return }
|
|
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session)
|
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
|
return
|
|
}
|
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
extension NostrTransport {
|
|
/// Converts npub bech32 string to hex pubkey
|
|
@MainActor
|
|
private func npubToHex(_ npub: String) -> String? {
|
|
do {
|
|
let (hrp, data) = try Bech32.decode(npub)
|
|
guard hrp == "npub" else { return nil }
|
|
return data.hexEncodedString()
|
|
} catch {
|
|
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Creates and sends a gift-wrapped private message event
|
|
@MainActor
|
|
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
|
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
|
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
|
return
|
|
}
|
|
if registerPending {
|
|
dependencies.registerPendingGiftWrap(event.id)
|
|
}
|
|
dependencies.sendEvent(event)
|
|
}
|
|
|
|
|
|
/// Sends a single ack item (invoked by the pacer, one per interval)
|
|
private func sendAckItem(_ item: QueuedAck) {
|
|
Task { @MainActor in
|
|
switch item {
|
|
case .readDirect(let receipt, let peerID):
|
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
|
let recipientHex = npubToHex(recipientNpub),
|
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
|
SecureLogger.debug("NostrTransport: preparing READ ack id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: receipt.originalMessageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
|
return
|
|
}
|
|
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
|
|
|
case .deliveredDirect(let messageID, let peerID):
|
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
|
let recipientHex = npubToHex(recipientNpub),
|
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
|
return
|
|
}
|
|
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
|
|
|
case .deliveredGeohash(let messageID, let recipientHex, let identity):
|
|
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
|
|
|
case .readGeohash(let messageID, let recipientHex, let identity):
|
|
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
|
if let noiseKey = Data(hexString: peerID.id),
|
|
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
|
|
let npub = fav.peerNostrPublicKey {
|
|
return npub
|
|
}
|
|
if peerID.id.count == 16,
|
|
let fav = dependencies.favoriteStatusForPeerID(peerID),
|
|
let npub = fav.peerNostrPublicKey {
|
|
return npub
|
|
}
|
|
return nil
|
|
}
|
|
}
|