Harden private media migration compatibility

This commit is contained in:
jack
2026-07-25 23:49:41 +02:00
committed by jack
parent 502692d552
commit e38373885d
31 changed files with 2703 additions and 120 deletions
+12
View File
@@ -12,6 +12,7 @@ final class ConversationUIModel: ObservableObject {
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
/// Who is talking live in the public mesh channel right now (floor
/// courtesy: the composer mic tints "busy" while someone holds the floor).
@Published private(set) var activeLiveVoiceTalker: String?
@@ -153,6 +154,13 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendVoiceNote(at: url)
}
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
chatViewModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: approved
)
}
/// Capture backend for the mic gesture: live PTT when the current DM
/// peer can hear it now, classic voice note otherwise.
func makeVoiceCaptureSession() -> VoiceCaptureSession {
@@ -193,6 +201,10 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$activeLiveVoiceTalker)
chatViewModel.$legacyPrivateMediaConsentRequest
.receive(on: DispatchQueue.main)
.assign(to: &$legacyPrivateMediaConsentRequest)
conversations.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
+6
View File
@@ -189,6 +189,12 @@ struct IdentityCache: Codable {
// Fingerprint -> when we verified it (orders outgoing vouch batches;
// entries verified before this field exists sort as oldest)
var verifiedAt: [String: Date]? = nil
// Stable Noise fingerprints that have advertised encrypted private media
// in a signature-verified announce. Optional for decoding caches written
// before this migration. Entries are monotonic until a panic wipe so an
// old/replayed announce cannot silently downgrade a peer to clear media.
var privateMediaCapableFingerprints: Set<String>? = nil
}
//
@@ -140,6 +140,10 @@ protocol SecureIdentityStateManagerProtocol {
func markVouchBatchSent(to fingerprint: String, at date: Date)
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
// MARK: Private-media downgrade protection
func markPrivateMediaCapable(fingerprint: String)
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
}
/// Singleton manager for secure identity state persistence and retrieval.
@@ -157,6 +161,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
private let queueSpecificKey = DispatchSpecificKey<UInt8>()
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
@@ -214,6 +219,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
self.encryptionKey = loadedKey
self.encryptionKeyIsEphemeral = keyIsEphemeral
queue.setSpecific(key: queueSpecificKey, value: 1)
// Only read the persisted cache when we hold the real key; with an
// ephemeral key the decrypt would fail and discard the real cache.
@@ -370,6 +376,35 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
}
}
// MARK: - Private-media downgrade protection
func markPrivateMediaCapable(fingerprint: String) {
guard !fingerprint.isEmpty else { return }
let insertAndPersist = {
var pinned = self.cache.privateMediaCapableFingerprints ?? []
guard pinned.insert(fingerprint).inserted else { return }
self.cache.privateMediaCapableFingerprints = pinned
self.saveIdentityCache()
}
// Downgrade decisions can run immediately after an authenticated
// announce. Make the pin visible before returning; merely enqueueing a
// barrier leaves a cross-queue window where a replay can look legacy.
// The queue-specific fast path prevents self-deadlock if a future
// identity-state mutation records the capability from inside `queue`.
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
insertAndPersist()
} else {
queue.sync(flags: .barrier, execute: insertAndPersist)
}
}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
guard !fingerprint.isEmpty else { return false }
return queue.sync {
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
}
}
func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) {
+1 -1
View File
@@ -30,7 +30,7 @@ struct NoisePayload {
// Safely get the first byte
let firstByte = data[data.startIndex]
guard let type = NoisePayloadType(rawValue: firstByte) else {
guard let type = NoisePayloadType.decoded(rawValue: firstByte) else {
return nil
}
+1 -1
View File
@@ -27,7 +27,7 @@ final class SecureNoiseSession: NoiseSession {
// Ordinary Noise messages keep the protocol ceiling. Finalized media
// is the sole typed-payload extension and remains under the framed-file
// cap enforced again at the service and file-decoder layers.
let isPrivateFile = plaintext.first == NoisePayloadType.privateFile.rawValue
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first)
&& NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext)
guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else {
throw NoiseSecurityError.messageTooLarge
+18 -3
View File
@@ -79,15 +79,30 @@ enum NoisePayloadType: UInt8 {
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
// Live voice (push-to-talk)
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
// Finalized private media. The complete BitchatFilePacket is encrypted
// inside Noise before the outer noiseEncrypted packet is fragmented.
case privateFile = 0x09
// Finalized private media. `0x20` is the value already deployed by the
// Android client. The complete BitchatFilePacket is encrypted inside
// Noise before the outer noiseEncrypted packet is fragmented.
case privateFile = 0x20
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
// Transitive verification (web of trust)
case vouch = 0x12 // Batch of vouch attestations
/// #1434 briefly used 0x09 before release. Accept it while prerelease
/// builds age out, but never emit it. Decoders canonicalize both values to
/// `.privateFile` so the compatibility alias cannot leak into app logic.
static let prereleasePrivateFileRawValue: UInt8 = 0x09
static func decoded(rawValue: UInt8) -> NoisePayloadType? {
rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue)
}
static func isPrivateFile(rawValue: UInt8?) -> Bool {
guard let rawValue else { return false }
return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue
}
var description: String {
switch self {
case .privateMessage: return "privateMessage"
@@ -153,7 +153,7 @@ final class BLENoisePacketHandler {
let payloadType = decrypted[0]
let payloadData = decrypted.dropFirst()
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
guard let noisePayloadType = NoisePayloadType.decoded(rawValue: payloadType) else {
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
return
}
@@ -53,6 +53,12 @@ struct BLENoiseSessionQueues {
return payloads
}
func containsTypedPayload(transferId: String) -> Bool {
typedPayloadsByPeerID.values.contains { payloads in
payloads.contains { $0.transferId == transferId }
}
}
@discardableResult
mutating func removeTypedPayload(transferId: String) -> Bool {
for peerID in Array(typedPayloadsByPeerID.keys) {
@@ -17,6 +17,9 @@ struct BLEOutboundFragmentPlan {
}
enum BLEOutboundFragmentPlanner {
/// Current Android receivers reject fragment sets above 256. Private
/// media v1 treats that deployed ceiling as a cross-platform contract.
static let privateMediaV1MaxFragments = 256
private static let minimumChunkSize = 64
private static let fragmentIDLength = 8
@@ -71,6 +74,10 @@ enum BLEOutboundFragmentPlanner {
)
}
static func isPrivateMediaV1Compatible(_ plan: BLEOutboundFragmentPlan) -> Bool {
plan.totalFragments <= privateMediaV1MaxFragments
}
private static func sizingPolicy(
for packet: BitchatPacket,
requestedMaxChunk: Int?,
+10 -2
View File
@@ -10,6 +10,9 @@ struct BLEPeerInfo: Equatable {
var isVerifiedNickname: Bool
var lastSeen: Date
var capabilities: PeerCapabilities = []
/// Distinguishes an old client that omitted the capabilities TLV from a
/// modern client that explicitly advertised a set without a given bit.
var capabilitiesWereExplicitlyAdvertised: Bool = false
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
var bridgeGeohash: String?
}
@@ -114,6 +117,10 @@ struct BLEPeerRegistry {
peers[peerID.toShort()]?.capabilities ?? []
}
func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true
}
/// Peers whose last verified announce advertised the given capability.
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
@@ -181,7 +188,7 @@ struct BLEPeerRegistry {
signingPublicKey: Data?,
isConnected: Bool,
now: Date,
capabilities: PeerCapabilities = [],
capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil
) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID]
@@ -199,7 +206,8 @@ struct BLEPeerRegistry {
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now,
capabilities: capabilities,
capabilities: capabilities ?? [],
capabilitiesWereExplicitlyAdvertised: capabilities != nil,
bridgeGeohash: bridgeGeohash
)
+752 -39
View File
@@ -7,6 +7,175 @@ import Combine
import UIKit
#endif
/// Linearizes app-private-media admission against cancellation before work is
/// handed to the fragment scheduler. A transfer starts here synchronously,
/// before its `messageQueue` work item is enqueued; cancel/delete can therefore
/// leave a tombstone that the deferred work must observe.
///
/// Active admissions and cancellation tombstones have independent count
/// bounds. Tombstones may age out or evict older tombstones; active entries
/// are never evicted under pressure. A one-hour active timeout is reported as
/// an explicit transfer failure and removes any handshake-queued payload.
private final class BLEPrivateMediaTransferAdmissionRegistry {
enum BeginResult: Equatable {
case admitted
case alreadyKnown
case capacityExhausted
}
private enum State: Equatable {
case active
case cancelled
}
private struct Entry {
var state: State
var updatedAt: Date
}
private let lock = NSLock()
private let maxActiveEntries = 512
private let maxCancelledTombstones = 512
private let lifetime: TimeInterval = 60 * 60
private let onActiveExpired: (String) -> Void
private var entries: [String: Entry] = [:]
init(onActiveExpired: @escaping (String) -> Void) {
self.onActiveExpired = onActiveExpired
}
func begin(_ transferId: String, now: Date = Date()) -> BeginResult {
guard !transferId.isEmpty else { return .alreadyKnown }
lock.lock()
let expiredActive = pruneLocked(now: now)
// Transfer IDs are invocation-unique. Never revive a cancellation or
// admit a duplicate invocation that reused an in-flight identifier.
let result: BeginResult
if entries[transferId] != nil {
result = .alreadyKnown
} else if activeCountLocked >= maxActiveEntries {
// Never evict an admitted transfer: doing so strands its UI
// placeholder with no completion event. Reject the newcomer and
// let the caller surface the bounded-pressure failure instead.
result = .capacityExhausted
} else {
entries[transferId] = Entry(state: .active, updatedAt: now)
result = .admitted
}
lock.unlock()
notifyExpired(expiredActive)
return result
}
func cancel(_ transferId: String, now: Date = Date()) {
guard !transferId.isEmpty else { return }
lock.lock()
// Cancel the requested active entry before expiry pruning so a user
// cancellation wins over a simultaneous timeout notification.
entries[transferId] = Entry(state: .cancelled, updatedAt: now)
let expiredActive = pruneLocked(now: now)
trimCancelledTombstonesLocked()
lock.unlock()
notifyExpired(expiredActive)
}
func isActive(_ transferId: String, now: Date = Date()) -> Bool {
lock.lock()
let expiredActive = pruneLocked(now: now)
let active = entries[transferId]?.state == .active
if active {
entries[transferId]?.updatedAt = now
}
lock.unlock()
notifyExpired(expiredActive)
return active
}
/// Runs `body` while holding the admission lock. Callers use this at the
/// collections-queue append/submit boundary so cancellation and admission
/// have one deterministic order: whichever acquires this lock first wins.
func withActive<Result>(
_ transferId: String,
now: Date = Date(),
_ body: () -> Result
) -> Result? {
lock.lock()
let expiredActive = pruneLocked(now: now)
guard entries[transferId]?.state == .active else {
lock.unlock()
notifyExpired(expiredActive)
return nil
}
entries[transferId]?.updatedAt = now
let result = body()
lock.unlock()
notifyExpired(expiredActive)
return result
}
func finish(_ transferId: String) {
lock.lock()
entries.removeValue(forKey: transferId)
lock.unlock()
}
var count: Int {
lock.lock()
let expiredActive = pruneLocked(now: Date())
let result = entries.count
lock.unlock()
notifyExpired(expiredActive)
return result
}
func prune(now: Date = Date()) {
lock.lock()
let expiredActive = pruneLocked(now: now)
lock.unlock()
notifyExpired(expiredActive)
}
private var activeCountLocked: Int {
entries.values.reduce(into: 0) { count, entry in
if entry.state == .active { count += 1 }
}
}
/// Removes stale tombstones silently and stale active admissions with a
/// caller-visible timeout notification. Must be called with `lock` held;
/// notifications are delivered only after the lock is released.
private func pruneLocked(now: Date) -> [String] {
var expiredActive: [String] = []
let expiredEntries = entries.filter {
now.timeIntervalSince($0.value.updatedAt) > lifetime
}
for (transferId, entry) in expiredEntries {
if entry.state == .active {
expiredActive.append(transferId)
}
entries.removeValue(forKey: transferId)
}
trimCancelledTombstonesLocked()
return expiredActive
}
private func trimCancelledTombstonesLocked() {
let cancelled = entries
.filter { $0.value.state == .cancelled }
.sorted { $0.value.updatedAt < $1.value.updatedAt }
let overflow = max(0, cancelled.count - maxCancelledTombstones)
for victim in cancelled.prefix(overflow) {
entries.removeValue(forKey: victim.key)
}
}
private func notifyExpired(_ transferIds: [String]) {
for transferId in transferIds {
onActiveExpired(transferId)
}
}
}
/// BLEService Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
@@ -108,6 +277,10 @@ final class BLEService: NSObject {
/// before it hands a packet to `messageQueue`.
var _test_beforeReceivePacketHandoff: (() -> Void)?
var _test_onReceivePacketHandoff: (() -> Void)?
var _test_onPrivateMediaSessionReconciled: ((PeerID) -> Void)?
/// May block in tests to hold the serial message queue immediately before
/// the deferred private-media admission check.
var _test_beforePrivateMediaDeferredSend: ((String) -> Void)?
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
@@ -136,6 +309,9 @@ final class BLEService: NSObject {
// 5. Fragment Reassembly (necessary for messages > MTU)
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in
self?.handlePrivateMediaAdmissionExpiry(transferId)
}
private let incomingFileStore: BLEIncomingFileStore
// Simple announce throttling
@@ -883,6 +1059,69 @@ final class BLEService: NSObject {
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
}
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
let state: (
capabilities: PeerCapabilities,
fingerprint: String?
) = collectionsQueue.sync {
let info = peerRegistry.info(for: peerID.toShort())
return (
info?.capabilities ?? [],
info?.noisePublicKey?.sha256Fingerprint()
)
}
if state.capabilities.contains(.privateMedia) {
return .encrypted
}
guard let fingerprint = 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. Session authentication is required for pinning, below,
// but old clients may need the consented fallback before a session.
return .blockedDowngrade
}
// During the mixed-version migration, an unpinned peer is legacy
// eligible whether the capabilities TLV was absent or explicitly
// omitted this bit. Only a capability observation bound to a matching
// authenticated Noise session may make the no-bit state a downgrade.
return identityManager.hasObservedPrivateMediaCapability(fingerprint: fingerprint)
? .blockedDowngrade
: .legacyRequiresConsent
}
/// Pins private-media support only when the capability-bearing registry
/// identity and a completed Noise session authenticate the same static
/// key. A signed announce alone does not prove possession of that Noise
/// key and must never create a downgrade pin.
private func reconcilePrivateMediaCapabilityPin(
for peerID: PeerID,
authenticatedFingerprint: String? = nil
) {
let normalizedPeerID = peerID.toShort()
let advertisedFingerprint: String? = collectionsQueue.sync {
guard let info = peerRegistry.info(for: normalizedPeerID),
info.capabilities.contains(.privateMedia) else { return nil }
return info.noisePublicKey?.sha256Fingerprint()
}
guard let advertisedFingerprint else { return }
let sessionFingerprint = authenticatedFingerprint
?? noiseService.getPeerFingerprint(normalizedPeerID)
guard let sessionFingerprint,
sessionFingerprint.caseInsensitiveCompare(advertisedFingerprint) == .orderedSame else {
if authenticatedFingerprint != nil {
SecureLogger.warning(
"Refusing private-media capability pin for \(normalizedPeerID.id.prefix(8))…: authenticated Noise key does not match verified announce",
category: .security
)
}
return
}
identityManager.markPrivateMediaCapable(fingerprint: advertisedFingerprint)
}
/// Enables or disables a runtime-advertised capability bit (e.g. the
/// internet-gateway toggle) and re-announces so peers learn promptly.
/// Build-time bits stay in `PeerCapabilities.localSupported`.
@@ -1010,7 +1249,28 @@ final class BLEService: NSObject {
// MARK: Messaging
private func handlePrivateMediaAdmissionExpiry(_ transferId: String) {
// Expiry can be discovered from the BLE maintenance queue or while a
// caller already owns collectionsQueue. Cleanup is therefore
// fire-and-forget; never synchronously re-enter the collections lock.
collectionsQueue.async(flags: .barrier) { [weak self] in
_ = self?.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId)
}
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.private_media_admission_expired",
defaultValue: "Media transfer timed out before it could start",
comment: "Failure reason when private-media admission expires before fragment scheduling"
)
)
}
func cancelTransfer(_ transferId: String) {
// Cancellation must become visible synchronously. Scheduler/pending-
// Noise cleanup remains asynchronous, but deferred private-media work
// cannot pass another admission boundary after this returns.
privateMediaTransferAdmissions.cancel(transferId)
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
@@ -1087,50 +1347,236 @@ final class BLEService: NSObject {
}
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
sendFilePrivate(
filePacket,
to: peerID,
transferId: transferId,
allowLegacyFallback: false
)
}
func sendFilePrivate(
_ filePacket: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
// Register before enqueueing onto messageQueue. This closes the window
// where cancel/delete could run first, observe no scheduler state, and
// then be followed by a deferred clear-media send.
switch privateMediaTransferAdmissions.begin(transferId) {
case .admitted:
break
case .alreadyKnown:
SecureLogger.debug(
"Private media admission already cancelled or duplicated for \(transferId.prefix(8))",
category: .security
)
return
case .capacityExhausted:
SecureLogger.warning(
"Private media admission capacity exhausted for \(transferId.prefix(8))",
category: .security
)
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.private_media_admission_full",
defaultValue: "Too many media transfers are waiting; try again shortly",
comment: "Failure reason when too many private-media transfers are awaiting admission"
)
)
return
}
messageQueue.async { [weak self] in
guard let self = self else { return }
guard !self.isPanicSuspended else { return }
let targetID = peerID.toShort()
let supportsPrivateMedia = self.collectionsQueue.sync {
self.peerRegistry.capabilities(for: targetID).contains(.privateMedia)
#if DEBUG
self._test_beforePrivateMediaDeferredSend?(transferId)
#endif
guard !self.isPanicSuspended else {
self.privateMediaTransferAdmissions.finish(transferId)
return
}
guard supportsPrivateMedia else {
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
self.privateMediaTransferAdmissions.finish(transferId)
return
}
let targetID = peerID.toShort()
switch self.privateMediaSendPolicy(to: targetID) {
case .encrypted:
break
case .legacyRequiresConsent:
guard allowLegacyFallback else {
SecureLogger.warning(
"Private media blocked pending explicit legacy-clear consent for \(targetID.id.prefix(8))",
category: .security
)
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.legacy_media_consent_required",
defaultValue: "Confirmation required before sending without end-to-end encryption",
comment: "Failure reason when a legacy private-media send lacks per-send consent"
)
)
self.privateMediaTransferAdmissions.finish(transferId)
return
}
// Migration path accepted by current Android and used by older
// iOS releases: preserve the directed raw file-transfer wire
// shape, but require the signature the receive path verifies.
// The allow flag belongs to this invocation only and is
// consumed here; a retry must obtain fresh user consent.
self.sendSignedLegacyPrivateFile(
filePacket,
to: targetID,
transferId: transferId
)
return
case .blockedDowngrade:
SecureLogger.warning(
"Private media not sent: \(targetID.id.prefix(8)) did not advertise encrypted-media support",
"Private media downgrade blocked for \(targetID.id.prefix(8))",
category: .security
)
TransferProgressManager.shared.rejectBeforeStart(id: transferId)
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.private_media_downgrade_blocked",
defaultValue: "Encrypted media required; ask this contact to upgrade",
comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade"
)
)
self.privateMediaTransferAdmissions.finish(transferId)
return
}
guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
TransferProgressManager.shared.rejectBeforeStart(id: transferId)
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(localized: "content.delivery.reason.media_encoding_failed", defaultValue: "Failed to prepare media", comment: "Failure reason when private media cannot be encoded")
)
self.privateMediaTransferAdmissions.finish(transferId)
return
}
guard self.noiseService.hasEstablishedSession(with: targetID) else {
self.collectionsQueue.sync(flags: .barrier) {
self.pendingNoiseSessionQueues.appendTypedPayload(
typedPayload,
transferId: transferId,
for: targetID
)
let queued = self.collectionsQueue.sync(flags: .barrier) {
self.privateMediaTransferAdmissions.withActive(transferId) {
self.pendingNoiseSessionQueues.appendTypedPayload(
typedPayload,
transferId: transferId,
for: targetID
)
return true
} ?? false
}
guard queued else {
self.privateMediaTransferAdmissions.finish(transferId)
return
}
SecureLogger.debug("📥 Queued private file for \(targetID.id.prefix(8))… pending handshake", category: .session)
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
self.collectionsQueue.sync(flags: .barrier) {
_ = self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId)
}
self.privateMediaTransferAdmissions.finish(transferId)
return
}
self.initiateNoiseHandshake(with: targetID)
return
}
do {
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
self.privateMediaTransferAdmissions.finish(transferId)
return
}
let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID)
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
self.privateMediaTransferAdmissions.finish(transferId)
return
}
SecureLogger.debug("📁 Sending encrypted private file to \(targetID.id.prefix(8))… plaintextBytes=\(typedPayload.count)", category: .session)
self.broadcastPacket(packet, transferId: transferId)
self.broadcastPacket(
packet,
transferId: transferId,
requiresPrivateMediaAdmission: true
)
} catch {
SecureLogger.error("❌ Failed to encrypt private file for \(targetID.id.prefix(8))…: \(error)", category: .security)
TransferProgressManager.shared.rejectBeforeStart(id: transferId)
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer")
)
self.privateMediaTransferAdmissions.finish(transferId)
}
}
}
/// Compatibility-only fallback for peers that have not advertised
/// encrypted private media. The payload is authenticated but visible to
/// relays, matching the pre-migration behavior until those clients upgrade.
private func sendSignedLegacyPrivateFile(
_ filePacket: BitchatFilePacket,
to targetID: PeerID,
transferId: String
) {
guard privateMediaTransferAdmissions.isActive(transferId) else {
privateMediaTransferAdmissions.finish(transferId)
return
}
guard let payload = filePacket.encode(),
let recipientData = Data(hexString: targetID.id) else {
SecureLogger.error("❌ Failed to encode legacy private file transfer", category: .session)
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(localized: "content.delivery.reason.media_encoding_failed", defaultValue: "Failed to prepare media", comment: "Failure reason when private media cannot be encoded")
)
privateMediaTransferAdmissions.finish(transferId)
return
}
let unsigned = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: myPeerIDData,
recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL,
version: 2
)
guard let signed = noiseService.signPacket(unsigned) else {
SecureLogger.error("❌ Failed to sign legacy private file transfer", category: .security)
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(localized: "content.delivery.reason.media_signing_failed", defaultValue: "Failed to authenticate media", comment: "Failure reason when a legacy private-media packet cannot be signed")
)
privateMediaTransferAdmissions.finish(transferId)
return
}
// Signing can be non-trivial; cancellation that won while it ran must
// still prevent the clear payload from reaching the broadcast path.
guard privateMediaTransferAdmissions.isActive(transferId) else {
privateMediaTransferAdmissions.finish(transferId)
return
}
SecureLogger.warning(
"📁 Sending signed legacy private file to \(targetID.id.prefix(8))…; peer has not advertised E2E media",
category: .security
)
broadcastPacket(
signed,
transferId: transferId,
requiresPrivateMediaAdmission: true
)
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Hop like sendMessage: callers are often on the main actor, and the
@@ -1251,8 +1697,26 @@ final class BLEService: NSObject {
// MARK: - Packet Broadcasting
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
guard !isPanicSuspended else { return }
private func broadcastPacket(
_ packet: BitchatPacket,
transferId: String? = nil,
requiresPrivateMediaAdmission: Bool = false
) {
guard !isPanicSuspended else {
if requiresPrivateMediaAdmission, let transferId {
privateMediaTransferAdmissions.finish(transferId)
}
return
}
if requiresPrivateMediaAdmission {
guard let transferId,
privateMediaTransferAdmissions.isActive(transferId) else {
if let transferId {
privateMediaTransferAdmissions.finish(transferId)
}
return
}
}
// Apply route if recipient exists (centralized route application)
let packetToSend: BitchatPacket
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
@@ -1261,14 +1725,75 @@ final class BLEService: NSObject {
packetToSend = packet
}
// Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
// Cross-platform private-media v1 is bounded by Android's deployed
// 256-fragment receive cap. Run the same planner the scheduler will
// use, after route application, for both encrypted and consented raw
// migration sends. Reject before reserving a transfer slot or writing
// any fragment; public media is intentionally unaffected.
if let transferId,
let recipientPeerID = PeerID(hexData: packetToSend.recipientID),
packetToSend.type == MessageType.noiseEncrypted.rawValue
|| packetToSend.type == MessageType.fileTransfer.rawValue {
let compatibilityRequest = BLEOutboundFragmentTransferRequest(
packet: packetToSend,
pad: padForBLE,
maxChunk: nil,
directedPeer: recipientPeerID,
transferId: transferId
)
guard let plan = BLEOutboundFragmentPlanner.makePlan(
for: compatibilityRequest,
defaultChunkSize: defaultFragmentSize,
bleMaxMTU: bleMaxMTU
), BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(plan) else {
SecureLogger.warning(
"Private media rejected: exceeds cross-platform 256-fragment limit",
category: .security
)
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.private_media_too_many_fragments",
defaultValue: "File is too large for this contact's client (more than 256 mesh fragments)",
comment: "Failure reason when private media exceeds the Android-compatible fragment limit"
)
)
if requiresPrivateMediaAdmission {
privateMediaTransferAdmissions.finish(transferId)
}
return
}
}
// Route planning and fragment preflight can take enough time for a
// user cancellation to win. Recheck before exposing even the test tap,
// then check atomically with scheduler admission below.
if requiresPrivateMediaAdmission {
guard let transferId,
privateMediaTransferAdmissions.isActive(transferId) else {
if let transferId {
privateMediaTransferAdmissions.finish(transferId)
}
return
}
}
#if DEBUG
_test_onOutboundPacket?(packetToSend)
#endif
// Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
if packetToSend.type == MessageType.fileTransfer.rawValue {
sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId)
sendFragmentedPacket(
packetToSend,
pad: padForBLE,
maxChunk: nil,
directedOnlyPeer: nil,
transferId: transferId,
requiresPrivateMediaAdmission: requiresPrivateMediaAdmission
)
return
}
// App-initiated private media is already one opaque Noise ciphertext.
@@ -1282,7 +1807,18 @@ final class BLEService: NSObject {
pad: padForBLE,
maxChunk: nil,
directedOnlyPeer: recipientPeerID,
transferId: transferId
transferId: transferId,
requiresPrivateMediaAdmission: requiresPrivateMediaAdmission
)
return
}
if requiresPrivateMediaAdmission {
if let transferId {
privateMediaTransferAdmissions.finish(transferId)
}
SecureLogger.error(
"Private media admission reached an unsupported non-directed packet shape",
category: .security
)
return
}
@@ -2631,18 +3167,20 @@ extension BLEService {
func _test_seedConnectedPeer(
_ peerID: PeerID,
nickname: String,
capabilities: PeerCapabilities = []
capabilities: PeerCapabilities? = nil,
noisePublicKey: Data? = nil
) {
collectionsQueue.sync(flags: .barrier) {
peerRegistry.upsert(BLEPeerInfo(
peerID: peerID,
nickname: nickname,
isConnected: true,
noisePublicKey: nil,
noisePublicKey: noisePublicKey,
signingPublicKey: nil,
isVerifiedNickname: true,
lastSeen: Date(),
capabilities: capabilities
capabilities: capabilities ?? [],
capabilitiesWereExplicitlyAdvertised: capabilities != nil
))
}
}
@@ -2657,6 +3195,90 @@ extension BLEService {
try noiseService.processHandshakeMessage(from: peerID, message: message)
}
func _test_enqueuePendingNoisePayload(
_ payload: Data,
transferId: String,
for peerID: PeerID
) {
guard privateMediaTransferAdmissions.begin(transferId) == .admitted else { return }
collectionsQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.appendTypedPayload(
payload,
transferId: transferId,
for: peerID
)
}
}
func _test_sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) {
sendPendingNoisePayloadsAfterHandshake(for: peerID)
}
func _test_privateMediaTransferState(
transferId: String
) -> (admissionActive: Bool, pendingNoise: Bool, activeScheduler: Int, pendingScheduler: Int) {
let scheduler = collectionsQueue.sync {
(
pendingNoiseSessionQueues.containsTypedPayload(transferId: transferId),
outboundFragmentTransfers.activeCount,
outboundFragmentTransfers.pendingCount
)
}
return (
privateMediaTransferAdmissions.isActive(transferId),
scheduler.0,
scheduler.1,
scheduler.2
)
}
func _test_privateMediaAdmissionEntryCount() -> Int {
privateMediaTransferAdmissions.count
}
@discardableResult
func _test_beginPrivateMediaAdmission(_ transferId: String, now: Date) -> Bool {
privateMediaTransferAdmissions.begin(transferId, now: now) == .admitted
}
func _test_isPrivateMediaAdmissionActive(_ transferId: String, now: Date) -> Bool {
privateMediaTransferAdmissions.isActive(transferId, now: now)
}
func _test_finishPrivateMediaAdmission(_ transferId: String) {
privateMediaTransferAdmissions.finish(transferId)
}
func _test_drainPrivateMediaSendPipeline() async {
let collectionsQueue = self.collectionsQueue
await withCheckedContinuation { continuation in
messageQueue.async {
collectionsQueue.async(flags: .barrier) {
continuation.resume()
}
}
}
}
func _test_broadcastPrivateMediaPacket(
_ packet: BitchatPacket,
transferId: String
) {
broadcastPacket(
packet,
transferId: transferId,
requiresPrivateMediaAdmission: true
)
}
/// Builds an authenticated-session packet from an exact typed plaintext.
/// Compatibility tests use this to model Android's deployed 0x20 file
/// payload and the short-lived 0x09 prerelease payload without exposing a
/// production API that can emit the old value.
func _test_makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
try makeEncryptedNoisePacket(typedPayload, to: peerID)
}
static func _test_shouldRediscoverBitChatService(
invalidatedServiceUUIDs: [CBUUID],
cachedServiceUUIDs: [CBUUID]?
@@ -3698,6 +4320,13 @@ extension BLEService {
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))")
self?.messageQueue.async { [weak self] in
self?.reconcilePrivateMediaCapabilityPin(
for: peerID,
authenticatedFingerprint: fingerprint
)
#if DEBUG
self?._test_onPrivateMediaSessionReconciled?(peerID)
#endif
self?.sendPendingMessagesAfterHandshake(for: peerID)
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
}
@@ -3759,7 +4388,7 @@ extension BLEService {
private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
let encrypted: Data
let isPrivateFile = typedPayload.first == NoisePayloadType.privateFile.rawValue
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
if isPrivateFile {
encrypted = try noiseService.encryptPrivateFilePayload(typedPayload, for: peerID)
} else {
@@ -4807,7 +5436,8 @@ extension BLEService {
directedOnlyPeer: PeerID? = nil,
transferId: String? = nil,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
requireNoiseAuthenticatedPeerLink: Bool = false,
requiresPrivateMediaAdmission: Bool = false
) -> Bool {
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
@@ -4819,8 +5449,34 @@ extension BLEService {
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
)
let result = collectionsQueue.sync(flags: .barrier) {
outboundFragmentTransfers.submit(request, maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers)
let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = collectionsQueue.sync(flags: .barrier) {
if requiresPrivateMediaAdmission {
guard let transferId else { return nil }
// This lock is taken while the scheduler is already protected
// by collectionsQueue. Cancellation takes the admission lock
// synchronously but never waits on collectionsQueue, avoiding
// lock inversion while giving submit/cancel one linear order.
return privateMediaTransferAdmissions.withActive(transferId) {
outboundFragmentTransfers.submit(
request,
maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers
)
}
}
return outboundFragmentTransfers.submit(
request,
maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers
)
}
guard let result else {
if let transferId, requiresPrivateMediaAdmission {
privateMediaTransferAdmissions.finish(transferId)
}
return false
}
if let transferId, requiresPrivateMediaAdmission {
// The scheduler now owns normal cancellation (active or pending).
privateMediaTransferAdmissions.finish(transferId)
}
return handleFragmentTransferSubmitResult(result)
}
@@ -4896,14 +5552,24 @@ extension BLEService {
}
}
let transferIdentifier: String? = {
guard let id = reservedTransferId else { return nil }
collectionsQueue.sync(flags: .barrier) {
_ = self.outboundFragmentTransfers.activateReservedTransfer(id: id, totalFragments: plan.totalFragments, workItems: [])
let transferIdentifier: String?
if let id = reservedTransferId {
let activated = collectionsQueue.sync(flags: .barrier) {
self.outboundFragmentTransfers.activateReservedTransfer(
id: id,
totalFragments: plan.totalFragments,
workItems: []
)
}
// Cancellation may remove the reservation between submit and plan
// construction. Treat that as cancellation, not as permission to
// schedule an untracked fragment train.
guard activated else { return false }
TransferProgressManager.shared.start(id: id, totalFragments: plan.totalFragments)
return id
}()
transferIdentifier = id
} else {
transferIdentifier = nil
}
let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in
guard let self else { return false }
@@ -5236,6 +5902,16 @@ extension BLEService {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let result = announceHandler.handle(packet, from: peerID)
// The verified announce and Noise handshake can arrive in either
// order. If authentication already completed, compare its static-key
// fingerprint with the newly upserted registry key before pinning;
// otherwise the session callback performs this reconciliation later.
if let result,
result.isVerified,
result.announcement.capabilities?.contains(.privateMedia) == true {
reconcilePrivateMediaCapabilityPin(for: result.peerID)
}
// A verified announce is the moment a signing key becomes bound to this
// owner's noise key: retry any prekey bundle that raced ahead of it.
if let result, result.isVerified {
@@ -5546,7 +6222,7 @@ extension BLEService {
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now,
capabilities: announcement.capabilities ?? [],
capabilities: announcement.capabilities,
bridgeGeohash: announcement.bridgeGeohash
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
},
@@ -5902,13 +6578,45 @@ extension BLEService {
guard !payloads.isEmpty else { return }
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session)
for pending in payloads {
let privateMediaTransferId: String? = {
guard NoisePayloadType.isPrivateFile(rawValue: pending.payload.first) else { return nil }
return pending.transferId
}()
if let transferId = privateMediaTransferId,
!privateMediaTransferAdmissions.isActive(transferId) {
privateMediaTransferAdmissions.finish(transferId)
continue
}
do {
if let transferId = privateMediaTransferId,
!privateMediaTransferAdmissions.isActive(transferId) {
privateMediaTransferAdmissions.finish(transferId)
continue
}
let packet = try makeEncryptedNoisePacket(pending.payload, to: peerID)
if let transferId = privateMediaTransferId,
!privateMediaTransferAdmissions.isActive(transferId) {
privateMediaTransferAdmissions.finish(transferId)
continue
}
broadcastPacket(
try makeEncryptedNoisePacket(pending.payload, to: peerID),
transferId: pending.transferId
packet,
transferId: pending.transferId,
requiresPrivateMediaAdmission: privateMediaTransferId != nil
)
} catch {
SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)")
if let transferId = pending.transferId {
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.encryption_failed",
defaultValue: "Failed to encrypt media",
comment: "Failure reason shown when queued private media cannot be encrypted after handshake"
)
)
privateMediaTransferAdmissions.finish(transferId)
}
}
}
}
@@ -6069,6 +6777,11 @@ extension BLEService {
private func performCleanup() {
let now = Date()
// Admission expiry is a visible transfer failure, never a silent
// eviction. The registry delivers notifications after releasing its
// lock, so this maintenance pass cannot deadlock a concurrent cancel.
privateMediaTransferAdmissions.prune(now: now)
// Clean old processed messages efficiently
messageDeduplicator.cleanup()
@@ -746,7 +746,7 @@ final class NoiseEncryptionService {
/// the bounded `BitchatFilePacket` envelope and refuses every other typed
/// payload so the larger allocation budget cannot become a generic bypass.
func encryptPrivateFilePayload(_ data: Data, for peerID: PeerID) throws -> Data {
guard data.first == NoisePayloadType.privateFile.rawValue,
guard NoisePayloadType.isPrivateFile(rawValue: data.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge
}
@@ -787,7 +787,7 @@ final class NoiseEncryptionService {
let decrypted = try sessionManager.decrypt(data, from: peerID)
if !isStandardCiphertext {
guard decrypted.first == NoisePayloadType.privateFile.rawValue,
guard NoisePayloadType.isPrivateFile(rawValue: decrypted.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else {
throw NoiseSecurityError.messageTooLarge
}
@@ -11,6 +11,7 @@ final class TransferProgressManager {
case updated(id: String, sentFragments: Int, totalFragments: Int)
case completed(id: String, totalFragments: Int)
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
case rejected(id: String, reason: String)
}
private let subject = PassthroughSubject<Event, Never>()
@@ -49,15 +50,14 @@ final class TransferProgressManager {
}
}
/// Reject a transfer before fragment scheduling (for example, when the
/// remote build did not advertise the required wire capability). Unlike
/// `cancel`, this still emits an event when no progress state exists yet,
/// allowing the UI to remove its already-created sending placeholder.
func rejectBeforeStart(id: String) {
/// Fails a preflight check while keeping the outgoing placeholder visible
/// with an actionable reason instead of treating policy/size rejection as
/// a user cancellation.
func rejectBeforeStart(id: String, reason: String) {
queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
let state = self.states.removeValue(forKey: id) ?? (sent: 0, total: 0)
self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total))
self.states.removeValue(forKey: id)
self.subject.send(.rejected(id: id, reason: reason))
}
}
+26
View File
@@ -83,6 +83,15 @@ enum TransportEvent: @unchecked Sendable {
case bluetoothStateUpdated(CBManagerState)
}
/// Downgrade-safe decision for a private-media recipient. Callers ask before
/// prompting, and BLEService checks again when it consumes any one-shot
/// legacy consent.
enum PrivateMediaSendPolicy: Equatable {
case encrypted
case legacyRequiresConsent
case blockedDowngrade
}
protocol TransportEventDelegate: AnyObject {
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
}
@@ -163,6 +172,12 @@ protocol Transport: AnyObject {
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
func cancelTransfer(_ transferId: String)
// Live voice / push-to-talk (mesh transports only): one encoded
@@ -208,6 +223,7 @@ protocol Transport: AnyObject {
/// Capabilities the peer advertised in its last verified announce;
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike
@@ -278,6 +294,7 @@ extension Transport {
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
@@ -294,6 +311,15 @@ extension Transport {
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
guard !allowLegacyFallback else { return }
sendFilePrivate(packet, to: peerID, transferId: transferId)
}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
@@ -6,6 +6,19 @@ import Foundation
import UIKit
#endif
struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable {
let id: UUID
let peerID: PeerID
let peerName: String
let transferId: String
let messageID: String
}
struct PendingLegacyPrivateMediaConsent {
let request: LegacyPrivateMediaConsentRequest
let completion: @MainActor (Bool) -> Void
}
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
@@ -43,7 +56,20 @@ protocol ChatMediaTransferContext: AnyObject {
func recordContentKey(_ key: String, timestamp: Date)
// MARK: Mesh file transfer
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
func requestLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
messageID: String,
completion: @escaping @MainActor (Bool) -> Void
)
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func cancelTransfer(_ transferId: String)
}
@@ -59,8 +85,43 @@ extension ChatViewModel: ChatMediaTransferContext {
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten mesh service accesses.
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
meshService.privateMediaSendPolicy(to: peerID)
}
func requestLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
messageID: String,
completion: @escaping @MainActor (Bool) -> Void
) {
enqueueLegacyPrivateMediaConsent(
for: peerID,
transferId: transferId,
messageID: messageID,
completion: completion
)
}
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
invalidateLegacyPrivateMediaConsent(
transferId: transferId,
messageID: messageID
)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
meshService.sendFilePrivate(
packet,
to: peerID,
transferId: transferId,
allowLegacyFallback: allowLegacyFallback
)
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
@@ -133,6 +194,7 @@ final class ChatMediaTransferCoordinator {
private unowned let context: any ChatMediaTransferContext
private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage
private let imagePreparationBarrier = ImagePreparationBarrier()
private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:]
@@ -141,10 +203,14 @@ final class ChatMediaTransferCoordinator {
context: any ChatMediaTransferContext,
prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = {
try ChatMediaPreparation.prepareImagePacket(from: $0)
},
prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = {
try ChatMediaPreparation.prepareVoiceNotePacket(at: $0)
}
) {
self.context = context
self.prepareImagePacket = prepareImagePacket
self.prepareVoiceNotePacket = prepareVoiceNotePacket
}
func sendVoiceNote(at url: URL) {
@@ -162,20 +228,30 @@ final class ChatMediaTransferCoordinator {
)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
// Own the transfer before detached preparation begins. Cancel/delete
// must be able to invalidate this exact invocation even while file I/O
// is still running off the main actor.
registerTransfer(transferId: transferId, messageID: messageID)
let prepareVoiceNotePacket = self.prepareVoiceNotePacket
let generation = imagePreparationBarrier.currentGeneration
Task.detached(priority: .userInitiated) { [weak self] in
do {
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
let packet = try prepareVoiceNotePacket(url)
await MainActor.run { [weak self] in
guard let self,
self.imagePreparationBarrier.isCurrent(generation) else {
self.imagePreparationBarrier.isCurrent(generation),
self.isRegisteredTransfer(transferId, messageID: messageID) else {
return
}
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
self.beginPrivateMediaSend(
packet,
to: peerID,
transferId: transferId,
messageID: messageID
)
} else {
self.context.sendFileBroadcast(packet, transferId: transferId)
}
@@ -185,7 +261,8 @@ final class ChatMediaTransferCoordinator {
try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in
guard let self,
self.imagePreparationBarrier.isCurrent(generation) else {
self.imagePreparationBarrier.isCurrent(generation),
self.isRegisteredTransfer(transferId, messageID: messageID) else {
return
}
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
@@ -194,7 +271,8 @@ final class ChatMediaTransferCoordinator {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self,
self.imagePreparationBarrier.isCurrent(generation) else {
self.imagePreparationBarrier.isCurrent(generation),
self.isRegisteredTransfer(transferId, messageID: messageID) else {
return
}
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
@@ -307,7 +385,12 @@ final class ChatMediaTransferCoordinator {
let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
self.beginPrivateMediaSend(
prepared.packet,
to: peerID,
transferId: transferId,
messageID: messageID
)
} else {
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
}
@@ -373,17 +456,85 @@ final class ChatMediaTransferCoordinator {
return message
}
private func beginPrivateMediaSend(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
messageID: String
) {
switch context.privateMediaSendPolicy(to: peerID) {
case .encrypted:
context.sendFilePrivate(
packet,
to: peerID,
transferId: transferId,
allowLegacyFallback: false
)
case .legacyRequiresConsent:
context.requestLegacyPrivateMediaConsent(
for: peerID,
transferId: transferId,
messageID: messageID
) { [weak self] approved in
guard let self else { return }
// Consent belongs to this exact placeholder/transfer. A late
// dialog callback after cancel/delete must never resurrect it.
guard self.messageIDToTransferId[messageID] == transferId,
self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else {
return
}
guard approved else {
self.handleMediaSendFailure(
messageID: messageID,
reason: String(
localized: "content.delivery.reason.legacy_media_declined",
defaultValue: "Not sent without end-to-end encryption",
comment: "Failure reason after declining the warning for a legacy clear private-media send"
)
)
return
}
self.context.sendFilePrivate(
packet,
to: peerID,
transferId: transferId,
allowLegacyFallback: true
)
}
case .blockedDowngrade:
handleMediaSendFailure(
messageID: messageID,
reason: String(
localized: "content.delivery.reason.private_media_downgrade_blocked",
defaultValue: "Encrypted media required; ask this contact to upgrade",
comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade"
)
)
}
}
func registerTransfer(transferId: String, messageID: String) {
transferIdToMessageIDs[transferId, default: []].append(messageID)
messageIDToTransferId[messageID] = transferId
}
private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool {
messageIDToTransferId[messageID] == transferId
&& transferIdToMessageIDs[transferId]?.contains(messageID) == true
}
func makeTransferID(messageID: String) -> String {
"\(messageID)-\(UUID().uuidString)"
}
func clearTransferMapping(for messageID: String) {
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
context.cancelLegacyPrivateMediaConsent(
transferId: transferId,
messageID: messageID
)
guard var queue = transferIdToMessageIDs[transferId] else { return }
if !queue.isEmpty {
@@ -418,6 +569,9 @@ final class ChatMediaTransferCoordinator {
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true)
case .rejected(let id, let reason):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
handleMediaSendFailure(messageID: messageID, reason: reason)
}
}
@@ -458,6 +612,13 @@ final class ChatMediaTransferCoordinator {
}
func deleteMediaMessage(messageID: String) {
// Delete is also a send cancellation. In particular, an approved
// legacy-clear send may still be waiting on BLEService.messageQueue;
// removing only the UI mapping would let that deferred work transmit.
if let transferId = messageIDToTransferId[messageID],
transferIdToMessageIDs[transferId]?.first == messageID {
context.cancelTransfer(transferId)
}
clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true)
}
+93
View File
@@ -375,6 +375,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
if Thread.isMainThread {
@@ -1268,6 +1270,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
mediaTransferCoordinator.resetForPanic()
liveVoiceCoordinator.resetForPanic()
// Deny and release any clear-media confirmations before identities,
// message state, and local files are wiped.
cancelAllLegacyPrivateMediaConsents()
// Clear all messages (public timelines and private chats live in the
// single-writer ConversationStore; the derived `messages` view and
// the legacy mirror empty with it)
@@ -1922,4 +1928,91 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.sendHapticFeedback(for: message)
}
}
@MainActor
extension ChatViewModel {
func enqueueLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
messageID: String,
completion: @escaping @MainActor (Bool) -> Void
) {
let request = LegacyPrivateMediaConsentRequest(
id: UUID(),
peerID: peerID,
peerName: nicknameForPeer(peerID),
transferId: transferId,
messageID: messageID
)
pendingLegacyPrivateMediaConsents.append(PendingLegacyPrivateMediaConsent(
request: request,
completion: completion
))
if legacyPrivateMediaConsentRequest == nil {
legacyPrivateMediaConsentRequest = request
}
}
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
// SwiftUI may report both the selected button and the presentation
// binding's dismissal. Resolve only the exact request that was shown;
// a duplicate callback for it must not consume the next queued send.
guard legacyPrivateMediaConsentRequest?.id == requestID,
pendingLegacyPrivateMediaConsents.first?.request.id == requestID else {
return
}
let resolved = pendingLegacyPrivateMediaConsents.removeFirst()
// Drive the boolean presentation state through false before showing
// the next queued per-send warning. Otherwise SwiftUI sees truetrue,
// closes the first dialog, and never presents the second.
legacyPrivateMediaConsentRequest = nil
resolved.completion(approved)
presentNextLegacyPrivateMediaConsentDeferred()
}
func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) {
let invalidatedIDs = Set(
pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in
let request = pending.request
return request.transferId == transferId && request.messageID == messageID
? request.id
: nil
}
)
guard !invalidatedIDs.isEmpty else { return }
pendingLegacyPrivateMediaConsents.removeAll {
invalidatedIDs.contains($0.request.id)
}
if let currentID = legacyPrivateMediaConsentRequest?.id,
invalidatedIDs.contains(currentID) {
legacyPrivateMediaConsentRequest = nil
presentNextLegacyPrivateMediaConsentDeferred()
}
}
func cancelAllLegacyPrivateMediaConsents() {
let pending = pendingLegacyPrivateMediaConsents
pendingLegacyPrivateMediaConsents.removeAll()
legacyPrivateMediaConsentRequest = nil
for item in pending {
item.completion(false)
}
}
private func presentNextLegacyPrivateMediaConsentDeferred() {
guard legacyPrivateMediaConsentRequest == nil,
let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else {
return
}
DispatchQueue.main.async { [weak self] in
guard let self,
self.legacyPrivateMediaConsentRequest == nil,
self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else {
return
}
self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request
}
}
}
// End of ChatViewModel class
+58
View File
@@ -36,6 +36,7 @@ struct ContentPeopleSheetView: View {
#endif
var body: some View {
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
NavigationStack {
Group {
if privateConversationModel.selectedPeerID != nil {
@@ -97,6 +98,63 @@ struct ContentPeopleSheetView: View {
}
.themedSheetBackground()
.foregroundColor(palette.primary)
.confirmationDialog(
String(
localized: "content.private_media.legacy_warning.title",
defaultValue: "Send without end-to-end encryption?",
comment: "Title warning before sending private media to an older client in a clear signed envelope"
),
isPresented: Binding(
get: { legacyConsentRequest != nil },
set: { isPresented in
if !isPresented, let requestID = legacyConsentRequest?.id {
conversationUIModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: false
)
}
}
),
titleVisibility: .visible
) {
Button(
String(
localized: "content.private_media.legacy_warning.send",
defaultValue: "send visible file",
comment: "Destructive confirmation action for one legacy clear private-media send"
),
role: .destructive
) {
if let requestID = legacyConsentRequest?.id {
conversationUIModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: true
)
}
}
Button("common.cancel", role: .cancel) {
if let requestID = legacyConsentRequest?.id {
conversationUIModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: false
)
}
}
} message: {
if let request = legacyConsentRequest {
Text(
String(
format: String(
localized: "content.private_media.legacy_warning.message",
defaultValue: "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?",
comment: "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name"
),
locale: .current,
request.peerName
)
)
}
}
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif