mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Harden private media migration compatibility
This commit is contained in:
@@ -12,6 +12,7 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
@Published private(set) var currentNickname: String
|
@Published private(set) var 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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 true→true,
|
||||||
|
// closes the first dialog, and never presents the second.
|
||||||
|
legacyPrivateMediaConsentRequest = nil
|
||||||
|
resolved.completion(approved)
|
||||||
|
presentNextLegacyPrivateMediaConsentDeferred()
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||||
|
let invalidatedIDs = Set(
|
||||||
|
pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in
|
||||||
|
let request = pending.request
|
||||||
|
return request.transferId == transferId && request.messageID == messageID
|
||||||
|
? request.id
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
guard !invalidatedIDs.isEmpty else { return }
|
||||||
|
|
||||||
|
pendingLegacyPrivateMediaConsents.removeAll {
|
||||||
|
invalidatedIDs.contains($0.request.id)
|
||||||
|
}
|
||||||
|
if let currentID = legacyPrivateMediaConsentRequest?.id,
|
||||||
|
invalidatedIDs.contains(currentID) {
|
||||||
|
legacyPrivateMediaConsentRequest = nil
|
||||||
|
presentNextLegacyPrivateMediaConsentDeferred()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelAllLegacyPrivateMediaConsents() {
|
||||||
|
let pending = pendingLegacyPrivateMediaConsents
|
||||||
|
pendingLegacyPrivateMediaConsents.removeAll()
|
||||||
|
legacyPrivateMediaConsentRequest = nil
|
||||||
|
for item in pending {
|
||||||
|
item.completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentNextLegacyPrivateMediaConsentDeferred() {
|
||||||
|
guard legacyPrivateMediaConsentRequest == nil,
|
||||||
|
let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.legacyPrivateMediaConsentRequest == nil,
|
||||||
|
self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// End of ChatViewModel class
|
// End of ChatViewModel class
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]] = [:]
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user