mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Merge Noise peer binding dependency
This commit is contained in:
@@ -12,6 +12,7 @@ final class ConversationUIModel: ObservableObject {
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var isBatchingPublic = false
|
||||
@Published private(set) var canSendMediaInCurrentContext = true
|
||||
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||
/// Who is talking live in the public mesh channel right now (floor
|
||||
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
||||
@Published private(set) var activeLiveVoiceTalker: String?
|
||||
@@ -153,6 +154,13 @@ final class ConversationUIModel: ObservableObject {
|
||||
chatViewModel.sendVoiceNote(at: url)
|
||||
}
|
||||
|
||||
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
|
||||
chatViewModel.resolveLegacyPrivateMediaConsent(
|
||||
requestID: requestID,
|
||||
approved: approved
|
||||
)
|
||||
}
|
||||
|
||||
/// Capture backend for the mic gesture: live PTT when the current DM
|
||||
/// peer can hear it now, classic voice note otherwise.
|
||||
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
||||
@@ -193,6 +201,10 @@ final class ConversationUIModel: ObservableObject {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$activeLiveVoiceTalker)
|
||||
|
||||
chatViewModel.$legacyPrivateMediaConsentRequest
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$legacyPrivateMediaConsentRequest)
|
||||
|
||||
conversations.$activeChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
|
||||
@@ -189,6 +189,12 @@ struct IdentityCache: Codable {
|
||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||
// entries verified before this field exists sort as oldest)
|
||||
var verifiedAt: [String: Date]? = nil
|
||||
|
||||
// Stable Noise fingerprints that have advertised encrypted private media
|
||||
// in a signature-verified announce. Optional for decoding caches written
|
||||
// before this migration. Entries are monotonic until a panic wipe so an
|
||||
// old/replayed announce cannot silently downgrade a peer to clear media.
|
||||
var privateMediaCapableFingerprints: Set<String>? = nil
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -140,6 +140,10 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
||||
|
||||
// MARK: Private-media downgrade protection
|
||||
func markPrivateMediaCapable(fingerprint: String)
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
|
||||
}
|
||||
|
||||
/// Singleton manager for secure identity state persistence and retrieval.
|
||||
@@ -157,6 +161,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
private let queueSpecificKey = DispatchSpecificKey<UInt8>()
|
||||
|
||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||
@@ -214,6 +219,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
self.encryptionKey = loadedKey
|
||||
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||
queue.setSpecific(key: queueSpecificKey, value: 1)
|
||||
|
||||
// Only read the persisted cache when we hold the real key; with an
|
||||
// ephemeral key the decrypt would fail and discard the real cache.
|
||||
@@ -370,6 +376,35 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private-media downgrade protection
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {
|
||||
guard !fingerprint.isEmpty else { return }
|
||||
let insertAndPersist = {
|
||||
var pinned = self.cache.privateMediaCapableFingerprints ?? []
|
||||
guard pinned.insert(fingerprint).inserted else { return }
|
||||
self.cache.privateMediaCapableFingerprints = pinned
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
// Downgrade decisions can run immediately after an authenticated
|
||||
// announce. Make the pin visible before returning; merely enqueueing a
|
||||
// barrier leaves a cross-queue window where a replay can look legacy.
|
||||
// The queue-specific fast path prevents self-deadlock if a future
|
||||
// identity-state mutation records the capability from inside `queue`.
|
||||
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||
insertAndPersist()
|
||||
} else {
|
||||
queue.sync(flags: .barrier, execute: insertAndPersist)
|
||||
}
|
||||
}
|
||||
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
|
||||
guard !fingerprint.isEmpty else { return false }
|
||||
return queue.sync {
|
||||
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
|
||||
}
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.async(flags: .barrier) {
|
||||
|
||||
@@ -30,7 +30,7 @@ struct NoisePayload {
|
||||
|
||||
// Safely get the first byte
|
||||
let firstByte = data[data.startIndex]
|
||||
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
||||
guard let type = NoisePayloadType.decoded(rawValue: firstByte) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,30 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
/// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes)
|
||||
/// added by `NoiseCipherState` around every transport plaintext.
|
||||
static let transportCiphertextOverhead = 20
|
||||
|
||||
/// Private files are an explicit BitChat extension to the ordinary Noise
|
||||
/// message-size ceiling. They remain bounded by the same framed-file cap
|
||||
/// used by the binary and fragment decoders. Only the `.privateFile`
|
||||
/// typed-payload path is allowed to use this larger budget.
|
||||
private static let privateFileOuterPacketOverhead =
|
||||
(BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes
|
||||
+ BinaryProtocol.senderIDSize
|
||||
+ BinaryProtocol.recipientIDSize
|
||||
static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes
|
||||
- privateFileOuterPacketOverhead
|
||||
- transportCiphertextOverhead
|
||||
static let maxPrivateFileCiphertextSize =
|
||||
maxPrivateFilePlaintextSize + transportCiphertextOverhead
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
@@ -14,6 +14,19 @@ struct NoiseSecurityValidator {
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
static func validateCiphertextSize(_ data: Data) -> Bool {
|
||||
data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
+ NoiseSecurityConstants.transportCiphertextOverhead
|
||||
}
|
||||
|
||||
static func validatePrivateFileMessageSize(_ data: Data) -> Bool {
|
||||
data.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize
|
||||
}
|
||||
|
||||
static func validatePrivateFileCiphertextSize(_ data: Data) -> Bool {
|
||||
data.count <= NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
|
||||
@@ -24,8 +24,12 @@ final class SecureNoiseSession: NoiseSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
// Ordinary Noise messages keep the protocol ceiling. Finalized media
|
||||
// is the sole typed-payload extension and remains under the framed-file
|
||||
// cap enforced again at the service and file-decoder layers.
|
||||
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first)
|
||||
&& NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext)
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
@@ -42,8 +46,11 @@ final class SecureNoiseSession: NoiseSession {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
// The payload type is encrypted, so a large candidate can only be
|
||||
// bounded here; `NoiseEncryptionService.decrypt` authenticates it and
|
||||
// then requires the resulting type to be `.privateFile`.
|
||||
guard NoiseSecurityValidator.validateCiphertextSize(ciphertext)
|
||||
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
|
||||
@@ -79,12 +79,30 @@ enum NoisePayloadType: UInt8 {
|
||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||
// Live voice (push-to-talk)
|
||||
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
||||
// Finalized private media. `0x20` is the value already deployed by the
|
||||
// Android client. The complete BitchatFilePacket is encrypted inside
|
||||
// Noise before the outer noiseEncrypted packet is fragmented.
|
||||
case privateFile = 0x20
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
// Transitive verification (web of trust)
|
||||
case vouch = 0x12 // Batch of vouch attestations
|
||||
|
||||
/// #1434 briefly used 0x09 before release. Accept it while prerelease
|
||||
/// builds age out, but never emit it. Decoders canonicalize both values to
|
||||
/// `.privateFile` so the compatibility alias cannot leak into app logic.
|
||||
static let prereleasePrivateFileRawValue: UInt8 = 0x09
|
||||
|
||||
static func decoded(rawValue: UInt8) -> NoisePayloadType? {
|
||||
rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue)
|
||||
}
|
||||
|
||||
static func isPrivateFile(rawValue: UInt8?) -> Bool {
|
||||
guard let rawValue else { return false }
|
||||
return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .privateMessage: return "privateMessage"
|
||||
@@ -93,6 +111,7 @@ enum NoisePayloadType: UInt8 {
|
||||
case .groupInvite: return "groupInvite"
|
||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||
case .voiceFrame: return "voiceFrame"
|
||||
case .privateFile: return "privateFile"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
case .verifyResponse: return "verifyResponse"
|
||||
case .vouch: return "vouch"
|
||||
|
||||
@@ -3,5 +3,5 @@ import BitFoundation
|
||||
extension PeerCapabilities {
|
||||
/// Capabilities this build advertises in its announce packets.
|
||||
/// Each feature adds its bit here when it ships.
|
||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||
/// Verifies a packet's signature against a candidate signing key (registry path).
|
||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Local signing key used to authenticate our own gossip-sync replays.
|
||||
let localSigningPublicKey: () -> Data
|
||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||
/// Tracks the broadcast file packet for gossip sync.
|
||||
@@ -46,54 +48,105 @@ final class BLEFileTransferHandler {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// Returns `false` when the packet fails sender authentication and must
|
||||
/// not be relayed onward. Every other outcome returns `true`: files
|
||||
/// directed to another peer are forwarded untouched, and local-only drops
|
||||
/// (malformed payload, quota, save failure) don't affect multi-hop
|
||||
/// delivery to nodes that may handle them fine.
|
||||
/// Returns `false` when the raw packet fails sender authentication (or is
|
||||
/// a live self-echo) and must not be relayed onward. Authentication runs
|
||||
/// before the routing decision, so a forged directed packet cannot use a
|
||||
/// node that is not its recipient as an unsigned forwarding hop.
|
||||
@discardableResult
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
let env = environment
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
|
||||
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
||||
return true
|
||||
}
|
||||
|
||||
let localPeerID = env.localPeerID()
|
||||
let peersSnapshot = env.peersSnapshot()
|
||||
guard let senderNickname = resolveSenderNickname(
|
||||
|
||||
guard let senderNickname = authenticatedRawSenderNickname(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
isBroadcast: !deliveryPlan.isPrivateMessage,
|
||||
peers: peersSnapshot,
|
||||
env: env
|
||||
) else {
|
||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
SecureLogger.warning("🚫 Dropping raw file transfer with missing/invalid signature from \(peerID.id.prefix(8))…", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: localPeerID) else {
|
||||
return true
|
||||
}
|
||||
|
||||
if deliveryPlan.shouldTrackForSync {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
|
||||
_ = storeIncomingPayload(
|
||||
packet.payload,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
env: env
|
||||
)
|
||||
// Once authenticated, a local decode/quota/save failure is not proof
|
||||
// that downstream nodes should be denied the valid signed packet.
|
||||
return true
|
||||
}
|
||||
|
||||
/// Accepts a file packet only after it has been authenticated and
|
||||
/// decrypted by the peer's Noise session. The inner packet deliberately
|
||||
/// has no redundant signature: Noise supplies sender authentication and
|
||||
/// confidentiality, while this handler retains the same validation,
|
||||
/// quota, persistence, and UI-delivery behavior as public files.
|
||||
@discardableResult
|
||||
func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool {
|
||||
let env = environment
|
||||
let peers = env.peersSnapshot()
|
||||
let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
allowConnectedUnverified: true
|
||||
) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||||
|
||||
return storeIncomingPayload(
|
||||
payload,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: true,
|
||||
env: env
|
||||
)
|
||||
}
|
||||
|
||||
private func storeIncomingPayload(
|
||||
_ payload: Data,
|
||||
from peerID: PeerID,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
isPrivate: Bool,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> Bool {
|
||||
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
||||
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||
case .success(let acceptance):
|
||||
filePacket = acceptance.filePacket
|
||||
mime = acceptance.mime
|
||||
case .failure(.malformedPayload):
|
||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||
return true
|
||||
return false
|
||||
case .failure(.payloadTooLarge(let bytes)):
|
||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||
return true
|
||||
return false
|
||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
||||
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
||||
return true
|
||||
return false
|
||||
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
||||
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
@@ -106,28 +159,27 @@ final class BLEFileTransferHandler {
|
||||
mime.defaultExtension,
|
||||
mime.category.rawValue
|
||||
) else {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
if deliveryPlan.isPrivateMessage {
|
||||
if isPrivate {
|
||||
env.updatePeerLastSeen(peerID)
|
||||
}
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
let message = BitchatMessage(
|
||||
sender: senderNickname,
|
||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||
timestamp: ts,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
isPrivate: isPrivate,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
// Received messages need an explicit status: BitchatMessage
|
||||
// defaults private messages to .sending, which the media views
|
||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
||||
deliveryStatus: deliveryPlan.isPrivateMessage
|
||||
? .delivered(to: env.localNickname(), at: ts)
|
||||
deliveryStatus: isPrivate
|
||||
? .delivered(to: env.localNickname(), at: timestamp)
|
||||
: nil
|
||||
)
|
||||
|
||||
@@ -137,51 +189,38 @@ final class BLEFileTransferHandler {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Resolves the authenticated display name for a file transfer's sender.
|
||||
///
|
||||
/// Directed (private) transfers are addressed to us specifically and keep
|
||||
/// the lenient connected-peer path. Broadcast transfers carry an
|
||||
/// attacker-controllable `senderID` exactly like public messages and public
|
||||
/// voice frames — registry membership alone is NOT proof of identity, so a
|
||||
/// valid packet signature from the claimed sender is required before we
|
||||
/// trust it. Without this, a peer that observed a public voice burst could
|
||||
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
|
||||
/// overwrite the signature-verified live bubble with attacker audio.
|
||||
private func resolveSenderNickname(
|
||||
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||
/// is broadcast, addressed to us, or merely passing through. Registry
|
||||
/// signing keys are preferred; persisted identities cover peers that have
|
||||
/// rotated or are not currently present in the registry.
|
||||
private func authenticatedRawSenderNickname(
|
||||
packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
isBroadcast: Bool,
|
||||
peers: [PeerID: BLEPeerInfo],
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> String? {
|
||||
guard isBroadcast else {
|
||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
allowConnectedUnverified: true
|
||||
) ?? env.signedSenderDisplayName(packet, peerID)
|
||||
}
|
||||
guard packet.signature != nil else { return nil }
|
||||
|
||||
// Our own broadcasts replayed back via gossip sync (ttl==0) are
|
||||
// trivially authentic and cannot be verified against the peer registry
|
||||
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
|
||||
// does. Verify against the signing key already in the
|
||||
// (synchronously-updated) registry first, then fall back to the
|
||||
// persisted-identity signature lookup for peers not yet cached there.
|
||||
let isSelf = peerID == env.localPeerID()
|
||||
let registrySigningKey = peers[peerID]?.signingPublicKey
|
||||
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
||||
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
||||
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
|
||||
let localPeerID = env.localPeerID()
|
||||
let candidateKey = peerID == localPeerID
|
||||
? env.localSigningPublicKey()
|
||||
: peers[peerID]?.signingPublicKey
|
||||
let verifiedWithKnownKey = candidateKey.map {
|
||||
env.verifyPacketSignature(packet, $0)
|
||||
} ?? false
|
||||
let signedDisplayName = verifiedWithKnownKey
|
||||
? nil
|
||||
: env.signedSenderDisplayName(packet, peerID)
|
||||
guard verifiedWithKnownKey || signedDisplayName != nil else { return nil }
|
||||
|
||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localPeerID: localPeerID,
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
allowConnectedUnverified: false
|
||||
) ?? signedDisplayName
|
||||
// The packet signature authenticates the announced peer; the old
|
||||
// connected-but-unsigned leniency is not involved.
|
||||
allowConnectedUnverified: true
|
||||
) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,8 +201,11 @@ struct BLEFragmentAssemblyBuffer {
|
||||
}
|
||||
|
||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||
if originalType == MessageType.fileTransfer.rawValue {
|
||||
if originalType == MessageType.fileTransfer.rawValue
|
||||
|| originalType == MessageType.noiseEncrypted.rawValue {
|
||||
// Allow headroom for TLV metadata and binary framing overhead.
|
||||
// A large noiseEncrypted packet can be an E2E-encrypted private
|
||||
// file; its authenticated plaintext is validated after decrypt.
|
||||
return FileTransferLimits.maxFramedFileBytes
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ final class BLENoisePacketHandler {
|
||||
let payloadType = decrypted[0]
|
||||
let payloadData = decrypted.dropFirst()
|
||||
|
||||
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
|
||||
guard let noisePayloadType = NoisePayloadType.decoded(rawValue: payloadType) else {
|
||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ enum BLENoisePayloadFactory {
|
||||
typedPayload(.delivered, payload: Data(messageID.utf8))
|
||||
}
|
||||
|
||||
static func privateFile(_ filePacket: BitchatFilePacket) -> Data? {
|
||||
guard let payload = filePacket.encode() else { return nil }
|
||||
return typedPayload(.privateFile, payload: payload)
|
||||
}
|
||||
|
||||
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
||||
var typed = Data([type.rawValue])
|
||||
typed.append(payload)
|
||||
|
||||
@@ -6,9 +6,16 @@ struct BLEPendingPrivateMessage: Equatable {
|
||||
let messageID: String
|
||||
}
|
||||
|
||||
struct BLEPendingTypedPayload: Equatable {
|
||||
let payload: Data
|
||||
/// Present for app-initiated media so handshake queuing preserves the
|
||||
/// fragment scheduler's progress/cancellation identity.
|
||||
let transferId: String?
|
||||
}
|
||||
|
||||
struct BLENoiseSessionQueues {
|
||||
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
||||
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
|
||||
private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:]
|
||||
|
||||
var isEmpty: Bool {
|
||||
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
||||
@@ -34,13 +41,35 @@ struct BLENoiseSessionQueues {
|
||||
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
||||
}
|
||||
|
||||
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
|
||||
typedPayloadsByPeerID[peerID, default: []].append(payload)
|
||||
mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) {
|
||||
typedPayloadsByPeerID[peerID, default: []].append(
|
||||
BLEPendingTypedPayload(payload: payload, transferId: transferId)
|
||||
)
|
||||
}
|
||||
|
||||
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
|
||||
mutating func takeTypedPayloads(for peerID: PeerID) -> [BLEPendingTypedPayload] {
|
||||
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
||||
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
||||
return payloads
|
||||
}
|
||||
|
||||
func containsTypedPayload(transferId: String) -> Bool {
|
||||
typedPayloadsByPeerID.values.contains { payloads in
|
||||
payloads.contains { $0.transferId == transferId }
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func removeTypedPayload(transferId: String) -> Bool {
|
||||
for peerID in Array(typedPayloadsByPeerID.keys) {
|
||||
guard var payloads = typedPayloadsByPeerID[peerID],
|
||||
let index = payloads.firstIndex(where: { $0.transferId == transferId }) else {
|
||||
continue
|
||||
}
|
||||
payloads.remove(at: index)
|
||||
typedPayloadsByPeerID[peerID] = payloads.isEmpty ? nil : payloads
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ struct BLEOutboundFragmentPlan {
|
||||
}
|
||||
|
||||
enum BLEOutboundFragmentPlanner {
|
||||
/// Current Android receivers reject fragment sets above 256. Private
|
||||
/// media v1 treats that deployed ceiling as a cross-platform contract.
|
||||
static let privateMediaV1MaxFragments = 256
|
||||
private static let minimumChunkSize = 64
|
||||
private static let fragmentIDLength = 8
|
||||
|
||||
@@ -71,6 +74,10 @@ enum BLEOutboundFragmentPlanner {
|
||||
)
|
||||
}
|
||||
|
||||
static func isPrivateMediaV1Compatible(_ plan: BLEOutboundFragmentPlan) -> Bool {
|
||||
plan.totalFragments <= privateMediaV1MaxFragments
|
||||
}
|
||||
|
||||
private static func sizingPolicy(
|
||||
for packet: BitchatPacket,
|
||||
requestedMaxChunk: Int?,
|
||||
|
||||
@@ -29,8 +29,9 @@ struct BLEOutboundFragmentTransferRequest {
|
||||
}
|
||||
|
||||
var resolvedTransferId: String? {
|
||||
if let transferId { return transferId }
|
||||
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
||||
return transferId ?? packet.payload.sha256Hex()
|
||||
return packet.payload.sha256Hex()
|
||||
}
|
||||
|
||||
/// Content identity independent of the caller-chosen transfer ID: the
|
||||
|
||||
@@ -10,6 +10,9 @@ struct BLEPeerInfo: Equatable {
|
||||
var isVerifiedNickname: Bool
|
||||
var lastSeen: Date
|
||||
var capabilities: PeerCapabilities = []
|
||||
/// Distinguishes an old client that omitted the capabilities TLV from a
|
||||
/// modern client that explicitly advertised a set without a given bit.
|
||||
var capabilitiesWereExplicitlyAdvertised: Bool = false
|
||||
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
|
||||
var bridgeGeohash: String?
|
||||
}
|
||||
@@ -114,6 +117,10 @@ struct BLEPeerRegistry {
|
||||
peers[peerID.toShort()]?.capabilities ?? []
|
||||
}
|
||||
|
||||
func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
|
||||
peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true
|
||||
}
|
||||
|
||||
/// Peers whose last verified announce advertised the given capability.
|
||||
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
||||
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
||||
@@ -181,7 +188,7 @@ struct BLEPeerRegistry {
|
||||
signingPublicKey: Data?,
|
||||
isConnected: Bool,
|
||||
now: Date,
|
||||
capabilities: PeerCapabilities = [],
|
||||
capabilities: PeerCapabilities? = nil,
|
||||
bridgeGeohash: String? = nil
|
||||
) -> BLEPeerAnnounceUpdate {
|
||||
let existing = peers[peerID]
|
||||
@@ -199,7 +206,8 @@ struct BLEPeerRegistry {
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now,
|
||||
capabilities: capabilities,
|
||||
capabilities: capabilities ?? [],
|
||||
capabilitiesWereExplicitlyAdvertised: capabilities != nil,
|
||||
bridgeGeohash: bridgeGeohash
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -725,11 +725,38 @@ final class NoiseEncryptionService {
|
||||
|
||||
return try sessionManager.encrypt(data, for: peerID)
|
||||
}
|
||||
|
||||
/// Encrypts a finalized private-media packet. Ordinary Noise application
|
||||
/// messages retain the 64 KiB ceiling; this purpose-specific path permits
|
||||
/// the bounded `BitchatFilePacket` envelope and refuses every other typed
|
||||
/// payload so the larger allocation budget cannot become a generic bypass.
|
||||
func encryptPrivateFilePayload(_ data: Data, for peerID: PeerID) throws -> Data {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: data.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
guard rateLimiter.allowMessage(from: peerID) else {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
|
||||
guard hasEstablishedSession(with: peerID) else {
|
||||
onHandshakeRequired?(peerID)
|
||||
throw NoiseEncryptionError.handshakeRequired
|
||||
}
|
||||
|
||||
// `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed
|
||||
// nonce/tag overhead, so the result is bounded without a second copy.
|
||||
return try sessionManager.encrypt(data, for: peerID)
|
||||
}
|
||||
|
||||
/// Decrypt data from a specific peer
|
||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
||||
// A larger candidate is admitted only up to the framed-file ceiling;
|
||||
// after authenticated decryption it must prove it is `.privateFile`.
|
||||
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
@@ -743,7 +770,14 @@ final class NoiseEncryptionService {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
|
||||
return try sessionManager.decrypt(data, from: peerID)
|
||||
let decrypted = try sessionManager.decrypt(data, from: peerID)
|
||||
if !isStandardCiphertext {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: decrypted.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
|
||||
// MARK: - Peer Management
|
||||
|
||||
@@ -11,6 +11,7 @@ final class TransferProgressManager {
|
||||
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
||||
case completed(id: String, totalFragments: Int)
|
||||
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
||||
case rejected(id: String, reason: String)
|
||||
}
|
||||
|
||||
private let subject = PassthroughSubject<Event, Never>()
|
||||
@@ -49,6 +50,17 @@ final class TransferProgressManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fails a preflight check while keeping the outgoing placeholder visible
|
||||
/// with an actionable reason instead of treating policy/size rejection as
|
||||
/// a user cancellation.
|
||||
func rejectBeforeStart(id: String, reason: String) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.states.removeValue(forKey: id)
|
||||
self.subject.send(.rejected(id: id, reason: reason))
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
||||
var result: (sent: Int, total: Int)?
|
||||
queue.sync {
|
||||
|
||||
@@ -83,6 +83,15 @@ enum TransportEvent: @unchecked Sendable {
|
||||
case bluetoothStateUpdated(CBManagerState)
|
||||
}
|
||||
|
||||
/// Downgrade-safe decision for a private-media recipient. Callers ask before
|
||||
/// prompting, and BLEService checks again when it consumes any one-shot
|
||||
/// legacy consent.
|
||||
enum PrivateMediaSendPolicy: Equatable {
|
||||
case encrypted
|
||||
case legacyRequiresConsent
|
||||
case blockedDowngrade
|
||||
}
|
||||
|
||||
protocol TransportEventDelegate: AnyObject {
|
||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||
}
|
||||
@@ -163,6 +172,12 @@ protocol Transport: AnyObject {
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
|
||||
// Live voice / push-to-talk (mesh transports only): one encoded
|
||||
@@ -208,6 +223,7 @@ protocol Transport: AnyObject {
|
||||
/// Capabilities the peer advertised in its last verified announce;
|
||||
/// empty for peers that predate the capabilities TLV.
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||
/// Sends an encoded vouch-attestation batch inside the Noise session.
|
||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
|
||||
/// Appends a peer-authenticated observer. Unlike
|
||||
@@ -278,6 +294,7 @@ extension Transport {
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
||||
func broadcastGroupMessage(_ envelope: Data) {}
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
|
||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||
@@ -294,6 +311,15 @@ extension Transport {
|
||||
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
guard !allowLegacyFallback else { return }
|
||||
sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||
|
||||
@@ -6,6 +6,19 @@ import Foundation
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let peerID: PeerID
|
||||
let peerName: String
|
||||
let transferId: String
|
||||
let messageID: String
|
||||
}
|
||||
|
||||
struct PendingLegacyPrivateMediaConsent {
|
||||
let request: LegacyPrivateMediaConsentRequest
|
||||
let completion: @MainActor (Bool) -> Void
|
||||
}
|
||||
|
||||
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
|
||||
///
|
||||
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
||||
@@ -43,7 +56,20 @@ protocol ChatMediaTransferContext: AnyObject {
|
||||
func recordContentKey(_ key: String, timestamp: Date)
|
||||
|
||||
// MARK: Mesh file transfer
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
)
|
||||
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String)
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
)
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
}
|
||||
@@ -59,8 +85,43 @@ extension ChatViewModel: ChatMediaTransferContext {
|
||||
// other contexts or satisfied by existing `ChatViewModel` members. The
|
||||
// members below flatten mesh service accesses.
|
||||
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||
meshService.privateMediaSendPolicy(to: peerID)
|
||||
}
|
||||
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
enqueueLegacyPrivateMediaConsent(
|
||||
for: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||
invalidateLegacyPrivateMediaConsent(
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
}
|
||||
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
meshService.sendFilePrivate(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: allowLegacyFallback
|
||||
)
|
||||
}
|
||||
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||
@@ -75,12 +136,19 @@ extension ChatViewModel: ChatMediaTransferContext {
|
||||
@MainActor
|
||||
final class ChatMediaTransferCoordinator {
|
||||
private unowned let context: any ChatMediaTransferContext
|
||||
private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket
|
||||
|
||||
private(set) var transferIdToMessageIDs: [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.prepareVoiceNotePacket = prepareVoiceNotePacket
|
||||
}
|
||||
|
||||
func sendVoiceNote(at url: URL) {
|
||||
@@ -98,16 +166,28 @@ final class ChatMediaTransferCoordinator {
|
||||
)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
// Own the transfer before detached preparation begins. Cancel/delete
|
||||
// must be able to invalidate this exact invocation even while file I/O
|
||||
// is still running off the main actor.
|
||||
registerTransfer(transferId: transferId, messageID: messageID)
|
||||
let prepareVoiceNotePacket = self.prepareVoiceNotePacket
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
do {
|
||||
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||
let packet = try prepareVoiceNotePacket(url)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
guard let self,
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
if let peerID = targetPeer {
|
||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
self.beginPrivateMediaSend(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
} else {
|
||||
self.context.sendFileBroadcast(packet, transferId: transferId)
|
||||
}
|
||||
@@ -116,13 +196,19 @@ final class ChatMediaTransferCoordinator {
|
||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
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"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -193,7 +279,12 @@ final class ChatMediaTransferCoordinator {
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
|
||||
self.beginPrivateMediaSend(
|
||||
prepared.packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
} else {
|
||||
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
|
||||
}
|
||||
@@ -253,17 +344,85 @@ final class ChatMediaTransferCoordinator {
|
||||
return message
|
||||
}
|
||||
|
||||
private func beginPrivateMediaSend(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String
|
||||
) {
|
||||
switch context.privateMediaSendPolicy(to: peerID) {
|
||||
case .encrypted:
|
||||
context.sendFilePrivate(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: false
|
||||
)
|
||||
|
||||
case .legacyRequiresConsent:
|
||||
context.requestLegacyPrivateMediaConsent(
|
||||
for: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
) { [weak self] approved in
|
||||
guard let self else { return }
|
||||
// Consent belongs to this exact placeholder/transfer. A late
|
||||
// dialog callback after cancel/delete must never resurrect it.
|
||||
guard self.messageIDToTransferId[messageID] == transferId,
|
||||
self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else {
|
||||
return
|
||||
}
|
||||
guard approved else {
|
||||
self.handleMediaSendFailure(
|
||||
messageID: messageID,
|
||||
reason: String(
|
||||
localized: "content.delivery.reason.legacy_media_declined",
|
||||
defaultValue: "Not sent without end-to-end encryption",
|
||||
comment: "Failure reason after declining the warning for a legacy clear private-media send"
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
self.context.sendFilePrivate(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: true
|
||||
)
|
||||
}
|
||||
|
||||
case .blockedDowngrade:
|
||||
handleMediaSendFailure(
|
||||
messageID: messageID,
|
||||
reason: String(
|
||||
localized: "content.delivery.reason.private_media_downgrade_blocked",
|
||||
defaultValue: "Encrypted media required; ask this contact to upgrade",
|
||||
comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func registerTransfer(transferId: String, messageID: String) {
|
||||
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
||||
messageIDToTransferId[messageID] = transferId
|
||||
}
|
||||
|
||||
private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool {
|
||||
messageIDToTransferId[messageID] == transferId
|
||||
&& transferIdToMessageIDs[transferId]?.contains(messageID) == true
|
||||
}
|
||||
|
||||
func makeTransferID(messageID: String) -> String {
|
||||
"\(messageID)-\(UUID().uuidString)"
|
||||
}
|
||||
|
||||
func clearTransferMapping(for messageID: String) {
|
||||
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
|
||||
context.cancelLegacyPrivateMediaConsent(
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
guard var queue = transferIdToMessageIDs[transferId] else { return }
|
||||
|
||||
if !queue.isEmpty {
|
||||
@@ -298,6 +457,9 @@ final class ChatMediaTransferCoordinator {
|
||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
case .rejected(let id, let reason):
|
||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||
handleMediaSendFailure(messageID: messageID, reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +500,13 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
func deleteMediaMessage(messageID: String) {
|
||||
// Delete is also a send cancellation. In particular, an approved
|
||||
// legacy-clear send may still be waiting on BLEService.messageQueue;
|
||||
// removing only the UI mapping would let that deferred work transmit.
|
||||
if let transferId = messageIDToTransferId[messageID],
|
||||
transferIdToMessageIDs[transferId]?.first == messageID {
|
||||
context.cancelTransfer(transferId)
|
||||
}
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
|
||||
@@ -407,6 +407,12 @@ private extension ChatTransportEventCoordinator {
|
||||
|
||||
case .voiceFrame:
|
||||
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
|
||||
|
||||
case .privateFile:
|
||||
// BLEService validates and persists decrypted private files before
|
||||
// emitting a normal `.messageReceived` event. No raw file bytes
|
||||
// should cross this UI-facing typed-payload fallback.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -347,6 +347,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
@Published var showBluetoothAlert = false
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
||||
|
||||
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
||||
if Thread.isMainThread {
|
||||
@@ -1156,6 +1158,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
func panicClearAllData() {
|
||||
// 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
|
||||
// single-writer ConversationStore; the derived `messages` view and
|
||||
// the legacy mirror empty with it)
|
||||
@@ -1804,4 +1810,91 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
publicConversationCoordinator.sendHapticFeedback(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension ChatViewModel {
|
||||
func enqueueLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
let request = LegacyPrivateMediaConsentRequest(
|
||||
id: UUID(),
|
||||
peerID: peerID,
|
||||
peerName: nicknameForPeer(peerID),
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
pendingLegacyPrivateMediaConsents.append(PendingLegacyPrivateMediaConsent(
|
||||
request: request,
|
||||
completion: completion
|
||||
))
|
||||
if legacyPrivateMediaConsentRequest == nil {
|
||||
legacyPrivateMediaConsentRequest = request
|
||||
}
|
||||
}
|
||||
|
||||
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
|
||||
// SwiftUI may report both the selected button and the presentation
|
||||
// binding's dismissal. Resolve only the exact request that was shown;
|
||||
// a duplicate callback for it must not consume the next queued send.
|
||||
guard legacyPrivateMediaConsentRequest?.id == requestID,
|
||||
pendingLegacyPrivateMediaConsents.first?.request.id == requestID else {
|
||||
return
|
||||
}
|
||||
let resolved = pendingLegacyPrivateMediaConsents.removeFirst()
|
||||
// Drive the boolean presentation state through false before showing
|
||||
// the next queued per-send warning. Otherwise SwiftUI sees true→true,
|
||||
// closes the first dialog, and never presents the second.
|
||||
legacyPrivateMediaConsentRequest = nil
|
||||
resolved.completion(approved)
|
||||
presentNextLegacyPrivateMediaConsentDeferred()
|
||||
}
|
||||
|
||||
func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||
let invalidatedIDs = Set(
|
||||
pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in
|
||||
let request = pending.request
|
||||
return request.transferId == transferId && request.messageID == messageID
|
||||
? request.id
|
||||
: nil
|
||||
}
|
||||
)
|
||||
guard !invalidatedIDs.isEmpty else { return }
|
||||
|
||||
pendingLegacyPrivateMediaConsents.removeAll {
|
||||
invalidatedIDs.contains($0.request.id)
|
||||
}
|
||||
if let currentID = legacyPrivateMediaConsentRequest?.id,
|
||||
invalidatedIDs.contains(currentID) {
|
||||
legacyPrivateMediaConsentRequest = nil
|
||||
presentNextLegacyPrivateMediaConsentDeferred()
|
||||
}
|
||||
}
|
||||
|
||||
func cancelAllLegacyPrivateMediaConsents() {
|
||||
let pending = pendingLegacyPrivateMediaConsents
|
||||
pendingLegacyPrivateMediaConsents.removeAll()
|
||||
legacyPrivateMediaConsentRequest = nil
|
||||
for item in pending {
|
||||
item.completion(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentNextLegacyPrivateMediaConsentDeferred() {
|
||||
guard legacyPrivateMediaConsentRequest == nil,
|
||||
let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
self.legacyPrivateMediaConsentRequest == nil,
|
||||
self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else {
|
||||
return
|
||||
}
|
||||
self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request
|
||||
}
|
||||
}
|
||||
}
|
||||
// End of ChatViewModel class
|
||||
|
||||
@@ -307,7 +307,7 @@ final class NostrInboundPipeline {
|
||||
// claiming to be group traffic over Nostr is ignored.
|
||||
// Live voice is mesh-only: latency and relay cost make it
|
||||
// meaningless over Nostr.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -363,7 +363,7 @@ final class NostrInboundPipeline {
|
||||
// claiming to be group traffic over Nostr is ignored.
|
||||
// Live voice is mesh-only: latency and relay cost make it
|
||||
// meaningless over Nostr.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -446,7 +446,7 @@ final class NostrInboundPipeline {
|
||||
// in v1; group traffic over Nostr is ignored.
|
||||
// Live voice is mesh-only: latency and relay cost make it
|
||||
// meaningless over Nostr.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ struct ContentPeopleSheetView: View {
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
|
||||
NavigationStack {
|
||||
Group {
|
||||
if privateConversationModel.selectedPeerID != nil {
|
||||
@@ -97,6 +98,63 @@ struct ContentPeopleSheetView: View {
|
||||
}
|
||||
.themedSheetBackground()
|
||||
.foregroundColor(palette.primary)
|
||||
.confirmationDialog(
|
||||
String(
|
||||
localized: "content.private_media.legacy_warning.title",
|
||||
defaultValue: "Send without end-to-end encryption?",
|
||||
comment: "Title warning before sending private media to an older client in a clear signed envelope"
|
||||
),
|
||||
isPresented: Binding(
|
||||
get: { legacyConsentRequest != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented, let requestID = legacyConsentRequest?.id {
|
||||
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||
requestID: requestID,
|
||||
approved: false
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(
|
||||
String(
|
||||
localized: "content.private_media.legacy_warning.send",
|
||||
defaultValue: "send visible file",
|
||||
comment: "Destructive confirmation action for one legacy clear private-media send"
|
||||
),
|
||||
role: .destructive
|
||||
) {
|
||||
if let requestID = legacyConsentRequest?.id {
|
||||
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||
requestID: requestID,
|
||||
approved: true
|
||||
)
|
||||
}
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {
|
||||
if let requestID = legacyConsentRequest?.id {
|
||||
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||
requestID: requestID,
|
||||
approved: false
|
||||
)
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
if let request = legacyConsentRequest {
|
||||
Text(
|
||||
String(
|
||||
format: String(
|
||||
localized: "content.private_media.legacy_warning.message",
|
||||
defaultValue: "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?",
|
||||
comment: "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name"
|
||||
),
|
||||
locale: .current,
|
||||
request.peerName
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
#endif
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||
//
|
||||
// Scope note: the async media-preparation pipelines (`ImageUtils`,
|
||||
// `ChatMediaPreparation`) run real file/codec work and remain covered by
|
||||
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer
|
||||
// bookkeeping, and the blocked-context guards.
|
||||
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
|
||||
// tests inject a paused voice-note preparer to exercise cancellation ownership
|
||||
// across the detached-preparation/MainActor boundary deterministically.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@@ -86,11 +85,64 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
|
||||
// Mesh file transfer
|
||||
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
||||
private(set) var privateFileLegacyAllowances: [Bool] = []
|
||||
private(set) var broadcastFileSends: [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))
|
||||
privateFileLegacyAllowances.append(allowLegacyFallback)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/// Exercises `ChatMediaTransferCoordinator` against
|
||||
@@ -166,6 +268,14 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.removedMessages.count == 1)
|
||||
#expect(context.removedMessages.first?.messageID == "m2")
|
||||
#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
|
||||
@@ -188,6 +298,20 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#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
|
||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
@@ -206,4 +330,224 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.appendedPublicMessages.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)
|
||||
}
|
||||
|
||||
@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
|
||||
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
private var blockedFingerprints: Set<String> = []
|
||||
private var blockedNostrPubkeys: Set<String> = []
|
||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||
private var privateMediaCapableFingerprints: Set<String> = []
|
||||
|
||||
init(_: KeychainManagerProtocol) {}
|
||||
|
||||
@@ -87,7 +88,9 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
||||
|
||||
func clearAllIdentityData() {}
|
||||
func clearAllIdentityData() {
|
||||
privateMediaCapableFingerprints.removeAll()
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
@@ -101,6 +104,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
Set()
|
||||
}
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {
|
||||
privateMediaCapableFingerprints.insert(fingerprint)
|
||||
}
|
||||
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
|
||||
privateMediaCapableFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
// MARK: Vouching (transitive verification)
|
||||
|
||||
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
|
||||
|
||||
@@ -36,6 +36,7 @@ final class MockTransport: Transport {
|
||||
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, 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 sentVerifyChallenges: [(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 peerFingerprints: [PeerID: String] = [:]
|
||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
// MARK: - Transport Protocol Implementation
|
||||
@@ -186,6 +188,21 @@ final class MockTransport: Transport {
|
||||
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
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) {
|
||||
|
||||
@@ -33,6 +33,7 @@ struct BLEFileTransferHandlerTests {
|
||||
recorder.signatureVerifyCount += 1
|
||||
return recorder.signatureVerifies
|
||||
},
|
||||
localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey },
|
||||
signedSenderDisplayName: { _, peerID in
|
||||
recorder.signedNameQueries.append(peerID)
|
||||
return recorder.signedName
|
||||
@@ -92,12 +93,11 @@ struct BLEFileTransferHandlerTests {
|
||||
@Test
|
||||
func selfEchoIsDropped() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
||||
|
||||
// The relay pipeline already suppresses self-originated packets, so the
|
||||
// handler reports "relayable" rather than treating the echo as forged.
|
||||
#expect(handler.handle(packet, from: localPeerID))
|
||||
#expect(!handler.handle(packet, from: localPeerID))
|
||||
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
@@ -120,7 +120,12 @@ struct BLEFileTransferHandlerTests {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
mimeType: "application/pdf",
|
||||
content: Data("%PDF-1.7".utf8),
|
||||
hasSignature: false
|
||||
)
|
||||
|
||||
// Failed sender authentication must also stop the packet from being
|
||||
// relayed to downstream nodes.
|
||||
@@ -129,7 +134,7 @@ struct BLEFileTransferHandlerTests {
|
||||
// Broadcast files carry an attacker-controllable senderID, so — like
|
||||
// public messages — a connected-but-unverified peer must present a valid
|
||||
// packet signature. No signing key + no signed identity means dropped.
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
@@ -153,12 +158,11 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws {
|
||||
// Our own broadcast file replayed via gossip sync arrives with ttl==0
|
||||
// (so it is not treated as a self-echo) and cannot be verified against
|
||||
// the peer registry — it must still be accepted, matching
|
||||
// BLEPublicMessageHandler's self exemption.
|
||||
func signedSelfBroadcastReplayIsDelivered() throws {
|
||||
// Our own broadcast file replayed via gossip sync arrives with ttl==0;
|
||||
// it is verified against our local signing key before delivery.
|
||||
let recorder = Recorder()
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: localPeerID,
|
||||
@@ -169,7 +173,7 @@ struct BLEFileTransferHandlerTests {
|
||||
|
||||
#expect(handler.handle(packet, from: localPeerID))
|
||||
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signatureVerifyCount == 1)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
||||
@@ -205,7 +209,8 @@ struct BLEFileTransferHandlerTests {
|
||||
sender: remotePeerID,
|
||||
mimeType: "audio/mp4",
|
||||
content: m4a,
|
||||
fileName: "voice_1122334455667788"
|
||||
fileName: "voice_1122334455667788",
|
||||
hasSignature: false
|
||||
)
|
||||
|
||||
// The spoofed note must be dropped locally AND not relayed onward.
|
||||
@@ -215,7 +220,7 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateFileFromConnectedUnverifiedPeerIsAccepted() throws {
|
||||
func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
@@ -223,23 +228,25 @@ struct BLEFileTransferHandlerTests {
|
||||
sender: remotePeerID,
|
||||
mimeType: "application/pdf",
|
||||
content: Data("%PDF-1.7".utf8),
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
hasSignature: false
|
||||
)
|
||||
|
||||
#expect(handler.handle(packet, from: remotePeerID))
|
||||
#expect(!handler.handle(packet, from: remotePeerID))
|
||||
|
||||
// Directed transfers keep the lenient connected-peer path (no broadcast
|
||||
// exposure); no signature check is required.
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func fileDirectedToAnotherPeerIsIgnored() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
@@ -260,7 +267,8 @@ struct BLEFileTransferHandlerTests {
|
||||
@Test
|
||||
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
@@ -282,6 +290,56 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "secret.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
let timestamp = Date(timeIntervalSince1970: 1_234)
|
||||
|
||||
#expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp))
|
||||
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.quotaReservations == [content.count])
|
||||
#expect(recorder.saveCalls.first?.data == content)
|
||||
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let oversizedCount = FileTransferLimits.maxPayloadBytes + 1
|
||||
var length = UInt32(oversizedCount).bigEndian
|
||||
var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV
|
||||
withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) }
|
||||
payload.append(Data(repeating: 0x41, count: oversizedCount))
|
||||
|
||||
#expect(!handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func malformedPayloadIsTrackedForSyncButDropped() {
|
||||
let recorder = Recorder()
|
||||
@@ -294,7 +352,7 @@ struct BLEFileTransferHandlerTests {
|
||||
recipientID: nil,
|
||||
timestamp: 900_000,
|
||||
payload: Data([0x01, 0x02, 0x03]),
|
||||
signature: nil,
|
||||
signature: Data(repeating: 0x5A, count: 64),
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
|
||||
@@ -403,7 +461,8 @@ struct BLEFileTransferHandlerTests {
|
||||
content: Data,
|
||||
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
||||
recipientID: Data? = nil,
|
||||
fileName: String = "sample"
|
||||
fileName: String = "sample",
|
||||
hasSignature: Bool = true
|
||||
) throws -> BitchatPacket {
|
||||
let filePacket = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
@@ -418,7 +477,7 @@ struct BLEFileTransferHandlerTests {
|
||||
recipientID: recipientID,
|
||||
timestamp: 900_000,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,6 +117,35 @@ struct BLEFragmentAssemblyBufferTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let fragmentID = Data(repeating: 0x15, count: 8)
|
||||
let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: 0,
|
||||
total: 2,
|
||||
originalType: MessageType.noiseEncrypted.rawValue,
|
||||
fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes)
|
||||
)))
|
||||
let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: 1,
|
||||
total: 2,
|
||||
originalType: MessageType.noiseEncrypted.rawValue,
|
||||
fragmentData: Data([0x02])
|
||||
)))
|
||||
|
||||
_ = buffer.append(first, maxInFlightAssemblies: 8)
|
||||
let result = buffer.append(second, maxInFlightAssemblies: 8)
|
||||
|
||||
if case let .complete(_, data, _) = result {
|
||||
#expect(data.count == FileTransferLimits.maxPayloadBytes + 1)
|
||||
} else {
|
||||
Issue.record("Expected encrypted private-file assembly to use framed-file limit")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeExpiredDropsOldAssemblies() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct BLENoisePayloadFactoryTests {
|
||||
@@ -31,4 +32,51 @@ struct BLENoisePayloadFactoryTests {
|
||||
|
||||
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateFilePayloadPrefixesCanonicalFilePacket() throws {
|
||||
let content = Data("%PDF-secret".utf8)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "secret.pdf",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "application/pdf",
|
||||
content: content
|
||||
)
|
||||
|
||||
let payload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||
|
||||
#expect(payload.first == 0x20, "Encrypted files must use Android's deployed wire value")
|
||||
let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst())))
|
||||
#expect(decoded.fileName == "secret.pdf")
|
||||
#expect(decoded.mimeType == "application/pdf")
|
||||
#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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,10 @@ struct BLENoiseSessionQueuesTests {
|
||||
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
||||
queues.appendTypedPayload(Data([0x02]), for: peerID)
|
||||
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])])
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||
BLEPendingTypedPayload(payload: Data([0x01]), transferId: nil),
|
||||
BLEPendingTypedPayload(payload: Data([0x02]), transferId: nil)
|
||||
])
|
||||
#expect(queues.takeTypedPayloads(for: peerID).isEmpty)
|
||||
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
|
||||
}
|
||||
@@ -64,4 +67,21 @@ struct BLENoiseSessionQueuesTests {
|
||||
|
||||
#expect(queues.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() {
|
||||
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
var queues = BLENoiseSessionQueues()
|
||||
|
||||
queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID)
|
||||
queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID)
|
||||
|
||||
let removed = queues.removeTypedPayload(transferId: "media-1")
|
||||
let removedAgain = queues.removeTypedPayload(transferId: "media-1")
|
||||
#expect(removed)
|
||||
#expect(!removedAgain)
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||
BLEPendingTypedPayload(payload: Data([0x01, 0xBB]), transferId: nil)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,58 @@ struct BLEOutboundFragmentPlannerTests {
|
||||
) == 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(
|
||||
payload: Data,
|
||||
route: [Data]? = nil,
|
||||
|
||||
@@ -20,6 +20,24 @@ struct BLEOutboundFragmentTransferSchedulerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func explicitTransferIDReservesEncryptedPrivateFileFragments() {
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
let request = makeRequest(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
transferId: "private-media"
|
||||
)
|
||||
|
||||
let result = scheduler.submit(request, maxConcurrentTransfers: 1)
|
||||
|
||||
if case let .start(_, reservedTransferId) = result {
|
||||
#expect(reservedTransferId == "private-media")
|
||||
#expect(scheduler.activeCount == 1)
|
||||
} else {
|
||||
Issue.record("Expected encrypted private media to reserve its progress slot")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func submitQueuesFileTransferWhenSlotsAreFull() {
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
|
||||
@@ -42,6 +42,37 @@ struct BLEPeerRegistryTests {
|
||||
#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")
|
||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||
let offlinePeer = PeerID(str: "1122334455667788")
|
||||
|
||||
@@ -237,6 +237,55 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
|
||||
}
|
||||
|
||||
@Test("Large private-file payloads use the bounded Noise extension")
|
||||
func largePrivateFileNoiseRoundTrip() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
try establishSessions(alice: alice, bob: bob)
|
||||
|
||||
let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "large-private.pdf",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "application/pdf",
|
||||
content: content
|
||||
)
|
||||
let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||
#expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize)
|
||||
#expect(typedPayload.first == NoisePayloadType.privateFile.rawValue)
|
||||
#expect(
|
||||
typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize,
|
||||
"typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)"
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try alice.encrypt(typedPayload, for: bobPeerID)
|
||||
Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling")
|
||||
} catch NoiseSecurityError.messageTooLarge {
|
||||
// Expected: only the purpose-specific private-file API may extend it.
|
||||
}
|
||||
|
||||
let ciphertext: Data
|
||||
do {
|
||||
ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: bobPeerID)
|
||||
} catch {
|
||||
Issue.record("Private-file encryption failed: \(error)")
|
||||
return
|
||||
}
|
||||
let decrypted: Data
|
||||
do {
|
||||
decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
||||
} catch {
|
||||
Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(ciphertext.range(of: content) == nil)
|
||||
#expect(decrypted == typedPayload)
|
||||
}
|
||||
|
||||
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||
func handshakeRequiredAndSessionNotEstablishedErrors() throws {
|
||||
let service = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
@@ -399,6 +399,34 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
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 {
|
||||
let keychain = FailingCacheSaveKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
@@ -49,7 +49,7 @@ struct TransferProgressManagerTests {
|
||||
recorder.append("updated:\(id):\(sent):\(total)")
|
||||
case .completed(let id, let total):
|
||||
recorder.append("completed:\(id):\(total)")
|
||||
case .cancelled:
|
||||
case .cancelled, .rejected:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ struct TransferProgressManagerTests {
|
||||
recorder.append("started:\(id):\(total)")
|
||||
case .cancelled(let id, let sent, let total):
|
||||
recorder.append("cancelled:\(id):\(sent):\(total)")
|
||||
case .updated, .completed:
|
||||
case .updated, .completed, .rejected:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,28 @@ struct TransferProgressManagerTests {
|
||||
#expect(manager.snapshot(id: transferID) == nil)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@Test("Preflight policy rejection publishes a visible failure reason")
|
||||
@MainActor
|
||||
func rejectBeforeStartPublishesReason() async {
|
||||
let manager = TransferProgressManager()
|
||||
let transferID = "transfer-visible-reject"
|
||||
let recorder = EventRecorder()
|
||||
let cancellable = manager.publisher.sink { event in
|
||||
if case .rejected(let id, let reason) = event {
|
||||
recorder.append("rejected:\(id):\(reason)")
|
||||
}
|
||||
}
|
||||
|
||||
manager.rejectBeforeStart(id: transferID, reason: "upgrade required")
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({
|
||||
recorder.values == ["rejected:\(transferID):upgrade required"]
|
||||
}, timeout: 5.0)
|
||||
#expect(didReceive)
|
||||
#expect(manager.snapshot(id: transferID) == nil)
|
||||
_ = cancellable
|
||||
}
|
||||
}
|
||||
|
||||
private final class EventRecorder: @unchecked Sendable {
|
||||
|
||||
@@ -266,6 +266,9 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
verified.removeAll()
|
||||
}
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {}
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
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,6 +24,10 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
||||
/// (uplink/downlink carriers for mesh-only peers). Advertised alongside
|
||||
/// a `bridgeGeohash` TLV carrying the rendezvous cell.
|
||||
public static let bridge = PeerCapabilities(rawValue: 1 << 7)
|
||||
/// Finalized direct-message media encrypted as Noise payload `0x20`
|
||||
/// 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)
|
||||
|
||||
/// Minimal little-endian byte encoding; always at least one byte so an
|
||||
/// empty set is distinguishable from an absent TLV.
|
||||
|
||||
@@ -16,11 +16,12 @@ struct PeerCapabilitiesTests {
|
||||
#expect(PeerCapabilities([]).encoded() == Data([0x00]))
|
||||
#expect(PeerCapabilities.prekeys.encoded() == Data([0x01]))
|
||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
||||
|
||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
||||
|
||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics]
|
||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||
|
||||
Reference in New Issue
Block a user