mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:45:22 +00:00
Encrypt private media before fragmentation
This commit is contained in:
@@ -6,11 +6,30 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum NoiseSecurityConstants {
|
enum NoiseSecurityConstants {
|
||||||
// Maximum message size to prevent memory exhaustion
|
// Maximum message size to prevent memory exhaustion
|
||||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
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
|
// Maximum handshake message size
|
||||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ struct NoiseSecurityValidator {
|
|||||||
static func validateMessageSize(_ data: Data) -> Bool {
|
static func validateMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
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
|
/// Validate handshake message size
|
||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
throw NoiseSecurityError.sessionExhausted
|
throw NoiseSecurityError.sessionExhausted
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// Ordinary Noise messages keep the protocol ceiling. Finalized media
|
||||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
// is the sole typed-payload extension and remains under the framed-file
|
||||||
|
// cap enforced again at the service and file-decoder layers.
|
||||||
|
let isPrivateFile = plaintext.first == NoisePayloadType.privateFile.rawValue
|
||||||
|
&& NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext)
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +46,11 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
throw NoiseSecurityError.sessionExpired
|
throw NoiseSecurityError.sessionExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// The payload type is encrypted, so a large candidate can only be
|
||||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
// 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
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||||
// Live voice (push-to-talk)
|
// Live voice (push-to-talk)
|
||||||
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
||||||
|
// Finalized private media. The complete BitchatFilePacket is encrypted
|
||||||
|
// inside Noise before the outer noiseEncrypted packet is fragmented.
|
||||||
|
case privateFile = 0x09
|
||||||
// Verification (QR-based OOB binding)
|
// Verification (QR-based OOB binding)
|
||||||
case verifyChallenge = 0x10 // Verification challenge
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
case verifyResponse = 0x11 // Verification response
|
case verifyResponse = 0x11 // Verification response
|
||||||
@@ -93,6 +96,7 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case .groupInvite: return "groupInvite"
|
case .groupInvite: return "groupInvite"
|
||||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||||
case .voiceFrame: return "voiceFrame"
|
case .voiceFrame: return "voiceFrame"
|
||||||
|
case .privateFile: return "privateFile"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
case .vouch: return "vouch"
|
case .vouch: return "vouch"
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import BitFoundation
|
|||||||
extension PeerCapabilities {
|
extension PeerCapabilities {
|
||||||
/// Capabilities this build advertises in its announce packets.
|
/// Capabilities this build advertises in its announce packets.
|
||||||
/// Each feature adds its bit here when it ships.
|
/// 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]
|
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||||
/// Verifies a packet's signature against a candidate signing key (registry path).
|
/// Verifies a packet's signature against a candidate signing key (registry path).
|
||||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
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.
|
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||||
/// Tracks the broadcast file packet for gossip sync.
|
/// Tracks the broadcast file packet for gossip sync.
|
||||||
@@ -46,54 +48,105 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `false` when the packet fails sender authentication and must
|
/// Returns `false` when the raw packet fails sender authentication (or is
|
||||||
/// not be relayed onward. Every other outcome returns `true`: files
|
/// a live self-echo) and must not be relayed onward. Authentication runs
|
||||||
/// directed to another peer are forwarded untouched, and local-only drops
|
/// before the routing decision, so a forged directed packet cannot use a
|
||||||
/// (malformed payload, quota, save failure) don't affect multi-hop
|
/// node that is not its recipient as an unsigned forwarding hop.
|
||||||
/// delivery to nodes that may handle them fine.
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
let env = environment
|
let env = environment
|
||||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
|
let localPeerID = env.localPeerID()
|
||||||
|
|
||||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
let peersSnapshot = env.peersSnapshot()
|
let peersSnapshot = env.peersSnapshot()
|
||||||
guard let senderNickname = resolveSenderNickname(
|
|
||||||
|
guard let senderNickname = authenticatedRawSenderNickname(
|
||||||
packet: packet,
|
packet: packet,
|
||||||
from: peerID,
|
from: peerID,
|
||||||
isBroadcast: !deliveryPlan.isPrivateMessage,
|
|
||||||
peers: peersSnapshot,
|
peers: peersSnapshot,
|
||||||
env: env
|
env: env
|
||||||
) else {
|
) 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
|
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 {
|
if deliveryPlan.shouldTrackForSync {
|
||||||
env.trackPacketSeen(packet)
|
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 filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||||
case .success(let acceptance):
|
case .success(let acceptance):
|
||||||
filePacket = acceptance.filePacket
|
filePacket = acceptance.filePacket
|
||||||
mime = acceptance.mime
|
mime = acceptance.mime
|
||||||
case .failure(.malformedPayload):
|
case .failure(.malformedPayload):
|
||||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||||
return true
|
return false
|
||||||
case .failure(.payloadTooLarge(let bytes)):
|
case .failure(.payloadTooLarge(let bytes)):
|
||||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||||
return true
|
return false
|
||||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
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)
|
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)):
|
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)
|
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
|
// BCH-01-002: Enforce storage quota before saving
|
||||||
@@ -106,28 +159,27 @@ final class BLEFileTransferHandler {
|
|||||||
mime.defaultExtension,
|
mime.defaultExtension,
|
||||||
mime.category.rawValue
|
mime.category.rawValue
|
||||||
) else {
|
) else {
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if deliveryPlan.isPrivateMessage {
|
if isPrivate {
|
||||||
env.updatePeerLastSeen(peerID)
|
env.updatePeerLastSeen(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
sender: senderNickname,
|
sender: senderNickname,
|
||||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||||
timestamp: ts,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: deliveryPlan.isPrivateMessage,
|
isPrivate: isPrivate,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID,
|
||||||
// Received messages need an explicit status: BitchatMessage
|
// Received messages need an explicit status: BitchatMessage
|
||||||
// defaults private messages to .sending, which the media views
|
// defaults private messages to .sending, which the media views
|
||||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
// render as an in-flight send (empty reveal mask, disabled tap).
|
||||||
deliveryStatus: deliveryPlan.isPrivateMessage
|
deliveryStatus: isPrivate
|
||||||
? .delivered(to: env.localNickname(), at: ts)
|
? .delivered(to: env.localNickname(), at: timestamp)
|
||||||
: nil
|
: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,51 +189,38 @@ final class BLEFileTransferHandler {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the authenticated display name for a file transfer's sender.
|
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||||
///
|
/// is broadcast, addressed to us, or merely passing through. Registry
|
||||||
/// Directed (private) transfers are addressed to us specifically and keep
|
/// signing keys are preferred; persisted identities cover peers that have
|
||||||
/// the lenient connected-peer path. Broadcast transfers carry an
|
/// rotated or are not currently present in the registry.
|
||||||
/// attacker-controllable `senderID` exactly like public messages and public
|
private func authenticatedRawSenderNickname(
|
||||||
/// 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(
|
|
||||||
packet: BitchatPacket,
|
packet: BitchatPacket,
|
||||||
from peerID: PeerID,
|
from peerID: PeerID,
|
||||||
isBroadcast: Bool,
|
|
||||||
peers: [PeerID: BLEPeerInfo],
|
peers: [PeerID: BLEPeerInfo],
|
||||||
env: BLEFileTransferHandlerEnvironment
|
env: BLEFileTransferHandlerEnvironment
|
||||||
) -> String? {
|
) -> String? {
|
||||||
guard isBroadcast else {
|
guard packet.signature != nil else { return nil }
|
||||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
|
||||||
peerID: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
localNickname: env.localNickname(),
|
|
||||||
peers: peers,
|
|
||||||
allowConnectedUnverified: true
|
|
||||||
) ?? env.signedSenderDisplayName(packet, peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our own broadcasts replayed back via gossip sync (ttl==0) are
|
let localPeerID = env.localPeerID()
|
||||||
// trivially authentic and cannot be verified against the peer registry
|
let candidateKey = peerID == localPeerID
|
||||||
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
|
? env.localSigningPublicKey()
|
||||||
// does. Verify against the signing key already in the
|
: peers[peerID]?.signingPublicKey
|
||||||
// (synchronously-updated) registry first, then fall back to the
|
let verifiedWithKnownKey = candidateKey.map {
|
||||||
// persisted-identity signature lookup for peers not yet cached there.
|
env.verifyPacketSignature(packet, $0)
|
||||||
let isSelf = peerID == env.localPeerID()
|
} ?? false
|
||||||
let registrySigningKey = peers[peerID]?.signingPublicKey
|
let signedDisplayName = verifiedWithKnownKey
|
||||||
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
? nil
|
||||||
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
: env.signedSenderDisplayName(packet, peerID)
|
||||||
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
|
guard verifiedWithKnownKey || signedDisplayName != nil else { return nil }
|
||||||
|
|
||||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
localPeerID: env.localPeerID(),
|
localPeerID: localPeerID,
|
||||||
localNickname: env.localNickname(),
|
localNickname: env.localNickname(),
|
||||||
peers: peers,
|
peers: peers,
|
||||||
allowConnectedUnverified: false
|
// The packet signature authenticates the announced peer; the old
|
||||||
) ?? signedDisplayName
|
// 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 {
|
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.
|
// 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
|
return FileTransferLimits.maxFramedFileBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ enum BLENoisePayloadFactory {
|
|||||||
typedPayload(.delivered, payload: Data(messageID.utf8))
|
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 {
|
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
||||||
var typed = Data([type.rawValue])
|
var typed = Data([type.rawValue])
|
||||||
typed.append(payload)
|
typed.append(payload)
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ struct BLEPendingPrivateMessage: Equatable {
|
|||||||
let messageID: String
|
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 {
|
struct BLENoiseSessionQueues {
|
||||||
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
||||||
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
|
private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:]
|
||||||
|
|
||||||
var isEmpty: Bool {
|
var isEmpty: Bool {
|
||||||
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
||||||
@@ -34,13 +41,29 @@ struct BLENoiseSessionQueues {
|
|||||||
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
|
mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) {
|
||||||
typedPayloadsByPeerID[peerID, default: []].append(payload)
|
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] ?? []
|
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
||||||
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
||||||
return payloads
|
return payloads
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ struct BLEOutboundFragmentTransferRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resolvedTransferId: String? {
|
var resolvedTransferId: String? {
|
||||||
|
if let transferId { return transferId }
|
||||||
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
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
|
/// Content identity independent of the caller-chosen transfer ID: the
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ final class BLEService: NSObject {
|
|||||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||||
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
||||||
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
|
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
|
||||||
private let incomingFileStore = BLEIncomingFileStore()
|
private let incomingFileStore: BLEIncomingFileStore
|
||||||
|
|
||||||
// Simple announce throttling
|
// Simple announce throttling
|
||||||
private var announceThrottle = BLEAnnounceThrottle()
|
private var announceThrottle = BLEAnnounceThrottle()
|
||||||
@@ -275,10 +275,12 @@ final class BLEService: NSObject {
|
|||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol,
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
initializeBluetoothManagers: Bool = true
|
initializeBluetoothManagers: Bool = true,
|
||||||
|
incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore()
|
||||||
) {
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
|
self.incomingFileStore = incomingFileStore
|
||||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
super.init()
|
super.init()
|
||||||
@@ -877,7 +879,9 @@ final class BLEService: NSObject {
|
|||||||
SecureLogger.debug("🛑 Removed pending transfer \(id.prefix(8))… before start", category: .session)
|
SecureLogger.debug("🛑 Removed pending transfer \(id.prefix(8))… before start", category: .session)
|
||||||
|
|
||||||
case .missing:
|
case .missing:
|
||||||
break
|
if self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) {
|
||||||
|
SecureLogger.debug("🛑 Removed handshake-queued transfer \(transferId.prefix(8))…", category: .session)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -935,35 +939,44 @@ final class BLEService: NSObject {
|
|||||||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard let payload = filePacket.encode() else {
|
|
||||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility
|
|
||||||
// This ensures 64-hex Noise keys are converted to the canonical routing format
|
|
||||||
let targetID = peerID.toShort()
|
let targetID = peerID.toShort()
|
||||||
guard let recipientData = Data(hexString: targetID.id) else {
|
let supportsPrivateMedia = self.collectionsQueue.sync {
|
||||||
SecureLogger.error("❌ Invalid recipient peer ID for file transfer: \(peerID.id.prefix(8))…", category: .session)
|
self.peerRegistry.capabilities(for: targetID).contains(.privateMedia)
|
||||||
|
}
|
||||||
|
guard supportsPrivateMedia else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Private media not sent: \(targetID.id.prefix(8))… did not advertise encrypted-media support",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
TransferProgressManager.shared.rejectBeforeStart(id: transferId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
|
||||||
|
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||||
|
TransferProgressManager.shared.rejectBeforeStart(id: transferId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard self.noiseService.hasEstablishedSession(with: targetID) else {
|
||||||
|
self.collectionsQueue.sync(flags: .barrier) {
|
||||||
|
self.pendingNoiseSessionQueues.appendTypedPayload(
|
||||||
|
typedPayload,
|
||||||
|
transferId: transferId,
|
||||||
|
for: targetID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SecureLogger.debug("📥 Queued private file for \(targetID.id.prefix(8))… pending handshake", category: .session)
|
||||||
|
self.initiateNoiseHandshake(with: targetID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var packet = BitchatPacket(
|
do {
|
||||||
type: MessageType.fileTransfer.rawValue,
|
let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID)
|
||||||
senderID: self.myPeerIDData,
|
SecureLogger.debug("📁 Sending encrypted private file to \(targetID.id.prefix(8))… plaintextBytes=\(typedPayload.count)", category: .session)
|
||||||
recipientID: recipientData,
|
self.broadcastPacket(packet, transferId: transferId)
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
} catch {
|
||||||
payload: payload,
|
SecureLogger.error("❌ Failed to encrypt private file for \(targetID.id.prefix(8))…: \(error)", category: .security)
|
||||||
signature: nil,
|
TransferProgressManager.shared.rejectBeforeStart(id: transferId)
|
||||||
ttl: self.messageTTL,
|
|
||||||
version: 2
|
|
||||||
)
|
|
||||||
|
|
||||||
if let signed = self.noiseService.signPacket(packet) {
|
|
||||||
packet = signed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.debug("📁 Sending private file transfer to \(peerID.id.prefix(8))… bytes=\(payload.count)", category: .session)
|
|
||||||
self.broadcastPacket(packet, transferId: transferId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,6 +1119,21 @@ final class BLEService: NSObject {
|
|||||||
sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId)
|
sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// App-initiated private media is already one opaque Noise ciphertext.
|
||||||
|
// Always fragment that outer packet so the existing transfer scheduler
|
||||||
|
// retains progress/cancel behavior without exposing the file TLVs.
|
||||||
|
if packetToSend.type == MessageType.noiseEncrypted.rawValue,
|
||||||
|
let transferId,
|
||||||
|
let recipientPeerID = PeerID(hexData: packetToSend.recipientID) {
|
||||||
|
sendFragmentedPacket(
|
||||||
|
packetToSend,
|
||||||
|
pad: padForBLE,
|
||||||
|
maxChunk: nil,
|
||||||
|
directedOnlyPeer: recipientPeerID,
|
||||||
|
transferId: transferId
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let data = packetToSend.toBinaryData(padding: padForBLE) else {
|
guard let data = packetToSend.toBinaryData(padding: padForBLE) else {
|
||||||
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
|
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
|
||||||
return
|
return
|
||||||
@@ -1533,6 +1561,9 @@ final class BLEService: NSObject {
|
|||||||
verifyPacketSignature: { [weak self] packet, signingPublicKey in
|
verifyPacketSignature: { [weak self] packet, signingPublicKey in
|
||||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||||
},
|
},
|
||||||
|
localSigningPublicKey: { [weak self] in
|
||||||
|
self?.noiseService.getSigningPublicKeyData() ?? Data()
|
||||||
|
},
|
||||||
signedSenderDisplayName: { [weak self] packet, peerID in
|
signedSenderDisplayName: { [weak self] packet, peerID in
|
||||||
self?.signedSenderDisplayName(for: packet, from: peerID)
|
self?.signedSenderDisplayName(for: packet, from: peerID)
|
||||||
},
|
},
|
||||||
@@ -2380,7 +2411,11 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
func _test_seedConnectedPeer(
|
||||||
|
_ peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
capabilities: PeerCapabilities = []
|
||||||
|
) {
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
peerRegistry.upsert(BLEPeerInfo(
|
peerRegistry.upsert(BLEPeerInfo(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
@@ -2389,7 +2424,8 @@ extension BLEService {
|
|||||||
noisePublicKey: nil,
|
noisePublicKey: nil,
|
||||||
signingPublicKey: nil,
|
signingPublicKey: nil,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: Date()
|
lastSeen: Date(),
|
||||||
|
capabilities: capabilities
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3443,7 +3479,13 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
|
private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
|
||||||
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
let encrypted: Data
|
||||||
|
let isPrivateFile = typedPayload.first == NoisePayloadType.privateFile.rawValue
|
||||||
|
if isPrivateFile {
|
||||||
|
encrypted = try noiseService.encryptPrivateFilePayload(typedPayload, for: peerID)
|
||||||
|
} else {
|
||||||
|
encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
||||||
|
}
|
||||||
return BitchatPacket(
|
return BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: myPeerIDData,
|
||||||
@@ -3451,7 +3493,9 @@ extension BLEService {
|
|||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: encrypted,
|
payload: encrypted,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: messageTTL
|
ttl: messageTTL,
|
||||||
|
// v1 has a 16-bit payload length; finalized media can exceed it.
|
||||||
|
version: isPrivateFile ? 2 : 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5524,6 +5568,14 @@ extension BLEService {
|
|||||||
self?.noiseService.clearSession(for: peerID)
|
self?.noiseService.clearSession(for: peerID)
|
||||||
},
|
},
|
||||||
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
||||||
|
if type == .privateFile {
|
||||||
|
self?.fileTransferHandler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: peerID,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Single main-actor hop delivering `.noisePayloadReceived`.
|
// Single main-actor hop delivering `.noisePayloadReceived`.
|
||||||
self?.notifyUI { [weak self] in
|
self?.notifyUI { [weak self] in
|
||||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||||
@@ -5540,14 +5592,17 @@ extension BLEService {
|
|||||||
// MARK: Helper Functions
|
// MARK: Helper Functions
|
||||||
|
|
||||||
private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) {
|
private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) {
|
||||||
let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
|
let payloads = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingTypedPayload] in
|
||||||
pendingNoiseSessionQueues.takeTypedPayloads(for: peerID)
|
pendingNoiseSessionQueues.takeTypedPayloads(for: peerID)
|
||||||
}
|
}
|
||||||
guard !payloads.isEmpty else { return }
|
guard !payloads.isEmpty else { return }
|
||||||
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session)
|
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session)
|
||||||
for payload in payloads {
|
for pending in payloads {
|
||||||
do {
|
do {
|
||||||
broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID))
|
broadcastPacket(
|
||||||
|
try makeEncryptedNoisePacket(pending.payload, to: peerID),
|
||||||
|
transferId: pending.transferId
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)")
|
SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -725,11 +725,38 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
return try sessionManager.encrypt(data, for: peerID)
|
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 data.first == NoisePayloadType.privateFile.rawValue,
|
||||||
|
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
|
/// Decrypt data from a specific peer
|
||||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||||
// Validate message size
|
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
// 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
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +770,14 @@ final class NoiseEncryptionService {
|
|||||||
throw NoiseEncryptionError.sessionNotEstablished
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
}
|
}
|
||||||
|
|
||||||
return try sessionManager.decrypt(data, from: peerID)
|
let decrypted = try sessionManager.decrypt(data, from: peerID)
|
||||||
|
if !isStandardCiphertext {
|
||||||
|
guard decrypted.first == NoisePayloadType.privateFile.rawValue,
|
||||||
|
NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Peer Management
|
// MARK: - Peer Management
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ final class TransferProgressManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reject a transfer before fragment scheduling (for example, when the
|
||||||
|
/// remote build did not advertise the required wire capability). Unlike
|
||||||
|
/// `cancel`, this still emits an event when no progress state exists yet,
|
||||||
|
/// allowing the UI to remove its already-created sending placeholder.
|
||||||
|
func rejectBeforeStart(id: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let state = self.states.removeValue(forKey: id) ?? (sent: 0, total: 0)
|
||||||
|
self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
||||||
var result: (sent: Int, total: Int)?
|
var result: (sent: Int, total: Int)?
|
||||||
queue.sync {
|
queue.sync {
|
||||||
|
|||||||
@@ -407,6 +407,12 @@ private extension ChatTransportEventCoordinator {
|
|||||||
|
|
||||||
case .voiceFrame:
|
case .voiceFrame:
|
||||||
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ final class NostrInboundPipeline {
|
|||||||
// claiming to be group traffic over Nostr is ignored.
|
// claiming to be group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,7 +366,7 @@ final class NostrInboundPipeline {
|
|||||||
// claiming to be group traffic over Nostr is ignored.
|
// claiming to be group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,7 +449,7 @@ final class NostrInboundPipeline {
|
|||||||
// in v1; group traffic over Nostr is ignored.
|
// in v1; group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
/// Wire-level coverage for finalized DM media. The sender encrypts one typed
|
||||||
|
/// private-file payload, relays see only the outer Noise packet/fragments, and
|
||||||
|
/// the receiver reassembles, decrypts, validates, persists, and delivers it.
|
||||||
|
@Suite("Private media end to end", .serialized)
|
||||||
|
struct PrivateMediaEndToEndTests {
|
||||||
|
@Test
|
||||||
|
func peerWithoutPrivateMediaCapabilityIsRejectedBeforeWireSend() async throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("private-media-capability-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
|
||||||
|
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
|
||||||
|
alice._test_seedConnectedPeer(bob.myPeerID, nickname: "Old Bob", capabilities: [])
|
||||||
|
bob._test_seedConnectedPeer(alice.myPeerID, nickname: "Alice", capabilities: .privateMedia)
|
||||||
|
try establishSession(alice: alice, bob: bob)
|
||||||
|
|
||||||
|
let tap = PacketTap()
|
||||||
|
alice._test_onOutboundPacket = tap.record
|
||||||
|
let transferID = "unsupported-private-media-\(UUID().uuidString)"
|
||||||
|
let cancellation = TransferCancellationRecorder(transferID: transferID)
|
||||||
|
let cancellable = TransferProgressManager.shared.publisher.sink { cancellation.record($0) }
|
||||||
|
let content = Data("%PDF-1.7\nprivate".utf8)
|
||||||
|
alice.sendFilePrivate(
|
||||||
|
BitchatFilePacket(
|
||||||
|
fileName: "private.pdf",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
content: content
|
||||||
|
),
|
||||||
|
to: bob.myPeerID,
|
||||||
|
transferId: transferID
|
||||||
|
)
|
||||||
|
|
||||||
|
let rejected = await TestHelpers.waitUntil(
|
||||||
|
{ cancellation.wasCancelled },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(rejected)
|
||||||
|
let mediaWireTypes: Set<UInt8> = [
|
||||||
|
MessageType.noiseEncrypted.rawValue,
|
||||||
|
MessageType.fileTransfer.rawValue,
|
||||||
|
MessageType.fragment.rawValue
|
||||||
|
]
|
||||||
|
#expect(tap.snapshot().allSatisfy { !mediaWireTypes.contains($0.type) })
|
||||||
|
_ = cancellable
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func privateJPEGIsOpaqueBeforeFragmentationAndDelivers() async throws {
|
||||||
|
let marker = Data("JPEG_PRIVATE_MARKER_7f5e5eacb86f4b9a".utf8)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xE0])
|
||||||
|
+ marker
|
||||||
|
+ Data(repeating: 0x4A, count: 6 * 1024)
|
||||||
|
try await assertPrivateMediaRoundTrip(
|
||||||
|
fileName: "private.jpg",
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content,
|
||||||
|
marker: marker,
|
||||||
|
expectedMessagePrefix: "[image]"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func finalizedPrivateM4AIsOpaqueBeforeFragmentationAndDelivers() async throws {
|
||||||
|
let marker = Data("M4A_PRIVATE_MARKER_e0cd431b61fb4a6c".utf8)
|
||||||
|
let content = Data([0x00, 0x00, 0x00, 0x18])
|
||||||
|
+ Data("ftypM4A ".utf8)
|
||||||
|
+ marker
|
||||||
|
+ Data(repeating: 0x4D, count: 6 * 1024)
|
||||||
|
try await assertPrivateMediaRoundTrip(
|
||||||
|
fileName: "voice_0011223344556677.m4a",
|
||||||
|
mimeType: "audio/mp4",
|
||||||
|
content: content,
|
||||||
|
marker: marker,
|
||||||
|
expectedMessagePrefix: "[voice]"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func privatePDFIsOpaqueBeforeFragmentationAndDelivers() async throws {
|
||||||
|
let marker = Data("PDF_PRIVATE_MARKER_b333f84b8fc7478d".utf8)
|
||||||
|
let content = Data("%PDF-1.7\n".utf8)
|
||||||
|
+ marker
|
||||||
|
+ Data(repeating: 0x50, count: 6 * 1024)
|
||||||
|
try await assertPrivateMediaRoundTrip(
|
||||||
|
fileName: "private.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
content: content,
|
||||||
|
marker: marker,
|
||||||
|
expectedMessagePrefix: "[file]"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func privateMediaAboveOrdinaryNoiseLimitUsesV2OuterPacketAndDelivers() async throws {
|
||||||
|
let marker = Data("LARGE_PRIVATE_MARKER_1ec63f261a7041ee".utf8)
|
||||||
|
let content = Data("%PDF-1.7\n".utf8)
|
||||||
|
+ marker
|
||||||
|
+ Data(repeating: 0x4C, count: 70 * 1024)
|
||||||
|
try await assertPrivateMediaRoundTrip(
|
||||||
|
fileName: "large-private.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
content: content,
|
||||||
|
marker: marker,
|
||||||
|
expectedMessagePrefix: "[file]",
|
||||||
|
expectedOuterVersion: 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertPrivateMediaRoundTrip(
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
content: Data,
|
||||||
|
marker: Data,
|
||||||
|
expectedMessagePrefix: String,
|
||||||
|
expectedOuterVersion: UInt8 = 2
|
||||||
|
) async throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("private-media-e2e-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
let aliceRoot = root.appendingPathComponent("alice", isDirectory: true)
|
||||||
|
let bobRoot = root.appendingPathComponent("bob", isDirectory: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
|
||||||
|
let alice = makeService(baseDirectory: aliceRoot)
|
||||||
|
let bob = makeService(baseDirectory: bobRoot)
|
||||||
|
let tap = PacketTap()
|
||||||
|
let delegate = MessageCaptureDelegate()
|
||||||
|
alice._test_onOutboundPacket = tap.record
|
||||||
|
bob.delegate = delegate
|
||||||
|
|
||||||
|
alice._test_seedConnectedPeer(bob.myPeerID, nickname: "Bob", capabilities: .privateMedia)
|
||||||
|
bob._test_seedConnectedPeer(alice.myPeerID, nickname: "Alice", capabilities: .privateMedia)
|
||||||
|
try establishSession(alice: alice, bob: bob)
|
||||||
|
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: mimeType,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)")
|
||||||
|
|
||||||
|
let fragmented = await TestHelpers.waitUntil(
|
||||||
|
{ tap.hasCompleteFragmentTrain },
|
||||||
|
timeout: 10
|
||||||
|
)
|
||||||
|
#expect(fragmented)
|
||||||
|
|
||||||
|
let outbound = tap.snapshot()
|
||||||
|
let encryptedPackets = outbound.filter { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||||
|
let fragments = outbound
|
||||||
|
.filter { $0.type == MessageType.fragment.rawValue }
|
||||||
|
.sorted { fragmentIndex($0) < fragmentIndex($1) }
|
||||||
|
|
||||||
|
#expect(encryptedPackets.count == 1)
|
||||||
|
#expect(encryptedPackets.first?.version == expectedOuterVersion)
|
||||||
|
#expect(!fragments.isEmpty)
|
||||||
|
#expect(outbound.allSatisfy { $0.type != MessageType.fileTransfer.rawValue })
|
||||||
|
for packet in encryptedPackets + fragments {
|
||||||
|
#expect(packet.payload.range(of: marker) == nil)
|
||||||
|
#expect(packet.payload.range(of: content) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real BLE delivers the train at the scheduler's paced interval. Feed
|
||||||
|
// bounded batches here instead of enqueuing hundreds of synthetic
|
||||||
|
// callbacks at once, which can exhaust libdispatch worker threads as
|
||||||
|
// they wait on the fragment-assembly barrier.
|
||||||
|
for batchStart in stride(from: 0, to: fragments.count, by: 16) {
|
||||||
|
let batchEnd = min(batchStart + 16, fragments.count)
|
||||||
|
for fragment in fragments[batchStart..<batchEnd] {
|
||||||
|
bob._test_handlePacket(fragment, fromPeerID: alice.myPeerID)
|
||||||
|
}
|
||||||
|
await bob._test_drainFragmentPipeline()
|
||||||
|
}
|
||||||
|
|
||||||
|
let delivered = await TestHelpers.waitUntil(
|
||||||
|
{ delegate.snapshot().count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(delivered)
|
||||||
|
|
||||||
|
let message = try #require(delegate.snapshot().first)
|
||||||
|
#expect(message.isPrivate)
|
||||||
|
#expect(message.senderPeerID == alice.myPeerID)
|
||||||
|
#expect(message.content.hasPrefix(expectedMessagePrefix))
|
||||||
|
|
||||||
|
let stored = recursivelyStoredFiles(under: bobRoot)
|
||||||
|
#expect(stored.count == 1)
|
||||||
|
let storedURL = try #require(stored.first)
|
||||||
|
#expect(try Data(contentsOf: storedURL) == content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeService(baseDirectory: URL) -> BLEService {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
return BLEService(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
|
||||||
|
identityManager: MockIdentityManager(keychain),
|
||||||
|
initializeBluetoothManagers: false,
|
||||||
|
incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func establishSession(alice: BLEService, bob: BLEService) throws {
|
||||||
|
let first = try alice._test_noiseInitiateHandshake(with: bob.myPeerID)
|
||||||
|
let second = try #require(
|
||||||
|
try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: first)
|
||||||
|
)
|
||||||
|
let third = try #require(
|
||||||
|
try alice._test_noiseProcessHandshakeMessage(from: bob.myPeerID, message: second)
|
||||||
|
)
|
||||||
|
_ = try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: third)
|
||||||
|
#expect(alice.canDeliverSecurely(to: bob.myPeerID))
|
||||||
|
#expect(bob.canDeliverSecurely(to: alice.myPeerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recursivelyStoredFiles(under root: URL) -> [URL] {
|
||||||
|
guard let enumerator = FileManager.default.enumerator(
|
||||||
|
at: root,
|
||||||
|
includingPropertiesForKeys: [.isRegularFileKey]
|
||||||
|
) else { return [] }
|
||||||
|
|
||||||
|
return enumerator.compactMap { item in
|
||||||
|
guard let url = item as? URL,
|
||||||
|
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fragmentIndex(_ packet: BitchatPacket) -> Int {
|
||||||
|
guard packet.payload.count >= 10 else { return .max }
|
||||||
|
return (Int(packet.payload[8]) << 8) | Int(packet.payload[9])
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PacketTap: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var packets: [BitchatPacket] = []
|
||||||
|
|
||||||
|
func record(_ packet: BitchatPacket) {
|
||||||
|
lock.lock()
|
||||||
|
packets.append(packet)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> [BitchatPacket] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return packets
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasCompleteFragmentTrain: Bool {
|
||||||
|
let fragments = snapshot().filter { $0.type == MessageType.fragment.rawValue }
|
||||||
|
guard let first = fragments.first, first.payload.count >= 12 else { return false }
|
||||||
|
let total = (Int(first.payload[10]) << 8) | Int(first.payload[11])
|
||||||
|
return total > 0 && fragments.count >= total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var messages: [BitchatMessage] = []
|
||||||
|
|
||||||
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
|
lock.lock()
|
||||||
|
messages.append(message)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> [BitchatMessage] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TransferCancellationRecorder: @unchecked Sendable {
|
||||||
|
private let transferID: String
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var cancelled = false
|
||||||
|
|
||||||
|
init(transferID: String) {
|
||||||
|
self.transferID = transferID
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(_ event: TransferProgressManager.Event) {
|
||||||
|
guard case .cancelled(let id, _, _) = event, id == transferID else { return }
|
||||||
|
lock.lock()
|
||||||
|
cancelled = true
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
var wasCancelled: Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recorder.signatureVerifyCount += 1
|
recorder.signatureVerifyCount += 1
|
||||||
return recorder.signatureVerifies
|
return recorder.signatureVerifies
|
||||||
},
|
},
|
||||||
|
localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey },
|
||||||
signedSenderDisplayName: { _, peerID in
|
signedSenderDisplayName: { _, peerID in
|
||||||
recorder.signedNameQueries.append(peerID)
|
recorder.signedNameQueries.append(peerID)
|
||||||
return recorder.signedName
|
return recorder.signedName
|
||||||
@@ -92,12 +93,11 @@ struct BLEFileTransferHandlerTests {
|
|||||||
@Test
|
@Test
|
||||||
func selfEchoIsDropped() throws {
|
func selfEchoIsDropped() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
|
recorder.signatureVerifies = true
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
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
|
#expect(!handler.handle(packet, from: localPeerID))
|
||||||
// handler reports "relayable" rather than treating the echo as forged.
|
|
||||||
#expect(handler.handle(packet, from: localPeerID))
|
|
||||||
|
|
||||||
expectNoSideEffects(recorder)
|
expectNoSideEffects(recorder)
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,12 @@ struct BLEFileTransferHandlerTests {
|
|||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||||
let handler = makeHandler(recorder: recorder)
|
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
|
// Failed sender authentication must also stop the packet from being
|
||||||
// relayed to downstream nodes.
|
// relayed to downstream nodes.
|
||||||
@@ -129,7 +134,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
// Broadcast files carry an attacker-controllable senderID, so — like
|
// Broadcast files carry an attacker-controllable senderID, so — like
|
||||||
// public messages — a connected-but-unverified peer must present a valid
|
// public messages — a connected-but-unverified peer must present a valid
|
||||||
// packet signature. No signing key + no signed identity means dropped.
|
// packet signature. No signing key + no signed identity means dropped.
|
||||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.trackedPackets.isEmpty)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.isEmpty)
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
}
|
}
|
||||||
@@ -153,12 +158,11 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws {
|
func signedSelfBroadcastReplayIsDelivered() throws {
|
||||||
// Our own broadcast file replayed via gossip sync arrives with ttl==0
|
// 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
|
// it is verified against our local signing key before delivery.
|
||||||
// the peer registry — it must still be accepted, matching
|
|
||||||
// BLEPublicMessageHandler's self exemption.
|
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
|
recorder.signatureVerifies = true
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(
|
let packet = try makeFileTransferPacket(
|
||||||
sender: localPeerID,
|
sender: localPeerID,
|
||||||
@@ -169,7 +173,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
|
|
||||||
#expect(handler.handle(packet, from: localPeerID))
|
#expect(handler.handle(packet, from: localPeerID))
|
||||||
|
|
||||||
#expect(recorder.signatureVerifyCount == 0)
|
#expect(recorder.signatureVerifyCount == 1)
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
||||||
@@ -205,7 +209,8 @@ struct BLEFileTransferHandlerTests {
|
|||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
mimeType: "audio/mp4",
|
mimeType: "audio/mp4",
|
||||||
content: m4a,
|
content: m4a,
|
||||||
fileName: "voice_1122334455667788"
|
fileName: "voice_1122334455667788",
|
||||||
|
hasSignature: false
|
||||||
)
|
)
|
||||||
|
|
||||||
// The spoofed note must be dropped locally AND not relayed onward.
|
// The spoofed note must be dropped locally AND not relayed onward.
|
||||||
@@ -215,7 +220,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func privateFileFromConnectedUnverifiedPeerIsAccepted() throws {
|
func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
@@ -223,23 +228,25 @@ struct BLEFileTransferHandlerTests {
|
|||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
mimeType: "application/pdf",
|
mimeType: "application/pdf",
|
||||||
content: Data("%PDF-1.7".utf8),
|
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.signatureVerifyCount == 0)
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func fileDirectedToAnotherPeerIsIgnored() throws {
|
func fileDirectedToAnotherPeerIsIgnored() throws {
|
||||||
let recorder = Recorder()
|
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 handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(
|
let packet = try makeFileTransferPacket(
|
||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
@@ -260,7 +267,8 @@ struct BLEFileTransferHandlerTests {
|
|||||||
@Test
|
@Test
|
||||||
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
||||||
let recorder = Recorder()
|
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 handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(
|
let packet = try makeFileTransferPacket(
|
||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
@@ -282,6 +290,56 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
#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
|
@Test
|
||||||
func malformedPayloadIsTrackedForSyncButDropped() {
|
func malformedPayloadIsTrackedForSyncButDropped() {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
@@ -294,7 +352,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: 900_000,
|
timestamp: 900_000,
|
||||||
payload: Data([0x01, 0x02, 0x03]),
|
payload: Data([0x01, 0x02, 0x03]),
|
||||||
signature: nil,
|
signature: Data(repeating: 0x5A, count: 64),
|
||||||
ttl: TransportConfig.messageTTLDefault
|
ttl: TransportConfig.messageTTLDefault
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -403,7 +461,8 @@ struct BLEFileTransferHandlerTests {
|
|||||||
content: Data,
|
content: Data,
|
||||||
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
||||||
recipientID: Data? = nil,
|
recipientID: Data? = nil,
|
||||||
fileName: String = "sample"
|
fileName: String = "sample",
|
||||||
|
hasSignature: Bool = true
|
||||||
) throws -> BitchatPacket {
|
) throws -> BitchatPacket {
|
||||||
let filePacket = BitchatFilePacket(
|
let filePacket = BitchatFilePacket(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
@@ -418,7 +477,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
timestamp: 900_000,
|
timestamp: 900_000,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil,
|
||||||
ttl: ttl
|
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
|
@Test
|
||||||
func removeExpiredDropsOldAssemblies() throws {
|
func removeExpiredDropsOldAssemblies() throws {
|
||||||
var buffer = BLEFragmentAssemblyBuffer()
|
var buffer = BLEFragmentAssemblyBuffer()
|
||||||
|
|||||||
@@ -31,4 +31,23 @@ struct BLENoisePayloadFactoryTests {
|
|||||||
|
|
||||||
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
|
#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 == NoisePayloadType.privateFile.rawValue)
|
||||||
|
let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst())))
|
||||||
|
#expect(decoded.fileName == "secret.pdf")
|
||||||
|
#expect(decoded.mimeType == "application/pdf")
|
||||||
|
#expect(decoded.content == content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ struct BLENoiseSessionQueuesTests {
|
|||||||
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
||||||
queues.appendTypedPayload(Data([0x02]), 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.takeTypedPayloads(for: peerID).isEmpty)
|
||||||
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
|
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
|
||||||
}
|
}
|
||||||
@@ -64,4 +67,21 @@ struct BLENoiseSessionQueuesTests {
|
|||||||
|
|
||||||
#expect(queues.isEmpty)
|
#expect(queues.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() {
|
||||||
|
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||||
|
var queues = BLENoiseSessionQueues()
|
||||||
|
|
||||||
|
queues.appendTypedPayload(Data([0x09, 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)
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
@Test
|
||||||
func submitQueuesFileTransferWhenSlotsAreFull() {
|
func submitQueuesFileTransferWhenSlotsAreFull() {
|
||||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
|||||||
@@ -237,6 +237,55 @@ struct NoiseEncryptionServiceTests {
|
|||||||
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
|
#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(str: "0011223344556677")
|
||||||
|
let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
||||||
|
try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)
|
||||||
|
|
||||||
|
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: alicePeerID)
|
||||||
|
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: alicePeerID)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Private-file encryption failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let decrypted: Data
|
||||||
|
do {
|
||||||
|
decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
|
||||||
|
} 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")
|
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||||
func handshakeRequiredAndSessionNotEstablishedErrors() throws {
|
func handshakeRequiredAndSessionNotEstablishedErrors() throws {
|
||||||
let service = NoiseEncryptionService(keychain: MockKeychain())
|
let service = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
|||||||
@@ -105,6 +105,30 @@ struct TransferProgressManagerTests {
|
|||||||
#expect(manager.snapshot(id: transferID) == nil)
|
#expect(manager.snapshot(id: transferID) == nil)
|
||||||
_ = cancellable
|
_ = cancellable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Preflight rejection publishes cancellation without a started state")
|
||||||
|
@MainActor
|
||||||
|
func rejectBeforeStartPublishesCancellation() async {
|
||||||
|
let manager = TransferProgressManager()
|
||||||
|
let transferID = "transfer-preflight-reject"
|
||||||
|
var cancellable: AnyCancellable?
|
||||||
|
let recorder = EventRecorder()
|
||||||
|
|
||||||
|
cancellable = manager.publisher.sink { event in
|
||||||
|
if case .cancelled(let id, let sent, let total) = event {
|
||||||
|
recorder.append("cancelled:\(id):\(sent):\(total)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.rejectBeforeStart(id: transferID)
|
||||||
|
|
||||||
|
let didReceive = await TestHelpers.waitUntil({
|
||||||
|
recorder.values == ["cancelled:\(transferID):0:0"]
|
||||||
|
}, timeout: 5.0)
|
||||||
|
#expect(didReceive)
|
||||||
|
#expect(manager.snapshot(id: transferID) == nil)
|
||||||
|
_ = cancellable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class EventRecorder: @unchecked Sendable {
|
private final class EventRecorder: @unchecked Sendable {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
|||||||
/// (uplink/downlink carriers for mesh-only peers). Advertised alongside
|
/// (uplink/downlink carriers for mesh-only peers). Advertised alongside
|
||||||
/// a `bridgeGeohash` TLV carrying the rendezvous cell.
|
/// a `bridgeGeohash` TLV carrying the rendezvous cell.
|
||||||
public static let bridge = PeerCapabilities(rawValue: 1 << 7)
|
public static let bridge = PeerCapabilities(rawValue: 1 << 7)
|
||||||
|
/// Finalized direct-message media encrypted as a `.privateFile` Noise
|
||||||
|
/// payload before outer BLE fragmentation.
|
||||||
|
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
||||||
|
|
||||||
/// Minimal little-endian byte encoding; always at least one byte so an
|
/// Minimal little-endian byte encoding; always at least one byte so an
|
||||||
/// empty set is distinguishable from an absent TLV.
|
/// empty set is distinguishable from an absent TLV.
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ struct PeerCapabilitiesTests {
|
|||||||
#expect(PeerCapabilities([]).encoded() == Data([0x00]))
|
#expect(PeerCapabilities([]).encoded() == Data([0x00]))
|
||||||
#expect(PeerCapabilities.prekeys.encoded() == Data([0x01]))
|
#expect(PeerCapabilities.prekeys.encoded() == Data([0x01]))
|
||||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||||
|
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
||||||
|
|
||||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
let high = PeerCapabilities(rawValue: 1 << 9)
|
||||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
#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: all.encoded()) == all)
|
||||||
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||||
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||||
|
|||||||
Reference in New Issue
Block a user