mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 16:45:22 +00:00
Correlate private media delivery receipts
This commit is contained in:
@@ -154,3 +154,90 @@ struct BitchatFilePacket {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-compatible identity for private media exchanged by clients using the
|
||||
/// current iOS entropy-bearing filenames, without extending the deployed file
|
||||
/// TLV. Android clients reject unknown file tags, so eligible senders and
|
||||
/// receivers derive the receipt key from fields already on the wire.
|
||||
///
|
||||
/// Locally-created image and voice-note filenames contain a UUID or live-voice
|
||||
/// burst ID. Including the normalized direction keeps a reused filename
|
||||
/// distinct across chats while allowing short and full Noise-key peer IDs to
|
||||
/// converge. Android and older-iOS timestamp-only names remain ineligible and
|
||||
/// retain their legacy random local IDs (transfer-compatible, no receipts).
|
||||
enum PrivateMediaMessageIdentity {
|
||||
private static let domain = Data("bitchat-private-media-message-v1".utf8)
|
||||
private static let idPrefix = "media-"
|
||||
private static let digestHexLength = 32
|
||||
|
||||
static func isStableID(_ candidate: String) -> Bool {
|
||||
guard candidate.hasPrefix(idPrefix) else { return false }
|
||||
let digest = candidate.dropFirst(idPrefix.count)
|
||||
guard digest.utf8.count == digestHexLength else { return false }
|
||||
return digest.utf8.allSatisfy { byte in
|
||||
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|
||||
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
|
||||
}
|
||||
}
|
||||
|
||||
static func stableID(
|
||||
senderPeerID: PeerID,
|
||||
recipientPeerID: PeerID,
|
||||
fileName: String?
|
||||
) -> String? {
|
||||
guard let fileName, !fileName.isEmpty else { return nil }
|
||||
let leafName = (fileName as NSString).lastPathComponent
|
||||
guard leafName == fileName else { return nil }
|
||||
|
||||
let path = leafName as NSString
|
||||
let stem = path.deletingPathExtension
|
||||
let fileExtension = path.pathExtension.lowercased()
|
||||
switch true {
|
||||
case stem.hasPrefix("img_"):
|
||||
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
|
||||
case stem.hasPrefix("voice_"):
|
||||
guard fileExtension == "m4a" else { return nil }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
let entropyToken = stem.split(separator: "_").last.map(String.init)
|
||||
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
|
||||
let voiceBurstID = stem.hasPrefix("voice_")
|
||||
? String(stem.dropFirst("voice_".count))
|
||||
: ""
|
||||
let hasBurstEntropy = voiceBurstID.count == 16
|
||||
&& voiceBurstID.allSatisfy(\.isHexDigit)
|
||||
guard hasUUIDEntropy || hasBurstEntropy else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fields = [
|
||||
Data(senderPeerID.toShort().bare.utf8),
|
||||
Data(recipientPeerID.toShort().bare.utf8),
|
||||
Data(leafName.utf8)
|
||||
]
|
||||
var input = domain
|
||||
for field in fields {
|
||||
guard let length = UInt32(exactly: field.count) else { return nil }
|
||||
var bigEndianLength = length.bigEndian
|
||||
withUnsafeBytes(of: &bigEndianLength) {
|
||||
input.append(contentsOf: $0)
|
||||
}
|
||||
input.append(field)
|
||||
}
|
||||
|
||||
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
|
||||
}
|
||||
|
||||
static func stableID(
|
||||
for packet: BitchatFilePacket,
|
||||
senderPeerID: PeerID,
|
||||
recipientPeerID: PeerID
|
||||
) -> String? {
|
||||
stableID(
|
||||
senderPeerID: senderPeerID,
|
||||
recipientPeerID: recipientPeerID,
|
||||
fileName: packet.fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,17 +32,63 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
_ fallbackExtension: String?,
|
||||
_ defaultPrefix: String
|
||||
) -> URL?
|
||||
/// Checks the authenticated sender before any private-media disk work.
|
||||
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||
let updatePeerLastSeen: (PeerID) -> Void
|
||||
/// Re-acknowledges a stable private-media duplicate without saving or
|
||||
/// re-delivering it. This lets a sender recover from a lost ACK.
|
||||
let acknowledgePrivateMediaDuplicate: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
||||
let deliverMessage: (BitchatMessage) -> Void
|
||||
}
|
||||
|
||||
/// Process-lifetime reservation cache for stable private-media IDs.
|
||||
///
|
||||
/// The first arrival reserves its ID before quota enforcement and commits it
|
||||
/// only after durable save. Concurrent/retried arrivals are rejected before
|
||||
/// they can create uniquified orphan files or churn the incoming-media quota.
|
||||
private final class PrivateMediaArrivalDeduplicator {
|
||||
enum Reservation {
|
||||
case reserved
|
||||
case pending
|
||||
case accepted
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var accepted = BoundedIDSet(capacity: 4_096)
|
||||
private var pending: Set<String> = []
|
||||
|
||||
func reserve(_ messageID: String) -> Reservation {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if accepted.contains(messageID) {
|
||||
return .accepted
|
||||
}
|
||||
if pending.contains(messageID) {
|
||||
return .pending
|
||||
}
|
||||
|
||||
pending.insert(messageID)
|
||||
return .reserved
|
||||
}
|
||||
|
||||
func finish(_ messageID: String, accepted didAccept: Bool) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
pending.remove(messageID)
|
||||
if didAccept {
|
||||
accepted.insert(messageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
||||
/// resolution, delivery planning, payload validation, quota-checked storage,
|
||||
/// and UI delivery.
|
||||
final class BLEFileTransferHandler {
|
||||
private let environment: BLEFileTransferHandlerEnvironment
|
||||
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
|
||||
|
||||
init(environment: BLEFileTransferHandlerEnvironment) {
|
||||
self.environment = environment
|
||||
@@ -129,6 +175,7 @@ final class BLEFileTransferHandler {
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> Bool {
|
||||
|
||||
let localPeerID = env.localPeerID()
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||
@@ -149,6 +196,51 @@ final class BLEFileTransferHandler {
|
||||
return false
|
||||
}
|
||||
|
||||
if isPrivate, env.isPrivateMediaSenderBlocked(peerID) {
|
||||
SecureLogger.debug(
|
||||
"🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write",
|
||||
category: .security
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
let messageID = isPrivate
|
||||
? PrivateMediaMessageIdentity.stableID(
|
||||
for: filePacket,
|
||||
senderPeerID: peerID,
|
||||
recipientPeerID: localPeerID
|
||||
)
|
||||
: nil
|
||||
if let messageID {
|
||||
switch privateMediaArrivals.reserve(messageID) {
|
||||
case .reserved:
|
||||
break
|
||||
case .pending:
|
||||
// The first arrival has not reached durable storage yet.
|
||||
// Coalesce this retry without ACKing so a failed first save
|
||||
// remains retryable by the sender.
|
||||
SecureLogger.debug(
|
||||
"📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
case .accepted:
|
||||
env.updatePeerLastSeen(peerID)
|
||||
env.acknowledgePrivateMediaDuplicate(messageID, peerID)
|
||||
SecureLogger.debug(
|
||||
"📁 Ignored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
var acceptedStableMedia = false
|
||||
defer {
|
||||
if let messageID {
|
||||
privateMediaArrivals.finish(messageID, accepted: acceptedStableMedia)
|
||||
}
|
||||
}
|
||||
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
env.enforceStorageQuota(filePacket.content.count)
|
||||
|
||||
@@ -167,6 +259,7 @@ final class BLEFileTransferHandler {
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: senderNickname,
|
||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||
timestamp: timestamp,
|
||||
@@ -185,6 +278,7 @@ final class BLEFileTransferHandler {
|
||||
|
||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||
|
||||
acceptedStableMedia = messageID != nil
|
||||
env.deliverMessage(message)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2527,9 +2527,30 @@ final class BLEService: NSObject {
|
||||
defaultPrefix: defaultPrefix
|
||||
)
|
||||
},
|
||||
isPrivateMediaSenderBlocked: { [weak self] peerID in
|
||||
guard let self else { return false }
|
||||
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
||||
?? self.collectionsQueue.sync {
|
||||
self.peerRegistry.info(for: peerID)?.noisePublicKey
|
||||
}
|
||||
guard let senderStaticKey else { return false }
|
||||
return self.identityManager.isBlocked(
|
||||
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||
)
|
||||
},
|
||||
updatePeerLastSeen: { [weak self] peerID in
|
||||
self?.updatePeerLastSeen(peerID)
|
||||
},
|
||||
acknowledgePrivateMediaDuplicate: { [weak self] messageID, peerID in
|
||||
guard let self,
|
||||
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
|
||||
!self.identityManager.isBlocked(
|
||||
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.sendDeliveryAck(for: messageID, to: peerID)
|
||||
},
|
||||
deliverMessage: { [weak self] message in
|
||||
// Single main-actor hop delivering `.messageReceived`.
|
||||
self?.emitTransportEvent(.messageReceived(message))
|
||||
|
||||
@@ -10,6 +10,7 @@ import Foundation
|
||||
@MainActor
|
||||
protocol ChatLiveVoiceContext: AnyObject {
|
||||
var nickname: String { get }
|
||||
var myPeerID: PeerID { get }
|
||||
var selectedPrivateChatPeer: PeerID? { get }
|
||||
/// Whether the public mesh timeline is what's on screen (autoplay gate
|
||||
/// for public bursts).
|
||||
@@ -30,6 +31,12 @@ protocol ChatLiveVoiceContext: AnyObject {
|
||||
func upsertPublicMeshMessage(_ message: BitchatMessage)
|
||||
@discardableResult
|
||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
|
||||
/// Records and sends the finalized note's read receipt after a live
|
||||
/// bubble adopts its wire-derivable message ID.
|
||||
func hasSentReadReceipt(_ messageID: String) -> Bool
|
||||
@discardableResult
|
||||
func markReadReceiptSent(_ messageID: String) -> Bool
|
||||
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||
/// Removes a message from whichever conversation holds it.
|
||||
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
||||
/// Publishes who is currently talking live in the public mesh channel
|
||||
@@ -272,8 +279,16 @@ final class ChatLiveVoiceCoordinator {
|
||||
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
|
||||
let finished = entry.value
|
||||
|
||||
// A DM live bubble starts before the finalized file exists and
|
||||
// therefore has a receiver-local random ID. Adopt the finalized
|
||||
// message's deterministic ID so delivery/read ACKs address the same
|
||||
// row as the sender's media placeholder. Public notes retain their
|
||||
// live-bubble ID because public transfers have no private receipts.
|
||||
let replacementID = finished.scope == .directMessage
|
||||
? message.id
|
||||
: finished.messageID
|
||||
let replacement = BitchatMessage(
|
||||
id: finished.messageID,
|
||||
id: replacementID,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: finished.messageTimestamp,
|
||||
@@ -287,7 +302,31 @@ final class ChatLiveVoiceCoordinator {
|
||||
)
|
||||
switch finished.scope {
|
||||
case .directMessage:
|
||||
// Capture read state before rekeying. The user may have read the
|
||||
// live bubble and navigated away before the finalized .m4a lands.
|
||||
let shouldSendAdoptedReadReceipt =
|
||||
context.hasSentReadReceipt(finished.messageID)
|
||||
|| context.selectedPrivateChatPeer == finished.peerID
|
||||
|
||||
// Insert first so replacing the only row in a DM never
|
||||
// transiently deletes its conversation, unread state, or current
|
||||
// selection. Then remove the receiver-local live-bubble alias.
|
||||
context.upsertPrivateMessage(replacement, in: finished.peerID)
|
||||
if replacementID != finished.messageID {
|
||||
context.removePrivateMessage(withID: finished.messageID)
|
||||
}
|
||||
// The live bubble may already have emitted a receiver-local READ
|
||||
// before the sender created its finalized media row. Re-emit once
|
||||
// for the adopted stable ID now that the file has arrived.
|
||||
if shouldSendAdoptedReadReceipt,
|
||||
context.markReadReceiptSent(replacementID) {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: replacementID,
|
||||
readerID: context.myPeerID,
|
||||
readerNickname: context.nickname
|
||||
)
|
||||
context.sendMeshReadReceipt(receipt, to: finished.peerID)
|
||||
}
|
||||
case .publicMesh:
|
||||
context.upsertPublicMeshMessage(replacement)
|
||||
}
|
||||
|
||||
@@ -252,9 +252,17 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
let targetPeer = context.selectedPrivateChatPeer
|
||||
let privateMessageID = targetPeer.flatMap { peerID in
|
||||
PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: context.myPeerID,
|
||||
recipientPeerID: peerID,
|
||||
fileName: url.lastPathComponent
|
||||
)
|
||||
}
|
||||
let message = enqueueMediaMessage(
|
||||
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
|
||||
targetPeer: targetPeer
|
||||
targetPeer: targetPeer,
|
||||
messageID: privateMessageID
|
||||
)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
@@ -419,9 +427,17 @@ final class ChatMediaTransferCoordinator {
|
||||
try? FileManager.default.removeItem(at: prepared.outputURL)
|
||||
return
|
||||
}
|
||||
let privateMessageID = targetPeer.flatMap { peerID in
|
||||
PrivateMediaMessageIdentity.stableID(
|
||||
for: prepared.packet,
|
||||
senderPeerID: self.context.myPeerID,
|
||||
recipientPeerID: peerID
|
||||
)
|
||||
}
|
||||
let message = self.enqueueMediaMessage(
|
||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||
targetPeer: targetPeer
|
||||
targetPeer: targetPeer,
|
||||
messageID: privateMessageID
|
||||
)
|
||||
let messageID = message.id
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
@@ -459,12 +475,17 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
|
||||
func enqueueMediaMessage(
|
||||
content: String,
|
||||
targetPeer: PeerID?,
|
||||
messageID: String? = nil
|
||||
) -> BitchatMessage {
|
||||
let timestamp = Date()
|
||||
let message: BitchatMessage
|
||||
|
||||
if let peerID = targetPeer {
|
||||
message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: context.nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
|
||||
@@ -495,6 +495,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a read receipt has already been recorded for `messageID`.
|
||||
@MainActor
|
||||
func hasSentReadReceipt(_ messageID: String) -> Bool {
|
||||
sentReadReceipts.contains(messageID)
|
||||
}
|
||||
|
||||
/// Records that a read receipt is being sent for `messageID`.
|
||||
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||
@MainActor
|
||||
|
||||
Reference in New Issue
Block a user