mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 22:25:19 +00:00
Encrypt private media before fragmentation
This commit is contained in:
@@ -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 = plaintext.first == NoisePayloadType.privateFile.rawValue
|
||||
&& 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,6 +79,9 @@ 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. The complete BitchatFilePacket is encrypted
|
||||
// inside Noise before the outer noiseEncrypted packet is fragmented.
|
||||
case privateFile = 0x09
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
@@ -93,6 +96,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,29 @@ 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
|
||||
}
|
||||
|
||||
@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? {
|
||||
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
|
||||
|
||||
@@ -136,7 +136,7 @@ final class BLEService: NSObject {
|
||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
||||
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
|
||||
private let incomingFileStore = BLEIncomingFileStore()
|
||||
private let incomingFileStore: BLEIncomingFileStore
|
||||
|
||||
// Simple announce throttling
|
||||
private var announceThrottle = BLEAnnounceThrottle()
|
||||
@@ -285,10 +285,12 @@ final class BLEService: NSObject {
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
initializeBluetoothManagers: Bool = true,
|
||||
incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
|
||||
startSuspendedForPanicRecovery: Bool = false
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
self.incomingFileStore = incomingFileStore
|
||||
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
|
||||
self._isPanicSuspended = startSuspendedForPanicRecovery
|
||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||
@@ -1026,7 +1028,9 @@ final class BLEService: NSObject {
|
||||
SecureLogger.debug("🛑 Removed pending transfer \(id.prefix(8))… before start", category: .session)
|
||||
|
||||
case .missing:
|
||||
break
|
||||
if self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) {
|
||||
SecureLogger.debug("🛑 Removed handshake-queued transfer \(transferId.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1086,35 +1090,44 @@ final class BLEService: NSObject {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard !self.isPanicSuspended 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()
|
||||
guard let recipientData = Data(hexString: targetID.id) else {
|
||||
SecureLogger.error("❌ Invalid recipient peer ID for file transfer: \(peerID.id.prefix(8))…", category: .session)
|
||||
let supportsPrivateMedia = self.collectionsQueue.sync {
|
||||
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
|
||||
}
|
||||
|
||||
var packet = BitchatPacket(
|
||||
type: MessageType.fileTransfer.rawValue,
|
||||
senderID: self.myPeerIDData,
|
||||
recipientID: recipientData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: self.messageTTL,
|
||||
version: 2
|
||||
)
|
||||
|
||||
if let signed = self.noiseService.signPacket(packet) {
|
||||
packet = signed
|
||||
do {
|
||||
let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID)
|
||||
SecureLogger.debug("📁 Sending encrypted private file to \(targetID.id.prefix(8))… plaintextBytes=\(typedPayload.count)", category: .session)
|
||||
self.broadcastPacket(packet, transferId: transferId)
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to encrypt private file for \(targetID.id.prefix(8))…: \(error)", category: .security)
|
||||
TransferProgressManager.shared.rejectBeforeStart(id: transferId)
|
||||
}
|
||||
|
||||
SecureLogger.debug("📁 Sending private file transfer to \(peerID.id.prefix(8))… bytes=\(payload.count)", category: .session)
|
||||
self.broadcastPacket(packet, transferId: transferId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,6 +1271,21 @@ final class BLEService: NSObject {
|
||||
sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId)
|
||||
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 {
|
||||
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
|
||||
return
|
||||
@@ -1707,6 +1735,9 @@ final class BLEService: NSObject {
|
||||
verifyPacketSignature: { [weak self] packet, signingPublicKey in
|
||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||
},
|
||||
localSigningPublicKey: { [weak self] in
|
||||
self?.noiseService.getSigningPublicKeyData() ?? Data()
|
||||
},
|
||||
signedSenderDisplayName: { [weak self] packet, peerID in
|
||||
self?.signedSenderDisplayName(for: packet, from: peerID)
|
||||
},
|
||||
@@ -2597,7 +2628,11 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
||||
func _test_seedConnectedPeer(
|
||||
_ peerID: PeerID,
|
||||
nickname: String,
|
||||
capabilities: PeerCapabilities = []
|
||||
) {
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
peerRegistry.upsert(BLEPeerInfo(
|
||||
peerID: peerID,
|
||||
@@ -2606,7 +2641,8 @@ extension BLEService {
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date()
|
||||
lastSeen: Date(),
|
||||
capabilities: capabilities
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -3722,7 +3758,13 @@ extension BLEService {
|
||||
}
|
||||
|
||||
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(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
@@ -3730,7 +3772,9 @@ extension BLEService {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
ttl: messageTTL,
|
||||
// v1 has a 16-bit payload length; finalized media can exceed it.
|
||||
version: isPrivateFile ? 2 : 1
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5828,6 +5872,14 @@ extension BLEService {
|
||||
self?.noiseService.clearSession(for: peerID)
|
||||
},
|
||||
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`.
|
||||
self?.notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||
@@ -5844,14 +5896,17 @@ extension BLEService {
|
||||
// MARK: Helper Functions
|
||||
|
||||
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)
|
||||
}
|
||||
guard !payloads.isEmpty else { return }
|
||||
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 {
|
||||
broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID))
|
||||
broadcastPacket(
|
||||
try makeEncryptedNoisePacket(pending.payload, to: peerID),
|
||||
transferId: pending.transferId
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)")
|
||||
}
|
||||
|
||||
@@ -740,11 +740,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 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -758,7 +785,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 decrypted.first == NoisePayloadType.privateFile.rawValue,
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
|
||||
// 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)? {
|
||||
var result: (sent: Int, total: Int)?
|
||||
queue.sync {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +310,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
|
||||
}
|
||||
}
|
||||
@@ -366,7 +366,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
|
||||
}
|
||||
}
|
||||
@@ -449,7 +449,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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user