mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 11:05:20 +00:00
Retry private messages after Noise session replacement
This commit is contained in:
@@ -107,12 +107,20 @@ final class MessageRouter {
|
||||
private var bridgeDepositsInFlight = Set<String>()
|
||||
|
||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
/// IDs whose latest router-owned transmission used an already-established
|
||||
/// secure session and still awaits an ack. This deliberately excludes
|
||||
/// messages handed to BLE while a handshake is pending: BLE owns those
|
||||
/// sends and drains its queue after authentication, so retrying them here
|
||||
/// would duplicate every normal first-handshake DM.
|
||||
private var securelyTransmittedMessageIDs = Set<String>()
|
||||
|
||||
// Outbox limits to prevent unbounded memory growth
|
||||
private static let maxMessagesPerPeer = 100
|
||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
// Bound resends of messages sent on a weak reachability signal that never
|
||||
// get a delivery ack (e.g. peer on an old client that doesn't ack).
|
||||
// Bound actual sends that never receive an ack, whether they used weak
|
||||
// reachability or an apparently secure session that keeps being replaced.
|
||||
// Connected pre-handshake sends are transport-owned and do not burn this
|
||||
// cap because BLE queues/drains them itself.
|
||||
private static let maxSendAttempts = 8
|
||||
// Redundant couriers improve delivery odds; receivers dedup by message ID.
|
||||
private static let maxCouriersPerMessage = 3
|
||||
@@ -181,15 +189,28 @@ final class MessageRouter {
|
||||
// MARK: - Message Sending
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
let message = QueuedMessage(
|
||||
content: content,
|
||||
nickname: recipientNickname,
|
||||
messageID: messageID,
|
||||
timestamp: now(),
|
||||
sendAttempts: 1
|
||||
)
|
||||
|
||||
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
||||
// A live link that can complete an encrypted delivery is a
|
||||
// strong delivery signal; trust it outright.
|
||||
// Even an established Noise session can be stale after the peer
|
||||
// restarts or replaces its app. Persist before handing the packet
|
||||
// to the transport so a fast ack cannot race ahead of retention,
|
||||
// then keep the copy until a delivery/read ack clears it. A
|
||||
// replacement handshake will retry this same message ID, which
|
||||
// receivers deduplicate.
|
||||
enqueue(message, for: peerID)
|
||||
securelyTransmittedMessageIDs.insert(messageID)
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
return
|
||||
}
|
||||
|
||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1)
|
||||
if let transport = connectedTransport(for: peerID) {
|
||||
// "Connected" without an established secure session is forgeable:
|
||||
// link bindings heal on signature-verified "direct" announces, but
|
||||
@@ -207,8 +228,9 @@ final class MessageRouter {
|
||||
// deposit is cleared on ack. Don't "optimize" the courier call
|
||||
// away.
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
enqueue(message, for: peerID)
|
||||
securelyTransmittedMessageIDs.remove(messageID)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||
return
|
||||
}
|
||||
@@ -219,8 +241,8 @@ final class MessageRouter {
|
||||
// Send now, but retain a copy until a delivery/read ack clears it;
|
||||
// receivers dedup resends by message ID.
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
enqueue(message, for: peerID)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
// "Reachable" without prompt delivery means the send only joined
|
||||
// a queue (Nostr with relays down): also hand a sealed copy to
|
||||
// any connected couriers rather than waiting for internet that
|
||||
@@ -366,15 +388,41 @@ final class MessageRouter {
|
||||
|
||||
// MARK: - Outbox Management
|
||||
|
||||
/// A delivery or read ack confirms receipt; stop retaining the message.
|
||||
/// A locally trusted delivery transition confirms receipt; stop retaining
|
||||
/// every copy of the message. Authenticated remote receipts must use the
|
||||
/// peer-bound overload below instead.
|
||||
func markDelivered(_ messageID: String) {
|
||||
clearRetainedMessage(messageID, allowedPeerIDs: nil)
|
||||
}
|
||||
|
||||
/// Stops retaining a message only for the authenticated conversation
|
||||
/// aliases that produced the accepted receipt. A peer that learns another
|
||||
/// conversation's message ID cannot use it to clear that conversation's
|
||||
/// retry state.
|
||||
func markDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||
guard !peerIDs.isEmpty else { return }
|
||||
clearRetainedMessage(messageID, allowedPeerIDs: peerIDs)
|
||||
}
|
||||
|
||||
private func clearRetainedMessage(
|
||||
_ messageID: String,
|
||||
allowedPeerIDs: Set<PeerID>?
|
||||
) {
|
||||
var cleared = false
|
||||
for (peerID, queue) in outbox {
|
||||
if let allowedPeerIDs, !allowedPeerIDs.contains(peerID) {
|
||||
continue
|
||||
}
|
||||
let filtered = queue.filter { $0.messageID != messageID }
|
||||
guard filtered.count != queue.count else { continue }
|
||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||
cleared = true
|
||||
}
|
||||
if !outbox.values.contains(where: { queue in
|
||||
queue.contains { $0.messageID == messageID }
|
||||
}) {
|
||||
securelyTransmittedMessageIDs.remove(messageID)
|
||||
}
|
||||
// The durable snapshot may still be hidden by protected data. Record
|
||||
// the ack even when this cold-load view cannot find the message, then
|
||||
// persist the current view so the store retains a removal tombstone.
|
||||
@@ -401,6 +449,11 @@ final class MessageRouter {
|
||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||
cleared = true
|
||||
}
|
||||
if !outbox.values.contains(where: { queue in
|
||||
queue.contains { $0.messageID == messageID }
|
||||
}) {
|
||||
securelyTransmittedMessageIDs.remove(messageID)
|
||||
}
|
||||
// Preserve the scoped ack even when protected data hides the durable
|
||||
// queue during a cold launch.
|
||||
outboxStore?.recordRemoval(messageID: messageID, for: peerIDs)
|
||||
@@ -434,7 +487,34 @@ final class MessageRouter {
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func removeQueuedMessage(_ messageID: String, for peerID: PeerID) -> Bool {
|
||||
guard let queue = outbox[peerID],
|
||||
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
|
||||
return false
|
||||
}
|
||||
var updated = queue
|
||||
updated.remove(at: index)
|
||||
outbox[peerID] = updated.isEmpty ? nil : updated
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func incrementSendAttemptsIfQueued(_ messageID: String, for peerID: PeerID) -> Bool {
|
||||
guard var queue = outbox[peerID],
|
||||
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
|
||||
// A synchronous delivery/read ack may have cleared the retained
|
||||
// copy while `sendPrivateMessage` was on the stack. Never
|
||||
// resurrect it from the flush snapshot.
|
||||
return false
|
||||
}
|
||||
queue[index].sendAttempts += 1
|
||||
outbox[peerID] = queue
|
||||
return true
|
||||
}
|
||||
|
||||
private func dropMessage(_ messageID: String, for peerID: PeerID) {
|
||||
securelyTransmittedMessageIDs.remove(messageID)
|
||||
metrics?.record(.outboxDropped)
|
||||
onMessageDropped?(messageID, peerID)
|
||||
}
|
||||
@@ -478,6 +558,7 @@ final class MessageRouter {
|
||||
/// Panic wipe: forget queued mail on disk and in memory.
|
||||
func wipeOutbox() {
|
||||
outbox.removeAll()
|
||||
securelyTransmittedMessageIDs.removeAll()
|
||||
outboxStore?.wipe()
|
||||
}
|
||||
|
||||
@@ -505,26 +586,156 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retries only messages that the router previously transmitted through
|
||||
/// an already-established secure session and that still await an ack.
|
||||
///
|
||||
/// A peer restart can leave that local session looking usable until the
|
||||
/// replacement handshake arrives; the first ciphertext is then
|
||||
/// undecryptable remotely. Normal pre-handshake sends are intentionally
|
||||
/// absent from `securelyTransmittedMessageIDs` because BLE already queues
|
||||
/// and drains them when authentication completes.
|
||||
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||
typealias Candidate = (
|
||||
peerID: PeerID,
|
||||
message: QueuedMessage,
|
||||
aliasOrder: Int,
|
||||
queueOrder: Int
|
||||
)
|
||||
|
||||
var visitedPeerIDs = Set<PeerID>()
|
||||
var retriedMessageIDs = Set<String>()
|
||||
var outboxChanged = false
|
||||
let currentDate = now()
|
||||
var candidates: [Candidate] = []
|
||||
|
||||
for (aliasOrder, peerID) in peerIDAliases.enumerated() {
|
||||
guard visitedPeerIDs.insert(peerID).inserted else { continue }
|
||||
guard let queued = outbox[peerID], !queued.isEmpty,
|
||||
let transport = connectedTransport(for: peerID),
|
||||
transport.canDeliverSecurely(to: peerID) else {
|
||||
continue
|
||||
}
|
||||
|
||||
for (queueOrder, message) in queued.enumerated() {
|
||||
guard securelyTransmittedMessageIDs.contains(message.messageID) else { continue }
|
||||
candidates.append((
|
||||
peerID: peerID,
|
||||
message: message,
|
||||
aliasOrder: aliasOrder,
|
||||
queueOrder: queueOrder
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation migration can leave retained messages split across the
|
||||
// ephemeral and stable outbox keys. Merge both queues into one
|
||||
// chronological stream so callback alias order cannot send newer mail
|
||||
// ahead of older mail.
|
||||
candidates.sort { lhs, rhs in
|
||||
if lhs.message.timestamp != rhs.message.timestamp {
|
||||
return lhs.message.timestamp < rhs.message.timestamp
|
||||
}
|
||||
if lhs.aliasOrder != rhs.aliasOrder {
|
||||
return lhs.aliasOrder < rhs.aliasOrder
|
||||
}
|
||||
if lhs.queueOrder != rhs.queueOrder {
|
||||
return lhs.queueOrder < rhs.queueOrder
|
||||
}
|
||||
return lhs.message.messageID < rhs.message.messageID
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
let peerID = candidate.peerID
|
||||
let message = candidate.message
|
||||
guard retriedMessageIDs.insert(message.messageID).inserted,
|
||||
securelyTransmittedMessageIDs.contains(message.messageID),
|
||||
queuedMessage(message.messageID, for: peerID) != nil,
|
||||
let transport = connectedTransport(for: peerID),
|
||||
transport.canDeliverSecurely(to: peerID) else {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentDate.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning(
|
||||
"📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) secure attempts",
|
||||
category: .session
|
||||
)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
SecureLogger.debug(
|
||||
"Auth retry -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
transport.sendPrivateMessage(
|
||||
message.content,
|
||||
to: peerID,
|
||||
recipientNickname: message.nickname,
|
||||
messageID: message.messageID
|
||||
)
|
||||
metrics?.record(.outboxResent)
|
||||
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||
}
|
||||
|
||||
if outboxChanged {
|
||||
persistOutbox()
|
||||
}
|
||||
}
|
||||
|
||||
func flushOutbox(for peerID: PeerID) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||
|
||||
let now = now()
|
||||
var remaining: [QueuedMessage] = []
|
||||
var outboxChanged = false
|
||||
|
||||
for message in queued {
|
||||
// A synchronous ack from an earlier send in this flush may have
|
||||
// removed an entry from the live outbox. The snapshot is only an
|
||||
// iteration order; never use it to recreate removed messages.
|
||||
guard queuedMessage(message.messageID, for: peerID) != nil else { continue }
|
||||
|
||||
// Skip expired messages (TTL exceeded)
|
||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
||||
// Live link with a secure session: send and stop retaining.
|
||||
// A secure session is meaningful enough to retry, but not
|
||||
// proof that this particular ciphertext reached the peer: the
|
||||
// remote app may have restarted while our old session still
|
||||
// looked established. Retain until an ack, while bounding
|
||||
// actual secure transmissions for peers that never ack.
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
securelyTransmittedMessageIDs.insert(message.messageID)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||
} else if let transport = connectedTransport(for: peerID) {
|
||||
// "Connected" without a secure session — possibly a stolen
|
||||
// binding from a replayed announce: send (a genuine link
|
||||
@@ -537,9 +748,9 @@ final class MessageRouter {
|
||||
// preserve. Retention stays bounded by the 24h outbox TTL
|
||||
// and the per-peer FIFO cap.
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
securelyTransmittedMessageIDs.remove(message.messageID)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
remaining.append(message)
|
||||
} else if let transport = reachableTransport(for: peerID) {
|
||||
// Reachability without a connection is a freshness heuristic,
|
||||
// so the send can silently go nowhere: send but keep retaining
|
||||
@@ -547,26 +758,22 @@ final class MessageRouter {
|
||||
// that never ack.
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
var retained = message
|
||||
retained.sendAttempts += 1
|
||||
remaining.append(retained)
|
||||
} else {
|
||||
remaining.append(message)
|
||||
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||
}
|
||||
}
|
||||
|
||||
if remaining.isEmpty {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
} else {
|
||||
outbox[peerID] = remaining
|
||||
if outboxChanged {
|
||||
persistOutbox()
|
||||
}
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
func flushAllOutbox() {
|
||||
|
||||
@@ -33,6 +33,12 @@ protocol ChatDeliveryContext: AnyObject {
|
||||
func notifyUIChanged()
|
||||
/// Confirms receipt so the message router stops retaining the message for resend.
|
||||
func markMessageDelivered(_ messageID: String)
|
||||
/// Peer-bound form for authenticated remote receipts. Only the supplied
|
||||
/// conversation aliases may have retained state terminalized.
|
||||
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>)
|
||||
/// Returns true only when `messageID` is one of our outgoing messages in
|
||||
/// at least one of the authenticated peer's direct-conversation aliases.
|
||||
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatDeliveryContext {
|
||||
@@ -59,6 +65,18 @@ extension ChatViewModel: ChatDeliveryContext {
|
||||
messageID: messageID
|
||||
)
|
||||
}
|
||||
|
||||
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||
messageRouter.markDelivered(messageID, from: peerIDs)
|
||||
}
|
||||
|
||||
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||
peerIDs.contains { peerID in
|
||||
privateMessages(for: peerID).contains { message in
|
||||
message.id == messageID && message.senderPeerID == myPeerID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin mapper from delivery events (read receipts, transport delivery
|
||||
@@ -86,9 +104,10 @@ final class ChatDeliveryCoordinator {
|
||||
|
||||
@MainActor
|
||||
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
||||
updateMessageDeliveryStatus(
|
||||
updateAcknowledgedMessageDeliveryStatus(
|
||||
receipt.originalMessageID,
|
||||
status: .read(by: receipt.readerNickname, at: receipt.timestamp)
|
||||
status: .read(by: receipt.readerNickname, at: receipt.timestamp),
|
||||
from: [receipt.readerID]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,17 +124,43 @@ final class ChatDeliveryCoordinator {
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
||||
return false
|
||||
}
|
||||
switch status {
|
||||
case .delivered, .read:
|
||||
// Confirmed receipt — stop retaining the message for resend.
|
||||
// Terminalize only after the store accepted the transition.
|
||||
context.markMessageDelivered(messageID)
|
||||
default:
|
||||
break
|
||||
}
|
||||
context.notifyUIChanged()
|
||||
return true
|
||||
}
|
||||
|
||||
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
||||
/// Applies an authenticated remote delivery/read receipt only when it
|
||||
/// belongs to one of our outgoing messages in that peer's conversation.
|
||||
/// Retry state is cleared after, never before, the status transition is
|
||||
/// accepted by the store.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func updateAcknowledgedMessageDeliveryStatus(
|
||||
_ messageID: String,
|
||||
status: DeliveryStatus,
|
||||
from peerIDAliases: Set<PeerID>
|
||||
) -> Bool {
|
||||
switch status {
|
||||
case .delivered, .read:
|
||||
break
|
||||
default:
|
||||
return false
|
||||
}
|
||||
guard !peerIDAliases.isEmpty,
|
||||
context.isOutgoingPrivateMessage(messageID, toAny: peerIDAliases),
|
||||
context.setDeliveryStatus(status, forMessageID: messageID) else {
|
||||
return false
|
||||
}
|
||||
context.markMessageDelivered(messageID, from: peerIDAliases)
|
||||
context.notifyUIChanged()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -62,10 +62,15 @@ protocol ChatTransportEventContext: AnyObject {
|
||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||
|
||||
// MARK: Delivery status
|
||||
/// Applies the status to every known location of the message.
|
||||
/// Returns `false` when no message with that ID was updated.
|
||||
/// Applies an authenticated receipt to the message only when it belongs
|
||||
/// to the supplied peer conversation aliases. Returns `false` for an
|
||||
/// unknown ID, wrong peer, or rejected status transition.
|
||||
@discardableResult
|
||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool
|
||||
func applyAcknowledgedMessageDeliveryStatus(
|
||||
_ messageID: String,
|
||||
status: DeliveryStatus,
|
||||
from peerIDAliases: Set<PeerID>
|
||||
) -> Bool
|
||||
func deliveryStatus(for messageID: String) -> DeliveryStatus?
|
||||
|
||||
// MARK: Verification payloads
|
||||
@@ -122,8 +127,16 @@ extension ChatViewModel: ChatTransportEventContext {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||
deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: status)
|
||||
func applyAcknowledgedMessageDeliveryStatus(
|
||||
_ messageID: String,
|
||||
status: DeliveryStatus,
|
||||
from peerIDAliases: Set<PeerID>
|
||||
) -> Bool {
|
||||
deliveryCoordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: status,
|
||||
from: peerIDAliases
|
||||
)
|
||||
}
|
||||
|
||||
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||
@@ -446,9 +459,10 @@ private extension ChatTransportEventCoordinator {
|
||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||
|
||||
let name = deliveryStatusName(for: peerID, in: context)
|
||||
let didUpdate = context.applyMessageDeliveryStatus(
|
||||
let didUpdate = context.applyAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: name, at: Date())
|
||||
status: .delivered(to: name, at: Date()),
|
||||
from: receiptPeerAliases(for: peerID, in: context)
|
||||
)
|
||||
|
||||
if !didUpdate {
|
||||
@@ -463,9 +477,10 @@ private extension ChatTransportEventCoordinator {
|
||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||
|
||||
let name = deliveryStatusName(for: peerID, in: context)
|
||||
let didUpdate = context.applyMessageDeliveryStatus(
|
||||
let didUpdate = context.applyAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .read(by: name, at: Date())
|
||||
status: .read(by: name, at: Date()),
|
||||
from: receiptPeerAliases(for: peerID, in: context)
|
||||
)
|
||||
|
||||
if !didUpdate {
|
||||
@@ -503,4 +518,21 @@ private extension ChatTransportEventCoordinator {
|
||||
func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String {
|
||||
context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func receiptPeerAliases(
|
||||
for peerID: PeerID,
|
||||
in context: any ChatTransportEventContext
|
||||
) -> Set<PeerID> {
|
||||
var aliases: Set<PeerID> = [peerID]
|
||||
// The active authenticated Noise key is authoritative. A cached
|
||||
// ephemeral→stable mapping can predate an identity replacement, so
|
||||
// use it only when the live session cannot provide its static key.
|
||||
if let keyData = context.noiseSessionPublicKeyData(for: peerID) {
|
||||
aliases.insert(PeerID(hexData: keyData))
|
||||
} else if let stablePeerID = context.cachedStablePeerID(for: peerID) {
|
||||
aliases.insert(stablePeerID)
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ protocol ChatVerificationContext: AnyObject {
|
||||
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
|
||||
func triggerHandshake(with peerID: PeerID)
|
||||
func privateMediaPeerDidAuthenticate(_ peerID: PeerID)
|
||||
/// Retries only private messages previously transmitted through a secure
|
||||
/// session and still pending an ack. Both ephemeral and stable aliases
|
||||
/// are supplied because either can own the outbox entry.
|
||||
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID])
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
@@ -121,6 +125,10 @@ extension ChatViewModel: ChatVerificationContext {
|
||||
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
|
||||
}
|
||||
|
||||
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||
messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||
}
|
||||
@@ -216,16 +224,37 @@ final class ChatVerificationCoordinator {
|
||||
|
||||
self.context.invalidateEncryptionCache(for: peerID)
|
||||
|
||||
if self.context.cachedStablePeerID(for: peerID) == nil,
|
||||
let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
|
||||
var authenticatedStablePeerID: PeerID?
|
||||
if let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
|
||||
let stablePeerID = PeerID(hexData: keyData)
|
||||
self.context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||
authenticatedStablePeerID = stablePeerID
|
||||
if self.context.cachedStablePeerID(for: peerID) != stablePeerID {
|
||||
// The freshly authenticated Noise key outranks a
|
||||
// stale announce-derived alias.
|
||||
self.context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||
}
|
||||
SecureLogger.debug(
|
||||
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
// A locally established session may have belonged to the
|
||||
// peer's previous app process. The first ciphertext sent
|
||||
// into that stale session is retained by MessageRouter;
|
||||
// retry it now that this newly authenticated/replacement
|
||||
// session can actually decrypt it.
|
||||
var peerIDAliases = [peerID]
|
||||
if let stablePeerID = authenticatedStablePeerID
|
||||
?? self.context.cachedStablePeerID(for: peerID),
|
||||
stablePeerID != peerID {
|
||||
// Conversations can migrate from the ephemeral BLE ID
|
||||
// to the authenticated Noise-key ID. Retry both aliases
|
||||
// because either may own the retained outbox entry.
|
||||
peerIDAliases.append(stablePeerID)
|
||||
}
|
||||
self.context.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
|
||||
|
||||
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
||||
self.context.sendVerifyChallenge(
|
||||
to: peerID,
|
||||
|
||||
Reference in New Issue
Block a user