mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 13:25:20 +00:00
Harden private media migration compatibility
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 true→true,
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user