Persist private media delivery receipts

This commit is contained in:
jack
2026-07-26 05:41:28 +02:00
parent 405cfa611b
commit 075306e650
15 changed files with 1434 additions and 72 deletions
@@ -3,5 +3,11 @@ import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
static let localSupported: PeerCapabilities = [
.vouch,
.prekeys,
.groups,
.privateMedia,
.privateMediaReceipts
]
}
+188 -34
View File
@@ -32,54 +32,78 @@ struct BLEFileTransferHandlerEnvironment {
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> URL?
/// Resolves the durable receiver decision for a stable private-media ID.
let privateMediaReceiptState: (
_ messageID: String
) -> BLEPrivateMediaReceiptState
/// Atomically records a stable private-media ID after the payload save.
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
/// Rolls back a saved payload when its durable receipt commit fails.
let removeIncomingFile: (_ storedURL: URL) -> Void
/// 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
/// Acknowledges stable private media only after its synchronous
/// conversation delivery has completed.
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
/// Delivers `.messageReceived` as one main-actor hop while
/// `shouldDeliver` remains true before and after the synchronous sink.
/// The completion authorizes the stable-media ACK.
let deliverMessage: (
_ message: BitchatMessage,
_ shouldDeliver: @escaping () -> Bool,
_ completion: @escaping () -> Void
) -> 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.
/// The first arrival reserves its ID before quota enforcement. Concurrent
/// arrivals remain coalesced in memory, while accepted state is resolved from
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
/// if quota cleanup removed the file.
private final class PrivateMediaArrivalDeduplicator {
enum Reservation {
case reserved
case pending
case accepted
case accepted(URL)
case tombstoned
case unavailable
}
private let lock = NSLock()
private var accepted = BoundedIDSet(capacity: 4_096)
private var pending: Set<String> = []
func reserve(_ messageID: String) -> Reservation {
func reserve(
_ messageID: String,
receiptState: () -> BLEPrivateMediaReceiptState
) -> Reservation {
lock.lock()
defer { lock.unlock() }
if accepted.contains(messageID) {
return .accepted
}
if pending.contains(messageID) {
return .pending
}
switch receiptState() {
case .accepted(let existingURL):
return .accepted(existingURL)
case .tombstoned:
return .tombstoned
case .unavailable:
return .unavailable
case .absent:
break
}
pending.insert(messageID)
return .reserved
}
func finish(_ messageID: String, accepted didAccept: Bool) {
func finish(_ messageID: String) {
lock.lock()
defer { lock.unlock() }
pending.remove(messageID)
if didAccept {
accepted.insert(messageID)
}
}
}
@@ -132,6 +156,7 @@ final class BLEFileTransferHandler {
senderNickname: senderNickname,
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
isPrivate: deliveryPlan.isPrivateMessage,
usesDurableReceipts: false,
env: env
)
// Once authenticated, a local decode/quota/save failure is not proof
@@ -162,6 +187,11 @@ final class BLEFileTransferHandler {
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: true,
// Every authenticated Noise private-file keeps the stable ID/ACK
// contract introduced with capability bit 8. Bit 9 advertises
// sender-side automatic retry support; it must not downgrade
// prior iOS clients to random IDs or single-check delivery.
usesDurableReceipts: true,
env: env
)
}
@@ -172,6 +202,7 @@ final class BLEFileTransferHandler {
senderNickname: String,
timestamp: Date,
isPrivate: Bool,
usesDurableReceipts: Bool,
env: BLEFileTransferHandlerEnvironment
) -> Bool {
@@ -204,7 +235,7 @@ final class BLEFileTransferHandler {
return true
}
let messageID = isPrivate
let messageID = usesDurableReceipts
? PrivateMediaMessageIdentity.stableID(
for: filePacket,
senderPeerID: peerID,
@@ -212,7 +243,10 @@ final class BLEFileTransferHandler {
)
: nil
if let messageID {
switch privateMediaArrivals.reserve(messageID) {
switch privateMediaArrivals.reserve(
messageID,
receiptState: { env.privateMediaReceiptState(messageID) }
) {
case .reserved:
break
case .pending:
@@ -224,20 +258,55 @@ final class BLEFileTransferHandler {
category: .session
)
return true
case .accepted:
case .accepted(let existingFile):
env.updatePeerLastSeen(peerID)
env.acknowledgePrivateMediaDuplicate(messageID, peerID)
let message = incomingMessage(
messageID: messageID,
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: true,
peerID: peerID,
destination: existingFile,
category: storedMediaCategory(
for: existingFile,
fallback: mime.category
),
env: env
)
SecureLogger.debug(
"📁 Ignored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))",
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8)) -> \(existingFile.lastPathComponent)",
category: .session
)
deliverStableMessage(
message,
messageID: messageID,
peerID: peerID,
expectedURL: existingFile,
env: env
)
return true
case .tombstoned:
// Explicit deletion is a durable terminal receiver decision.
env.updatePeerLastSeen(peerID)
env.acknowledgePrivateMedia(messageID, peerID)
SecureLogger.debug(
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))",
category: .session
)
return true
case .unavailable:
// Never turn an unreadable ledger into an empty ledger. The
// sender can retry after the transient storage failure clears.
SecureLogger.warning(
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
category: .session
)
return true
}
}
var acceptedStableMedia = false
defer {
if let messageID {
privateMediaArrivals.finish(messageID, accepted: acceptedStableMedia)
privateMediaArrivals.finish(messageID)
}
}
@@ -254,14 +323,82 @@ final class BLEFileTransferHandler {
return false
}
if let messageID,
!env.commitPrivateMediaFile(messageID, destination) {
// A payload without its durable ID mapping cannot safely suppress
// a retry after relaunch. Roll it back and withhold UI/ACK.
env.removeIncomingFile(destination)
return false
}
if isPrivate {
env.updatePeerLastSeen(peerID)
}
let message = BitchatMessage(
let message = incomingMessage(
messageID: messageID,
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: isPrivate,
peerID: peerID,
destination: destination,
category: mime.category,
env: env
)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
if let messageID {
deliverStableMessage(
message,
messageID: messageID,
peerID: peerID,
expectedURL: destination,
env: env
)
} else {
env.deliverMessage(message, { true }, {})
}
return true
}
private func deliverStableMessage(
_ message: BitchatMessage,
messageID: String,
peerID: PeerID,
expectedURL: URL,
env: BLEFileTransferHandlerEnvironment
) {
env.deliverMessage(
message,
{
guard case .accepted(let resolvedURL) =
env.privateMediaReceiptState(messageID) else {
return false
}
return resolvedURL.standardizedFileURL
== expectedURL.standardizedFileURL
},
{
env.acknowledgePrivateMedia(messageID, peerID)
}
)
}
private func incomingMessage(
messageID: String?,
senderNickname: String,
timestamp: Date,
isPrivate: Bool,
peerID: PeerID,
destination: URL,
category: MimeType.Category,
env: BLEFileTransferHandlerEnvironment
) -> BitchatMessage {
BitchatMessage(
id: messageID,
sender: senderNickname,
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
timestamp: timestamp,
isRelay: false,
originalSender: nil,
@@ -269,18 +406,35 @@ final class BLEFileTransferHandler {
recipientNickname: nil,
senderPeerID: peerID,
// Received messages need an explicit status: BitchatMessage
// defaults private messages to .sending, which the media views
// render as an in-flight send (empty reveal mask, disabled tap).
// defaults private messages to .sending, which media views render
// as an in-flight send.
deliveryStatus: isPrivate
? .delivered(to: env.localNickname(), at: timestamp)
: nil
)
}
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
acceptedStableMedia = messageID != nil
env.deliverMessage(message)
return true
/// The durable URL is authoritative during reconstruction. A sender that
/// reuses a stable filename with a different MIME type must not change how
/// the already-stored payload renders.
private func storedMediaCategory(
for url: URL,
fallback: MimeType.Category
) -> MimeType.Category {
let mediaDirectory = url
.deletingLastPathComponent()
.deletingLastPathComponent()
.lastPathComponent
switch mediaDirectory {
case MimeType.Category.audio.mediaDir:
return .audio
case MimeType.Category.image.mediaDir:
return .image
case MimeType.Category.file.mediaDir:
return .file
default:
return fallback
}
}
/// Every remaining raw file transfer is signed, regardless of whether it
@@ -134,6 +134,7 @@ struct BLEIncomingFileStore {
private let baseDirectory: URL?
private let dateProvider: () -> Date
private let panicMarkerWriter: (Data, URL) throws -> Void
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
init(
fileManager: FileManager = .default,
@@ -147,6 +148,11 @@ struct BLEIncomingFileStore {
self.baseDirectory = baseDirectory
self.dateProvider = dateProvider
self.panicMarkerWriter = panicMarkerWriter
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
fileManager: fileManager,
baseDirectory: baseDirectory,
now: dateProvider
)
}
/// Panic-wipe every managed incoming and outgoing media artifact before
@@ -159,6 +165,11 @@ struct BLEIncomingFileStore {
func panicWipe(
hasDurablePendingMarker: Bool = false
) throws {
// The receipt index caches tombstones as well as accepted payloads.
// Always invalidate it on return, including partial-failure paths, so
// no pre-panic receiver decision survives after identity reset.
defer { privateMediaReceipts.resetForPanic() }
let markerError: Error?
do {
try markPanicRecoveryPending()
@@ -257,6 +268,35 @@ struct BLEIncomingFileStore {
}
}
func privateMediaReceiptState(
messageID: String
) -> BLEPrivateMediaReceiptState {
privateMediaReceipts.state(for: messageID)
}
func commitPrivateMediaFile(
messageID: String,
storedURL: URL
) -> Bool {
privateMediaReceipts.commitAccepted(
messageID: messageID,
storedURL: storedURL
)
}
/// Best-effort rollback for a payload whose durable receipt commit failed.
func removeIncomingFile(at storedURL: URL) {
guard isURLInsideFilesDirectory(storedURL) else { return }
do {
try fileManager.removeItem(at: storedURL)
} catch {
SecureLogger.warning(
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
category: .session
)
}
}
/// Frees least-recently-modified incoming files until `reservingBytes`
/// fits under the quota. Files named `voice_live_*` (in-flight live
/// captures) are never evicted regardless of who triggers enforcement
@@ -349,6 +389,13 @@ struct BLEIncomingFileStore {
]
}
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
return false
}
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
}
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
var candidate = (name ?? "")
.replacingOccurrences(of: "\0", with: "")
@@ -0,0 +1,556 @@
import BitLogger
import Foundation
enum BLEPrivateMediaReceiptState: Equatable {
/// No durable receiver decision exists for this stable message ID.
case absent
/// The payload is durably mapped to a file that still exists.
case accepted(URL)
/// The user explicitly deleted the payload; retries must not resurrect it.
case tombstoned
/// Durable state could not be read safely. Callers must fail closed and
/// must not save, deliver, or acknowledge the payload.
case unavailable
}
/// Durable, per-message receiver decisions for stable private media.
///
/// Each ID has its own atomic record so one hot lookup never rewrites or
/// decodes the entire ledger. The process-lifetime index is installed only
/// after a complete, successful directory scan. An enumeration, read, decode,
/// or structural-validation failure therefore remains retryable and cannot be
/// mistaken for an empty ledger.
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
typealias DataReader = (_ url: URL) throws -> Data
private static let receiptDirectoryName = ".private-media-receipts"
private struct ReceiptRecord: Codable, Equatable {
enum Kind: String, Codable {
case accepted
case tombstone
}
let kind: Kind
/// Path below the app's `files/` root. Absolute application-container
/// prefixes are not stable across updates, restores, or reinstalls.
let relativePath: String?
let recordedAt: Date
}
private final class Runtime: @unchecked Sendable {
let lock = NSLock()
var records: [String: ReceiptRecord]?
var volatileTombstones: [String: Date] = [:]
}
private let fileManager: FileManager
private let baseDirectory: URL?
private let capacity: Int
private let ttl: TimeInterval
private let now: () -> Date
private let directoryReader: DirectoryReader?
private let dataReader: DataReader?
private let runtime = Runtime()
init(
fileManager: FileManager = .default,
baseDirectory: URL? = nil,
capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity,
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
now: @escaping () -> Date = Date.init,
directoryReader: DirectoryReader? = nil,
dataReader: DataReader? = nil
) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.capacity = max(1, capacity)
self.ttl = max(0, ttl)
self.now = now
self.directoryReader = directoryReader
self.dataReader = dataReader
}
/// Drops process-lifetime decisions after the enclosing media directory
/// has been panic-wiped. A later lookup must rebuild from the durable
/// ledger instead of retaining an accepted receipt or tombstone whose
/// backing files no longer exist.
func resetForPanic() {
runtime.lock.lock()
runtime.records = nil
runtime.volatileTombstones.removeAll(keepingCapacity: false)
runtime.lock.unlock()
}
func state(for messageID: String) -> BLEPrivateMediaReceiptState {
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
return .absent
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
if let tombstonedAt = runtime.volatileTombstones[messageID] {
if !isExpired(tombstonedAt, at: date) {
return .tombstoned
}
runtime.volatileTombstones.removeValue(forKey: messageID)
}
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
return .unavailable
}
guard let record = records[messageID] else { return .absent }
if isExpired(record.recordedAt, at: date) {
records.removeValue(forKey: messageID)
runtime.records = records
removeRecord(messageID: messageID, from: directory)
return .absent
}
switch record.kind {
case .tombstone:
removePayloadRecordedByTombstone(record)
return .tombstoned
case .accepted:
guard let relativePath = record.relativePath,
let existingURL = existingPayload(relativePath: relativePath) else {
// Quota cleanup is not explicit deletion. Remove the stale
// receipt so a sender retry can restore the payload and bubble.
records.removeValue(forKey: messageID)
runtime.records = records
removeRecord(messageID: messageID, from: directory)
return .absent
}
return .accepted(existingURL)
}
}
/// Records an accepted ID only after the payload is on disk. Callers must
/// roll the payload back and withhold UI delivery/ACK when this returns
/// false.
func commitAccepted(messageID: String, storedURL: URL) -> Bool {
guard PrivateMediaMessageIdentity.isStableID(messageID),
validExistingPayload(storedURL) != nil,
let relativePath = relativePath(for: storedURL) else {
return false
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
if let tombstonedAt = runtime.volatileTombstones[messageID],
!isExpired(tombstonedAt, at: date) {
return false
}
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
return false
}
if let existing = records[messageID],
existing.kind == .tombstone,
!isExpired(existing.recordedAt, at: date) {
return false
}
let victim = capacityVictim(
for: .accepted,
replacing: messageID,
in: records
)
if records[messageID]?.kind != .accepted,
records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity,
victim == nil {
return false
}
let record = ReceiptRecord(
kind: .accepted,
relativePath: relativePath,
recordedAt: date
)
guard persist(record, messageID: messageID, to: directory) else {
return false
}
records[messageID] = record
if let victim, victim != messageID {
records.removeValue(forKey: victim)
removeRecord(messageID: victim, from: directory)
}
runtime.records = records
return true
}
/// Foundation for explicit media deletion. This branch does not wire the
/// chat-clear UI; it only makes a tombstone durable and fail closed.
func recordDeleted(messageID: String) -> Bool {
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
return false
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
addVolatileTombstone(messageID, at: date)
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
if let existing = records[messageID],
existing.kind == .tombstone,
!isExpired(existing.recordedAt, at: date) {
runtime.volatileTombstones.removeValue(forKey: messageID)
removePayloadRecordedByTombstone(existing)
return true
}
let victim = capacityVictim(
for: .tombstone,
replacing: messageID,
in: records
)
if records[messageID]?.kind != .tombstone,
records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity,
victim == nil {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
let tombstone = ReceiptRecord(
kind: .tombstone,
// Retain the accepted path so a crash between the atomic record
// write and payload unlink can finish cleanup after relaunch.
relativePath: records[messageID]?.relativePath,
recordedAt: date
)
guard persist(tombstone, messageID: messageID, to: directory) else {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
records[messageID] = tombstone
if let victim, victim != messageID {
records.removeValue(forKey: victim)
removeRecord(messageID: victim, from: directory)
}
runtime.records = records
runtime.volatileTombstones.removeValue(forKey: messageID)
removePayloadRecordedByTombstone(tombstone)
return true
}
private func loadIndexIfNeeded(
from directory: URL,
at date: Date
) -> [String: ReceiptRecord]? {
if let records = runtime.records {
return records
}
do {
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: nil
)
} catch {
SecureLogger.error(
"❌ Failed to create private-media receipt directory: \(error)",
category: .session
)
return nil
}
let urls: [URL]
do {
if let directoryReader {
urls = try directoryReader(directory)
} else {
urls = try fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil,
options: []
)
}
} catch {
SecureLogger.error(
"❌ Failed to enumerate private-media receipts: \(error)",
category: .session
)
return nil
}
var records: [String: ReceiptRecord] = [:]
var expired: [String] = []
var tombstones: [ReceiptRecord] = []
for url in urls {
guard url.pathExtension == "json" else { continue }
let messageID = url.deletingPathExtension().lastPathComponent
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
continue
}
let record: ReceiptRecord
do {
let data = try dataReader?(url) ?? Data(contentsOf: url)
record = try JSONDecoder().decode(ReceiptRecord.self, from: data)
} catch {
// Never delete or skip an unreadable stable-ID record. Treating
// it as absent could resurrect accepted or deleted media.
SecureLogger.error(
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
return nil
}
guard isStructurallyValid(record) else {
SecureLogger.error(
"❌ Invalid private-media receipt \(messageID.prefix(12))",
category: .session
)
return nil
}
if isExpired(record.recordedAt, at: date) {
expired.append(messageID)
continue
}
records[messageID] = record
if record.kind == .tombstone {
tombstones.append(record)
}
}
let overflow = overflowVictims(in: records)
for messageID in overflow {
records.removeValue(forKey: messageID)
}
// Install the index only after every stable-ID record was read and
// validated successfully. Cleanup cannot influence a failed scan.
runtime.records = records
for messageID in expired + overflow {
removeRecord(messageID: messageID, from: directory)
}
for tombstone in tombstones {
removePayloadRecordedByTombstone(tombstone)
}
return records
}
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
switch record.kind {
case .tombstone:
guard let relativePath = record.relativePath else { return true }
return candidatePayload(relativePath: relativePath) != nil
case .accepted:
guard let relativePath = record.relativePath else { return false }
return candidatePayload(relativePath: relativePath) != nil
}
}
private func isExpired(_ recordedAt: Date, at date: Date) -> Bool {
date.timeIntervalSince(recordedAt) > ttl
}
private func overflowVictims(
in records: [String: ReceiptRecord]
) -> [String] {
var victims: [String] = []
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
let matching = records.filter { $0.value.kind == kind }
let overflow = matching.count - capacity
guard overflow > 0 else { continue }
victims.append(contentsOf: matching.sorted { lhs, rhs in
if lhs.value.recordedAt == rhs.value.recordedAt {
return lhs.key < rhs.key
}
return lhs.value.recordedAt < rhs.value.recordedAt
}
.prefix(overflow)
.map(\.key))
}
return victims
}
/// Accepted receipts and tombstones have independent capacity. High media
/// volume cannot evict explicit deletion intent, and vice versa.
private func capacityVictim(
for incomingKind: ReceiptRecord.Kind,
replacing messageID: String,
in records: [String: ReceiptRecord]
) -> String? {
guard records[messageID]?.kind != incomingKind else { return nil }
let matching = records.filter {
$0.key != messageID && $0.value.kind == incomingKind
}
guard matching.count >= capacity else { return nil }
return matching.min { lhs, rhs in
if lhs.value.recordedAt == rhs.value.recordedAt {
return lhs.key < rhs.key
}
return lhs.value.recordedAt < rhs.value.recordedAt
}?.key
}
private func persist(
_ record: ReceiptRecord,
messageID: String,
to directory: URL
) -> Bool {
do {
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: nil
)
let data = try JSONEncoder().encode(record)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
let url = recordURL(messageID: messageID, in: directory)
try data.write(to: url, options: options)
return true
} catch {
SecureLogger.error(
"❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
return false
}
}
private func removeRecord(messageID: String, from directory: URL) {
let url = recordURL(messageID: messageID, in: directory)
guard fileManager.fileExists(atPath: url.path) else { return }
do {
try fileManager.removeItem(at: url)
} catch {
SecureLogger.warning(
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
}
}
private func recordURL(messageID: String, in directory: URL) -> URL {
directory
.appendingPathComponent(messageID, isDirectory: false)
.appendingPathExtension("json")
}
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) {
guard record.kind == .tombstone,
let relativePath = record.relativePath,
let payload = candidatePayload(relativePath: relativePath),
fileManager.fileExists(atPath: payload.path) else {
return
}
do {
try fileManager.removeItem(at: payload)
} catch {
SecureLogger.warning(
"⚠️ Failed to remove explicitly deleted private media: \(error)",
category: .session
)
}
}
private func addVolatileTombstone(_ messageID: String, at date: Date) {
runtime.volatileTombstones[messageID] = date
let overflow = runtime.volatileTombstones.count - capacity
guard overflow > 0 else { return }
let oldest = runtime.volatileTombstones.sorted {
if $0.value == $1.value { return $0.key < $1.key }
return $0.value < $1.value
}
for (oldMessageID, _) in oldest.prefix(overflow) {
runtime.volatileTombstones.removeValue(forKey: oldMessageID)
}
}
private func validExistingPayload(_ url: URL) -> URL? {
let standardized = url.standardizedFileURL
guard isInsideFilesDirectory(standardized) else { return nil }
var isDirectory: ObjCBool = false
guard fileManager.fileExists(
atPath: standardized.path,
isDirectory: &isDirectory
), !isDirectory.boolValue else {
return nil
}
return standardized
}
private func relativePath(for url: URL) -> String? {
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
return nil
}
let prefix = filesRoot.path + "/"
let standardized = url.standardizedFileURL
guard standardized.path.hasPrefix(prefix) else { return nil }
let relativePath = String(standardized.path.dropFirst(prefix.count))
return relativePath.isEmpty ? nil : relativePath
}
private func existingPayload(relativePath: String) -> URL? {
guard let candidate = candidatePayload(relativePath: relativePath) else {
return nil
}
return validExistingPayload(candidate)
}
private func candidatePayload(relativePath: String) -> URL? {
guard !relativePath.isEmpty,
let filesRoot = try? filesDirectory().standardizedFileURL else {
return nil
}
let candidate = filesRoot
.appendingPathComponent(relativePath, isDirectory: false)
.standardizedFileURL
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil }
return candidate
}
private func isInsideFilesDirectory(_ url: URL) -> Bool {
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
return false
}
return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/")
}
private func resolvedReceiptDirectory() -> URL? {
return try? filesDirectory().appendingPathComponent(
Self.receiptDirectoryName,
isDirectory: true
)
}
private func filesDirectory() throws -> URL {
let root = try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let files = root.appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(
at: files,
withIntermediateDirectories: true,
attributes: nil
)
return files
}
}
+68 -11
View File
@@ -1119,6 +1119,28 @@ final class BLEService: NSObject {
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
}
private func privateMediaPolicyFingerprint(
for peerID: PeerID,
expectedSessionGeneration: UUID?
) -> String? {
let normalizedPeerID = peerID.toShort()
if let expectedSessionGeneration,
noiseService.sessionGeneration(for: normalizedPeerID)
== expectedSessionGeneration,
let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID),
noiseService.sessionGeneration(for: normalizedPeerID)
== expectedSessionGeneration {
// The exact authenticated Noise static key is stronger than a
// registry entry populated by a public announce.
return fingerprint
}
return collectionsQueue.sync {
peerRegistry.info(for: normalizedPeerID)?
.noisePublicKey?
.sha256Fingerprint()
}
}
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
let normalizedPeerID = peerID.toShort()
let state: (
@@ -1146,7 +1168,10 @@ final class BLEService: NSObject {
return .awaitingCapabilityProof
}
guard let fingerprint = state.fingerprint else {
guard let fingerprint = privateMediaPolicyFingerprint(
for: normalizedPeerID,
expectedSessionGeneration: state.sessionGeneration
) ?? state.fingerprint else {
// A raw fallback must be bound to the stable Noise key from a
// verified registry entry; a routing ID alone can rotate or be
// spoofed. Without that key neither proof nor safe migration state
@@ -1200,11 +1225,13 @@ final class BLEService: NSObject {
return
}
let fingerprint: String? = self.collectionsQueue.sync {
self.peerRegistry.info(for: normalizedPeerID)?
.noisePublicKey?
.sha256Fingerprint()
let generation = self.collectionsQueue.sync {
self.privateMediaSessionGenerations[normalizedPeerID]
}
let fingerprint = self.privateMediaPolicyFingerprint(
for: normalizedPeerID,
expectedSessionGeneration: generation
)
guard let fingerprint else {
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
return
@@ -2527,6 +2554,20 @@ final class BLEService: NSObject {
defaultPrefix: defaultPrefix
)
},
privateMediaReceiptState: { [weak self] messageID in
self?.incomingFileStore.privateMediaReceiptState(
messageID: messageID
) ?? .unavailable
},
commitPrivateMediaFile: { [weak self] messageID, storedURL in
self?.incomingFileStore.commitPrivateMediaFile(
messageID: messageID,
storedURL: storedURL
) ?? false
},
removeIncomingFile: { [weak self] storedURL in
self?.incomingFileStore.removeIncomingFile(at: storedURL)
},
isPrivateMediaSenderBlocked: { [weak self] peerID in
guard let self else { return false }
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
@@ -2541,7 +2582,7 @@ final class BLEService: NSObject {
updatePeerLastSeen: { [weak self] peerID in
self?.updatePeerLastSeen(peerID)
},
acknowledgePrivateMediaDuplicate: { [weak self] messageID, peerID in
acknowledgePrivateMedia: { [weak self] messageID, peerID in
guard let self,
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
!self.identityManager.isBlocked(
@@ -2551,9 +2592,12 @@ final class BLEService: NSObject {
}
self.sendDeliveryAck(for: messageID, to: peerID)
},
deliverMessage: { [weak self] message in
// Single main-actor hop delivering `.messageReceived`.
self?.emitTransportEvent(.messageReceived(message))
deliverMessage: { [weak self] message, shouldDeliver, completion in
self?.emitTransportEvent(
.messageReceived(message),
shouldDeliver: shouldDeliver,
completion: completion
)
}
)
}
@@ -4270,9 +4314,22 @@ extension BLEService {
}
}
private func emitTransportEvent(_ event: TransportEvent) {
private func emitTransportEvent(
_ event: TransportEvent,
shouldDeliver: (() -> Bool)? = nil,
completion: (() -> Void)? = nil
) {
notifyUI { [weak self] in
_ = self?.deliverTransportEvent(event)
guard let self,
shouldDeliver?() ?? true,
self.deliverTransportEvent(event),
// Quota cleanup can race the asynchronous main-actor hop or
// the synchronous ConversationStore upsert. ACK only while
// the exact durable mapping and file still resolve.
shouldDeliver?() ?? true else {
return
}
completion?()
}
}
+7
View File
@@ -15,6 +15,13 @@ enum TransportConfig {
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
/// Accepted private-media receipts and explicit-deletion tombstones each
/// receive this independent capacity.
static let privateMediaReceivedLedgerCapacity: Int = 4_096
/// A bounded retry horizon prevents stable receipt state from growing into
/// permanent application history.
static let privateMediaReceivedLedgerTTLSeconds: TimeInterval =
7 * 24 * 60 * 60
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media