Harden private media migration compatibility

This commit is contained in:
jack
2026-07-12 10:09:33 -04:00
parent 7faa9d7d4a
commit 8c6b60c675
31 changed files with 2627 additions and 119 deletions
+12
View File
@@ -12,6 +12,7 @@ final class ConversationUIModel: ObservableObject {
@Published private(set) var currentNickname: String @Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false @Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true @Published private(set) var canSendMediaInCurrentContext = true
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
/// Who is talking live in the public mesh channel right now (floor /// Who is talking live in the public mesh channel right now (floor
/// courtesy: the composer mic tints "busy" while someone holds the floor). /// courtesy: the composer mic tints "busy" while someone holds the floor).
@Published private(set) var activeLiveVoiceTalker: String? @Published private(set) var activeLiveVoiceTalker: String?
@@ -153,6 +154,13 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendVoiceNote(at: url) 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 /// Capture backend for the mic gesture: live PTT when the current DM
/// peer can hear it now, classic voice note otherwise. /// peer can hear it now, classic voice note otherwise.
func makeVoiceCaptureSession() -> VoiceCaptureSession { func makeVoiceCaptureSession() -> VoiceCaptureSession {
@@ -193,6 +201,10 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: &$activeLiveVoiceTalker) .assign(to: &$activeLiveVoiceTalker)
chatViewModel.$legacyPrivateMediaConsentRequest
.receive(on: DispatchQueue.main)
.assign(to: &$legacyPrivateMediaConsentRequest)
conversations.$activeChannel conversations.$activeChannel
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] channel in .sink { [weak self] channel in
+6
View File
@@ -189,6 +189,12 @@ struct IdentityCache: Codable {
// Fingerprint -> when we verified it (orders outgoing vouch batches; // Fingerprint -> when we verified it (orders outgoing vouch batches;
// entries verified before this field exists sort as oldest) // entries verified before this field exists sort as oldest)
var verifiedAt: [String: Date]? = nil 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 markVouchBatchSent(to fingerprint: String, at date: Date)
func signingPublicKey(forFingerprint fingerprint: String) -> Data? func signingPublicKey(forFingerprint fingerprint: String) -> Data?
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] 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. /// Singleton manager for secure identity state persistence and retrieval.
@@ -157,6 +161,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// Thread safety // Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) 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`. // Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather // Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
@@ -214,6 +219,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
self.encryptionKey = loadedKey self.encryptionKey = loadedKey
self.encryptionKeyIsEphemeral = keyIsEphemeral self.encryptionKeyIsEphemeral = keyIsEphemeral
queue.setSpecific(key: queueSpecificKey, value: 1)
// Only read the persisted cache when we hold the real key; with an // Only read the persisted cache when we hold the real key; with an
// ephemeral key the decrypt would fail and discard the real cache. // 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) } 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) { func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
+1 -1
View File
@@ -30,7 +30,7 @@ struct NoisePayload {
// Safely get the first byte // Safely get the first byte
let firstByte = data[data.startIndex] let firstByte = data[data.startIndex]
guard let type = NoisePayloadType(rawValue: firstByte) else { guard let type = NoisePayloadType.decoded(rawValue: firstByte) else {
return nil return nil
} }
+1 -1
View File
@@ -27,7 +27,7 @@ final class SecureNoiseSession: NoiseSession {
// Ordinary Noise messages keep the protocol ceiling. Finalized media // Ordinary Noise messages keep the protocol ceiling. Finalized media
// is the sole typed-payload extension and remains under the framed-file // is the sole typed-payload extension and remains under the framed-file
// cap enforced again at the service and file-decoder layers. // 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) && NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext)
guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else { guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else {
throw NoiseSecurityError.messageTooLarge 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) case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
// Live voice (push-to-talk) // Live voice (push-to-talk)
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket) case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
// Finalized private media. The complete BitchatFilePacket is encrypted // Finalized private media. `0x20` is the value already deployed by the
// inside Noise before the outer noiseEncrypted packet is fragmented. // Android client. The complete BitchatFilePacket is encrypted inside
case privateFile = 0x09 // Noise before the outer noiseEncrypted packet is fragmented.
case privateFile = 0x20
// Verification (QR-based OOB binding) // Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response case verifyResponse = 0x11 // Verification response
// Transitive verification (web of trust) // Transitive verification (web of trust)
case vouch = 0x12 // Batch of vouch attestations 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 { var description: String {
switch self { switch self {
case .privateMessage: return "privateMessage" case .privateMessage: return "privateMessage"
@@ -105,7 +105,7 @@ final class BLENoisePacketHandler {
let payloadType = decrypted[0] let payloadType = decrypted[0]
let payloadData = decrypted.dropFirst() 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)") SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
return return
} }
@@ -53,6 +53,12 @@ struct BLENoiseSessionQueues {
return payloads return payloads
} }
func containsTypedPayload(transferId: String) -> Bool {
typedPayloadsByPeerID.values.contains { payloads in
payloads.contains { $0.transferId == transferId }
}
}
@discardableResult @discardableResult
mutating func removeTypedPayload(transferId: String) -> Bool { mutating func removeTypedPayload(transferId: String) -> Bool {
for peerID in Array(typedPayloadsByPeerID.keys) { for peerID in Array(typedPayloadsByPeerID.keys) {
@@ -17,6 +17,9 @@ struct BLEOutboundFragmentPlan {
} }
enum BLEOutboundFragmentPlanner { 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 minimumChunkSize = 64
private static let fragmentIDLength = 8 private static let fragmentIDLength = 8
@@ -71,6 +74,10 @@ enum BLEOutboundFragmentPlanner {
) )
} }
static func isPrivateMediaV1Compatible(_ plan: BLEOutboundFragmentPlan) -> Bool {
plan.totalFragments <= privateMediaV1MaxFragments
}
private static func sizingPolicy( private static func sizingPolicy(
for packet: BitchatPacket, for packet: BitchatPacket,
requestedMaxChunk: Int?, requestedMaxChunk: Int?,
+10 -2
View File
@@ -10,6 +10,9 @@ struct BLEPeerInfo: Equatable {
var isVerifiedNickname: Bool var isVerifiedNickname: Bool
var lastSeen: Date var lastSeen: Date
var capabilities: PeerCapabilities = [] 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`. /// Rendezvous cell from the peer's announce when it advertises `.bridge`.
var bridgeGeohash: String? var bridgeGeohash: String?
} }
@@ -114,6 +117,10 @@ struct BLEPeerRegistry {
peers[peerID.toShort()]?.capabilities ?? [] peers[peerID.toShort()]?.capabilities ?? []
} }
func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true
}
/// Peers whose last verified announce advertised the given capability. /// Peers whose last verified announce advertised the given capability.
func peers(advertising capability: PeerCapabilities) -> [PeerID] { func peers(advertising capability: PeerCapabilities) -> [PeerID] {
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID) peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
@@ -181,7 +188,7 @@ struct BLEPeerRegistry {
signingPublicKey: Data?, signingPublicKey: Data?,
isConnected: Bool, isConnected: Bool,
now: Date, now: Date,
capabilities: PeerCapabilities = [], capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil bridgeGeohash: String? = nil
) -> BLEPeerAnnounceUpdate { ) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID] let existing = peers[peerID]
@@ -199,7 +206,8 @@ struct BLEPeerRegistry {
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
isVerifiedNickname: true, isVerifiedNickname: true,
lastSeen: now, lastSeen: now,
capabilities: capabilities, capabilities: capabilities ?? [],
capabilitiesWereExplicitlyAdvertised: capabilities != nil,
bridgeGeohash: bridgeGeohash bridgeGeohash: bridgeGeohash
) )
+732 -37
View File
@@ -7,6 +7,175 @@ import Combine
import UIKit import UIKit
#endif #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 /// BLEService Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI. /// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
@@ -104,6 +273,10 @@ final class BLEService: NSObject {
// Test-only tap on the outbound pipeline so multi-node tests can ferry // Test-only tap on the outbound pipeline so multi-node tests can ferry
// packets between in-process service instances. // packets between in-process service instances.
var _test_onOutboundPacket: ((BitchatPacket) -> Void)? var _test_onOutboundPacket: ((BitchatPacket) -> 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 #endif
private var selfBroadcastTracker = BLESelfBroadcastTracker() private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker() private let meshTopology = MeshTopologyTracker()
@@ -131,6 +304,9 @@ final class BLEService: NSObject {
// 5. Fragment Reassembly (necessary for messages > MTU) // 5. Fragment Reassembly (necessary for messages > MTU)
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in
self?.handlePrivateMediaAdmissionExpiry(transferId)
}
private let incomingFileStore: BLEIncomingFileStore private let incomingFileStore: BLEIncomingFileStore
// Simple announce throttling // Simple announce throttling
@@ -734,6 +910,69 @@ final class BLEService: NSObject {
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } 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 /// Enables or disables a runtime-advertised capability bit (e.g. the
/// internet-gateway toggle) and re-announces so peers learn promptly. /// internet-gateway toggle) and re-announces so peers learn promptly.
/// Build-time bits stay in `PeerCapabilities.localSupported`. /// Build-time bits stay in `PeerCapabilities.localSupported`.
@@ -861,7 +1100,28 @@ final class BLEService: NSObject {
// MARK: Messaging // 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) { 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 collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -937,49 +1197,232 @@ final class BLEService: NSObject {
} }
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) { 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 messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let targetID = peerID.toShort() #if DEBUG
let supportsPrivateMedia = self.collectionsQueue.sync { self._test_beforePrivateMediaDeferredSend?(transferId)
self.peerRegistry.capabilities(for: targetID).contains(.privateMedia) #endif
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
self.privateMediaTransferAdmissions.finish(transferId)
return
} }
guard supportsPrivateMedia else { 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( 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 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 return
} }
guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else { guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) 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 return
} }
guard self.noiseService.hasEstablishedSession(with: targetID) else { guard self.noiseService.hasEstablishedSession(with: targetID) else {
self.collectionsQueue.sync(flags: .barrier) { let queued = self.collectionsQueue.sync(flags: .barrier) {
self.pendingNoiseSessionQueues.appendTypedPayload( self.privateMediaTransferAdmissions.withActive(transferId) {
typedPayload, self.pendingNoiseSessionQueues.appendTypedPayload(
transferId: transferId, typedPayload,
for: targetID 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) 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) self.initiateNoiseHandshake(with: targetID)
return return
} }
do { do {
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
self.privateMediaTransferAdmissions.finish(transferId)
return
}
let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID) 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) 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 { } catch {
SecureLogger.error("❌ Failed to encrypt private file for \(targetID.id.prefix(8))…: \(error)", category: .security) 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) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Hop like sendMessage: callers are often on the main actor, and the // Hop like sendMessage: callers are often on the main actor, and the
@@ -1100,7 +1543,21 @@ final class BLEService: NSObject {
// MARK: - Packet Broadcasting // MARK: - Packet Broadcasting
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) { private func broadcastPacket(
_ packet: BitchatPacket,
transferId: String? = nil,
requiresPrivateMediaAdmission: Bool = false
) {
if requiresPrivateMediaAdmission {
guard let transferId,
privateMediaTransferAdmissions.isActive(transferId) else {
if let transferId {
privateMediaTransferAdmissions.finish(transferId)
}
return
}
}
// Apply route if recipient exists (centralized route application) // Apply route if recipient exists (centralized route application)
let packetToSend: BitchatPacket let packetToSend: BitchatPacket
if let recipientPeerID = PeerID(hexData: packet.recipientID) { if let recipientPeerID = PeerID(hexData: packet.recipientID) {
@@ -1109,14 +1566,75 @@ final class BLEService: NSObject {
packetToSend = packet 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 #if DEBUG
_test_onOutboundPacket?(packetToSend) _test_onOutboundPacket?(packetToSend)
#endif #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 { 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 return
} }
// App-initiated private media is already one opaque Noise ciphertext. // App-initiated private media is already one opaque Noise ciphertext.
@@ -1130,7 +1648,18 @@ final class BLEService: NSObject {
pad: padForBLE, pad: padForBLE,
maxChunk: nil, maxChunk: nil,
directedOnlyPeer: recipientPeerID, 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 return
} }
@@ -2370,18 +2899,20 @@ extension BLEService {
func _test_seedConnectedPeer( func _test_seedConnectedPeer(
_ peerID: PeerID, _ peerID: PeerID,
nickname: String, nickname: String,
capabilities: PeerCapabilities = [] capabilities: PeerCapabilities? = nil,
noisePublicKey: Data? = nil
) { ) {
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
peerRegistry.upsert(BLEPeerInfo( peerRegistry.upsert(BLEPeerInfo(
peerID: peerID, peerID: peerID,
nickname: nickname, nickname: nickname,
isConnected: true, isConnected: true,
noisePublicKey: nil, noisePublicKey: noisePublicKey,
signingPublicKey: nil, signingPublicKey: nil,
isVerifiedNickname: true, isVerifiedNickname: true,
lastSeen: Date(), lastSeen: Date(),
capabilities: capabilities capabilities: capabilities ?? [],
capabilitiesWereExplicitlyAdvertised: capabilities != nil
)) ))
} }
} }
@@ -2396,6 +2927,79 @@ extension BLEService {
try noiseService.processHandshakeMessage(from: peerID, message: message) 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()
}
}
}
}
/// 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( static func _test_shouldRediscoverBitChatService(
invalidatedServiceUUIDs: [CBUUID], invalidatedServiceUUIDs: [CBUUID],
cachedServiceUUIDs: [CBUUID]? cachedServiceUUIDs: [CBUUID]?
@@ -3375,6 +3979,13 @@ extension BLEService {
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))") SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))")
self?.messageQueue.async { [weak self] in self?.messageQueue.async { [weak self] in
self?.reconcilePrivateMediaCapabilityPin(
for: peerID,
authenticatedFingerprint: fingerprint
)
#if DEBUG
self?._test_onPrivateMediaSessionReconciled?(peerID)
#endif
self?.sendPendingMessagesAfterHandshake(for: peerID) self?.sendPendingMessagesAfterHandshake(for: peerID)
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID) self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
} }
@@ -3436,7 +4047,7 @@ extension BLEService {
private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket { private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
let encrypted: Data let encrypted: Data
let isPrivateFile = typedPayload.first == NoisePayloadType.privateFile.rawValue let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
if isPrivateFile { if isPrivateFile {
encrypted = try noiseService.encryptPrivateFilePayload(typedPayload, for: peerID) encrypted = try noiseService.encryptPrivateFilePayload(typedPayload, for: peerID)
} else { } else {
@@ -4480,7 +5091,8 @@ extension BLEService {
directedOnlyPeer: PeerID? = nil, directedOnlyPeer: PeerID? = nil,
transferId: String? = nil, transferId: String? = nil,
requireDirectPeerLink: Bool = false, requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false requireNoiseAuthenticatedPeerLink: Bool = false,
requiresPrivateMediaAdmission: Bool = false
) -> Bool { ) -> Bool {
let request = BLEOutboundFragmentTransferRequest( let request = BLEOutboundFragmentTransferRequest(
packet: packet, packet: packet,
@@ -4492,8 +5104,34 @@ extension BLEService {
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
) )
let result = collectionsQueue.sync(flags: .barrier) { let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = collectionsQueue.sync(flags: .barrier) {
outboundFragmentTransfers.submit(request, maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers) 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) return handleFragmentTransferSubmitResult(result)
} }
@@ -4569,14 +5207,24 @@ extension BLEService {
} }
} }
let transferIdentifier: String? = { let transferIdentifier: String?
guard let id = reservedTransferId else { return nil } if let id = reservedTransferId {
collectionsQueue.sync(flags: .barrier) { let activated = collectionsQueue.sync(flags: .barrier) {
_ = self.outboundFragmentTransfers.activateReservedTransfer(id: id, totalFragments: plan.totalFragments, workItems: []) 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) TransferProgressManager.shared.start(id: id, totalFragments: plan.totalFragments)
return id transferIdentifier = id
}() } else {
transferIdentifier = nil
}
let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in
guard let self else { return false } guard let self else { return false }
@@ -4891,6 +5539,16 @@ extension BLEService {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let result = announceHandler.handle(packet, from: 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 // 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. // owner's noise key: retry any prekey bundle that raced ahead of it.
if let result, result.isVerified { if let result, result.isVerified {
@@ -5201,7 +5859,7 @@ extension BLEService {
signingPublicKey: announcement.signingPublicKey, signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected, isConnected: isConnected,
now: now, now: now,
capabilities: announcement.capabilities ?? [], capabilities: announcement.capabilities,
bridgeGeohash: announcement.bridgeGeohash bridgeGeohash: announcement.bridgeGeohash
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
}, },
@@ -5546,13 +6204,45 @@ extension BLEService {
guard !payloads.isEmpty else { return } guard !payloads.isEmpty else { return }
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session) SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session)
for pending in payloads { 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 { 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( broadcastPacket(
try makeEncryptedNoisePacket(pending.payload, to: peerID), packet,
transferId: pending.transferId transferId: pending.transferId,
requiresPrivateMediaAdmission: privateMediaTransferId != nil
) )
} catch { } catch {
SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)") 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)
}
} }
} }
} }
@@ -5713,6 +6403,11 @@ extension BLEService {
private func performCleanup() { private func performCleanup() {
let now = Date() 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 // Clean old processed messages efficiently
messageDeduplicator.cleanup() messageDeduplicator.cleanup()
@@ -731,7 +731,7 @@ final class NoiseEncryptionService {
/// the bounded `BitchatFilePacket` envelope and refuses every other typed /// the bounded `BitchatFilePacket` envelope and refuses every other typed
/// payload so the larger allocation budget cannot become a generic bypass. /// payload so the larger allocation budget cannot become a generic bypass.
func encryptPrivateFilePayload(_ data: Data, for peerID: PeerID) throws -> Data { 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 { NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
@@ -772,7 +772,7 @@ final class NoiseEncryptionService {
let decrypted = try sessionManager.decrypt(data, from: peerID) let decrypted = try sessionManager.decrypt(data, from: peerID)
if !isStandardCiphertext { if !isStandardCiphertext {
guard decrypted.first == NoisePayloadType.privateFile.rawValue, guard NoisePayloadType.isPrivateFile(rawValue: decrypted.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else { NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
@@ -11,6 +11,7 @@ final class TransferProgressManager {
case updated(id: String, sentFragments: Int, totalFragments: Int) case updated(id: String, sentFragments: Int, totalFragments: Int)
case completed(id: String, totalFragments: Int) case completed(id: String, totalFragments: Int)
case cancelled(id: String, sentFragments: Int, totalFragments: Int) case cancelled(id: String, sentFragments: Int, totalFragments: Int)
case rejected(id: String, reason: String)
} }
private let subject = PassthroughSubject<Event, Never>() private let subject = PassthroughSubject<Event, Never>()
@@ -49,15 +50,14 @@ final class TransferProgressManager {
} }
} }
/// Reject a transfer before fragment scheduling (for example, when the /// Fails a preflight check while keeping the outgoing placeholder visible
/// remote build did not advertise the required wire capability). Unlike /// with an actionable reason instead of treating policy/size rejection as
/// `cancel`, this still emits an event when no progress state exists yet, /// a user cancellation.
/// allowing the UI to remove its already-created sending placeholder. func rejectBeforeStart(id: String, reason: String) {
func rejectBeforeStart(id: String) {
queue.async(flags: .barrier) { [weak self] in queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let state = self.states.removeValue(forKey: id) ?? (sent: 0, total: 0) self.states.removeValue(forKey: id)
self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total)) self.subject.send(.rejected(id: id, reason: reason))
} }
} }
+26
View File
@@ -83,6 +83,15 @@ enum TransportEvent: @unchecked Sendable {
case bluetoothStateUpdated(CBManagerState) 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 { protocol TransportEventDelegate: AnyObject {
@MainActor func didReceiveTransportEvent(_ event: TransportEvent) @MainActor func didReceiveTransportEvent(_ event: TransportEvent)
} }
@@ -163,6 +172,12 @@ protocol Transport: AnyObject {
func sendDeliveryAck(for messageID: String, to peerID: PeerID) func sendDeliveryAck(for messageID: String, to peerID: PeerID)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, 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) func cancelTransfer(_ transferId: String)
// Live voice / push-to-talk (mesh transports only): one encoded // 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; /// Capabilities the peer advertised in its last verified announce;
/// empty for peers that predate the capabilities TLV. /// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// Sends an encoded vouch-attestation batch inside the Noise session. /// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike /// Appends a peer-authenticated observer. Unlike
@@ -278,6 +294,7 @@ extension Transport {
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {} func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {} func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
@@ -294,6 +311,15 @@ extension Transport {
func currentMeshTopology() -> MeshTopologySnapshot? { nil } func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, 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 cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
@@ -6,6 +6,19 @@ import Foundation
import UIKit import UIKit
#endif #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. /// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
/// ///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the /// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
@@ -43,7 +56,20 @@ protocol ChatMediaTransferContext: AnyObject {
func recordContentKey(_ key: String, timestamp: Date) func recordContentKey(_ key: String, timestamp: Date)
// MARK: Mesh file transfer // 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 sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func cancelTransfer(_ transferId: String) func cancelTransfer(_ transferId: String)
} }
@@ -59,8 +85,43 @@ extension ChatViewModel: ChatMediaTransferContext {
// other contexts or satisfied by existing `ChatViewModel` members. The // other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten mesh service accesses. // members below flatten mesh service accesses.
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) 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) { func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
@@ -75,12 +136,19 @@ extension ChatViewModel: ChatMediaTransferContext {
@MainActor @MainActor
final class ChatMediaTransferCoordinator { final class ChatMediaTransferCoordinator {
private unowned let context: any ChatMediaTransferContext private unowned let context: any ChatMediaTransferContext
private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket
private(set) var transferIdToMessageIDs: [String: [String]] = [:] private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:] private(set) var messageIDToTransferId: [String: String] = [:]
init(context: any ChatMediaTransferContext) { init(
context: any ChatMediaTransferContext,
prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = {
try ChatMediaPreparation.prepareVoiceNotePacket(at: $0)
}
) {
self.context = context self.context = context
self.prepareVoiceNotePacket = prepareVoiceNotePacket
} }
func sendVoiceNote(at url: URL) { func sendVoiceNote(at url: URL) {
@@ -98,16 +166,28 @@ final class ChatMediaTransferCoordinator {
) )
let messageID = message.id let messageID = message.id
let transferId = makeTransferID(messageID: messageID) 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
Task.detached(priority: .userInitiated) { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in
do { do {
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url) let packet = try prepareVoiceNotePacket(url)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self,
self.registerTransfer(transferId: transferId, messageID: messageID) self.isRegisteredTransfer(transferId, messageID: messageID) else {
return
}
if let peerID = targetPeer { if let peerID = targetPeer {
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId) self.beginPrivateMediaSend(
packet,
to: peerID,
transferId: transferId,
messageID: messageID
)
} else { } else {
self.context.sendFileBroadcast(packet, transferId: transferId) self.context.sendFileBroadcast(packet, transferId: transferId)
} }
@@ -116,13 +196,19 @@ final class ChatMediaTransferCoordinator {
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self,
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")) 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 { } catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session) SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self,
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")) 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"))
} }
} }
@@ -193,7 +279,12 @@ final class ChatMediaTransferCoordinator {
let transferId = self.makeTransferID(messageID: messageID) let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer { if let peerID = targetPeer {
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId) self.beginPrivateMediaSend(
prepared.packet,
to: peerID,
transferId: transferId,
messageID: messageID
)
} else { } else {
self.context.sendFileBroadcast(prepared.packet, transferId: transferId) self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
} }
@@ -253,17 +344,85 @@ final class ChatMediaTransferCoordinator {
return message 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) { func registerTransfer(transferId: String, messageID: String) {
transferIdToMessageIDs[transferId, default: []].append(messageID) transferIdToMessageIDs[transferId, default: []].append(messageID)
messageIDToTransferId[messageID] = transferId messageIDToTransferId[messageID] = transferId
} }
private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool {
messageIDToTransferId[messageID] == transferId
&& transferIdToMessageIDs[transferId]?.contains(messageID) == true
}
func makeTransferID(messageID: String) -> String { func makeTransferID(messageID: String) -> String {
"\(messageID)-\(UUID().uuidString)" "\(messageID)-\(UUID().uuidString)"
} }
func clearTransferMapping(for messageID: String) { func clearTransferMapping(for messageID: String) {
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return } guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
context.cancelLegacyPrivateMediaConsent(
transferId: transferId,
messageID: messageID
)
guard var queue = transferIdToMessageIDs[transferId] else { return } guard var queue = transferIdToMessageIDs[transferId] else { return }
if !queue.isEmpty { if !queue.isEmpty {
@@ -298,6 +457,9 @@ final class ChatMediaTransferCoordinator {
guard let messageID = transferIdToMessageIDs[id]?.first else { return } guard let messageID = transferIdToMessageIDs[id]?.first else { return }
clearTransferMapping(for: messageID) clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true) 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)
} }
} }
@@ -338,6 +500,13 @@ final class ChatMediaTransferCoordinator {
} }
func deleteMediaMessage(messageID: String) { 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) clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true) context.removeMessage(withID: messageID, cleanupFile: true)
} }
+93
View File
@@ -347,6 +347,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@Published var showBluetoothAlert = false @Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = "" @Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown @Published var bluetoothState: CBManagerState = .unknown
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) { private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
if Thread.isMainThread { if Thread.isMainThread {
@@ -1156,6 +1158,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func panicClearAllData() { func panicClearAllData() {
// Messages are processed immediately - nothing to flush // Messages are processed immediately - nothing to flush
// 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 // Clear all messages (public timelines and private chats live in the
// single-writer ConversationStore; the derived `messages` view and // single-writer ConversationStore; the derived `messages` view and
// the legacy mirror empty with it) // the legacy mirror empty with it)
@@ -1804,4 +1810,91 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.sendHapticFeedback(for: message) 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 // End of ChatViewModel class
+58
View File
@@ -36,6 +36,7 @@ struct ContentPeopleSheetView: View {
#endif #endif
var body: some View { var body: some View {
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
NavigationStack { NavigationStack {
Group { Group {
if privateConversationModel.selectedPeerID != nil { if privateConversationModel.selectedPeerID != nil {
@@ -97,6 +98,63 @@ struct ContentPeopleSheetView: View {
} }
.themedSheetBackground() .themedSheetBackground()
.foregroundColor(palette.primary) .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) #if os(macOS)
.frame(minWidth: 420, minHeight: 520) .frame(minWidth: 420, minHeight: 520)
#endif #endif
@@ -7,10 +7,9 @@
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / // `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars. // `ChatPrivateConversationCoordinatorContextTests` exemplars.
// //
// Scope note: the async media-preparation pipelines (`ImageUtils`, // Real file/codec work remains covered by `ChatMediaPreparationTests`. These
// `ChatMediaPreparation`) run real file/codec work and remain covered by // tests inject a paused voice-note preparer to exercise cancellation ownership
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer // across the detached-preparation/MainActor boundary deterministically.
// bookkeeping, and the blocked-context guards.
// //
import Testing import Testing
@@ -86,11 +85,64 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
// Mesh file transfer // Mesh file transfer
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = [] private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
private(set) var privateFileLegacyAllowances: [Bool] = []
private(set) var broadcastFileSends: [String] = [] private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = [] private(set) var cancelledTransfers: [String] = []
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
private(set) var legacyConsentRequests: [(
id: UUID,
peerID: PeerID,
transferId: String,
messageID: String
)] = []
private(set) var invalidatedLegacyConsents: [(transferId: String, messageID: String)] = []
private var pendingLegacyConsentIDs: [UUID] = []
private var legacyConsentCompletions: [UUID: @MainActor (Bool) -> Void] = [:]
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
privateMediaPolicy
}
func requestLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
messageID: String,
completion: @escaping @MainActor (Bool) -> Void
) {
let id = UUID()
legacyConsentRequests.append((id, peerID, transferId, messageID))
pendingLegacyConsentIDs.append(id)
legacyConsentCompletions[id] = completion
}
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
invalidatedLegacyConsents.append((transferId, messageID))
let matchingIDs = Set(legacyConsentRequests.compactMap { request in
request.transferId == transferId && request.messageID == messageID
? request.id
: nil
})
pendingLegacyConsentIDs.removeAll { matchingIDs.contains($0) }
}
func resolveNextLegacyConsent(_ approved: Bool) {
guard !pendingLegacyConsentIDs.isEmpty else { return }
let id = pendingLegacyConsentIDs.removeFirst()
legacyConsentCompletions[id]?(approved)
}
func invokeLegacyConsentEvenIfInvalidated(id: UUID, approved: Bool) {
legacyConsentCompletions[id]?(approved)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
privateFileSends.append((peerID, transferId)) privateFileSends.append((peerID, transferId))
privateFileLegacyAllowances.append(allowLegacyFallback)
} }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
@@ -102,6 +154,56 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
} }
} }
private final class PausedVoiceNotePreparer: @unchecked Sendable {
private let condition = NSCondition()
private var started = false
private var released = false
private var finished = false
private let packet: BitchatFilePacket
init() {
let content = Data("voice".utf8)
packet = BitchatFilePacket(
fileName: "paused.m4a",
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
func prepare(_: URL) throws -> BitchatFilePacket {
condition.lock()
started = true
condition.broadcast()
while !released {
condition.wait()
}
finished = true
condition.broadcast()
condition.unlock()
return packet
}
var hasStarted: Bool {
condition.lock()
defer { condition.unlock() }
return started
}
var hasFinished: Bool {
condition.lock()
defer { condition.unlock() }
return finished
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
// MARK: - Coordinator Tests Against Mock Context // MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatMediaTransferCoordinator` against /// Exercises `ChatMediaTransferCoordinator` against
@@ -166,6 +268,14 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.removedMessages.count == 1) #expect(context.removedMessages.count == 1)
#expect(context.removedMessages.first?.messageID == "m2") #expect(context.removedMessages.first?.messageID == "m2")
#expect(context.removedMessages.first?.cleanupFile == true) #expect(context.removedMessages.first?.cleanupFile == true)
// A pre-start rejection keeps the placeholder visible and failed,
// including queued post-handshake encryption failures.
coordinator.registerTransfer(transferId: "t3", messageID: "m3")
coordinator.handleTransferEvent(.rejected(id: "t3", reason: "encryption failed"))
#expect(context.deliveryStatusUpdates.last?.messageID == "m3")
#expect(context.deliveryStatusUpdates.last?.status == .failed(reason: "encryption failed"))
#expect(coordinator.messageIDToTransferId["m3"] == nil)
} }
@Test @MainActor @Test @MainActor
@@ -188,6 +298,20 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.messageIDToTransferId.isEmpty) #expect(coordinator.messageIDToTransferId.isEmpty)
} }
@Test @MainActor
func deleteMediaMessage_cancelsApprovedTransferBeforeRemovingMapping() {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
coordinator.registerTransfer(transferId: "approved-delete", messageID: "message-delete")
coordinator.deleteMediaMessage(messageID: "message-delete")
#expect(context.cancelledTransfers == ["approved-delete"])
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
#expect(context.removedMessages.map(\.messageID) == ["message-delete"])
#expect(context.removedMessages.first?.cleanupFile == true)
}
@Test @MainActor @Test @MainActor
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws { func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
let context = MockChatMediaTransferContext() let context = MockChatMediaTransferContext()
@@ -206,4 +330,224 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.appendedPublicMessages.isEmpty) #expect(context.appendedPublicMessages.isEmpty)
#expect(coordinator.transferIdToMessageIDs.isEmpty) #expect(coordinator.transferIdToMessageIDs.isEmpty)
} }
@Test @MainActor
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "5566778899aabbcc")
context.selectedPrivateChatPeer = peerID
let preparer = PausedVoiceNotePreparer()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("paused-private-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer {
preparer.release()
try? FileManager.default.removeItem(at: url)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
let messageID = try #require(context.privateChats[peerID]?.first?.id)
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
coordinator.cancelMediaSend(messageID: messageID)
preparer.release()
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
for _ in 0..<10 { await Task.yield() }
#expect(context.cancelledTransfers == [transferId])
#expect(context.privateFileSends.isEmpty)
#expect(context.broadcastFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
#expect(context.removedMessages.map(\.messageID) == [messageID])
}
@Test @MainActor
func deletePublicVoiceNoteDuringDetachedPreparationCannotBroadcastOrRestoreMapping() async throws {
let context = MockChatMediaTransferContext()
let preparer = PausedVoiceNotePreparer()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("paused-public-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer {
preparer.release()
try? FileManager.default.removeItem(at: url)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
let messageID = try #require(context.appendedPublicMessages.first?.message.id)
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
coordinator.deleteMediaMessage(messageID: messageID)
preparer.release()
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
for _ in 0..<10 { await Task.yield() }
#expect(context.cancelledTransfers == [transferId])
#expect(context.broadcastFileSends.isEmpty)
#expect(context.privateFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
#expect(context.removedMessages.map(\.messageID) == [messageID])
}
@Test @MainActor
func voicePreparationFailureMarksPlaceholderFailedAndClearsEarlyMapping() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "66778899aabbccdd")
context.selectedPrivateChatPeer = peerID
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { _ in
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 999_999)
}
)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("failing-private-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{
context.deliveryStatusUpdates.contains { update in
if case .failed = update.status { return true }
return false
}
},
timeout: TestConstants.longTimeout
))
let messageID = try #require(context.privateChats[peerID]?.first?.id)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(context.privateFileSends.isEmpty)
#expect(context.broadcastFileSends.isEmpty)
}
@Test @MainActor
func legacyPrivateVoiceNoteWaitsForPerSendConsent() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .legacyRequiresConsent
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-consent-\(UUID().uuidString).m4a")
try (Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A voice".utf8)).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let prompted = await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(prompted)
#expect(context.legacyConsentRequests.map { $0.peerID } == [peerID])
#expect(context.privateFileSends.isEmpty)
context.resolveNextLegacyConsent(true)
#expect(context.privateFileSends.count == 1)
#expect(context.privateFileLegacyAllowances == [true])
}
@Test @MainActor
func legacyConsentApprovalAfterCancelCannotSend() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "2233445566778899")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .legacyRequiresConsent
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-cancel-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let prompted = await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(prompted)
let request = try #require(context.legacyConsentRequests.first)
coordinator.cancelMediaSend(messageID: request.messageID)
#expect(context.invalidatedLegacyConsents.contains {
$0.transferId == request.transferId && $0.messageID == request.messageID
})
// Model a stale framework callback that escaped active invalidation.
// The coordinator's transfer/message binding check is the final gate.
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
#expect(context.privateFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
}
@Test @MainActor
func legacyConsentApprovalAfterDeleteCannotSend() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "33445566778899aa")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .legacyRequiresConsent
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-delete-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let prompted = await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(prompted)
let request = try #require(context.legacyConsentRequests.first)
coordinator.deleteMediaMessage(messageID: request.messageID)
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
#expect(context.invalidatedLegacyConsents.contains {
$0.transferId == request.transferId && $0.messageID == request.messageID
})
#expect(context.privateFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
}
@Test @MainActor
func pinnedPrivateMediaDowngradeNeverPromptsOrSends() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .blockedDowngrade
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("blocked-downgrade-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let failed = await TestHelpers.waitUntil(
{ context.deliveryStatusUpdates.contains { update in
if case .failed = update.status { return true }
return false
} },
timeout: TestConstants.longTimeout
)
#expect(failed)
#expect(context.legacyConsentRequests.isEmpty)
#expect(context.privateFileSends.isEmpty)
}
} }
@@ -1048,6 +1048,89 @@ struct ChatViewModelMediaTransferTests {
#expect(viewModel.transferIdToMessageIDs.count == 1) #expect(viewModel.transferIdToMessageIDs.count == 1)
} }
@Test @MainActor
func legacyPrivateMediaConsentRequestsArePerSendAndQueued() async throws {
let (viewModel, _) = makeTestableViewModel()
let firstPeer = PeerID(str: "1111111111111111")
let secondPeer = PeerID(str: "2222222222222222")
var decisions: [Bool] = []
viewModel.enqueueLegacyPrivateMediaConsent(
for: firstPeer,
transferId: "transfer-1",
messageID: "message-1"
) { decisions.append($0) }
viewModel.enqueueLegacyPrivateMediaConsent(
for: secondPeer,
transferId: "transfer-2",
messageID: "message-2"
) { decisions.append($0) }
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == firstPeer)
let firstRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
let showedSecond = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
)
#expect(showedSecond)
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
// A button action and the dialog binding may both resolve the first
// ID. The stale second callback must not consume the queued request.
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: false)
#expect(decisions == [true])
#expect(viewModel.legacyPrivateMediaConsentRequest?.id == secondRequestID)
viewModel.resolveLegacyPrivateMediaConsent(requestID: secondRequestID, approved: false)
#expect(decisions == [true, false])
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
}
@Test @MainActor
func invalidatingPresentedLegacyConsentAdvancesQueueAndStaleResolutionNoops() async throws {
let (viewModel, _) = makeTestableViewModel()
let firstPeer = PeerID(str: "3333333333333333")
let secondPeer = PeerID(str: "4444444444444444")
var decisions: [String] = []
viewModel.enqueueLegacyPrivateMediaConsent(
for: firstPeer,
transferId: "transfer-cancelled",
messageID: "message-cancelled"
) { decisions.append("first:\($0)") }
viewModel.enqueueLegacyPrivateMediaConsent(
for: secondPeer,
transferId: "transfer-kept",
messageID: "message-kept"
) { decisions.append("second:\($0)") }
let cancelledRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.invalidateLegacyPrivateMediaConsent(
transferId: "transfer-cancelled",
messageID: "message-cancelled"
)
let advanced = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
)
#expect(advanced)
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
viewModel.resolveLegacyPrivateMediaConsent(
requestID: cancelledRequestID,
approved: true
)
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer)
#expect(decisions.isEmpty)
let keptRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.resolveLegacyPrivateMediaConsent(requestID: keptRequestID, approved: true)
#expect(decisions == ["second:true"])
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
}
@Test @MainActor @Test @MainActor
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws { func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
let (viewModel, transport) = makeTestableViewModel() let (viewModel, transport) = makeTestableViewModel()
@@ -11,47 +11,521 @@ import Testing
@Suite("Private media end to end", .serialized) @Suite("Private media end to end", .serialized)
struct PrivateMediaEndToEndTests { struct PrivateMediaEndToEndTests {
@Test @Test
func peerWithoutPrivateMediaCapabilityIsRejectedBeforeWireSend() async throws { func privateMediaCancellationTombstonesAreCountBounded() async {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-tombstone-bound-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let service = makeService(baseDirectory: root)
for index in 0..<600 {
service.cancelTransfer("cancelled-before-admission-\(index)")
}
#expect(service._test_privateMediaAdmissionEntryCount() <= 512)
await service._test_drainPrivateMediaSendPipeline()
}
@Test
func privateMediaAdmissionCapacityRejectsNewcomerWithoutEvictingActiveTransfer() async {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-admission-capacity-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let service = makeService(baseDirectory: root)
let now = Date()
let activeIDs = (0..<512).map { "capacity-active-\($0)" }
for transferId in activeIDs {
#expect(service._test_beginPrivateMediaAdmission(transferId, now: now))
}
defer {
for transferId in activeIDs {
service._test_finishPrivateMediaAdmission(transferId)
}
}
let overflowID = "capacity-overflow-\(UUID().uuidString)"
let rejections = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) }
let content = Data("%PDF-1.7\ncapacity".utf8)
service.sendFilePrivate(
BitchatFilePacket(
fileName: "capacity.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
),
to: PeerID(str: "1122334455667788"),
transferId: overflowID,
allowLegacyFallback: true
)
#expect(await TestHelpers.waitUntil(
{ rejections.contains(overflowID) },
timeout: TestConstants.longTimeout
))
#expect(rejections.reason(for: overflowID) != nil)
#expect(service._test_isPrivateMediaAdmissionActive(activeIDs[0], now: now))
#expect(service._test_privateMediaAdmissionEntryCount() == 512)
_ = cancellable
}
@Test
func expiredActivePrivateMediaAdmissionEmitsVisibleFailure() async {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-admission-expiry-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let service = makeService(baseDirectory: root)
let transferId = "expired-active-\(UUID().uuidString)"
let admittedAt = Date(timeIntervalSince1970: 1_000)
let rejections = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) }
#expect(service._test_beginPrivateMediaAdmission(transferId, now: admittedAt))
#expect(!service._test_isPrivateMediaAdmissionActive(
transferId,
now: admittedAt.addingTimeInterval(60 * 60 + 1)
))
#expect(await TestHelpers.waitUntil(
{ rejections.contains(transferId) },
timeout: TestConstants.longTimeout
))
#expect(rejections.reason(for: transferId) != nil)
#expect(service._test_privateMediaAdmissionEntryCount() == 0)
_ = cancellable
}
@Test
func approvedLegacySendCancelledBeforeDeferredAdmissionDoesNotTransmit() async throws {
try await assertApprovedLegacySendCancelledBeforeAdmission(label: "cancel")
}
@Test
func approvedLegacySendDeletedBeforeDeferredAdmissionDoesNotTransmit() async throws {
// ChatMediaTransferCoordinator.deleteMediaMessage now invokes this same
// synchronous transport cancellation before removing its mapping; its
// coordinator-level call is covered separately in the context tests.
try await assertApprovedLegacySendCancelledBeforeAdmission(label: "delete")
}
@Test
func legacyFallbackRequiresPerSendConsentAndConsumesItOnce() async throws {
let root = FileManager.default.temporaryDirectory let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-capability-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("private-media-capability-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) } defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) let aliceRoot = root.appendingPathComponent("alice", isDirectory: true)
let bobRoot = root.appendingPathComponent("bob", isDirectory: true)
let alice = makeService(baseDirectory: aliceRoot)
let bob = makeService(baseDirectory: bobRoot)
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Old Bob",
noisePublicKey: bob.noiseStaticPublicKeyData()
)
let tap = PacketTap()
let delegate = MessageCaptureDelegate()
alice._test_onOutboundPacket = tap.record
bob.delegate = delegate
let content = Data("%PDF-1.7\nprivate".utf8)
let file = BitchatFilePacket(
fileName: "private.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
)
let cancellations = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { cancellations.record($0) }
let deniedID = "legacy-without-consent-\(UUID().uuidString)"
alice.sendFilePrivate(
file,
to: bob.myPeerID,
transferId: deniedID
)
let denied = await TestHelpers.waitUntil(
{ cancellations.contains(deniedID) },
timeout: TestConstants.longTimeout
)
#expect(denied)
#expect(tap.snapshot().allSatisfy { $0.type != MessageType.fileTransfer.rawValue })
let allowedID = "legacy-with-consent-\(UUID().uuidString)"
alice.sendFilePrivate(
file,
to: bob.myPeerID,
transferId: allowedID,
allowLegacyFallback: true
)
let sent = await TestHelpers.waitUntil(
{ tap.snapshot().contains { $0.type == MessageType.fileTransfer.rawValue } },
timeout: TestConstants.longTimeout
)
#expect(sent)
let outbound = tap.snapshot()
let rawTransfers = outbound.filter { $0.type == MessageType.fileTransfer.rawValue }
let raw = try #require(rawTransfers.first)
#expect(rawTransfers.count == 1, "Migration fallback must never dual-send")
#expect(outbound.allSatisfy { $0.type != MessageType.noiseEncrypted.rawValue })
#expect(raw.recipientID == Data(hexString: bob.myPeerID.toShort().id))
#expect(raw.signature?.count == 64)
#expect(BitchatFilePacket.decode(raw.payload)?.content == content)
// Exercise the normal raw receive path with Alice's actual signing
// key. The migration fallback is accepted because it is directed and
// signed; the handler still rejects unsigned/forged raw transfers.
bob._test_handlePacket(
raw,
fromPeerID: alice.myPeerID,
signingPublicKey: alice.noiseSigningPublicKeyData()
)
let delivered = await TestHelpers.waitUntil(
{ delegate.snapshot().count == 1 },
timeout: TestConstants.longTimeout
)
#expect(delivered)
#expect(delegate.snapshot().first?.isPrivate == true)
#expect(recursivelyStoredFiles(under: bobRoot).count == 1)
// Consent is invocation-scoped, not a sticky peer preference.
let retryID = "legacy-retry-without-consent-\(UUID().uuidString)"
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID)
let retryDenied = await TestHelpers.waitUntil(
{ cancellations.contains(retryID) },
timeout: TestConstants.longTimeout
)
#expect(retryDenied)
#expect(tap.snapshot().filter { $0.type == MessageType.fileTransfer.rawValue }.count == 1)
_ = cancellable
}
@Test
func authenticatedPrivateMediaCapabilityPinsAgainstRawDowngrade() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-pin-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let identity = MockIdentityManager(MockKeychain())
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
identityManager: identity
)
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
alice._test_seedConnectedPeer(bob.myPeerID, nickname: "Old Bob", capabilities: []) let bobKey = bob.noiseStaticPublicKeyData()
bob._test_seedConnectedPeer(alice.myPeerID, nickname: "Alice", capabilities: .privateMedia)
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: .privateMedia,
noisePublicKey: bobKey
)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
let bobFingerprint = bobKey.sha256Fingerprint()
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
try establishSession(alice: alice, bob: bob) try establishSession(alice: alice, bob: bob)
let capabilityPinned = await TestHelpers.waitUntil(
{ identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) },
timeout: TestConstants.longTimeout
)
#expect(capabilityPinned)
// Model a later verified old/replayed announce for the same stable
// Noise identity. The current bit disappears, but the authenticated
// persistent pin wins.
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: [],
noisePublicKey: bobKey
)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade)
let tap = PacketTap() let tap = PacketTap()
alice._test_onOutboundPacket = tap.record alice._test_onOutboundPacket = tap.record
let transferID = "unsupported-private-media-\(UUID().uuidString)" let transferID = "pinned-downgrade-\(UUID().uuidString)"
let cancellation = TransferCancellationRecorder(transferID: transferID) let cancellations = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { cancellation.record($0) } let cancellable = TransferProgressManager.shared.publisher.sink { cancellations.record($0) }
let content = Data("%PDF-1.7\nprivate".utf8) let content = Data("%PDF-1.7\nblocked".utf8)
alice.sendFilePrivate( alice.sendFilePrivate(
BitchatFilePacket( BitchatFilePacket(
fileName: "private.pdf", fileName: "blocked.pdf",
fileSize: UInt64(content.count), fileSize: UInt64(content.count),
mimeType: "application/pdf", mimeType: "application/pdf",
content: content content: content
), ),
to: bob.myPeerID, to: bob.myPeerID,
transferId: transferID transferId: transferID,
allowLegacyFallback: true
) )
let blocked = await TestHelpers.waitUntil(
{ cancellations.contains(transferID) },
timeout: TestConstants.longTimeout
)
#expect(blocked)
#expect(tap.snapshot().allSatisfy { $0.type != MessageType.fileTransfer.rawValue })
_ = cancellable
}
@Test
func unpinnedExplicitCapabilitiesWithoutPrivateMediaRequireConsent() {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-explicit-capabilities-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Modern Bob",
capabilities: [],
noisePublicKey: bob.noiseStaticPublicKeyData()
)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
}
@Test
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-poisoning-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let identity = MockIdentityManager(MockKeychain())
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
identityManager: identity
)
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
let bobFingerprint = bob.noiseStaticPublicKeyData().sha256Fingerprint()
let capableAnnounce = try signedAnnounce(
from: bob,
capabilities: .privateMedia
)
alice._test_handlePacket(
capableAnnounce,
fromPeerID: bob.myPeerID,
preseedPeer: false
)
let advertised = await TestHelpers.waitUntil(
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted },
timeout: TestConstants.longTimeout
)
#expect(advertised)
// The production signed-announce path ran, but with no authenticated
// session it must remain a no-op. Querying policy is side-effect free.
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
let noBitAnnounce = try signedAnnounce(
from: bob,
capabilities: []
)
alice._test_handlePacket(
noBitAnnounce,
fromPeerID: bob.myPeerID,
preseedPeer: false
)
let remainedLegacyEligible = await TestHelpers.waitUntil(
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent },
timeout: TestConstants.longTimeout
)
#expect(remainedLegacyEligible)
}
@Test
func authenticatedFingerprintMismatchCannotPoisonCapabilityPin() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-key-mismatch-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let identity = MockIdentityManager(MockKeychain())
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
identityManager: identity
)
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
let impostor = makeService(baseDirectory: root.appendingPathComponent("impostor", isDirectory: true))
let impostorKey = impostor.noiseStaticPublicKeyData()
let reconciliations = PeerIDRecorder()
alice._test_onPrivateMediaSessionReconciled = reconciliations.record
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: .privateMedia,
noisePublicKey: impostorKey
)
try establishSession(alice: alice, bob: bob)
let sessionReconciled = await TestHelpers.waitUntil(
{ reconciliations.contains(bob.myPeerID) },
timeout: TestConstants.longTimeout
)
#expect(sessionReconciled)
#expect(!identity.hasObservedPrivateMediaCapability(
fingerprint: impostorKey.sha256Fingerprint()
))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: [],
noisePublicKey: impostorKey
)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
}
@Test
func capabilityAnnounceAfterNoiseSessionPinsMatchingFingerprint() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-race-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let identity = MockIdentityManager(MockKeychain())
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
identityManager: identity
)
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
try establishSession(alice: alice, bob: bob)
let bobKey = bob.noiseStaticPublicKeyData()
let capableAnnounce = try signedAnnounce(
from: bob,
capabilities: .privateMedia
)
alice._test_handlePacket(
capableAnnounce,
fromPeerID: bob.myPeerID,
preseedPeer: false
)
let pinned = await TestHelpers.waitUntil(
{
identity.hasObservedPrivateMediaCapability(
fingerprint: bobKey.sha256Fingerprint()
)
},
timeout: TestConstants.longTimeout
)
#expect(pinned)
}
@Test
func encryptedAndConsentedLegacySendsRejectAboveAndroidFragmentCap() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
let oldCarol = makeService(baseDirectory: root.appendingPathComponent("carol", isDirectory: true))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: .privateMedia,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
alice._test_seedConnectedPeer(
oldCarol.myPeerID,
nickname: "Old Carol",
noisePublicKey: oldCarol.noiseStaticPublicKeyData()
)
try establishSession(alice: alice, bob: bob)
var state: UInt64 = 0x1234_5678_9ABC_DEF0
let body = Data((0..<(130 * 1024)).map { _ in
state = state &* 6364136223846793005 &+ 1442695040888963407
return UInt8(truncatingIfNeeded: state >> 32)
})
let content = Data("%PDF-1.7\n".utf8) + body
let file = BitchatFilePacket(
fileName: "too-many-fragments.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
)
let tap = PacketTap()
alice._test_onOutboundPacket = tap.record
let rejections = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) }
let encryptedID = "encrypted-over-256-\(UUID().uuidString)"
let legacyID = "legacy-over-256-\(UUID().uuidString)"
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID)
alice.sendFilePrivate(
file,
to: oldCarol.myPeerID,
transferId: legacyID,
allowLegacyFallback: true
)
let bothRejected = await TestHelpers.waitUntil(
{ rejections.contains(encryptedID) && rejections.contains(legacyID) },
timeout: TestConstants.longTimeout
)
#expect(bothRejected)
#expect(rejections.reason(for: encryptedID)?.contains("256") == true)
#expect(rejections.reason(for: legacyID)?.contains("256") == true)
#expect(tap.snapshot().isEmpty, "No outer packet or fragment may be exposed before size rejection")
_ = cancellable
}
@Test
func queuedPrivateEncryptionFailureRejectsBoundTransfer() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-queued-failure-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
try establishSession(alice: alice, bob: bob)
let transferID = "queued-encryption-failure-\(UUID().uuidString)"
let rejections = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) }
var oversizedTypedPayload = Data([NoisePayloadType.privateFile.rawValue])
oversizedTypedPayload.append(Data(
repeating: 0x42,
count: NoiseSecurityConstants.maxPrivateFilePlaintextSize
))
alice._test_enqueuePendingNoisePayload(
oversizedTypedPayload,
transferId: transferID,
for: bob.myPeerID
)
alice._test_sendPendingNoisePayloadsAfterHandshake(for: bob.myPeerID)
let rejected = await TestHelpers.waitUntil( let rejected = await TestHelpers.waitUntil(
{ cancellation.wasCancelled }, { rejections.contains(transferID) },
timeout: TestConstants.longTimeout timeout: TestConstants.longTimeout
) )
#expect(rejected) #expect(rejected)
let mediaWireTypes: Set<UInt8> = [ #expect(rejections.reason(for: transferID)?.isEmpty == false)
MessageType.noiseEncrypted.rawValue,
MessageType.fileTransfer.rawValue,
MessageType.fragment.rawValue
]
#expect(tap.snapshot().allSatisfy { !mediaWireTypes.contains($0.type) })
_ = cancellable _ = cancellable
} }
@Test
func canonical0x20EncryptedFileIsAcceptedAcrossV1OuterPacket() async throws {
let content = Data("%PDF-1.7\nandroid-private".utf8)
try await assertInboundEncryptedPrivateMedia(
typeByte: 0x20,
content: content,
outerVersion: 1,
directoryLabel: "android-0x20"
)
}
@Test
func prerelease0x09LargeEncryptedFileIsAcceptedDuringMigration() async throws {
let content = Data("%PDF-1.7\nprerelease-private".utf8)
+ Data(repeating: 0x39, count: 70 * 1024)
#expect(content.count > NoiseSecurityConstants.maxMessageSize)
try await assertInboundEncryptedPrivateMedia(
typeByte: NoisePayloadType.prereleasePrivateFileRawValue,
content: content,
outerVersion: 2,
directoryLabel: "prerelease-0x09"
)
}
@Test @Test
func privateJPEGIsOpaqueBeforeFragmentationAndDelivers() async throws { func privateJPEGIsOpaqueBeforeFragmentationAndDelivers() async throws {
let marker = Data("JPEG_PRIVATE_MARKER_7f5e5eacb86f4b9a".utf8) let marker = Data("JPEG_PRIVATE_MARKER_7f5e5eacb86f4b9a".utf8)
@@ -84,11 +558,18 @@ struct PrivateMediaEndToEndTests {
} }
@Test @Test
func privatePDFIsOpaqueBeforeFragmentationAndDelivers() async throws { func capablePeerUsesCanonicalAndroid0x20EncryptedSend() async throws {
let marker = Data("PDF_PRIVATE_MARKER_b333f84b8fc7478d".utf8) let marker = Data("PDF_PRIVATE_MARKER_b333f84b8fc7478d".utf8)
let content = Data("%PDF-1.7\n".utf8) let content = Data("%PDF-1.7\n".utf8)
+ marker + marker
+ Data(repeating: 0x50, count: 6 * 1024) + Data(repeating: 0x50, count: 6 * 1024)
let file = BitchatFilePacket(
fileName: "private.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
)
#expect(BLENoisePayloadFactory.privateFile(file)?.first == 0x20)
try await assertPrivateMediaRoundTrip( try await assertPrivateMediaRoundTrip(
fileName: "private.pdf", fileName: "private.pdf",
mimeType: "application/pdf", mimeType: "application/pdf",
@@ -114,6 +595,129 @@ struct PrivateMediaEndToEndTests {
) )
} }
/// Models an already-established remote sender independently of the local
/// send policy. Exact Android b7f0b33d plaintext bytes are frozen in
/// `BLENoisePayloadFactoryTests`; this helper exercises the encrypted
/// inbound transport around that shared wire encoding.
private func assertInboundEncryptedPrivateMedia(
typeByte: UInt8,
content: Data,
outerVersion: UInt8,
directoryLabel: String
) async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-\(directoryLabel)-\(UUID().uuidString)", isDirectory: true)
let aliceRoot = root.appendingPathComponent("alice", isDirectory: true)
let bobRoot = root.appendingPathComponent("bob", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: aliceRoot)
let bob = makeService(baseDirectory: bobRoot)
let delegate = MessageCaptureDelegate()
bob.delegate = delegate
try establishSession(alice: alice, bob: bob)
let file = BitchatFilePacket(
fileName: "\(directoryLabel).pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
)
let encodedFile = try #require(file.encode())
var typedPayload = Data([typeByte])
typedPayload.append(encodedFile)
let encrypted = try alice._test_makeEncryptedNoisePacket(typedPayload, to: bob.myPeerID)
let remoteShapedPacket = BitchatPacket(
type: encrypted.type,
senderID: encrypted.senderID,
recipientID: encrypted.recipientID,
timestamp: encrypted.timestamp,
payload: encrypted.payload,
signature: nil,
ttl: encrypted.ttl,
version: outerVersion
)
bob._test_handlePacket(remoteShapedPacket, fromPeerID: alice.myPeerID)
let delivered = await TestHelpers.waitUntil(
{ delegate.snapshot().count == 1 },
timeout: TestConstants.longTimeout
)
#expect(delivered)
#expect(delegate.snapshot().first?.isPrivate == true)
let stored = recursivelyStoredFiles(under: bobRoot)
#expect(stored.count == 1)
if let storedURL = stored.first {
#expect(try Data(contentsOf: storedURL) == content)
}
}
private func assertApprovedLegacySendCancelledBeforeAdmission(label: String) async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-admission-\(label)-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Legacy Bob",
noisePublicKey: bob.noiseStaticPublicKeyData()
)
let transferId = "approved-\(label)-\(UUID().uuidString)"
let gate = PrivateMediaDeferredSendGate()
let tap = PacketTap()
alice._test_onOutboundPacket = tap.record
alice._test_beforePrivateMediaDeferredSend = { id in
guard id == transferId else { return }
gate.pause()
}
defer {
gate.release()
alice._test_beforePrivateMediaDeferredSend = nil
}
let content = Data("%PDF-1.7\ncancelled-before-admission".utf8)
alice.sendFilePrivate(
BitchatFilePacket(
fileName: "cancelled.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
),
to: bob.myPeerID,
transferId: transferId,
allowLegacyFallback: true
)
let paused = await TestHelpers.waitUntil(
{ gate.hasPaused },
timeout: TestConstants.longTimeout
)
#expect(paused)
// This is the transport action used by both cancel and delete. It must
// invalidate synchronously while messageQueue is still held above.
alice.cancelTransfer(transferId)
gate.release()
await alice._test_drainPrivateMediaSendPipeline()
let state = alice._test_privateMediaTransferState(transferId: transferId)
#expect(!state.admissionActive)
#expect(!state.pendingNoise)
#expect(state.activeScheduler == 0)
#expect(state.pendingScheduler == 0)
#expect(await TestHelpers.waitUntil(
{ alice._test_privateMediaAdmissionEntryCount() == 0 },
timeout: TestConstants.longTimeout
))
#expect(tap.snapshot().allSatisfy {
$0.type != MessageType.fileTransfer.rawValue
&& $0.type != MessageType.noiseEncrypted.rawValue
})
}
private func assertPrivateMediaRoundTrip( private func assertPrivateMediaRoundTrip(
fileName: String, fileName: String,
mimeType: String, mimeType: String,
@@ -135,8 +739,18 @@ struct PrivateMediaEndToEndTests {
alice._test_onOutboundPacket = tap.record alice._test_onOutboundPacket = tap.record
bob.delegate = delegate bob.delegate = delegate
alice._test_seedConnectedPeer(bob.myPeerID, nickname: "Bob", capabilities: .privateMedia) alice._test_seedConnectedPeer(
bob._test_seedConnectedPeer(alice.myPeerID, nickname: "Alice", capabilities: .privateMedia) bob.myPeerID,
nickname: "Bob",
capabilities: .privateMedia,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
bob._test_seedConnectedPeer(
alice.myPeerID,
nickname: "Alice",
capabilities: .privateMedia,
noisePublicKey: alice.noiseStaticPublicKeyData()
)
try establishSession(alice: alice, bob: bob) try establishSession(alice: alice, bob: bob)
let file = BitchatFilePacket( let file = BitchatFilePacket(
@@ -197,12 +811,15 @@ struct PrivateMediaEndToEndTests {
#expect(try Data(contentsOf: storedURL) == content) #expect(try Data(contentsOf: storedURL) == content)
} }
private func makeService(baseDirectory: URL) -> BLEService { private func makeService(
baseDirectory: URL,
identityManager: SecureIdentityStateManagerProtocol? = nil
) -> BLEService {
let keychain = MockKeychain() let keychain = MockKeychain()
return BLEService( return BLEService(
keychain: keychain, keychain: keychain,
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
identityManager: MockIdentityManager(keychain), identityManager: identityManager ?? MockIdentityManager(keychain),
initializeBluetoothManagers: false, initializeBluetoothManagers: false,
incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory) incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory)
) )
@@ -221,6 +838,30 @@ struct PrivateMediaEndToEndTests {
#expect(bob.canDeliverSecurely(to: alice.myPeerID)) #expect(bob.canDeliverSecurely(to: alice.myPeerID))
} }
private func signedAnnounce(
from service: BLEService,
capabilities: PeerCapabilities?
) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: "Bob",
noisePublicKey: service.noiseStaticPublicKeyData(),
signingPublicKey: service.noiseSigningPublicKeyData(),
directNeighbors: nil,
capabilities: capabilities
)
let payload = try #require(announcement.encode())
let unsigned = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: service.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return service.signPacketForBroadcast(unsigned)
}
private func recursivelyStoredFiles(under root: URL) -> [URL] { private func recursivelyStoredFiles(under root: URL) -> [URL] {
guard let enumerator = FileManager.default.enumerator( guard let enumerator = FileManager.default.enumerator(
at: root, at: root,
@@ -266,6 +907,52 @@ private final class PacketTap: @unchecked Sendable {
} }
} }
private final class PrivateMediaDeferredSendGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
private var released = false
var hasPaused: Bool {
condition.lock()
defer { condition.unlock() }
return paused
}
func pause() {
condition.lock()
paused = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
private final class PeerIDRecorder: @unchecked Sendable {
private let lock = NSLock()
private var peerIDs: [PeerID] = []
func record(_ peerID: PeerID) {
lock.lock()
peerIDs.append(peerID)
lock.unlock()
}
func contains(_ peerID: PeerID) -> Bool {
lock.lock()
defer { lock.unlock() }
return peerIDs.contains(peerID)
}
}
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable { private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var messages: [BitchatMessage] = [] private var messages: [BitchatMessage] = []
@@ -289,24 +976,38 @@ private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable
} }
private final class TransferCancellationRecorder: @unchecked Sendable { private final class TransferCancellationRecorder: @unchecked Sendable {
private let transferID: String
private let lock = NSLock() private let lock = NSLock()
private var cancelled = false private var transferIDs: Set<String> = []
private var rejectionReasons: [String: String] = [:]
init(transferID: String) {
self.transferID = transferID
}
func record(_ event: TransferProgressManager.Event) { func record(_ event: TransferProgressManager.Event) {
guard case .cancelled(let id, _, _) = event, id == transferID else { return } let id: String
switch event {
case .cancelled(let cancelledID, _, _):
id = cancelledID
case .rejected(let rejectedID, _):
id = rejectedID
case .started, .updated, .completed:
return
}
lock.lock() lock.lock()
cancelled = true transferIDs.insert(id)
if case .rejected(_, let reason) = event {
rejectionReasons[id] = reason
}
lock.unlock() lock.unlock()
} }
var wasCancelled: Bool { func contains(_ transferID: String) -> Bool {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
return cancelled return transferIDs.contains(transferID)
}
func reason(for transferID: String) -> String? {
lock.lock()
defer { lock.unlock() }
return rejectionReasons[transferID]
} }
} }
+12 -1
View File
@@ -14,6 +14,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
private var blockedFingerprints: Set<String> = [] private var blockedFingerprints: Set<String> = []
private var blockedNostrPubkeys: Set<String> = [] private var blockedNostrPubkeys: Set<String> = []
private var socialIdentities: [String: SocialIdentity] = [:] private var socialIdentities: [String: SocialIdentity] = [:]
private var privateMediaCapableFingerprints: Set<String> = []
init(_: KeychainManagerProtocol) {} init(_: KeychainManagerProtocol) {}
@@ -87,7 +88,9 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
func clearAllIdentityData() {} func clearAllIdentityData() {
privateMediaCapableFingerprints.removeAll()
}
func removeEphemeralSession(peerID: PeerID) {} func removeEphemeralSession(peerID: PeerID) {}
@@ -101,6 +104,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
Set() Set()
} }
func markPrivateMediaCapable(fingerprint: String) {
privateMediaCapableFingerprints.insert(fingerprint)
}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
privateMediaCapableFingerprints.contains(fingerprint)
}
// MARK: Vouching (transitive verification) // MARK: Vouching (transitive verification)
private var vouchesByVouchee: [String: [VouchRecord]] = [:] private var vouchesByVouchee: [String: [VouchRecord]] = [:]
+17
View File
@@ -36,6 +36,7 @@ final class MockTransport: Transport {
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = [] private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = [] private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
private(set) var cancelledTransfers: [String] = [] private(set) var cancelledTransfers: [String] = []
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
@@ -58,6 +59,7 @@ final class MockTransport: Transport {
var peerNicknames: [PeerID: String] = [:] var peerNicknames: [PeerID: String] = [:]
var peerFingerprints: [PeerID: String] = [:] var peerFingerprints: [PeerID: String] = [:]
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:] var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
private let mockKeychain = MockKeychain() private let mockKeychain = MockKeychain()
// MARK: - Transport Protocol Implementation // MARK: - Transport Protocol Implementation
@@ -186,6 +188,21 @@ final class MockTransport: Transport {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
sentPrivateFiles.append((packet, peerID, transferId)) sentPrivateFiles.append((packet, peerID, transferId))
sentPrivateFileLegacyAllowances.append(false)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
sentPrivateFiles.append((packet, peerID, transferId))
sentPrivateFileLegacyAllowances.append(allowLegacyFallback)
}
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
privateMediaPolicies[peerID] ?? .encrypted
} }
func cancelTransfer(_ transferId: String) { func cancelTransfer(_ transferId: String) {
@@ -1,5 +1,6 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
struct BLENoisePayloadFactoryTests { struct BLENoisePayloadFactoryTests {
@@ -44,10 +45,38 @@ struct BLENoisePayloadFactoryTests {
let payload = try #require(BLENoisePayloadFactory.privateFile(file)) let payload = try #require(BLENoisePayloadFactory.privateFile(file))
#expect(payload.first == NoisePayloadType.privateFile.rawValue) #expect(payload.first == 0x20, "Encrypted files must use Android's deployed wire value")
let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst()))) let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst())))
#expect(decoded.fileName == "secret.pdf") #expect(decoded.fileName == "secret.pdf")
#expect(decoded.mimeType == "application/pdf") #expect(decoded.mimeType == "application/pdf")
#expect(decoded.content == content) #expect(decoded.content == content)
} }
@Test
func androidB7f0b33PrivateFilePlaintextFixtureIsByteCompatible() throws {
// Runtime-emitted by Android commit b7f0b33d from
// BitchatFilePacket("a.txt", 3, "text/plain", [01, 02, 03]) and
// NoisePayload(type = FILE_TRANSFER, data = file.encode()).encode().
let fixtureHex = "20010005612e7478740200040000000303000a746578742f706c61696e0400000003010203"
let fixture = try #require(Data(hexString: fixtureHex))
let typed = try #require(NoisePayload.decode(fixture))
#expect(typed.type == .privateFile)
let file = try #require(BitchatFilePacket.decode(typed.data))
#expect(file.fileName == "a.txt")
#expect(file.fileSize == 3)
#expect(file.mimeType == "text/plain")
#expect(file.content == Data([0x01, 0x02, 0x03]))
#expect(BLENoisePayloadFactory.privateFile(file) == fixture)
}
@Test
func prereleasePrivateFileTypeCanonicalizesOnDecode() throws {
let encoded = Data([NoisePayloadType.prereleasePrivateFileRawValue, 0xCA, 0xFE])
let decoded = try #require(NoisePayload.decode(encoded))
#expect(decoded.type == .privateFile)
#expect(decoded.data == Data([0xCA, 0xFE]))
#expect(decoded.encode().first == 0x20)
}
} }
@@ -73,7 +73,7 @@ struct BLENoiseSessionQueuesTests {
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa") let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
var queues = BLENoiseSessionQueues() var queues = BLENoiseSessionQueues()
queues.appendTypedPayload(Data([0x09, 0xAA]), transferId: "media-1", for: peerID) queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID)
queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID) queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID)
let removed = queues.removeTypedPayload(transferId: "media-1") let removed = queues.removeTypedPayload(transferId: "media-1")
@@ -108,6 +108,58 @@ struct BLEOutboundFragmentPlannerTests {
) == nil) ) == nil)
} }
@Test("private media v1 accepts exactly 256 fragments and rejects 257")
func privateMediaCrossPlatformFragmentBoundary() throws {
let maxPayload = makePayload(count: 160 * 1024, seed: 0xFACE_CAFE)
func plan(payloadCount: Int) throws -> BLEOutboundFragmentPlan {
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: "0011223344556677") ?? Data(),
recipientID: Data(hexString: "8877665544332211"),
timestamp: 0x0102030405,
payload: Data(maxPayload.prefix(payloadCount)),
signature: nil,
ttl: 3,
version: 2
)
return try #require(BLEOutboundFragmentPlanner.makePlan(
for: BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: nil,
directedPeer: PeerID(str: "8877665544332211"),
transferId: "boundary"
),
defaultChunkSize: TransportConfig.bleDefaultFragmentSize,
bleMaxMTU: 512,
fragmentID: Data(repeating: 0xD4, count: 8)
))
}
func firstPlan(withAtLeast target: Int) throws -> BLEOutboundFragmentPlan {
var low = 1
var high = maxPayload.count
while low < high {
let mid = low + (high - low) / 2
if try plan(payloadCount: mid).totalFragments >= target {
high = mid
} else {
low = mid + 1
}
}
return try plan(payloadCount: low)
}
let at256 = try firstPlan(withAtLeast: 256)
let at257 = try firstPlan(withAtLeast: 257)
#expect(at256.totalFragments == 256)
#expect(BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at256))
#expect(at257.totalFragments == 257)
#expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257))
}
private func makePacket( private func makePacket(
payload: Data, payload: Data,
route: [Data]? = nil, route: [Data]? = nil,
@@ -42,6 +42,37 @@ struct BLEPeerRegistryTests {
#expect(registry.info(for: peerID)?.nickname == "alice-renamed") #expect(registry.info(for: peerID)?.nickname == "alice-renamed")
} }
@Test("registry preserves absent versus explicit empty capabilities")
func capabilitiesPresenceIsPreserved() {
var registry = BLEPeerRegistry()
let oldPeer = PeerID(str: "1122334455667788")
let modernPeer = PeerID(str: "8877665544332211")
_ = registry.upsertVerifiedAnnounce(
peerID: oldPeer,
nickname: "old",
noisePublicKey: Data(repeating: 0x11, count: 32),
signingPublicKey: Data(repeating: 0x12, count: 32),
isConnected: true,
now: Date(),
capabilities: nil
)
_ = registry.upsertVerifiedAnnounce(
peerID: modernPeer,
nickname: "modern",
noisePublicKey: Data(repeating: 0x21, count: 32),
signingPublicKey: Data(repeating: 0x22, count: 32),
isConnected: true,
now: Date(),
capabilities: []
)
#expect(registry.capabilities(for: oldPeer).isEmpty)
#expect(!registry.capabilitiesWereExplicitlyAdvertised(for: oldPeer))
#expect(registry.capabilities(for: modernPeer).isEmpty)
#expect(registry.capabilitiesWereExplicitlyAdvertised(for: modernPeer))
}
@Test("reachability keeps recent verified offline peers only when mesh is attached") @Test("reachability keeps recent verified offline peers only when mesh is attached")
func reachabilityRequiresMeshAttachmentForOfflinePeers() { func reachabilityRequiresMeshAttachmentForOfflinePeers() {
let offlinePeer = PeerID(str: "1122334455667788") let offlinePeer = PeerID(str: "1122334455667788")
@@ -399,6 +399,34 @@ final class SecureIdentityStateManagerTests: XCTestCase {
XCTAssertTrue(cleared) XCTAssertTrue(cleared)
} }
func test_privateMediaCapabilityPinPersistsMonotonicallyAndPanicClearRemovesIt() async {
let keychain = MockKeychain()
let fingerprint = Data(repeating: 0x42, count: 32).sha256Fingerprint()
let manager = SecureIdentityStateManager(keychain)
XCTAssertFalse(manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
manager.markPrivateMediaCapable(fingerprint: fingerprint)
XCTAssertTrue(
manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint),
"pin insertion must be synchronously visible to the next downgrade decision"
)
// Re-marking is idempotent, and the encrypted cache carries the pin
// across launches.
manager.markPrivateMediaCapable(fingerprint: fingerprint)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertTrue(reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
// ChatViewModel's panic path calls this same wipe after deleting
// keychain data; the in-memory pin must disappear immediately too.
reloaded.clearAllIdentityData()
let cleared = await waitUntil {
!reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint)
}
XCTAssertTrue(cleared)
}
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async { func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
let keychain = FailingCacheSaveKeychain() let keychain = FailingCacheSaveKeychain()
let manager = SecureIdentityStateManager(keychain) let manager = SecureIdentityStateManager(keychain)
@@ -49,7 +49,7 @@ struct TransferProgressManagerTests {
recorder.append("updated:\(id):\(sent):\(total)") recorder.append("updated:\(id):\(sent):\(total)")
case .completed(let id, let total): case .completed(let id, let total):
recorder.append("completed:\(id):\(total)") recorder.append("completed:\(id):\(total)")
case .cancelled: case .cancelled, .rejected:
break break
} }
} }
@@ -85,7 +85,7 @@ struct TransferProgressManagerTests {
recorder.append("started:\(id):\(total)") recorder.append("started:\(id):\(total)")
case .cancelled(let id, let sent, let total): case .cancelled(let id, let sent, let total):
recorder.append("cancelled:\(id):\(sent):\(total)") recorder.append("cancelled:\(id):\(sent):\(total)")
case .updated, .completed: case .updated, .completed, .rejected:
break break
} }
} }
@@ -106,24 +106,22 @@ struct TransferProgressManagerTests {
_ = cancellable _ = cancellable
} }
@Test("Preflight rejection publishes cancellation without a started state") @Test("Preflight policy rejection publishes a visible failure reason")
@MainActor @MainActor
func rejectBeforeStartPublishesCancellation() async { func rejectBeforeStartPublishesReason() async {
let manager = TransferProgressManager() let manager = TransferProgressManager()
let transferID = "transfer-preflight-reject" let transferID = "transfer-visible-reject"
var cancellable: AnyCancellable?
let recorder = EventRecorder() let recorder = EventRecorder()
let cancellable = manager.publisher.sink { event in
cancellable = manager.publisher.sink { event in if case .rejected(let id, let reason) = event {
if case .cancelled(let id, let sent, let total) = event { recorder.append("rejected:\(id):\(reason)")
recorder.append("cancelled:\(id):\(sent):\(total)")
} }
} }
manager.rejectBeforeStart(id: transferID) manager.rejectBeforeStart(id: transferID, reason: "upgrade required")
let didReceive = await TestHelpers.waitUntil({ let didReceive = await TestHelpers.waitUntil({
recorder.values == ["cancelled:\(transferID):0:0"] recorder.values == ["rejected:\(transferID):upgrade required"]
}, timeout: 5.0) }, timeout: 5.0)
#expect(didReceive) #expect(didReceive)
#expect(manager.snapshot(id: transferID) == nil) #expect(manager.snapshot(id: transferID) == nil)
@@ -266,6 +266,9 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
verified.removeAll() verified.removeAll()
} }
func markPrivateMediaCapable(fingerprint: String) {}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
func removeEphemeralSession(peerID: PeerID) {} func removeEphemeralSession(peerID: PeerID) {}
func setVerified(fingerprint: String, verified: Bool) { func setVerified(fingerprint: String, verified: Bool) {
+80
View File
@@ -0,0 +1,80 @@
# Private-media wire migration
Private files use the `BitchatFilePacket` TLV shared by iOS and Android. The
preferred direct-message wire form encrypts that complete TLV inside the
peer's Noise session before BLE fragmentation.
## Wire values and capability
- `NoisePayloadType.privateFile` is `0x20`, the value already deployed by the
Android client. New sends must use this value.
- iOS temporarily accepts `0x09`, which appeared in prerelease builds of the
private-media change. Decoders canonicalize it to `privateFile`; they never
emit it.
- A peer advertising `PeerCapabilities.privateMedia` receives one encrypted
`noiseEncrypted` transfer.
- An unpinned peer with a stable Noise key but without that capability is
eligible for one signed, directed
`fileTransfer`, matching the pre-migration wire form used by older iOS and
accepted by current Android clients, only after the sender confirms a
per-send warning that the file is not end-to-end encrypted and mesh relays
can see it. The
consent is consumed by that invocation and is never remembered.
- A signed announce never creates a pin by itself. The fingerprint is pinned
in the encrypted identity cache only after a completed Noise session
authenticates the same static key as the capability-bearing registry entry.
This comparison is performed in either event order: session then announce,
or announce then session. A later announce missing the bit is then treated
as a downgrade, and raw fallback is blocked even if a caller presents
legacy consent.
- During migration, both an absent capabilities TLV and an explicit TLV
without `privateMedia` are legacy-eligible when that stable fingerprint is
not pinned. This supports clients that added capability advertisement before
encrypted media. Neither shape bypasses a previously authenticated pin.
The `0x09` receive alias may be removed only after every TestFlight/internal
build that emitted it has expired and the project's minimum-supported-client
policy excludes those builds. Track that release criterion explicitly; do not
remove the alias on an arbitrary calendar date.
## Security boundary
The encrypted form provides Noise confidentiality and peer authentication.
The fallback is signed and its signature is required on receive, so relays
cannot forge its sender or contents. It is not confidential: relays can see
the raw file TLV. The UI says this explicitly and asks on every send. A peer
without a stable Noise key from a verified registry entry cannot use the
fallback. Keep
the fallback only for the mixed-version migration and remove it after
supported Android and iOS releases advertise `privateMedia`. Never replace it
with an unsigned fallback, persist blanket consent, or send both forms.
Incoming clients accept all three migration-era shapes:
| Sender | Inbound form | Result |
| --- | --- | --- |
| Current Android | Noise `0x20` | Decrypt and deliver |
| Prerelease iOS | Noise `0x09` | Decrypt, canonicalize, and deliver |
| Older client | Signed directed `fileTransfer` | Verify signature and deliver |
| Forged/unsigned raw sender | Directed `fileTransfer` | Reject |
Panic wipe clears the persistent capability pins together with the rest of
the encrypted identity cache.
This migration path is BLE-only. Nostr private-media transport is unchanged
and remains a follow-up; do not infer the BLE consent fallback or capability
pin semantics for Nostr delivery.
## Size interoperability
iOS bounds inbound file content at 1 MiB and applies the expanded allocation
budget only after a large Noise ciphertext authenticates to `0x20` or the
temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit.
Current Android builds cap each reassembly at 256 fragments. Depending on the
negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB,
well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the
actual route-aware BLE fragment planner before both encrypted and consented
legacy sends and rejects any plan above 256 fragments with a visible failure.
This fragment-count contract, rather than a guessed byte threshold, stays
correct as route overhead changes.
@@ -24,8 +24,9 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
/// (uplink/downlink carriers for mesh-only peers). Advertised alongside /// (uplink/downlink carriers for mesh-only peers). Advertised alongside
/// a `bridgeGeohash` TLV carrying the rendezvous cell. /// a `bridgeGeohash` TLV carrying the rendezvous cell.
public static let bridge = PeerCapabilities(rawValue: 1 << 7) public static let bridge = PeerCapabilities(rawValue: 1 << 7)
/// Finalized direct-message media encrypted as a `.privateFile` Noise /// Finalized direct-message media encrypted as Noise payload `0x20`
/// payload before outer BLE fragmentation. /// before outer BLE fragmentation. Peers that omit this bit require the
/// signed directed raw-file migration fallback.
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8) public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
/// Minimal little-endian byte encoding; always at least one byte so an /// Minimal little-endian byte encoding; always at least one byte so an