mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 05:05:20 +00:00
Encrypt private media before BLE fragmentation (#1434)
Closes the last cleartext private-content path over BLE: private DM images/voice were sent as plaintext signed fileTransfer packets, TTL-relayed across the mesh, so every relay saw the full bytes. Now the complete BitchatFilePacket is encrypted as a single Noise AEAD message (inner type 0x20, matching Android) and the opaque ciphertext is fragmented. Adds an authenticated in-session capability proof (0x21 TLV: capabilities + Ed25519 key), TOFU-style downgrade pinning, a per-send consent dialog for the signed-cleartext fallback to legacy peers, and a cancellation/admission registry so cancel/delete cannot race a deferred cleartext send. Android wire constants (0x20 / 0x21 / capability bit 8) confirmed shipping. The 256-fragment preflight cap applies only to the directed fileTransfer migration fallback; encrypted media to capable peers uses the full receiver ceiling. Rebased over #1428/#1349: identity reads go through BLELocalIdentityStateStore; the session-bound authenticated signing-key check and the announce-path TOFU pin are kept as complementary checks. Full local suite green (1744+197 tests).
This commit is contained in:
@@ -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,24 @@ 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 resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
)
|
||||
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 +89,50 @@ 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 resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -152,6 +224,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] = [:]
|
||||
@@ -160,10 +233,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) {
|
||||
@@ -181,22 +258,33 @@ final class ChatMediaTransferCoordinator {
|
||||
)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
let generation = imagePreparationBarrier.currentGeneration
|
||||
// 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 barrier = imagePreparationBarrier
|
||||
let generation = barrier.currentGeneration
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
||||
do {
|
||||
let packet = try await runBlockingMediaPreparation {
|
||||
try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||
try prepareVoiceNotePacket(url)
|
||||
}
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
await MainActor.run { [weak self, barrier] in
|
||||
guard let self,
|
||||
self.imagePreparationBarrier.isCurrent(generation) else {
|
||||
barrier.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)
|
||||
}
|
||||
@@ -204,18 +292,20 @@ final class ChatMediaTransferCoordinator {
|
||||
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
await MainActor.run { [weak self, barrier] in
|
||||
guard let self,
|
||||
self.imagePreparationBarrier.isCurrent(generation) else {
|
||||
barrier.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"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
await MainActor.run { [weak self, barrier] in
|
||||
guard let self,
|
||||
self.imagePreparationBarrier.isCurrent(generation) else {
|
||||
barrier.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"))
|
||||
@@ -337,7 +427,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)
|
||||
}
|
||||
@@ -403,17 +498,127 @@ final class ChatMediaTransferCoordinator {
|
||||
return message
|
||||
}
|
||||
|
||||
private func beginPrivateMediaSend(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String
|
||||
) {
|
||||
continuePrivateMediaSend(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID,
|
||||
policy: context.privateMediaSendPolicy(to: peerID)
|
||||
)
|
||||
}
|
||||
|
||||
private func continuePrivateMediaSend(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
policy: PrivateMediaSendPolicy
|
||||
) {
|
||||
switch policy {
|
||||
case .encrypted:
|
||||
context.sendFilePrivate(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: false
|
||||
)
|
||||
|
||||
case .awaitingCapabilityProof:
|
||||
context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in
|
||||
guard let self,
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
guard resolvedPolicy != .awaitingCapabilityProof else {
|
||||
self.handleMediaSendFailure(
|
||||
messageID: messageID,
|
||||
reason: String(
|
||||
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||||
defaultValue: "Could not confirm encrypted media support",
|
||||
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
self.continuePrivateMediaSend(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID,
|
||||
policy: resolvedPolicy
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -448,6 +653,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,6 +696,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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user