mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 03:05:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50f79673b7 | ||
|
|
ae0563e830 | ||
|
|
02f567229c | ||
|
|
7bba030937 | ||
|
|
9f31c56e2c |
@@ -141,6 +141,16 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this peer has an inbound ordinary XX responder that still
|
||||||
|
/// needs message 3 before its receive keys can become authoritative.
|
||||||
|
func isAwaitingResponderHandshakeCompletion(for peerID: PeerID) -> Bool {
|
||||||
|
managerQueue.sync {
|
||||||
|
guard let session = sessions[peerID] else { return false }
|
||||||
|
return session.role == .responder
|
||||||
|
&& session.getState() == .handshaking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Transfers one bounded recovery generation to whatever ordinary XX
|
/// Transfers one bounded recovery generation to whatever ordinary XX
|
||||||
/// handshake currently owns the peer, or starts the generation's single
|
/// handshake currently owns the peer, or starts the generation's single
|
||||||
/// retry. The request token prevents stale transport callbacks from
|
/// retry. The request token prevents stale transport callbacks from
|
||||||
@@ -1121,12 +1131,22 @@ final class NoiseSessionManager {
|
|||||||
/// the exact session object that authenticated these bytes.
|
/// the exact session object that authenticated these bytes.
|
||||||
func decryptWithSessionGeneration(
|
func decryptWithSessionGeneration(
|
||||||
_ ciphertext: Data,
|
_ ciphertext: Data,
|
||||||
from peerID: PeerID
|
from peerID: PeerID,
|
||||||
|
establishedGenerationIsReady: (UUID) -> Bool = { _ in true },
|
||||||
|
authorizeDecrypt: () throws -> Void = {}
|
||||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||||
try managerQueue.sync {
|
try managerQueue.sync {
|
||||||
if let session = sessions[peerID],
|
if let session = sessions[peerID],
|
||||||
session.isEstablished(),
|
session.isEstablished(),
|
||||||
let generation = sessionGenerations[peerID] {
|
let generation = sessionGenerations[peerID] {
|
||||||
|
// Keep the generation lease across the transport-readiness
|
||||||
|
// check and decrypt. Promotion/restoration needs this queue's
|
||||||
|
// barrier, so no new receive nonce can be consumed before BLE
|
||||||
|
// installs state for the exact generation.
|
||||||
|
guard establishedGenerationIsReady(generation) else {
|
||||||
|
throw NoiseEncryptionError.transportGenerationNotReady
|
||||||
|
}
|
||||||
|
try authorizeDecrypt()
|
||||||
return (try session.decrypt(ciphertext), generation)
|
return (try session.decrypt(ciphertext), generation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1138,6 +1158,7 @@ final class NoiseSessionManager {
|
|||||||
responder.role == .responder,
|
responder.role == .responder,
|
||||||
responder.getState() == .handshaking,
|
responder.getState() == .handshaking,
|
||||||
let quarantined = quarantinedTransports[peerID] {
|
let quarantined = quarantinedTransports[peerID] {
|
||||||
|
try authorizeDecrypt()
|
||||||
return (
|
return (
|
||||||
try quarantined.session.decrypt(ciphertext),
|
try quarantined.session.decrypt(ciphertext),
|
||||||
quarantined.generation
|
quarantined.generation
|
||||||
|
|||||||
@@ -154,3 +154,90 @@ struct BitchatFilePacket {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire-compatible identity for private media exchanged by clients using the
|
||||||
|
/// current iOS entropy-bearing filenames, without extending the deployed file
|
||||||
|
/// TLV. Android clients reject unknown file tags, so eligible senders and
|
||||||
|
/// receivers derive the receipt key from fields already on the wire.
|
||||||
|
///
|
||||||
|
/// Locally-created image and voice-note filenames contain a UUID or live-voice
|
||||||
|
/// burst ID. Including the normalized direction keeps a reused filename
|
||||||
|
/// distinct across chats while allowing short and full Noise-key peer IDs to
|
||||||
|
/// converge. Android and older-iOS timestamp-only names remain ineligible and
|
||||||
|
/// retain their legacy random local IDs (transfer-compatible, no receipts).
|
||||||
|
enum PrivateMediaMessageIdentity {
|
||||||
|
private static let domain = Data("bitchat-private-media-message-v1".utf8)
|
||||||
|
private static let idPrefix = "media-"
|
||||||
|
private static let digestHexLength = 32
|
||||||
|
|
||||||
|
static func isStableID(_ candidate: String) -> Bool {
|
||||||
|
guard candidate.hasPrefix(idPrefix) else { return false }
|
||||||
|
let digest = candidate.dropFirst(idPrefix.count)
|
||||||
|
guard digest.utf8.count == digestHexLength else { return false }
|
||||||
|
return digest.utf8.allSatisfy { byte in
|
||||||
|
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|
||||||
|
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stableID(
|
||||||
|
senderPeerID: PeerID,
|
||||||
|
recipientPeerID: PeerID,
|
||||||
|
fileName: String?
|
||||||
|
) -> String? {
|
||||||
|
guard let fileName, !fileName.isEmpty else { return nil }
|
||||||
|
let leafName = (fileName as NSString).lastPathComponent
|
||||||
|
guard leafName == fileName else { return nil }
|
||||||
|
|
||||||
|
let path = leafName as NSString
|
||||||
|
let stem = path.deletingPathExtension
|
||||||
|
let fileExtension = path.pathExtension.lowercased()
|
||||||
|
switch true {
|
||||||
|
case stem.hasPrefix("img_"):
|
||||||
|
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
|
||||||
|
case stem.hasPrefix("voice_"):
|
||||||
|
guard fileExtension == "m4a" else { return nil }
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let entropyToken = stem.split(separator: "_").last.map(String.init)
|
||||||
|
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
|
||||||
|
let voiceBurstID = stem.hasPrefix("voice_")
|
||||||
|
? String(stem.dropFirst("voice_".count))
|
||||||
|
: ""
|
||||||
|
let hasBurstEntropy = voiceBurstID.count == 16
|
||||||
|
&& voiceBurstID.allSatisfy(\.isHexDigit)
|
||||||
|
guard hasUUIDEntropy || hasBurstEntropy else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields = [
|
||||||
|
Data(senderPeerID.toShort().bare.utf8),
|
||||||
|
Data(recipientPeerID.toShort().bare.utf8),
|
||||||
|
Data(leafName.utf8)
|
||||||
|
]
|
||||||
|
var input = domain
|
||||||
|
for field in fields {
|
||||||
|
guard let length = UInt32(exactly: field.count) else { return nil }
|
||||||
|
var bigEndianLength = length.bigEndian
|
||||||
|
withUnsafeBytes(of: &bigEndianLength) {
|
||||||
|
input.append(contentsOf: $0)
|
||||||
|
}
|
||||||
|
input.append(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stableID(
|
||||||
|
for packet: BitchatFilePacket,
|
||||||
|
senderPeerID: PeerID,
|
||||||
|
recipientPeerID: PeerID
|
||||||
|
) -> String? {
|
||||||
|
stableID(
|
||||||
|
senderPeerID: senderPeerID,
|
||||||
|
recipientPeerID: recipientPeerID,
|
||||||
|
fileName: packet.fileName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,5 +3,11 @@ 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, .privateMedia]
|
static let localSupported: PeerCapabilities = [
|
||||||
|
.vouch,
|
||||||
|
.prekeys,
|
||||||
|
.groups,
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,79 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
_ fallbackExtension: String?,
|
_ fallbackExtension: String?,
|
||||||
_ defaultPrefix: String
|
_ defaultPrefix: String
|
||||||
) -> URL?
|
) -> URL?
|
||||||
|
/// Resolves the durable receiver decision for a stable private-media ID.
|
||||||
|
let privateMediaReceiptState: (
|
||||||
|
_ messageID: String
|
||||||
|
) -> BLEPrivateMediaReceiptState
|
||||||
|
/// Atomically records a stable private-media ID after the payload save.
|
||||||
|
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
|
||||||
|
/// Rolls back a saved payload when its durable receipt commit fails.
|
||||||
|
let removeIncomingFile: (_ storedURL: URL) -> Void
|
||||||
|
/// Checks the authenticated sender before any private-media disk work.
|
||||||
|
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||||
let updatePeerLastSeen: (PeerID) -> Void
|
let updatePeerLastSeen: (PeerID) -> Void
|
||||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
/// Acknowledges stable private media only after its synchronous
|
||||||
let deliverMessage: (BitchatMessage) -> Void
|
/// conversation delivery has completed.
|
||||||
|
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||||
|
/// Delivers `.messageReceived` as one main-actor hop while
|
||||||
|
/// `shouldDeliver` remains true before and after the synchronous sink.
|
||||||
|
/// The completion authorizes the stable-media ACK.
|
||||||
|
let deliverMessage: (
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
_ shouldDeliver: @escaping () -> Bool,
|
||||||
|
_ completion: @escaping () -> Void
|
||||||
|
) -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-lifetime reservation cache for stable private-media IDs.
|
||||||
|
///
|
||||||
|
/// The first arrival reserves its ID before quota enforcement. Concurrent
|
||||||
|
/// arrivals remain coalesced in memory, while accepted state is resolved from
|
||||||
|
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
|
||||||
|
/// if quota cleanup removed the file.
|
||||||
|
private final class PrivateMediaArrivalDeduplicator {
|
||||||
|
enum Reservation {
|
||||||
|
case reserved
|
||||||
|
case pending
|
||||||
|
case accepted(URL)
|
||||||
|
case tombstoned
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var pending: Set<String> = []
|
||||||
|
|
||||||
|
func reserve(
|
||||||
|
_ messageID: String,
|
||||||
|
receiptState: () -> BLEPrivateMediaReceiptState
|
||||||
|
) -> Reservation {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if pending.contains(messageID) {
|
||||||
|
return .pending
|
||||||
|
}
|
||||||
|
|
||||||
|
switch receiptState() {
|
||||||
|
case .accepted(let existingURL):
|
||||||
|
return .accepted(existingURL)
|
||||||
|
case .tombstoned:
|
||||||
|
return .tombstoned
|
||||||
|
case .unavailable:
|
||||||
|
return .unavailable
|
||||||
|
case .absent:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.insert(messageID)
|
||||||
|
return .reserved
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish(_ messageID: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
pending.remove(messageID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
||||||
@@ -43,6 +112,7 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
/// and UI delivery.
|
/// and UI delivery.
|
||||||
final class BLEFileTransferHandler {
|
final class BLEFileTransferHandler {
|
||||||
private let environment: BLEFileTransferHandlerEnvironment
|
private let environment: BLEFileTransferHandlerEnvironment
|
||||||
|
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
|
||||||
|
|
||||||
init(environment: BLEFileTransferHandlerEnvironment) {
|
init(environment: BLEFileTransferHandlerEnvironment) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -86,6 +156,7 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: senderNickname,
|
senderNickname: senderNickname,
|
||||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||||
isPrivate: deliveryPlan.isPrivateMessage,
|
isPrivate: deliveryPlan.isPrivateMessage,
|
||||||
|
usesDurableReceipts: false,
|
||||||
env: env
|
env: env
|
||||||
)
|
)
|
||||||
// Once authenticated, a local decode/quota/save failure is not proof
|
// Once authenticated, a local decode/quota/save failure is not proof
|
||||||
@@ -116,6 +187,11 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: senderNickname,
|
senderNickname: senderNickname,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
|
// Every authenticated Noise private-file keeps the stable ID/ACK
|
||||||
|
// contract introduced with capability bit 8. Bit 9 advertises
|
||||||
|
// sender-side automatic retry support; it must not downgrade
|
||||||
|
// prior iOS clients to random IDs or single-check delivery.
|
||||||
|
usesDurableReceipts: true,
|
||||||
env: env
|
env: env
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -126,9 +202,11 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: String,
|
senderNickname: String,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
isPrivate: Bool,
|
isPrivate: Bool,
|
||||||
|
usesDurableReceipts: Bool,
|
||||||
env: BLEFileTransferHandlerEnvironment
|
env: BLEFileTransferHandlerEnvironment
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
|
||||||
|
let localPeerID = env.localPeerID()
|
||||||
let filePacket: BitchatFilePacket
|
let filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: payload) {
|
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||||
@@ -149,6 +227,89 @@ final class BLEFileTransferHandler {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isPrivate, env.isPrivateMediaSenderBlocked(peerID) {
|
||||||
|
SecureLogger.debug(
|
||||||
|
"🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageID = usesDurableReceipts
|
||||||
|
? PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: filePacket,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipientPeerID: localPeerID
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
if let messageID {
|
||||||
|
switch privateMediaArrivals.reserve(
|
||||||
|
messageID,
|
||||||
|
receiptState: { env.privateMediaReceiptState(messageID) }
|
||||||
|
) {
|
||||||
|
case .reserved:
|
||||||
|
break
|
||||||
|
case .pending:
|
||||||
|
// The first arrival has not reached durable storage yet.
|
||||||
|
// Coalesce this retry without ACKing so a failed first save
|
||||||
|
// remains retryable by the sender.
|
||||||
|
SecureLogger.debug(
|
||||||
|
"📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
case .accepted(let existingFile):
|
||||||
|
env.updatePeerLastSeen(peerID)
|
||||||
|
let message = incomingMessage(
|
||||||
|
messageID: messageID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isPrivate: true,
|
||||||
|
peerID: peerID,
|
||||||
|
destination: existingFile,
|
||||||
|
category: storedMediaCategory(
|
||||||
|
for: existingFile,
|
||||||
|
fallback: mime.category
|
||||||
|
),
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
SecureLogger.debug(
|
||||||
|
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
deliverStableMessage(
|
||||||
|
message,
|
||||||
|
messageID: messageID,
|
||||||
|
peerID: peerID,
|
||||||
|
expectedURL: existingFile,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
case .tombstoned:
|
||||||
|
// Explicit deletion is a durable terminal receiver decision.
|
||||||
|
env.updatePeerLastSeen(peerID)
|
||||||
|
env.acknowledgePrivateMedia(messageID, peerID)
|
||||||
|
SecureLogger.debug(
|
||||||
|
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
case .unavailable:
|
||||||
|
// Never turn an unreadable ledger into an empty ledger. The
|
||||||
|
// sender can retry after the transient storage failure clears.
|
||||||
|
SecureLogger.warning(
|
||||||
|
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
if let messageID {
|
||||||
|
privateMediaArrivals.finish(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// BCH-01-002: Enforce storage quota before saving
|
// BCH-01-002: Enforce storage quota before saving
|
||||||
env.enforceStorageQuota(filePacket.content.count)
|
env.enforceStorageQuota(filePacket.content.count)
|
||||||
|
|
||||||
@@ -162,13 +323,82 @@ final class BLEFileTransferHandler {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let messageID,
|
||||||
|
!env.commitPrivateMediaFile(messageID, destination) {
|
||||||
|
// A payload without its durable ID mapping cannot safely suppress
|
||||||
|
// a retry after relaunch. Roll it back and withhold UI/ACK.
|
||||||
|
env.removeIncomingFile(destination)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if isPrivate {
|
if isPrivate {
|
||||||
env.updatePeerLastSeen(peerID)
|
env.updatePeerLastSeen(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = incomingMessage(
|
||||||
|
messageID: messageID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isPrivate: isPrivate,
|
||||||
|
peerID: peerID,
|
||||||
|
destination: destination,
|
||||||
|
category: mime.category,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
|
||||||
|
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||||
|
|
||||||
|
if let messageID {
|
||||||
|
deliverStableMessage(
|
||||||
|
message,
|
||||||
|
messageID: messageID,
|
||||||
|
peerID: peerID,
|
||||||
|
expectedURL: destination,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
env.deliverMessage(message, { true }, {})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deliverStableMessage(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
messageID: String,
|
||||||
|
peerID: PeerID,
|
||||||
|
expectedURL: URL,
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) {
|
||||||
|
env.deliverMessage(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
guard case .accepted(let resolvedURL) =
|
||||||
|
env.privateMediaReceiptState(messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return resolvedURL.standardizedFileURL
|
||||||
|
== expectedURL.standardizedFileURL
|
||||||
|
},
|
||||||
|
{
|
||||||
|
env.acknowledgePrivateMedia(messageID, peerID)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func incomingMessage(
|
||||||
|
messageID: String?,
|
||||||
|
senderNickname: String,
|
||||||
|
timestamp: Date,
|
||||||
|
isPrivate: Bool,
|
||||||
|
peerID: PeerID,
|
||||||
|
destination: URL,
|
||||||
|
category: MimeType.Category,
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) -> BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
sender: senderNickname,
|
sender: senderNickname,
|
||||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
@@ -176,17 +406,35 @@ final class BLEFileTransferHandler {
|
|||||||
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 media views render
|
||||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
// as an in-flight send.
|
||||||
deliveryStatus: isPrivate
|
deliveryStatus: isPrivate
|
||||||
? .delivered(to: env.localNickname(), at: timestamp)
|
? .delivered(to: env.localNickname(), at: timestamp)
|
||||||
: nil
|
: nil
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
/// The durable URL is authoritative during reconstruction. A sender that
|
||||||
|
/// reuses a stable filename with a different MIME type must not change how
|
||||||
env.deliverMessage(message)
|
/// the already-stored payload renders.
|
||||||
return true
|
private func storedMediaCategory(
|
||||||
|
for url: URL,
|
||||||
|
fallback: MimeType.Category
|
||||||
|
) -> MimeType.Category {
|
||||||
|
let mediaDirectory = url
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.lastPathComponent
|
||||||
|
switch mediaDirectory {
|
||||||
|
case MimeType.Category.audio.mediaDir:
|
||||||
|
return .audio
|
||||||
|
case MimeType.Category.image.mediaDir:
|
||||||
|
return .image
|
||||||
|
case MimeType.Category.file.mediaDir:
|
||||||
|
return .file
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every remaining raw file transfer is signed, regardless of whether it
|
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ struct BLEIncomingFileStore {
|
|||||||
private let baseDirectory: URL?
|
private let baseDirectory: URL?
|
||||||
private let dateProvider: () -> Date
|
private let dateProvider: () -> Date
|
||||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||||
|
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
||||||
|
|
||||||
init(
|
init(
|
||||||
fileManager: FileManager = .default,
|
fileManager: FileManager = .default,
|
||||||
@@ -147,6 +148,11 @@ struct BLEIncomingFileStore {
|
|||||||
self.baseDirectory = baseDirectory
|
self.baseDirectory = baseDirectory
|
||||||
self.dateProvider = dateProvider
|
self.dateProvider = dateProvider
|
||||||
self.panicMarkerWriter = panicMarkerWriter
|
self.panicMarkerWriter = panicMarkerWriter
|
||||||
|
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
||||||
|
fileManager: fileManager,
|
||||||
|
baseDirectory: baseDirectory,
|
||||||
|
now: dateProvider
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||||
@@ -159,6 +165,11 @@ struct BLEIncomingFileStore {
|
|||||||
func panicWipe(
|
func panicWipe(
|
||||||
hasDurablePendingMarker: Bool = false
|
hasDurablePendingMarker: Bool = false
|
||||||
) throws {
|
) throws {
|
||||||
|
// The receipt index caches tombstones as well as accepted payloads.
|
||||||
|
// Always invalidate it on return, including partial-failure paths, so
|
||||||
|
// no pre-panic receiver decision survives after identity reset.
|
||||||
|
defer { privateMediaReceipts.resetForPanic() }
|
||||||
|
|
||||||
let markerError: Error?
|
let markerError: Error?
|
||||||
do {
|
do {
|
||||||
try markPanicRecoveryPending()
|
try markPanicRecoveryPending()
|
||||||
@@ -257,6 +268,35 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func privateMediaReceiptState(
|
||||||
|
messageID: String
|
||||||
|
) -> BLEPrivateMediaReceiptState {
|
||||||
|
privateMediaReceipts.state(for: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitPrivateMediaFile(
|
||||||
|
messageID: String,
|
||||||
|
storedURL: URL
|
||||||
|
) -> Bool {
|
||||||
|
privateMediaReceipts.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: storedURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort rollback for a payload whose durable receipt commit failed.
|
||||||
|
func removeIncomingFile(at storedURL: URL) {
|
||||||
|
guard isURLInsideFilesDirectory(storedURL) else { return }
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: storedURL)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Frees least-recently-modified incoming files until `reservingBytes`
|
/// Frees least-recently-modified incoming files until `reservingBytes`
|
||||||
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
||||||
/// captures) are never evicted regardless of who triggers enforcement —
|
/// captures) are never evicted regardless of who triggers enforcement —
|
||||||
@@ -349,6 +389,13 @@ struct BLEIncomingFileStore {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
|
||||||
|
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
|
||||||
|
}
|
||||||
|
|
||||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
var candidate = (name ?? "")
|
var candidate = (name ?? "")
|
||||||
.replacingOccurrences(of: "\0", with: "")
|
.replacingOccurrences(of: "\0", with: "")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct BLENoiseHandshakeHandlingResult {
|
struct BLENoiseHandshakeHandlingResult {
|
||||||
@@ -33,6 +34,8 @@ struct BLENoisePacketHandlerEnvironment {
|
|||||||
-> NoiseHandshakeProcessingResult
|
-> NoiseHandshakeProcessingResult
|
||||||
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
||||||
let hasNoiseSession: (PeerID) -> Bool
|
let hasNoiseSession: (PeerID) -> Bool
|
||||||
|
/// Whether an inbound ordinary XX responder is waiting for message 3.
|
||||||
|
let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool
|
||||||
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
||||||
let initiateHandshake: (PeerID) -> Void
|
let initiateHandshake: (PeerID) -> Void
|
||||||
/// Broadcasts a packet on the mesh (caller is already on the message queue).
|
/// Broadcasts a packet on the mesh (caller is already on the message queue).
|
||||||
@@ -63,7 +66,28 @@ struct BLENoisePacketHandlerEnvironment {
|
|||||||
/// processing (with response), encrypted payload decryption and dispatch,
|
/// processing (with response), encrypted payload decryption and dispatch,
|
||||||
/// and session recovery on decrypt failure.
|
/// and session recovery on decrypt failure.
|
||||||
final class BLENoisePacketHandler {
|
final class BLENoisePacketHandler {
|
||||||
|
private struct DeferredCiphertext {
|
||||||
|
let packet: BitchatPacket
|
||||||
|
let receivedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Early post-handshake packets are normally tiny control messages or
|
||||||
|
/// queued DMs. Keep the recovery surface deliberately small so an
|
||||||
|
/// unauthenticated half-handshake cannot create an unbounded memory queue.
|
||||||
|
private static let maxDeferredPacketsPerPeer = 4
|
||||||
|
private static let maxDeferredPacketsGlobal = 32
|
||||||
|
/// One legacy sender can immediately follow message 3 with the largest
|
||||||
|
/// valid private-file ciphertext and has no application-level retry. Keep
|
||||||
|
/// room for that packet plus a small control-message budget.
|
||||||
|
private static let maxDeferredBytes =
|
||||||
|
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024
|
||||||
|
private static let deferredLifetime =
|
||||||
|
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||||
|
|
||||||
private let environment: BLENoisePacketHandlerEnvironment
|
private let environment: BLENoisePacketHandlerEnvironment
|
||||||
|
private let deferredLock = NSLock()
|
||||||
|
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
|
||||||
|
private var deferredCiphertextBytes = 0
|
||||||
|
|
||||||
init(environment: BLENoisePacketHandlerEnvironment) {
|
init(environment: BLENoisePacketHandlerEnvironment) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -105,8 +129,8 @@ final class BLENoisePacketHandler {
|
|||||||
env.broadcastPacket(responsePacket)
|
env.broadcastPacket(responsePacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session establishment will trigger onPeerAuthenticated callback
|
// The serialized authentication callback installs transport
|
||||||
// which will send any pending messages at the right time
|
// state before it drains any bounded early ciphertext.
|
||||||
return BLENoiseHandshakeHandlingResult(
|
return BLENoiseHandshakeHandlingResult(
|
||||||
processed: true,
|
processed: true,
|
||||||
didEstablishAuthenticatedSession:
|
didEstablishAuthenticatedSession:
|
||||||
@@ -151,6 +175,30 @@ final class BLENoisePacketHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
handleEncrypted(packet, from: peerID, isDeferredRetry: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by the transport's serialized authentication callback after it
|
||||||
|
/// has installed state for the promoted or restored session generation.
|
||||||
|
func handleSessionAuthenticated(_ peerID: PeerID) {
|
||||||
|
drainDeferredCiphertextsIfReady(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronously discards ciphertext retained for a pre-panic Noise
|
||||||
|
/// generation. The handler survives the service's identity replacement,
|
||||||
|
/// so keeping this queue would replay old bytes after post-panic auth.
|
||||||
|
func resetForPanic() {
|
||||||
|
deferredLock.lock()
|
||||||
|
deferredCiphertexts.removeAll(keepingCapacity: false)
|
||||||
|
deferredCiphertextBytes = 0
|
||||||
|
deferredLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEncrypted(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
from peerID: PeerID,
|
||||||
|
isDeferredRetry: Bool
|
||||||
|
) {
|
||||||
let env = environment
|
let env = environment
|
||||||
guard let recipientID = PeerID(hexData: packet.recipientID) else {
|
guard let recipientID = PeerID(hexData: packet.recipientID) else {
|
||||||
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
|
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
|
||||||
@@ -192,19 +240,205 @@ final class BLENoisePacketHandler {
|
|||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
||||||
|
} catch NoiseEncryptionError.transportGenerationNotReady {
|
||||||
|
if isDeferredRetry {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The manager promoted or restored keys before BLE's serialized
|
||||||
|
// callback installed generation-bound transport state. The
|
||||||
|
// manager rejected this before decrypting, so replay is safe.
|
||||||
|
deferCiphertext(packet, from: peerID)
|
||||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||||
|
if isDeferredRetry {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
// We received an encrypted message before establishing a session with this peer.
|
// We received an encrypted message before establishing a session with this peer.
|
||||||
// Trigger a handshake so future messages can be decrypted.
|
// An initiator may already have sent message 3 followed by this
|
||||||
|
// ciphertext, with BLE delivering the ciphertext first.
|
||||||
|
if env.isAwaitingResponderHandshakeCompletion(peerID) {
|
||||||
|
deferCiphertext(packet, from: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Otherwise trigger a handshake so future messages can decrypt.
|
||||||
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
|
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
|
||||||
if !env.hasNoiseSession(peerID) {
|
if !env.hasNoiseSession(peerID) {
|
||||||
env.initiateHandshake(peerID)
|
env.initiateHandshake(peerID)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if isDeferredRetry {
|
||||||
|
// An early packet cannot tear down the authenticated session
|
||||||
|
// merely because its single bounded retry still fails.
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A responder may retain an older transport as receive-only
|
||||||
|
// rollback state while ordinary XX waits for message 3. New-key
|
||||||
|
// ciphertext can fail against those retained receive keys first.
|
||||||
|
if env.isAwaitingResponderHandshakeCompletion(peerID) {
|
||||||
|
if isDeferrableEarlyHandshakeFailure(error) {
|
||||||
|
deferCiphertext(packet, from: peerID)
|
||||||
|
} else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isDropOnlyCiphertextFailure(error) {
|
||||||
|
// The packet is attacker-controlled and did not prove a
|
||||||
|
// transport-state failure. Never let malformed, replayed,
|
||||||
|
// forged, oversized, or rate-limited bytes evict working keys.
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Decryption failed - clear the corrupted session and re-initiate handshake
|
// Decryption failed - clear the corrupted session and re-initiate handshake
|
||||||
// This handles cases where session state got out of sync (nonce mismatch, etc.)
|
// Only local/session lifecycle failures reach this path.
|
||||||
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
|
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
|
||||||
env.clearSession(peerID)
|
env.clearSession(peerID)
|
||||||
env.initiateHandshake(peerID)
|
env.initiateHandshake(peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool {
|
||||||
|
if let noiseError = error as? NoiseError {
|
||||||
|
switch noiseError {
|
||||||
|
case .authenticationFailure, .replayDetected:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let cryptoError = error as? CryptoKitError,
|
||||||
|
case .authenticationFailure = cryptoError {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool {
|
||||||
|
if let securityError = error as? NoiseSecurityError {
|
||||||
|
switch securityError {
|
||||||
|
case .messageTooLarge, .rateLimitExceeded, .invalidPeerID:
|
||||||
|
return true
|
||||||
|
case .sessionExpired, .sessionExhausted:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let noiseError = error as? NoiseError {
|
||||||
|
switch noiseError {
|
||||||
|
case .invalidCiphertext, .authenticationFailure, .replayDetected:
|
||||||
|
return true
|
||||||
|
case .uninitializedCipher, .handshakeComplete,
|
||||||
|
.handshakeNotComplete, .missingLocalStaticKey,
|
||||||
|
.missingKeys, .invalidMessage, .invalidPublicKey,
|
||||||
|
.nonceExceeded:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return error is CryptoKitError
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
guard NoiseSecurityValidator.validatePrivateFileCiphertextSize(
|
||||||
|
packet.payload
|
||||||
|
) else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))…",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = environment.now()
|
||||||
|
deferredLock.lock()
|
||||||
|
defer { deferredLock.unlock() }
|
||||||
|
purgeExpiredCiphertextsLocked(now: now)
|
||||||
|
|
||||||
|
let peerCount = deferredCiphertexts[peerID]?.count ?? 0
|
||||||
|
let globalCount = deferredCiphertexts.values.reduce(0) {
|
||||||
|
$0 + $1.count
|
||||||
|
}
|
||||||
|
guard peerCount < Self.maxDeferredPacketsPerPeer,
|
||||||
|
globalCount < Self.maxDeferredPacketsGlobal,
|
||||||
|
deferredCiphertextBytes + packet.payload.count
|
||||||
|
<= Self.maxDeferredBytes else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deferredCiphertexts[peerID, default: []].append(
|
||||||
|
DeferredCiphertext(packet: packet, receivedAt: now)
|
||||||
|
)
|
||||||
|
deferredCiphertextBytes += packet.payload.count
|
||||||
|
SecureLogger.debug(
|
||||||
|
"Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drainDeferredCiphertextsIfReady(for peerID: PeerID) {
|
||||||
|
let env = environment
|
||||||
|
guard !env.isAwaitingResponderHandshakeCompletion(peerID),
|
||||||
|
env.hasNoiseSession(peerID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = env.now()
|
||||||
|
deferredLock.lock()
|
||||||
|
purgeExpiredCiphertextsLocked(now: now)
|
||||||
|
let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? []
|
||||||
|
deferredCiphertextBytes -= deferred.reduce(0) {
|
||||||
|
$0 + $1.packet.payload.count
|
||||||
|
}
|
||||||
|
deferredLock.unlock()
|
||||||
|
|
||||||
|
guard !deferred.isEmpty else { return }
|
||||||
|
SecureLogger.debug(
|
||||||
|
"Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
for item in deferred {
|
||||||
|
handleEncrypted(item.packet, from: peerID, isDeferredRetry: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func purgeExpiredCiphertextsLocked(now: Date) {
|
||||||
|
for peerID in Array(deferredCiphertexts.keys) {
|
||||||
|
guard let items = deferredCiphertexts[peerID] else { continue }
|
||||||
|
let retained = items.filter {
|
||||||
|
now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime
|
||||||
|
}
|
||||||
|
guard retained.count != items.count else { continue }
|
||||||
|
|
||||||
|
deferredCiphertextBytes -= items.reduce(0) {
|
||||||
|
$0 + $1.packet.payload.count
|
||||||
|
}
|
||||||
|
deferredCiphertextBytes += retained.reduce(0) {
|
||||||
|
$0 + $1.packet.payload.count
|
||||||
|
}
|
||||||
|
if retained.isEmpty {
|
||||||
|
deferredCiphertexts.removeValue(forKey: peerID)
|
||||||
|
} else {
|
||||||
|
deferredCiphertexts[peerID] = retained
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,556 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEPrivateMediaReceiptState: Equatable {
|
||||||
|
/// No durable receiver decision exists for this stable message ID.
|
||||||
|
case absent
|
||||||
|
/// The payload is durably mapped to a file that still exists.
|
||||||
|
case accepted(URL)
|
||||||
|
/// The user explicitly deleted the payload; retries must not resurrect it.
|
||||||
|
case tombstoned
|
||||||
|
/// Durable state could not be read safely. Callers must fail closed and
|
||||||
|
/// must not save, deliver, or acknowledge the payload.
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durable, per-message receiver decisions for stable private media.
|
||||||
|
///
|
||||||
|
/// Each ID has its own atomic record so one hot lookup never rewrites or
|
||||||
|
/// decodes the entire ledger. The process-lifetime index is installed only
|
||||||
|
/// after a complete, successful directory scan. An enumeration, read, decode,
|
||||||
|
/// or structural-validation failure therefore remains retryable and cannot be
|
||||||
|
/// mistaken for an empty ledger.
|
||||||
|
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||||
|
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
|
||||||
|
typealias DataReader = (_ url: URL) throws -> Data
|
||||||
|
private static let receiptDirectoryName = ".private-media-receipts"
|
||||||
|
|
||||||
|
private struct ReceiptRecord: Codable, Equatable {
|
||||||
|
enum Kind: String, Codable {
|
||||||
|
case accepted
|
||||||
|
case tombstone
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind: Kind
|
||||||
|
/// Path below the app's `files/` root. Absolute application-container
|
||||||
|
/// prefixes are not stable across updates, restores, or reinstalls.
|
||||||
|
let relativePath: String?
|
||||||
|
let recordedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Runtime: @unchecked Sendable {
|
||||||
|
let lock = NSLock()
|
||||||
|
var records: [String: ReceiptRecord]?
|
||||||
|
var volatileTombstones: [String: Date] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private let fileManager: FileManager
|
||||||
|
private let baseDirectory: URL?
|
||||||
|
private let capacity: Int
|
||||||
|
private let ttl: TimeInterval
|
||||||
|
private let now: () -> Date
|
||||||
|
private let directoryReader: DirectoryReader?
|
||||||
|
private let dataReader: DataReader?
|
||||||
|
private let runtime = Runtime()
|
||||||
|
|
||||||
|
init(
|
||||||
|
fileManager: FileManager = .default,
|
||||||
|
baseDirectory: URL? = nil,
|
||||||
|
capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity,
|
||||||
|
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
|
||||||
|
now: @escaping () -> Date = Date.init,
|
||||||
|
directoryReader: DirectoryReader? = nil,
|
||||||
|
dataReader: DataReader? = nil
|
||||||
|
) {
|
||||||
|
self.fileManager = fileManager
|
||||||
|
self.baseDirectory = baseDirectory
|
||||||
|
self.capacity = max(1, capacity)
|
||||||
|
self.ttl = max(0, ttl)
|
||||||
|
self.now = now
|
||||||
|
self.directoryReader = directoryReader
|
||||||
|
self.dataReader = dataReader
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops process-lifetime decisions after the enclosing media directory
|
||||||
|
/// has been panic-wiped. A later lookup must rebuild from the durable
|
||||||
|
/// ledger instead of retaining an accepted receipt or tombstone whose
|
||||||
|
/// backing files no longer exist.
|
||||||
|
func resetForPanic() {
|
||||||
|
runtime.lock.lock()
|
||||||
|
runtime.records = nil
|
||||||
|
runtime.volatileTombstones.removeAll(keepingCapacity: false)
|
||||||
|
runtime.lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func state(for messageID: String) -> BLEPrivateMediaReceiptState {
|
||||||
|
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||||
|
return .absent
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.lock.lock()
|
||||||
|
defer { runtime.lock.unlock() }
|
||||||
|
|
||||||
|
let date = now()
|
||||||
|
if let tombstonedAt = runtime.volatileTombstones[messageID] {
|
||||||
|
if !isExpired(tombstonedAt, at: date) {
|
||||||
|
return .tombstoned
|
||||||
|
}
|
||||||
|
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let directory = resolvedReceiptDirectory(),
|
||||||
|
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
guard let record = records[messageID] else { return .absent }
|
||||||
|
|
||||||
|
if isExpired(record.recordedAt, at: date) {
|
||||||
|
records.removeValue(forKey: messageID)
|
||||||
|
runtime.records = records
|
||||||
|
removeRecord(messageID: messageID, from: directory)
|
||||||
|
return .absent
|
||||||
|
}
|
||||||
|
|
||||||
|
switch record.kind {
|
||||||
|
case .tombstone:
|
||||||
|
removePayloadRecordedByTombstone(record)
|
||||||
|
return .tombstoned
|
||||||
|
|
||||||
|
case .accepted:
|
||||||
|
guard let relativePath = record.relativePath,
|
||||||
|
let existingURL = existingPayload(relativePath: relativePath) else {
|
||||||
|
// Quota cleanup is not explicit deletion. Remove the stale
|
||||||
|
// receipt so a sender retry can restore the payload and bubble.
|
||||||
|
records.removeValue(forKey: messageID)
|
||||||
|
runtime.records = records
|
||||||
|
removeRecord(messageID: messageID, from: directory)
|
||||||
|
return .absent
|
||||||
|
}
|
||||||
|
return .accepted(existingURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records an accepted ID only after the payload is on disk. Callers must
|
||||||
|
/// roll the payload back and withhold UI delivery/ACK when this returns
|
||||||
|
/// false.
|
||||||
|
func commitAccepted(messageID: String, storedURL: URL) -> Bool {
|
||||||
|
guard PrivateMediaMessageIdentity.isStableID(messageID),
|
||||||
|
validExistingPayload(storedURL) != nil,
|
||||||
|
let relativePath = relativePath(for: storedURL) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.lock.lock()
|
||||||
|
defer { runtime.lock.unlock() }
|
||||||
|
|
||||||
|
let date = now()
|
||||||
|
if let tombstonedAt = runtime.volatileTombstones[messageID],
|
||||||
|
!isExpired(tombstonedAt, at: date) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let directory = resolvedReceiptDirectory(),
|
||||||
|
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let existing = records[messageID],
|
||||||
|
existing.kind == .tombstone,
|
||||||
|
!isExpired(existing.recordedAt, at: date) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let victim = capacityVictim(
|
||||||
|
for: .accepted,
|
||||||
|
replacing: messageID,
|
||||||
|
in: records
|
||||||
|
)
|
||||||
|
if records[messageID]?.kind != .accepted,
|
||||||
|
records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity,
|
||||||
|
victim == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = ReceiptRecord(
|
||||||
|
kind: .accepted,
|
||||||
|
relativePath: relativePath,
|
||||||
|
recordedAt: date
|
||||||
|
)
|
||||||
|
guard persist(record, messageID: messageID, to: directory) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
records[messageID] = record
|
||||||
|
if let victim, victim != messageID {
|
||||||
|
records.removeValue(forKey: victim)
|
||||||
|
removeRecord(messageID: victim, from: directory)
|
||||||
|
}
|
||||||
|
runtime.records = records
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Foundation for explicit media deletion. This branch does not wire the
|
||||||
|
/// chat-clear UI; it only makes a tombstone durable and fail closed.
|
||||||
|
func recordDeleted(messageID: String) -> Bool {
|
||||||
|
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.lock.lock()
|
||||||
|
defer { runtime.lock.unlock() }
|
||||||
|
|
||||||
|
let date = now()
|
||||||
|
addVolatileTombstone(messageID, at: date)
|
||||||
|
|
||||||
|
guard let directory = resolvedReceiptDirectory(),
|
||||||
|
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||||
|
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let existing = records[messageID],
|
||||||
|
existing.kind == .tombstone,
|
||||||
|
!isExpired(existing.recordedAt, at: date) {
|
||||||
|
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||||
|
removePayloadRecordedByTombstone(existing)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let victim = capacityVictim(
|
||||||
|
for: .tombstone,
|
||||||
|
replacing: messageID,
|
||||||
|
in: records
|
||||||
|
)
|
||||||
|
if records[messageID]?.kind != .tombstone,
|
||||||
|
records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity,
|
||||||
|
victim == nil {
|
||||||
|
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let tombstone = ReceiptRecord(
|
||||||
|
kind: .tombstone,
|
||||||
|
// Retain the accepted path so a crash between the atomic record
|
||||||
|
// write and payload unlink can finish cleanup after relaunch.
|
||||||
|
relativePath: records[messageID]?.relativePath,
|
||||||
|
recordedAt: date
|
||||||
|
)
|
||||||
|
guard persist(tombstone, messageID: messageID, to: directory) else {
|
||||||
|
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
records[messageID] = tombstone
|
||||||
|
if let victim, victim != messageID {
|
||||||
|
records.removeValue(forKey: victim)
|
||||||
|
removeRecord(messageID: victim, from: directory)
|
||||||
|
}
|
||||||
|
runtime.records = records
|
||||||
|
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||||
|
removePayloadRecordedByTombstone(tombstone)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadIndexIfNeeded(
|
||||||
|
from directory: URL,
|
||||||
|
at date: Date
|
||||||
|
) -> [String: ReceiptRecord]? {
|
||||||
|
if let records = runtime.records {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Failed to create private-media receipt directory: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let urls: [URL]
|
||||||
|
do {
|
||||||
|
if let directoryReader {
|
||||||
|
urls = try directoryReader(directory)
|
||||||
|
} else {
|
||||||
|
urls = try fileManager.contentsOfDirectory(
|
||||||
|
at: directory,
|
||||||
|
includingPropertiesForKeys: nil,
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Failed to enumerate private-media receipts: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var records: [String: ReceiptRecord] = [:]
|
||||||
|
var expired: [String] = []
|
||||||
|
var tombstones: [ReceiptRecord] = []
|
||||||
|
for url in urls {
|
||||||
|
guard url.pathExtension == "json" else { continue }
|
||||||
|
let messageID = url.deletingPathExtension().lastPathComponent
|
||||||
|
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let record: ReceiptRecord
|
||||||
|
do {
|
||||||
|
let data = try dataReader?(url) ?? Data(contentsOf: url)
|
||||||
|
record = try JSONDecoder().decode(ReceiptRecord.self, from: data)
|
||||||
|
} catch {
|
||||||
|
// Never delete or skip an unreadable stable-ID record. Treating
|
||||||
|
// it as absent could resurrect accepted or deleted media.
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard isStructurallyValid(record) else {
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Invalid private-media receipt \(messageID.prefix(12))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isExpired(record.recordedAt, at: date) {
|
||||||
|
expired.append(messageID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records[messageID] = record
|
||||||
|
if record.kind == .tombstone {
|
||||||
|
tombstones.append(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overflow = overflowVictims(in: records)
|
||||||
|
for messageID in overflow {
|
||||||
|
records.removeValue(forKey: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install the index only after every stable-ID record was read and
|
||||||
|
// validated successfully. Cleanup cannot influence a failed scan.
|
||||||
|
runtime.records = records
|
||||||
|
|
||||||
|
for messageID in expired + overflow {
|
||||||
|
removeRecord(messageID: messageID, from: directory)
|
||||||
|
}
|
||||||
|
for tombstone in tombstones {
|
||||||
|
removePayloadRecordedByTombstone(tombstone)
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
|
||||||
|
switch record.kind {
|
||||||
|
case .tombstone:
|
||||||
|
guard let relativePath = record.relativePath else { return true }
|
||||||
|
return candidatePayload(relativePath: relativePath) != nil
|
||||||
|
case .accepted:
|
||||||
|
guard let relativePath = record.relativePath else { return false }
|
||||||
|
return candidatePayload(relativePath: relativePath) != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isExpired(_ recordedAt: Date, at date: Date) -> Bool {
|
||||||
|
date.timeIntervalSince(recordedAt) > ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
private func overflowVictims(
|
||||||
|
in records: [String: ReceiptRecord]
|
||||||
|
) -> [String] {
|
||||||
|
var victims: [String] = []
|
||||||
|
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
|
||||||
|
let matching = records.filter { $0.value.kind == kind }
|
||||||
|
let overflow = matching.count - capacity
|
||||||
|
guard overflow > 0 else { continue }
|
||||||
|
victims.append(contentsOf: matching.sorted { lhs, rhs in
|
||||||
|
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||||
|
return lhs.key < rhs.key
|
||||||
|
}
|
||||||
|
return lhs.value.recordedAt < rhs.value.recordedAt
|
||||||
|
}
|
||||||
|
.prefix(overflow)
|
||||||
|
.map(\.key))
|
||||||
|
}
|
||||||
|
return victims
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepted receipts and tombstones have independent capacity. High media
|
||||||
|
/// volume cannot evict explicit deletion intent, and vice versa.
|
||||||
|
private func capacityVictim(
|
||||||
|
for incomingKind: ReceiptRecord.Kind,
|
||||||
|
replacing messageID: String,
|
||||||
|
in records: [String: ReceiptRecord]
|
||||||
|
) -> String? {
|
||||||
|
guard records[messageID]?.kind != incomingKind else { return nil }
|
||||||
|
let matching = records.filter {
|
||||||
|
$0.key != messageID && $0.value.kind == incomingKind
|
||||||
|
}
|
||||||
|
guard matching.count >= capacity else { return nil }
|
||||||
|
return matching.min { lhs, rhs in
|
||||||
|
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||||
|
return lhs.key < rhs.key
|
||||||
|
}
|
||||||
|
return lhs.value.recordedAt < rhs.value.recordedAt
|
||||||
|
}?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist(
|
||||||
|
_ record: ReceiptRecord,
|
||||||
|
messageID: String,
|
||||||
|
to directory: URL
|
||||||
|
) -> Bool {
|
||||||
|
do {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
let data = try JSONEncoder().encode(record)
|
||||||
|
var options: Data.WritingOptions = [.atomic]
|
||||||
|
#if os(iOS)
|
||||||
|
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||||
|
#endif
|
||||||
|
let url = recordURL(messageID: messageID, in: directory)
|
||||||
|
try data.write(to: url, options: options)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeRecord(messageID: String, from directory: URL) {
|
||||||
|
let url = recordURL(messageID: messageID, in: directory)
|
||||||
|
guard fileManager.fileExists(atPath: url.path) else { return }
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: url)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordURL(messageID: String, in directory: URL) -> URL {
|
||||||
|
directory
|
||||||
|
.appendingPathComponent(messageID, isDirectory: false)
|
||||||
|
.appendingPathExtension("json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) {
|
||||||
|
guard record.kind == .tombstone,
|
||||||
|
let relativePath = record.relativePath,
|
||||||
|
let payload = candidatePayload(relativePath: relativePath),
|
||||||
|
fileManager.fileExists(atPath: payload.path) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: payload)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Failed to remove explicitly deleted private media: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addVolatileTombstone(_ messageID: String, at date: Date) {
|
||||||
|
runtime.volatileTombstones[messageID] = date
|
||||||
|
let overflow = runtime.volatileTombstones.count - capacity
|
||||||
|
guard overflow > 0 else { return }
|
||||||
|
let oldest = runtime.volatileTombstones.sorted {
|
||||||
|
if $0.value == $1.value { return $0.key < $1.key }
|
||||||
|
return $0.value < $1.value
|
||||||
|
}
|
||||||
|
for (oldMessageID, _) in oldest.prefix(overflow) {
|
||||||
|
runtime.volatileTombstones.removeValue(forKey: oldMessageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validExistingPayload(_ url: URL) -> URL? {
|
||||||
|
let standardized = url.standardizedFileURL
|
||||||
|
guard isInsideFilesDirectory(standardized) else { return nil }
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard fileManager.fileExists(
|
||||||
|
atPath: standardized.path,
|
||||||
|
isDirectory: &isDirectory
|
||||||
|
), !isDirectory.boolValue else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return standardized
|
||||||
|
}
|
||||||
|
|
||||||
|
private func relativePath(for url: URL) -> String? {
|
||||||
|
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let prefix = filesRoot.path + "/"
|
||||||
|
let standardized = url.standardizedFileURL
|
||||||
|
guard standardized.path.hasPrefix(prefix) else { return nil }
|
||||||
|
let relativePath = String(standardized.path.dropFirst(prefix.count))
|
||||||
|
return relativePath.isEmpty ? nil : relativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private func existingPayload(relativePath: String) -> URL? {
|
||||||
|
guard let candidate = candidatePayload(relativePath: relativePath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return validExistingPayload(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func candidatePayload(relativePath: String) -> URL? {
|
||||||
|
guard !relativePath.isEmpty,
|
||||||
|
let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let candidate = filesRoot
|
||||||
|
.appendingPathComponent(relativePath, isDirectory: false)
|
||||||
|
.standardizedFileURL
|
||||||
|
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil }
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isInsideFilesDirectory(_ url: URL) -> Bool {
|
||||||
|
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedReceiptDirectory() -> URL? {
|
||||||
|
return try? filesDirectory().appendingPathComponent(
|
||||||
|
Self.receiptDirectoryName,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filesDirectory() throws -> URL {
|
||||||
|
let root = try baseDirectory ?? fileManager.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let files = root.appendingPathComponent("files", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: files,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -707,6 +707,7 @@ final class BLEService: NSObject {
|
|||||||
/// or advertising while the full panic transaction is incomplete.
|
/// or advertising while the full panic transaction is incomplete.
|
||||||
func suspendForPanicReset() {
|
func suspendForPanicReset() {
|
||||||
setPanicSuspended(true)
|
setPanicSuspended(true)
|
||||||
|
noisePacketHandler.resetForPanic()
|
||||||
gossipSyncManager?.stop()
|
gossipSyncManager?.stop()
|
||||||
gossipSyncManager = nil
|
gossipSyncManager = nil
|
||||||
// Stop the radio and drain CoreBluetooth's delegate queue first. A
|
// Stop the radio and drain CoreBluetooth's delegate queue first. A
|
||||||
@@ -717,7 +718,11 @@ final class BLEService: NSObject {
|
|||||||
// Drain every receive/send submitted by callbacks that finished ahead
|
// Drain every receive/send submitted by callbacks that finished ahead
|
||||||
// of the radio stop. Later callbacks observe the closed lifecycle, and
|
// of the radio stop. Later callbacks observe the closed lifecycle, and
|
||||||
// generation-bound handoffs that raced this barrier reject themselves.
|
// generation-bound handoffs that raced this barrier reject themselves.
|
||||||
messageQueue.sync(flags: .barrier) {}
|
// Clear the old identity's bounded early-ciphertext queue again after
|
||||||
|
// those callbacks drain so none can repopulate it after the first wipe.
|
||||||
|
messageQueue.sync(flags: .barrier) {
|
||||||
|
noisePacketHandler.resetForPanic()
|
||||||
|
}
|
||||||
clearEmergencySessionState()
|
clearEmergencySessionState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,6 +741,7 @@ final class BLEService: NSObject {
|
|||||||
gossipSyncManager?.stop()
|
gossipSyncManager?.stop()
|
||||||
gossipSyncManager = nil
|
gossipSyncManager = nil
|
||||||
messageQueue.sync(flags: .barrier) {
|
messageQueue.sync(flags: .barrier) {
|
||||||
|
noisePacketHandler.resetForPanic()
|
||||||
pendingNoiseSessionQueues.removeAll()
|
pendingNoiseSessionQueues.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1113,6 +1119,28 @@ final class BLEService: NSObject {
|
|||||||
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func privateMediaPolicyFingerprint(
|
||||||
|
for peerID: PeerID,
|
||||||
|
expectedSessionGeneration: UUID?
|
||||||
|
) -> String? {
|
||||||
|
let normalizedPeerID = peerID.toShort()
|
||||||
|
if let expectedSessionGeneration,
|
||||||
|
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||||
|
== expectedSessionGeneration,
|
||||||
|
let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID),
|
||||||
|
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||||
|
== expectedSessionGeneration {
|
||||||
|
// The exact authenticated Noise static key is stronger than a
|
||||||
|
// registry entry populated by a public announce.
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
|
return collectionsQueue.sync {
|
||||||
|
peerRegistry.info(for: normalizedPeerID)?
|
||||||
|
.noisePublicKey?
|
||||||
|
.sha256Fingerprint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
let normalizedPeerID = peerID.toShort()
|
let normalizedPeerID = peerID.toShort()
|
||||||
let state: (
|
let state: (
|
||||||
@@ -1140,7 +1168,10 @@ final class BLEService: NSObject {
|
|||||||
return .awaitingCapabilityProof
|
return .awaitingCapabilityProof
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let fingerprint = state.fingerprint else {
|
guard let fingerprint = privateMediaPolicyFingerprint(
|
||||||
|
for: normalizedPeerID,
|
||||||
|
expectedSessionGeneration: state.sessionGeneration
|
||||||
|
) ?? state.fingerprint else {
|
||||||
// A raw fallback must be bound to the stable Noise key from a
|
// A raw fallback must be bound to the stable Noise key from a
|
||||||
// verified registry entry; a routing ID alone can rotate or be
|
// verified registry entry; a routing ID alone can rotate or be
|
||||||
// spoofed. Without that key neither proof nor safe migration state
|
// spoofed. Without that key neither proof nor safe migration state
|
||||||
@@ -1194,11 +1225,13 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let fingerprint: String? = self.collectionsQueue.sync {
|
let generation = self.collectionsQueue.sync {
|
||||||
self.peerRegistry.info(for: normalizedPeerID)?
|
self.privateMediaSessionGenerations[normalizedPeerID]
|
||||||
.noisePublicKey?
|
|
||||||
.sha256Fingerprint()
|
|
||||||
}
|
}
|
||||||
|
let fingerprint = self.privateMediaPolicyFingerprint(
|
||||||
|
for: normalizedPeerID,
|
||||||
|
expectedSessionGeneration: generation
|
||||||
|
)
|
||||||
guard let fingerprint else {
|
guard let fingerprint else {
|
||||||
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
||||||
return
|
return
|
||||||
@@ -2521,12 +2554,50 @@ final class BLEService: NSObject {
|
|||||||
defaultPrefix: defaultPrefix
|
defaultPrefix: defaultPrefix
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
privateMediaReceiptState: { [weak self] messageID in
|
||||||
|
self?.incomingFileStore.privateMediaReceiptState(
|
||||||
|
messageID: messageID
|
||||||
|
) ?? .unavailable
|
||||||
|
},
|
||||||
|
commitPrivateMediaFile: { [weak self] messageID, storedURL in
|
||||||
|
self?.incomingFileStore.commitPrivateMediaFile(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: storedURL
|
||||||
|
) ?? false
|
||||||
|
},
|
||||||
|
removeIncomingFile: { [weak self] storedURL in
|
||||||
|
self?.incomingFileStore.removeIncomingFile(at: storedURL)
|
||||||
|
},
|
||||||
|
isPrivateMediaSenderBlocked: { [weak self] peerID in
|
||||||
|
guard let self else { return false }
|
||||||
|
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
||||||
|
?? self.collectionsQueue.sync {
|
||||||
|
self.peerRegistry.info(for: peerID)?.noisePublicKey
|
||||||
|
}
|
||||||
|
guard let senderStaticKey else { return false }
|
||||||
|
return self.identityManager.isBlocked(
|
||||||
|
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||||
|
)
|
||||||
|
},
|
||||||
updatePeerLastSeen: { [weak self] peerID in
|
updatePeerLastSeen: { [weak self] peerID in
|
||||||
self?.updatePeerLastSeen(peerID)
|
self?.updatePeerLastSeen(peerID)
|
||||||
},
|
},
|
||||||
deliverMessage: { [weak self] message in
|
acknowledgePrivateMedia: { [weak self] messageID, peerID in
|
||||||
// Single main-actor hop delivering `.messageReceived`.
|
guard let self,
|
||||||
self?.emitTransportEvent(.messageReceived(message))
|
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
|
||||||
|
!self.identityManager.isBlocked(
|
||||||
|
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
|
},
|
||||||
|
deliverMessage: { [weak self] message, shouldDeliver, completion in
|
||||||
|
self?.emitTransportEvent(
|
||||||
|
.messageReceived(message),
|
||||||
|
shouldDeliver: shouldDeliver,
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3556,6 +3627,28 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replays the current generation's ready callback. Restore tests use
|
||||||
|
/// this to prove same-generation reconciliation is idempotent.
|
||||||
|
func _test_reconcileCurrentNoiseSession(for peerID: PeerID) {
|
||||||
|
let normalizedPeerID = peerID.toShort()
|
||||||
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
let generation = self.noiseService.sessionGeneration(
|
||||||
|
for: normalizedPeerID
|
||||||
|
),
|
||||||
|
let fingerprint = self.noiseService.getPeerFingerprint(
|
||||||
|
normalizedPeerID
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.handleNoisePeerAuthenticated(
|
||||||
|
peerID: normalizedPeerID,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
sessionGeneration: generation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds an authenticated-session packet from an exact typed plaintext.
|
/// Builds an authenticated-session packet from an exact typed plaintext.
|
||||||
/// Compatibility tests use this to model Android's deployed 0x20 file
|
/// Compatibility tests use this to model Android's deployed 0x20 file
|
||||||
/// payload and the short-lived 0x09 prerelease payload without exposing a
|
/// payload and the short-lived 0x09 prerelease payload without exposing a
|
||||||
@@ -4221,18 +4314,55 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func emitTransportEvent(_ event: TransportEvent) {
|
private func emitTransportEvent(
|
||||||
|
_ event: TransportEvent,
|
||||||
|
shouldDeliver: (() -> Bool)? = nil,
|
||||||
|
completion: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
self?.deliverTransportEvent(event)
|
guard let self,
|
||||||
|
shouldDeliver?() ?? true,
|
||||||
|
self.deliverTransportEvent(event),
|
||||||
|
// Quota cleanup can race the asynchronous main-actor hop or
|
||||||
|
// the synchronous ConversationStore upsert. ACK only while
|
||||||
|
// the exact durable mapping and file still resolve.
|
||||||
|
shouldDeliver?() ?? true else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func deliverTransportEvent(_ event: TransportEvent) {
|
@discardableResult
|
||||||
|
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
|
||||||
|
if case .messageReceived(let message) = event {
|
||||||
|
if let synchronousDelegate =
|
||||||
|
eventDelegate as? SynchronousMessageTransportEventDelegate {
|
||||||
|
return synchronousDelegate
|
||||||
|
.didReceiveTransportMessageSynchronously(message)
|
||||||
|
}
|
||||||
|
if let eventDelegate {
|
||||||
|
eventDelegate.didReceiveTransportEvent(event)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let synchronousDelegate =
|
||||||
|
delegate as? SynchronousMessageTransportEventDelegate {
|
||||||
|
return synchronousDelegate
|
||||||
|
.didReceiveTransportMessageSynchronously(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let eventDelegate {
|
if let eventDelegate {
|
||||||
eventDelegate.didReceiveTransportEvent(event)
|
eventDelegate.didReceiveTransportEvent(event)
|
||||||
|
return true
|
||||||
} else {
|
} else {
|
||||||
delegate?.receiveTransportEvent(event)
|
guard let delegate else { return false }
|
||||||
|
delegate.receiveTransportEvent(event)
|
||||||
|
if case .messageReceived = event {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4649,7 +4779,10 @@ extension BLEService {
|
|||||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||||
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
|
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
|
||||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…")
|
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…")
|
||||||
self?.messageQueue.async { [weak self] in
|
// Authentication can be reported while an initiator is still
|
||||||
|
// returning XX message 3. Serialize generation-bound state and
|
||||||
|
// every post-handshake drain behind the handshake packet handler.
|
||||||
|
self?.messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.handleNoisePeerAuthenticated(
|
self?.handleNoisePeerAuthenticated(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
@@ -4724,7 +4857,9 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
|
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
|
||||||
guard let self, let service else { return }
|
guard let self, let service else { return }
|
||||||
self.messageQueue.async { [weak self, weak service] in
|
// The manager makes restored keys visible atomically. Reconcile
|
||||||
|
// transport state and queued sends as the next serialized phase.
|
||||||
|
self.messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
||||||
guard let self,
|
guard let self,
|
||||||
let service,
|
let service,
|
||||||
self.noiseService === service,
|
self.noiseService === service,
|
||||||
@@ -4792,7 +4927,16 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
) else { return }
|
) else { return }
|
||||||
|
|
||||||
guard let watchdog = transition.watchdog else { return }
|
guard let watchdog = transition.watchdog else {
|
||||||
|
// A quarantined transport restored the same cryptographic
|
||||||
|
// generation. Its capability proof and announce state never
|
||||||
|
// became stale; only work queued while outbound keys were paused
|
||||||
|
// needs one idempotent ready transition.
|
||||||
|
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
|
||||||
|
sendPendingMessagesAfterHandshake(for: normalizedPeerID)
|
||||||
|
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
|
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
|
||||||
schedulePrivateMediaProofTimeout(
|
schedulePrivateMediaProofTimeout(
|
||||||
@@ -4801,6 +4945,10 @@ extension BLEService {
|
|||||||
sessionGeneration: generation,
|
sessionGeneration: generation,
|
||||||
nonce: watchdog.nonce
|
nonce: watchdog.nonce
|
||||||
)
|
)
|
||||||
|
// Cross-link delivery can put ciphertext sent immediately after
|
||||||
|
// message 3 ahead of message 3 itself. Retry the bounded queue only
|
||||||
|
// after this generation's transport state has been fully installed.
|
||||||
|
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
|
||||||
|
|
||||||
// `onPeerAuthenticated` can fire while the initiator is returning XX
|
// `onPeerAuthenticated` can fire while the initiator is returning XX
|
||||||
// message 3. This callback is queued behind the handshake handler, so
|
// message 3. This callback is queued behind the handshake handler, so
|
||||||
@@ -6415,7 +6563,12 @@ extension BLEService {
|
|||||||
// MARK: Packet Reception
|
// MARK: Packet Reception
|
||||||
|
|
||||||
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
// Call directly if already on messageQueue, otherwise dispatch
|
let isNoisePacket = packet.type == MessageType.noiseHandshake.rawValue
|
||||||
|
|| packet.type == MessageType.noiseEncrypted.rawValue
|
||||||
|
|
||||||
|
// Capture the panic lifecycle at the first off-messageQueue handoff.
|
||||||
|
// Noise packets still enter through a barrier so handshake promotion,
|
||||||
|
// quarantine, and encrypted delivery share one ordered session.
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||||
guard let lifecycleGeneration =
|
guard let lifecycleGeneration =
|
||||||
capturePanicLifecycleGeneration() else {
|
capturePanicLifecycleGeneration() else {
|
||||||
@@ -6424,7 +6577,8 @@ extension BLEService {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
_test_beforeReceivePacketHandoff?()
|
_test_beforeReceivePacketHandoff?()
|
||||||
#endif
|
#endif
|
||||||
messageQueue.async { [weak self] in
|
let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : []
|
||||||
|
messageQueue.async(flags: flags) { [weak self] in
|
||||||
guard let self,
|
guard let self,
|
||||||
self.isCurrentPanicLifecycleGeneration(
|
self.isCurrentPanicLifecycleGeneration(
|
||||||
lifecycleGeneration
|
lifecycleGeneration
|
||||||
@@ -6434,11 +6588,34 @@ extension BLEService {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
self._test_onReceivePacketHandoff?()
|
self._test_onReceivePacketHandoff?()
|
||||||
#endif
|
#endif
|
||||||
self.handleReceivedPacket(packet, from: peerID)
|
self.handleReceivedPacketOnQueue(packet, from: peerID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isNoisePacket {
|
||||||
|
guard let lifecycleGeneration =
|
||||||
|
capturePanicLifecycleGeneration() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(
|
||||||
|
lifecycleGeneration
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.handleReceivedPacketOnQueue(packet, from: peerID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleReceivedPacketOnQueue(packet, from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleReceivedPacketOnQueue(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
from peerID: PeerID
|
||||||
|
) {
|
||||||
let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
|
let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
|
||||||
let senderID = context.senderID
|
let senderID = context.senderID
|
||||||
let messageID = context.messageID
|
let messageID = context.messageID
|
||||||
@@ -7214,16 +7391,16 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
|
||||||
let result = noisePacketHandler.handleHandshakeWithResult(
|
let result = noisePacketHandler.handleHandshakeWithResult(
|
||||||
packet,
|
packet,
|
||||||
from: peerID
|
from: peerID
|
||||||
)
|
)
|
||||||
let isEstablished = noiseService.hasEstablishedSession(with: peerID)
|
// An inbound message 1 quarantines the old transport receive-only.
|
||||||
if wasEstablished, result.processed,
|
// Keep its generation-bound BLE state intact: the manager's new
|
||||||
!isEstablished {
|
// handshaking generation already gates every outbound policy, while
|
||||||
noteNoiseSessionCleared(for: peerID)
|
// a rollback can become ready again without repeating capability
|
||||||
}
|
// proof or announce side effects. Only the exact handshake candidate's
|
||||||
|
// authenticated completion may promote the physical ingress link.
|
||||||
if result.didEstablishAuthenticatedSession {
|
if result.didEstablishAuthenticatedSession {
|
||||||
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||||
}
|
}
|
||||||
@@ -7261,6 +7438,11 @@ extension BLEService {
|
|||||||
hasNoiseSession: { [weak self] peerID in
|
hasNoiseSession: { [weak self] peerID in
|
||||||
self?.noiseService.hasSession(with: peerID) ?? false
|
self?.noiseService.hasSession(with: peerID) ?? false
|
||||||
},
|
},
|
||||||
|
isAwaitingResponderHandshakeCompletion: { [weak self] peerID in
|
||||||
|
self?.noiseService.isAwaitingResponderHandshakeCompletion(
|
||||||
|
with: peerID
|
||||||
|
) ?? false
|
||||||
|
},
|
||||||
initiateHandshake: { [weak self] peerID in
|
initiateHandshake: { [weak self] peerID in
|
||||||
self?.initiateNoiseHandshake(with: peerID)
|
self?.initiateNoiseHandshake(with: peerID)
|
||||||
},
|
},
|
||||||
@@ -7274,7 +7456,14 @@ extension BLEService {
|
|||||||
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
|
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
|
||||||
let result = try self.noiseService.decryptWithSessionGeneration(
|
let result = try self.noiseService.decryptWithSessionGeneration(
|
||||||
payload,
|
payload,
|
||||||
from: peerID
|
from: peerID,
|
||||||
|
establishedGenerationIsReady: { generation in
|
||||||
|
self.collectionsQueue.sync {
|
||||||
|
self.privateMediaSessionGenerations[
|
||||||
|
peerID.toShort()
|
||||||
|
] == generation
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return BLENoiseDecryptionResult(
|
return BLENoiseDecryptionResult(
|
||||||
plaintext: result.plaintext,
|
plaintext: result.plaintext,
|
||||||
|
|||||||
@@ -853,6 +853,13 @@ final class NoiseEncryptionService {
|
|||||||
func hasSession(with peerID: PeerID) -> Bool {
|
func hasSession(with peerID: PeerID) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID) != nil
|
return sessionManager.getSession(for: peerID) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True while an inbound ordinary XX responder is waiting for message 3.
|
||||||
|
/// A small amount of immediately-following ciphertext may arrive first
|
||||||
|
/// over BLE and must be retried only after responder promotion.
|
||||||
|
func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool {
|
||||||
|
sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
@@ -920,20 +927,15 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
func decryptWithSessionGeneration(
|
func decryptWithSessionGeneration(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
from peerID: PeerID
|
from peerID: PeerID,
|
||||||
|
establishedGenerationIsReady: (UUID) -> Bool = { _ in true }
|
||||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||||
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
||||||
// A larger candidate is admitted only up to the framed-file ceiling;
|
// A larger ciphertext is admitted only up to the framed-file ceiling;
|
||||||
// after authenticated decryption it must prove it is `.privateFile`.
|
// after authenticated decryption it must prove it is `.privateFile`.
|
||||||
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||||
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
|
let isAdmittedCiphertext = isStandardCiphertext
|
||||||
throw NoiseSecurityError.messageTooLarge
|
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
|
||||||
}
|
|
||||||
|
|
||||||
// Check rate limit
|
|
||||||
guard rateLimiter.allowMessage(from: peerID) else {
|
|
||||||
throw NoiseSecurityError.rateLimitExceeded
|
|
||||||
}
|
|
||||||
|
|
||||||
// A quarantined transport is deliberately unavailable for outbound
|
// A quarantined transport is deliberately unavailable for outbound
|
||||||
// state, but remains receive-only until the responder proves identity
|
// state, but remains receive-only until the responder proves identity
|
||||||
@@ -942,7 +944,20 @@ final class NoiseEncryptionService {
|
|||||||
throw NoiseEncryptionError.sessionNotEstablished
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
|
let result = try sessionManager.decryptWithSessionGeneration(
|
||||||
|
data,
|
||||||
|
from: peerID,
|
||||||
|
establishedGenerationIsReady:
|
||||||
|
establishedGenerationIsReady,
|
||||||
|
authorizeDecrypt: { [rateLimiter] in
|
||||||
|
guard isAdmittedCiphertext else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
guard rateLimiter.allowMessage(from: peerID) else {
|
||||||
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
if !isStandardCiphertext {
|
if !isStandardCiphertext {
|
||||||
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||||
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||||
@@ -1159,6 +1174,9 @@ struct NoiseMessage: Codable {
|
|||||||
enum NoiseEncryptionError: Error {
|
enum NoiseEncryptionError: Error {
|
||||||
case handshakeRequired
|
case handshakeRequired
|
||||||
case sessionNotEstablished
|
case sessionNotEstablished
|
||||||
|
/// Manager keys are established or restored, but BLE has not installed
|
||||||
|
/// generation-bound transport state. No receive nonce was consumed.
|
||||||
|
case transportGenerationNotReady
|
||||||
/// Envelope references a prekey ID we don't hold (never ours, already
|
/// Envelope references a prekey ID we don't hold (never ours, already
|
||||||
/// deleted after its grace window, or wiped in a panic).
|
/// deleted after its grace window, or wiped in a panic).
|
||||||
case unknownPrekey
|
case unknownPrekey
|
||||||
|
|||||||
@@ -101,6 +101,13 @@ protocol TransportEventDelegate: AnyObject {
|
|||||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optional typed-event contract for sinks that can synchronously decide
|
||||||
|
/// whether an inbound message was accepted.
|
||||||
|
protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate {
|
||||||
|
@MainActor
|
||||||
|
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
protocol Transport: AnyObject {
|
protocol Transport: AnyObject {
|
||||||
// Event sink
|
// Event sink
|
||||||
var delegate: BitchatDelegate? { get set }
|
var delegate: BitchatDelegate? { get set }
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ enum TransportConfig {
|
|||||||
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
||||||
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
||||||
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
||||||
|
/// Accepted private-media receipts and explicit-deletion tombstones each
|
||||||
|
/// receive this independent capacity.
|
||||||
|
static let privateMediaReceivedLedgerCapacity: Int = 4_096
|
||||||
|
/// A bounded retry horizon prevents stable receipt state from growing into
|
||||||
|
/// permanent application history.
|
||||||
|
static let privateMediaReceivedLedgerTTLSeconds: TimeInterval =
|
||||||
|
7 * 24 * 60 * 60
|
||||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||||
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
protocol ChatLiveVoiceContext: AnyObject {
|
protocol ChatLiveVoiceContext: AnyObject {
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
|
var myPeerID: PeerID { get }
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
/// Whether the public mesh timeline is what's on screen (autoplay gate
|
/// Whether the public mesh timeline is what's on screen (autoplay gate
|
||||||
/// for public bursts).
|
/// for public bursts).
|
||||||
@@ -30,6 +31,12 @@ protocol ChatLiveVoiceContext: AnyObject {
|
|||||||
func upsertPublicMeshMessage(_ message: BitchatMessage)
|
func upsertPublicMeshMessage(_ message: BitchatMessage)
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
|
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
|
||||||
|
/// Records and sends the finalized note's read receipt after a live
|
||||||
|
/// bubble adopts its wire-derivable message ID.
|
||||||
|
func hasSentReadReceipt(_ messageID: String) -> Bool
|
||||||
|
@discardableResult
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool
|
||||||
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
/// Removes a message from whichever conversation holds it.
|
/// Removes a message from whichever conversation holds it.
|
||||||
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
||||||
/// Publishes who is currently talking live in the public mesh channel
|
/// Publishes who is currently talking live in the public mesh channel
|
||||||
@@ -272,8 +279,16 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
|
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
|
||||||
let finished = entry.value
|
let finished = entry.value
|
||||||
|
|
||||||
|
// A DM live bubble starts before the finalized file exists and
|
||||||
|
// therefore has a receiver-local random ID. Adopt the finalized
|
||||||
|
// message's deterministic ID so delivery/read ACKs address the same
|
||||||
|
// row as the sender's media placeholder. Public notes retain their
|
||||||
|
// live-bubble ID because public transfers have no private receipts.
|
||||||
|
let replacementID = finished.scope == .directMessage
|
||||||
|
? message.id
|
||||||
|
: finished.messageID
|
||||||
let replacement = BitchatMessage(
|
let replacement = BitchatMessage(
|
||||||
id: finished.messageID,
|
id: replacementID,
|
||||||
sender: message.sender,
|
sender: message.sender,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
timestamp: finished.messageTimestamp,
|
timestamp: finished.messageTimestamp,
|
||||||
@@ -287,7 +302,31 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
)
|
)
|
||||||
switch finished.scope {
|
switch finished.scope {
|
||||||
case .directMessage:
|
case .directMessage:
|
||||||
|
// Capture read state before rekeying. The user may have read the
|
||||||
|
// live bubble and navigated away before the finalized .m4a lands.
|
||||||
|
let shouldSendAdoptedReadReceipt =
|
||||||
|
context.hasSentReadReceipt(finished.messageID)
|
||||||
|
|| context.selectedPrivateChatPeer == finished.peerID
|
||||||
|
|
||||||
|
// Insert first so replacing the only row in a DM never
|
||||||
|
// transiently deletes its conversation, unread state, or current
|
||||||
|
// selection. Then remove the receiver-local live-bubble alias.
|
||||||
context.upsertPrivateMessage(replacement, in: finished.peerID)
|
context.upsertPrivateMessage(replacement, in: finished.peerID)
|
||||||
|
if replacementID != finished.messageID {
|
||||||
|
context.removePrivateMessage(withID: finished.messageID)
|
||||||
|
}
|
||||||
|
// The live bubble may already have emitted a receiver-local READ
|
||||||
|
// before the sender created its finalized media row. Re-emit once
|
||||||
|
// for the adopted stable ID now that the file has arrived.
|
||||||
|
if shouldSendAdoptedReadReceipt,
|
||||||
|
context.markReadReceiptSent(replacementID) {
|
||||||
|
let receipt = ReadReceipt(
|
||||||
|
originalMessageID: replacementID,
|
||||||
|
readerID: context.myPeerID,
|
||||||
|
readerNickname: context.nickname
|
||||||
|
)
|
||||||
|
context.sendMeshReadReceipt(receipt, to: finished.peerID)
|
||||||
|
}
|
||||||
case .publicMesh:
|
case .publicMesh:
|
||||||
context.upsertPublicMeshMessage(replacement)
|
context.upsertPublicMeshMessage(replacement)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,9 +252,17 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let targetPeer = context.selectedPrivateChatPeer
|
let targetPeer = context.selectedPrivateChatPeer
|
||||||
|
let privateMessageID = targetPeer.flatMap { peerID in
|
||||||
|
PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: context.myPeerID,
|
||||||
|
recipientPeerID: peerID,
|
||||||
|
fileName: url.lastPathComponent
|
||||||
|
)
|
||||||
|
}
|
||||||
let message = enqueueMediaMessage(
|
let message = enqueueMediaMessage(
|
||||||
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
|
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
|
||||||
targetPeer: targetPeer
|
targetPeer: targetPeer,
|
||||||
|
messageID: privateMessageID
|
||||||
)
|
)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = makeTransferID(messageID: messageID)
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
@@ -419,9 +427,17 @@ final class ChatMediaTransferCoordinator {
|
|||||||
try? FileManager.default.removeItem(at: prepared.outputURL)
|
try? FileManager.default.removeItem(at: prepared.outputURL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let privateMessageID = targetPeer.flatMap { peerID in
|
||||||
|
PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: prepared.packet,
|
||||||
|
senderPeerID: self.context.myPeerID,
|
||||||
|
recipientPeerID: peerID
|
||||||
|
)
|
||||||
|
}
|
||||||
let message = self.enqueueMediaMessage(
|
let message = self.enqueueMediaMessage(
|
||||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||||
targetPeer: targetPeer
|
targetPeer: targetPeer,
|
||||||
|
messageID: privateMessageID
|
||||||
)
|
)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = self.makeTransferID(messageID: messageID)
|
let transferId = self.makeTransferID(messageID: messageID)
|
||||||
@@ -459,12 +475,17 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
|
func enqueueMediaMessage(
|
||||||
|
content: String,
|
||||||
|
targetPeer: PeerID?,
|
||||||
|
messageID: String? = nil
|
||||||
|
) -> BitchatMessage {
|
||||||
let timestamp = Date()
|
let timestamp = Date()
|
||||||
let message: BitchatMessage
|
let message: BitchatMessage
|
||||||
|
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
message = BitchatMessage(
|
message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
sender: context.nickname,
|
sender: context.nickname,
|
||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
|
|||||||
@@ -100,9 +100,14 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
|
|||||||
|
|
||||||
func didUpdatePeerList(_ peers: [PeerID]) {
|
func didUpdatePeerList(_ peers: [PeerID]) {
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.handlePeerListUpdate(peers)
|
self?.didUpdatePeerListSynchronously(peers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didUpdatePeerListSynchronously(_ peers: [PeerID]) {
|
||||||
|
handlePeerListUpdate(peers)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatPeerListCoordinator {
|
private extension ChatPeerListCoordinator {
|
||||||
|
|||||||
@@ -163,21 +163,20 @@ final class ChatTransportEventCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
runOnMain { context in
|
runOnMain { [self] context in
|
||||||
guard !context.isMessageBlocked(message) else { return }
|
handleReceivedMessage(message, in: context)
|
||||||
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
|
|
||||||
|
|
||||||
if message.isPrivate {
|
|
||||||
context.handlePrivateMessage(message)
|
|
||||||
} else {
|
|
||||||
context.handlePublicMessage(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.checkForMentions(message)
|
|
||||||
context.sendHapticFeedback(for: message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Typed transport events already arrive on the main actor. Handle them
|
||||||
|
/// synchronously so observers see the ConversationStore mutation before
|
||||||
|
/// the transport completes delivery.
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func didReceiveMessageSynchronously(_ message: BitchatMessage) -> Bool {
|
||||||
|
handleReceivedMessage(message, in: context)
|
||||||
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(
|
func didReceivePublicMessage(
|
||||||
from peerID: PeerID,
|
from peerID: PeerID,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
@@ -185,28 +184,36 @@ final class ChatTransportEventCoordinator {
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
messageID: String?
|
messageID: String?
|
||||||
) {
|
) {
|
||||||
runOnMain { context in
|
runOnMain { [self] context in
|
||||||
let normalized = content.trimmed
|
handlePublicMessage(
|
||||||
let mentions = context.parseMentions(from: normalized)
|
from: peerID,
|
||||||
let message = BitchatMessage(
|
nickname: nickname,
|
||||||
id: messageID,
|
content: content,
|
||||||
sender: nickname,
|
|
||||||
content: normalized,
|
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
messageID: messageID,
|
||||||
originalSender: nil,
|
in: context
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: peerID,
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
)
|
||||||
|
|
||||||
context.handlePublicMessage(message)
|
|
||||||
context.checkForMentions(message)
|
|
||||||
context.sendHapticFeedback(for: message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didReceivePublicMessageSynchronously(
|
||||||
|
from peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date,
|
||||||
|
messageID: String?
|
||||||
|
) {
|
||||||
|
handlePublicMessage(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
messageID: messageID,
|
||||||
|
in: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func didReceiveNoisePayload(
|
func didReceiveNoisePayload(
|
||||||
from peerID: PeerID,
|
from peerID: PeerID,
|
||||||
type: NoisePayloadType,
|
type: NoisePayloadType,
|
||||||
@@ -224,59 +231,134 @@ final class ChatTransportEventCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didReceiveNoisePayloadSynchronously(
|
||||||
|
from peerID: PeerID,
|
||||||
|
type: NoisePayloadType,
|
||||||
|
payload: Data,
|
||||||
|
timestamp: Date
|
||||||
|
) {
|
||||||
|
handleNoisePayload(
|
||||||
|
from: peerID,
|
||||||
|
type: type,
|
||||||
|
payload: payload,
|
||||||
|
timestamp: timestamp,
|
||||||
|
in: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func didConnectToPeer(_ peerID: PeerID) {
|
func didConnectToPeer(_ peerID: PeerID) {
|
||||||
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
runOnMain { [weak self] _ in
|
||||||
|
self?.didConnectToPeerSynchronously(peerID)
|
||||||
runOnMain { context in
|
|
||||||
context.isConnected = true
|
|
||||||
context.registerEphemeralSession(peerID: peerID)
|
|
||||||
context.notifyUIChanged()
|
|
||||||
|
|
||||||
if let peer = context.unifiedPeer(for: peerID) {
|
|
||||||
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
|
|
||||||
context.cacheStablePeerID(stablePeerID, for: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.flushRouterOutbox(for: peerID)
|
|
||||||
context.retryCourierDeposits(via: peerID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didConnectToPeerSynchronously(_ peerID: PeerID) {
|
||||||
|
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
||||||
|
|
||||||
|
context.isConnected = true
|
||||||
|
context.registerEphemeralSession(peerID: peerID)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
|
||||||
|
if let peer = context.unifiedPeer(for: peerID) {
|
||||||
|
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
|
||||||
|
context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.flushRouterOutbox(for: peerID)
|
||||||
|
context.retryCourierDeposits(via: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {
|
func didDisconnectFromPeer(_ peerID: PeerID) {
|
||||||
|
runOnMain { [weak self] _ in
|
||||||
|
self?.didDisconnectFromPeerSynchronously(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didDisconnectFromPeerSynchronously(_ peerID: PeerID) {
|
||||||
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
|
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
|
||||||
|
|
||||||
runOnMain { context in
|
context.removeEphemeralSession(peerID: peerID)
|
||||||
context.removeEphemeralSession(peerID: peerID)
|
|
||||||
|
|
||||||
var stablePeerID = context.cachedStablePeerID(for: peerID)
|
var stablePeerID = context.cachedStablePeerID(for: peerID)
|
||||||
if stablePeerID == nil,
|
if stablePeerID == nil,
|
||||||
let key = context.noiseSessionPublicKeyData(for: peerID) {
|
let key = context.noiseSessionPublicKeyData(for: peerID) {
|
||||||
let derivedPeerID = PeerID(hexData: key)
|
let derivedPeerID = PeerID(hexData: key)
|
||||||
context.cacheStablePeerID(derivedPeerID, for: peerID)
|
context.cacheStablePeerID(derivedPeerID, for: peerID)
|
||||||
stablePeerID = derivedPeerID
|
stablePeerID = derivedPeerID
|
||||||
}
|
|
||||||
|
|
||||||
if let currentPeerID = context.selectedPrivateChatPeer,
|
|
||||||
currentPeerID == peerID,
|
|
||||||
let stablePeerID {
|
|
||||||
self.migrateSelectedConversationIfNeeded(
|
|
||||||
from: peerID,
|
|
||||||
to: stablePeerID,
|
|
||||||
in: context
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let receiptIDs = context.privateMessages(for: peerID)
|
|
||||||
.filter { $0.senderPeerID == peerID }
|
|
||||||
.map(\.id)
|
|
||||||
context.unmarkReadReceiptsSent(receiptIDs)
|
|
||||||
|
|
||||||
context.notifyUIChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let currentPeerID = context.selectedPrivateChatPeer,
|
||||||
|
currentPeerID == peerID,
|
||||||
|
let stablePeerID {
|
||||||
|
migrateSelectedConversationIfNeeded(
|
||||||
|
from: peerID,
|
||||||
|
to: stablePeerID,
|
||||||
|
in: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let receiptIDs = context.privateMessages(for: peerID)
|
||||||
|
.filter { $0.senderPeerID == peerID }
|
||||||
|
.map(\.id)
|
||||||
|
context.unmarkReadReceiptsSent(receiptIDs)
|
||||||
|
|
||||||
|
context.notifyUIChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatTransportEventCoordinator {
|
private extension ChatTransportEventCoordinator {
|
||||||
|
@MainActor
|
||||||
|
func handlePublicMessage(
|
||||||
|
from peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date,
|
||||||
|
messageID: String?,
|
||||||
|
in context: any ChatTransportEventContext
|
||||||
|
) {
|
||||||
|
let normalized = content.trimmed
|
||||||
|
let mentions = context.parseMentions(from: normalized)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: nickname,
|
||||||
|
content: normalized,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: false,
|
||||||
|
recipientNickname: nil,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
|
)
|
||||||
|
|
||||||
|
context.handlePublicMessage(message)
|
||||||
|
context.checkForMentions(message)
|
||||||
|
context.sendHapticFeedback(for: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func handleReceivedMessage(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
in context: any ChatTransportEventContext
|
||||||
|
) -> Bool {
|
||||||
|
guard !context.isMessageBlocked(message) else { return false }
|
||||||
|
guard !message.content.trimmed.isEmpty || message.isPrivate else { return false }
|
||||||
|
|
||||||
|
if message.isPrivate {
|
||||||
|
context.handlePrivateMessage(message)
|
||||||
|
} else {
|
||||||
|
context.handlePublicMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.checkForMentions(message)
|
||||||
|
context.sendHapticFeedback(for: message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
|
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
|
||||||
Task { @MainActor [weak context = self.context] in
|
Task { @MainActor [weak context = self.context] in
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ struct PanicNetworkLifecycle {
|
|||||||
/// Manages the application state and business logic for BitChat.
|
/// Manages the application state and business logic for BitChat.
|
||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// implementing the BitchatDelegate protocol to handle network events.
|
||||||
final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessageTransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
||||||
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
|
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
|
||||||
typealias Patterns = MessageFormattingEngine.Patterns
|
typealias Patterns = MessageFormattingEngine.Patterns
|
||||||
|
|
||||||
@@ -495,6 +495,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a read receipt has already been recorded for `messageID`.
|
||||||
|
@MainActor
|
||||||
|
func hasSentReadReceipt(_ messageID: String) -> Bool {
|
||||||
|
sentReadReceipts.contains(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
/// Records that a read receipt is being sent for `messageID`.
|
/// Records that a read receipt is being sent for `messageID`.
|
||||||
/// Returns `false` when one was already recorded — the caller must skip sending.
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -1701,7 +1707,81 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func didReceiveTransportEvent(_ event: TransportEvent) {
|
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||||
receiveTransportEvent(event)
|
switch event {
|
||||||
|
case .messageReceived(let message):
|
||||||
|
_ = didReceiveTransportMessageSynchronously(message)
|
||||||
|
|
||||||
|
case let .publicMessageReceived(
|
||||||
|
peerID,
|
||||||
|
nickname,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
messageID
|
||||||
|
):
|
||||||
|
transportEventCoordinator.didReceivePublicMessageSynchronously(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
|
||||||
|
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||||
|
transportEventCoordinator.didReceiveNoisePayloadSynchronously(
|
||||||
|
from: peerID,
|
||||||
|
type: type,
|
||||||
|
payload: payload,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
case let .groupMessageReceived(payload, timestamp):
|
||||||
|
groupCoordinator.handleGroupMessagePayload(
|
||||||
|
payload,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
case let .publicVoiceFrameReceived(
|
||||||
|
peerID,
|
||||||
|
nickname,
|
||||||
|
payload,
|
||||||
|
timestamp
|
||||||
|
):
|
||||||
|
liveVoiceCoordinator.handlePublicVoiceFramePayload(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
payload: payload,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
case .peerConnected(let peerID):
|
||||||
|
transportEventCoordinator.didConnectToPeerSynchronously(peerID)
|
||||||
|
|
||||||
|
case .peerDisconnected(let peerID):
|
||||||
|
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
|
||||||
|
|
||||||
|
case .peerListUpdated(let peers):
|
||||||
|
peerListCoordinator.didUpdatePeerListSynchronously(peers)
|
||||||
|
// A peer-list update follows every verified announce, which is
|
||||||
|
// where a peer's `.vouch` capability actually arrives.
|
||||||
|
vouchCoordinator.peersUpdated(peers)
|
||||||
|
|
||||||
|
case .peerSnapshotsUpdated:
|
||||||
|
break
|
||||||
|
|
||||||
|
case let .messageDeliveryStatusUpdated(messageID, status):
|
||||||
|
deliveryCoordinator.didUpdateMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: status
|
||||||
|
)
|
||||||
|
|
||||||
|
case .bluetoothStateUpdated(let state):
|
||||||
|
updateBluetoothState(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool {
|
||||||
|
transportEventCoordinator.didReceiveMessageSynchronously(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
|
|||||||
@@ -639,7 +639,7 @@ struct BLEServiceCoreTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func failedInboundReconnectRestoresAndDrainsTypedPayloadQueue() async throws {
|
func failedInboundReconnectRestoresAndDrainsWaitingWorkOnce() async throws {
|
||||||
let ble = makeService()
|
let ble = makeService()
|
||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
@@ -722,21 +722,48 @@ struct BLEServiceCoreTests {
|
|||||||
message: forgedMessage2
|
message: forgedMessage2
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
let forgedEarlyPayload = try #require(
|
||||||
|
BLENoisePayloadFactory.privateMessage(
|
||||||
|
content: "forged early message",
|
||||||
|
messageID: "forged-early"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try #require(
|
||||||
|
mallory.hasEstablishedSession(with: ble.myPeerID),
|
||||||
|
"forged initiator did not establish after producing message three"
|
||||||
|
)
|
||||||
|
let forgedEarlyCiphertext = try mallory.encrypt(
|
||||||
|
forgedEarlyPayload,
|
||||||
|
for: ble.myPeerID
|
||||||
|
)
|
||||||
|
let earlyPacket = BitchatPacket(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
|
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||||
|
recipientID: Data(hexString: ble.myPeerID.id),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
|
||||||
|
payload: forgedEarlyCiphertext,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 7
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(earlyPacket, fromPeerID: alicePeerID)
|
||||||
|
await ble._test_drainNoiseMessagePipeline()
|
||||||
|
|
||||||
let thirdPacket = BitchatPacket(
|
let thirdPacket = BitchatPacket(
|
||||||
type: MessageType.noiseHandshake.rawValue,
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||||
recipientID: Data(hexString: ble.myPeerID.id),
|
recipientID: Data(hexString: ble.myPeerID.id),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 2,
|
||||||
payload: forgedMessage3,
|
payload: forgedMessage3,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: 7
|
ttl: 7
|
||||||
)
|
)
|
||||||
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
|
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
|
||||||
|
|
||||||
// Restore re-enters the generation-bound authentication transition:
|
// Rollback restores the same generation. It retries the bounded early
|
||||||
// authenticated state and both outbound queues drain exactly once.
|
// ciphertext and drains both outbound queues, but must not repeat a
|
||||||
|
// new-generation capability proof or forced announce.
|
||||||
let drained = await TestHelpers.waitUntil(
|
let drained = await TestHelpers.waitUntil(
|
||||||
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
|
{ outbound.count(ofType: .noiseEncrypted) >= 2 },
|
||||||
timeout: TestConstants.longTimeout
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
try #require(drained)
|
try #require(drained)
|
||||||
@@ -744,11 +771,11 @@ struct BLEServiceCoreTests {
|
|||||||
let plaintexts = try outbound.snapshot()
|
let plaintexts = try outbound.snapshot()
|
||||||
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
|
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||||
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
|
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
|
||||||
#expect(plaintexts.count == 3)
|
#expect(plaintexts.count == 2)
|
||||||
#expect(
|
#expect(
|
||||||
plaintexts.filter {
|
plaintexts.filter {
|
||||||
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
|
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
|
||||||
}.count == 1
|
}.isEmpty
|
||||||
)
|
)
|
||||||
#expect(
|
#expect(
|
||||||
plaintexts.filter {
|
plaintexts.filter {
|
||||||
@@ -760,6 +787,13 @@ struct BLEServiceCoreTests {
|
|||||||
$0.first == NoisePayloadType.groupInvite.rawValue
|
$0.first == NoisePayloadType.groupInvite.rawValue
|
||||||
}.count == 1
|
}.count == 1
|
||||||
)
|
)
|
||||||
|
#expect(outbound.count(ofType: .announce) == 0)
|
||||||
|
|
||||||
|
// A duplicate ready callback cannot replay either buffer.
|
||||||
|
ble._test_reconcileCurrentNoiseSession(for: alicePeerID)
|
||||||
|
await ble._test_drainNoiseMessagePipeline()
|
||||||
|
#expect(outbound.count(ofType: .noiseEncrypted) == 2)
|
||||||
|
#expect(outbound.count(ofType: .announce) == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A legitimate rotation announce necessarily arrives on a link still
|
/// A legitimate rotation announce necessarily arrives on a link still
|
||||||
@@ -1255,6 +1289,7 @@ private final class PublicCaptureDelegate: BitchatDelegate {
|
|||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return publicMessages
|
return publicMessages
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import BitFoundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
||||||
var nickname = "me"
|
var nickname = "me"
|
||||||
|
var myPeerID = PeerID(str: "0102030405060708")
|
||||||
var selectedPrivateChatPeer: PeerID?
|
var selectedPrivateChatPeer: PeerID?
|
||||||
var isViewingPublicMeshTimeline = false
|
var isViewingPublicMeshTimeline = false
|
||||||
var blockedPeers: Set<PeerID> = []
|
var blockedPeers: Set<PeerID> = []
|
||||||
@@ -23,7 +24,10 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
|||||||
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
|
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
|
||||||
private(set) var upsertedPublicMessages: [BitchatMessage] = []
|
private(set) var upsertedPublicMessages: [BitchatMessage] = []
|
||||||
private(set) var removedMessageIDs: [String] = []
|
private(set) var removedMessageIDs: [String] = []
|
||||||
|
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
|
||||||
private(set) var talkerUpdates: [String?] = []
|
private(set) var talkerUpdates: [String?] = []
|
||||||
|
private(set) var privateMutationLog: [String] = []
|
||||||
|
private var readReceiptMessageIDs: Set<String> = []
|
||||||
|
|
||||||
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
|
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
|
||||||
func resolveNickname(for peerID: PeerID) -> String { "alice" }
|
func resolveNickname(for peerID: PeerID) -> String { "alice" }
|
||||||
@@ -31,6 +35,7 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
|||||||
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
|
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
|
||||||
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
|
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
|
||||||
upsertedMessages.append((message, peerID))
|
upsertedMessages.append((message, peerID))
|
||||||
|
privateMutationLog.append("upsert:\(message.id)")
|
||||||
}
|
}
|
||||||
func upsertPublicMeshMessage(_ message: BitchatMessage) {
|
func upsertPublicMeshMessage(_ message: BitchatMessage) {
|
||||||
upsertedPublicMessages.append(message)
|
upsertedPublicMessages.append(message)
|
||||||
@@ -38,8 +43,18 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
|
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
|
||||||
removedMessageIDs.append(messageID)
|
removedMessageIDs.append(messageID)
|
||||||
|
privateMutationLog.append("remove:\(messageID)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func hasSentReadReceipt(_ messageID: String) -> Bool {
|
||||||
|
readReceiptMessageIDs.contains(messageID)
|
||||||
|
}
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool {
|
||||||
|
readReceiptMessageIDs.insert(messageID).inserted
|
||||||
|
}
|
||||||
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
|
sentReadReceipts.append((receipt, peerID))
|
||||||
|
}
|
||||||
func removeMessage(withID messageID: String, cleanupFile: Bool) {
|
func removeMessage(withID messageID: String, cleanupFile: Bool) {
|
||||||
removedMessageIDs.append(messageID)
|
removedMessageIDs.append(messageID)
|
||||||
}
|
}
|
||||||
@@ -151,17 +166,29 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
|
|
||||||
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
|
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
|
||||||
let context = MockChatLiveVoiceContext()
|
let context = MockChatLiveVoiceContext()
|
||||||
|
context.selectedPrivateChatPeer = peer
|
||||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||||
let burstID = makeBurstID(0xB2)
|
let burstID = makeBurstID(0xB2)
|
||||||
let hex = burstID.hexEncodedString()
|
let hex = burstID.hexEncodedString()
|
||||||
|
let fileName = "voice_\(hex).m4a"
|
||||||
|
let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: peer,
|
||||||
|
recipientPeerID: context.myPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
|
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
|
||||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
||||||
let bubble = try #require(context.handledPrivateMessages.first)
|
let bubble = try #require(context.handledPrivateMessages.first)
|
||||||
|
// The user read the live bubble, then left before the finalized file
|
||||||
|
// arrived. Stable-ID adoption must preserve that read state.
|
||||||
|
#expect(context.markReadReceiptSent(bubble.id))
|
||||||
|
context.selectedPrivateChatPeer = nil
|
||||||
|
|
||||||
let note = BitchatMessage(
|
let note = BitchatMessage(
|
||||||
|
id: stableMessageID,
|
||||||
sender: "alice",
|
sender: "alice",
|
||||||
content: "[voice] voice_\(hex).m4a",
|
content: "[voice] \(fileName)",
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
@@ -170,12 +197,21 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
||||||
|
|
||||||
// The note replaced the live bubble in place: same message ID, new
|
// The finalized note adopts the sender-correlatable ID, removes the
|
||||||
// content, partial capture deleted.
|
// receiver-local live ID, and emits a fresh READ now that the sender
|
||||||
|
// has created its finalized media row.
|
||||||
let replacement = try #require(context.upsertedMessages.last)
|
let replacement = try #require(context.upsertedMessages.last)
|
||||||
#expect(replacement.message.id == bubble.id)
|
#expect(replacement.message.id == stableMessageID)
|
||||||
#expect(replacement.message.content == note.content)
|
#expect(replacement.message.content == note.content)
|
||||||
#expect(replacement.peerID == peer)
|
#expect(replacement.peerID == peer)
|
||||||
|
#expect(context.removedMessageIDs.contains(bubble.id))
|
||||||
|
#expect(Array(context.privateMutationLog.suffix(2)) == [
|
||||||
|
"upsert:\(stableMessageID)",
|
||||||
|
"remove:\(bubble.id)"
|
||||||
|
])
|
||||||
|
#expect(context.sentReadReceipts.count == 1)
|
||||||
|
#expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID)
|
||||||
|
#expect(context.sentReadReceipts.first?.peerID == peer)
|
||||||
// The promoted partial capture is deleted in favor of the note.
|
// The promoted partial capture is deleted in favor of the note.
|
||||||
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
|
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
|
||||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
@@ -449,7 +485,8 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
|
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
|
||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
|
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
|
||||||
#expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id)
|
#expect(try #require(context.upsertedMessages.last).message.id == dmNote.id)
|
||||||
|
#expect(context.removedMessageIDs.contains(dmBubble.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
|
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
|
||||||
@@ -479,8 +516,9 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
||||||
let replacement = try #require(context.upsertedMessages.last)
|
let replacement = try #require(context.upsertedMessages.last)
|
||||||
#expect(replacement.message.id == victimBubble.id)
|
#expect(replacement.message.id == note.id)
|
||||||
#expect(replacement.peerID == peer)
|
#expect(replacement.peerID == peer)
|
||||||
|
#expect(context.removedMessageIDs.contains(victimBubble.id))
|
||||||
|
|
||||||
// The attacker's note can only ever claim the attacker's own bubble.
|
// The attacker's note can only ever claim the attacker's own bubble.
|
||||||
let attackerNote = BitchatMessage(
|
let attackerNote = BitchatMessage(
|
||||||
@@ -489,8 +527,9 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
|
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
|
||||||
let attackerReplacement = try #require(context.upsertedMessages.last)
|
let attackerReplacement = try #require(context.upsertedMessages.last)
|
||||||
#expect(attackerReplacement.message.id == attackerBubble.id)
|
#expect(attackerReplacement.message.id == attackerNote.id)
|
||||||
#expect(attackerReplacement.peerID == attacker)
|
#expect(attackerReplacement.peerID == attacker)
|
||||||
|
#expect(context.removedMessageIDs.contains(attackerBubble.id))
|
||||||
|
|
||||||
// Both registry entries are consumed — nothing left to hijack.
|
// Both registry entries are consumed — nothing left to hijack.
|
||||||
#expect(!coordinator.absorbFinalizedVoiceNote(note))
|
#expect(!coordinator.absorbFinalizedVoiceNote(note))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||||
//
|
//
|
||||||
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
|
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
|
||||||
// tests inject a paused voice-note preparer to exercise cancellation ownership
|
// tests inject paused media preparers to exercise cancellation ownership
|
||||||
// across the detached-preparation/MainActor boundary deterministically.
|
// across the detached-preparation/MainActor boundary deterministically.
|
||||||
//
|
//
|
||||||
|
|
||||||
@@ -89,7 +89,11 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mesh file transfer
|
// Mesh file transfer
|
||||||
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
private(set) var privateFileSends: [(
|
||||||
|
packet: BitchatFilePacket,
|
||||||
|
peerID: PeerID,
|
||||||
|
transferId: String
|
||||||
|
)] = []
|
||||||
private(set) var privateFileLegacyAllowances: [Bool] = []
|
private(set) var privateFileLegacyAllowances: [Bool] = []
|
||||||
private(set) var broadcastFileSends: [String] = []
|
private(set) var broadcastFileSends: [String] = []
|
||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
@@ -154,7 +158,7 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
transferId: String,
|
transferId: String,
|
||||||
allowLegacyFallback: Bool
|
allowLegacyFallback: Bool
|
||||||
) {
|
) {
|
||||||
privateFileSends.append((peerID, transferId))
|
privateFileSends.append((packet, peerID, transferId))
|
||||||
privateFileLegacyAllowances.append(allowLegacyFallback)
|
privateFileLegacyAllowances.append(allowLegacyFallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,19 +176,8 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
|||||||
private var started = false
|
private var started = false
|
||||||
private var released = false
|
private var released = false
|
||||||
private var finished = false
|
private var finished = false
|
||||||
private let packet: BitchatFilePacket
|
|
||||||
|
|
||||||
init() {
|
func prepare(_ url: URL) throws -> BitchatFilePacket {
|
||||||
let content = Data("voice".utf8)
|
|
||||||
packet = BitchatFilePacket(
|
|
||||||
fileName: "paused.m4a",
|
|
||||||
fileSize: UInt64(content.count),
|
|
||||||
mimeType: "audio/mp4",
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepare(_: URL) throws -> BitchatFilePacket {
|
|
||||||
condition.lock()
|
condition.lock()
|
||||||
started = true
|
started = true
|
||||||
condition.broadcast()
|
condition.broadcast()
|
||||||
@@ -194,7 +187,13 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
|||||||
finished = true
|
finished = true
|
||||||
condition.broadcast()
|
condition.broadcast()
|
||||||
condition.unlock()
|
condition.unlock()
|
||||||
return packet
|
let content = Data("voice".utf8)
|
||||||
|
return BitchatFilePacket(
|
||||||
|
fileName: url.lastPathComponent,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "audio/mp4",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasStarted: Bool {
|
var hasStarted: Bool {
|
||||||
@@ -452,6 +451,109 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func privateVoiceNoteUsesWireDerivableMessageID() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("voice_receipt_\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ context.privateFileSends.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
let message = try #require(context.privateChats[peerID]?.first)
|
||||||
|
let sentPacket = try #require(context.privateFileSends.first?.packet)
|
||||||
|
#expect(message.id == PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: sentPacket,
|
||||||
|
senderPeerID: context.myPeerID,
|
||||||
|
recipientPeerID: peerID
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func privateImageUsesWireDerivableMessageID() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
let sourceURL = try makeCoordinatorTestImageURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
||||||
|
|
||||||
|
coordinator.sendImage(from: sourceURL)
|
||||||
|
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ context.privateFileSends.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
let message = try #require(context.privateChats[peerID]?.first)
|
||||||
|
let sentPacket = try #require(context.privateFileSends.first?.packet)
|
||||||
|
#expect(message.id == PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: sentPacket,
|
||||||
|
senderPeerID: context.myPeerID,
|
||||||
|
recipientPeerID: peerID
|
||||||
|
))
|
||||||
|
coordinator.cleanupLocalFile(forMessage: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicDuringImagePreparationDeletesStaleOutputWithoutSideEffects() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
let sourceURL = try makeCoordinatorTestImageURL()
|
||||||
|
let outputURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-stale-image-\(UUID().uuidString).jpg"
|
||||||
|
)
|
||||||
|
let preparer = PausedImagePreparer(outputURL: outputURL)
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: context,
|
||||||
|
prepareImagePacket: { url in try preparer.prepare(url) }
|
||||||
|
)
|
||||||
|
defer {
|
||||||
|
preparer.release()
|
||||||
|
try? FileManager.default.removeItem(at: sourceURL)
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.sendImage(from: sourceURL)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ preparer.hasStarted },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).asyncAfter(
|
||||||
|
deadline: .now() + .milliseconds(100)
|
||||||
|
) {
|
||||||
|
preparer.release()
|
||||||
|
}
|
||||||
|
coordinator.resetForPanic()
|
||||||
|
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ preparer.hasFinished },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ !FileManager.default.fileExists(atPath: outputURL.path) },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
#expect(context.privateChats[peerID]?.isEmpty != false)
|
||||||
|
#expect(context.appendedPublicMessages.isEmpty)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(context.broadcastFileSends.isEmpty)
|
||||||
|
#expect(context.systemMessages.isEmpty)
|
||||||
|
#expect(context.deliveryStatusUpdates.isEmpty)
|
||||||
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
|
|||||||
@@ -104,6 +104,20 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess
|
|||||||
/// no `ChatViewModel`.
|
/// no `ChatViewModel`.
|
||||||
struct ChatPeerListCoordinatorContextTests {
|
struct ChatPeerListCoordinatorContextTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func synchronousPeerListUpdate_appliesBeforeReturning() {
|
||||||
|
let context = MockChatPeerListContext()
|
||||||
|
let coordinator = ChatPeerListCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "0011223344556677")
|
||||||
|
|
||||||
|
coordinator.didUpdatePeerListSynchronously([peerID])
|
||||||
|
|
||||||
|
#expect(context.isConnected)
|
||||||
|
#expect(context.registeredEphemeralSessions == [peerID])
|
||||||
|
#expect(context.updateEncryptionStatusForPeersCount == 1)
|
||||||
|
#expect(context.cleanupOldReadReceiptsCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
|
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
|
||||||
let context = MockChatPeerListContext()
|
let context = MockChatPeerListContext()
|
||||||
|
|||||||
@@ -220,26 +220,85 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
|
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
|
||||||
let context = MockChatTransportEventContext()
|
let context = MockChatTransportEventContext()
|
||||||
let coordinator = ChatTransportEventCoordinator(context: context)
|
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
|
||||||
// Blocked messages are dropped before any handling.
|
// Blocked messages are dropped before any handling.
|
||||||
context.blockedMessageIDs = ["blocked"]
|
context.blockedMessageIDs = ["blocked", "blocked-private"]
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
|
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
|
||||||
|
coordinator.didReceiveMessage(makeMessage(
|
||||||
|
id: "blocked-private",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
))
|
||||||
// Empty public content is dropped too.
|
// Empty public content is dropped too.
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
|
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
|
||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
#expect(context.handledPublicMessages.isEmpty)
|
#expect(context.handledPublicMessages.isEmpty)
|
||||||
#expect(context.handledPrivateMessages.isEmpty)
|
#expect(context.handledPrivateMessages.isEmpty)
|
||||||
#expect(context.mentionCheckedMessageIDs.isEmpty)
|
#expect(context.mentionCheckedMessageIDs.isEmpty)
|
||||||
|
#expect(context.meshDeliveryAcks.isEmpty)
|
||||||
|
|
||||||
// Private goes to the private handler, public to the public handler;
|
// Private goes to the private handler, public to the public handler;
|
||||||
// both get mention checks and haptics.
|
// both get mention checks and haptics. Stable-media ACK authorization
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
|
// belongs to BLEFileTransferHandler after its durable commit and this
|
||||||
|
// synchronous acceptance result, not to the generic UI coordinator.
|
||||||
|
let stableMediaID = "media-\(String(repeating: "a", count: 32))"
|
||||||
|
coordinator.didReceiveMessage(makeMessage(
|
||||||
|
id: stableMediaID,
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
))
|
||||||
|
coordinator.didReceiveMessage(makeMessage(
|
||||||
|
id: "legacy-media",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
))
|
||||||
|
coordinator.didReceiveMessage(makeMessage(id: "pm-missing-sender", isPrivate: true))
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "pub"))
|
coordinator.didReceiveMessage(makeMessage(id: "pub"))
|
||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
#expect(context.handledPrivateMessages.map(\.id) == ["pm"])
|
#expect(context.handledPrivateMessages.map(\.id) == [
|
||||||
|
stableMediaID,
|
||||||
|
"legacy-media",
|
||||||
|
"pm-missing-sender"
|
||||||
|
])
|
||||||
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
|
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
|
||||||
#expect(context.mentionCheckedMessageIDs == ["pm", "pub"])
|
#expect(context.mentionCheckedMessageIDs == [
|
||||||
#expect(context.hapticMessageIDs == ["pm", "pub"])
|
stableMediaID,
|
||||||
|
"legacy-media",
|
||||||
|
"pm-missing-sender",
|
||||||
|
"pub"
|
||||||
|
])
|
||||||
|
#expect(context.hapticMessageIDs == [
|
||||||
|
stableMediaID,
|
||||||
|
"legacy-media",
|
||||||
|
"pm-missing-sender",
|
||||||
|
"pub"
|
||||||
|
])
|
||||||
|
#expect(context.meshDeliveryAcks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func synchronousMessageDeliveryReportsAcceptanceForAckGating() {
|
||||||
|
let context = MockChatTransportEventContext()
|
||||||
|
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
let blocked = makeMessage(
|
||||||
|
id: "blocked-private-media",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
context.blockedMessageIDs = [blocked.id]
|
||||||
|
|
||||||
|
#expect(coordinator.didReceiveMessageSynchronously(blocked) == false)
|
||||||
|
#expect(context.handledPrivateMessages.isEmpty)
|
||||||
|
|
||||||
|
let accepted = makeMessage(
|
||||||
|
id: "accepted-private-media",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
#expect(coordinator.didReceiveMessageSynchronously(accepted) == true)
|
||||||
|
#expect(context.handledPrivateMessages.map(\.id) == [accepted.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -295,6 +354,32 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
#expect(context.notifyUIChangedCount == 2)
|
#expect(context.notifyUIChangedCount == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func synchronousConnectAndDisconnect_applyBeforeReturning() {
|
||||||
|
let context = MockChatTransportEventContext()
|
||||||
|
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "2233445566778899")
|
||||||
|
let incoming = makeMessage(
|
||||||
|
id: "incoming-receipt",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
context.privateChats[peerID] = [incoming]
|
||||||
|
|
||||||
|
coordinator.didConnectToPeerSynchronously(peerID)
|
||||||
|
|
||||||
|
#expect(context.isConnected)
|
||||||
|
#expect(context.registeredEphemeralSessions == [peerID])
|
||||||
|
#expect(context.flushedOutboxPeerIDs == [peerID])
|
||||||
|
#expect(context.courierRetryPeerIDs == [peerID])
|
||||||
|
|
||||||
|
coordinator.didDisconnectFromPeerSynchronously(peerID)
|
||||||
|
|
||||||
|
#expect(context.removedEphemeralSessions == [peerID])
|
||||||
|
#expect(context.unmarkedReadReceiptBatches == [[incoming.id]])
|
||||||
|
#expect(context.notifyUIChangedCount == 2)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
|
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
|
||||||
let context = MockChatTransportEventContext()
|
let context = MockChatTransportEventContext()
|
||||||
|
|||||||
@@ -864,6 +864,80 @@ struct ChatViewModelPublicConversationTests {
|
|||||||
|
|
||||||
struct ChatViewModelPeerTests {
|
struct ChatViewModelPeerTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func typedPeerLifecycleEvents_applyBeforeReturning() {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
let incoming = BitchatMessage(
|
||||||
|
id: "typed-peer-incoming",
|
||||||
|
sender: "Alice",
|
||||||
|
content: "Hello",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
viewModel.seedPrivateChat([incoming], for: peerID)
|
||||||
|
viewModel.sentReadReceipts.insert(incoming.id)
|
||||||
|
|
||||||
|
viewModel.didReceiveTransportEvent(.peerConnected(peerID))
|
||||||
|
|
||||||
|
#expect(viewModel.isConnected)
|
||||||
|
|
||||||
|
viewModel.didReceiveTransportEvent(.peerDisconnected(peerID))
|
||||||
|
|
||||||
|
#expect(!viewModel.sentReadReceipts.contains(incoming.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func typedPeerListDeliveryAndBluetoothEvents_applyBeforeReturning() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let stalePeer = PeerID(str: "00000000000000a2")
|
||||||
|
let deliveryPeer = PeerID(str: "0102030405060708")
|
||||||
|
let messageID = "typed-delivery-status"
|
||||||
|
let delivered = DeliveryStatus.delivered(
|
||||||
|
to: "Alice",
|
||||||
|
at: Date(timeIntervalSince1970: 1_234)
|
||||||
|
)
|
||||||
|
let outgoing = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
content: "On the way",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "Alice",
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
)
|
||||||
|
viewModel.markPrivateChatUnread(stalePeer)
|
||||||
|
viewModel.seedPrivateChat([outgoing], for: deliveryPeer)
|
||||||
|
|
||||||
|
viewModel.didReceiveTransportEvent(.peerListUpdated([]))
|
||||||
|
#expect(!viewModel.unreadPrivateMessages.contains(stalePeer))
|
||||||
|
|
||||||
|
viewModel.didReceiveTransportEvent(
|
||||||
|
.messageDeliveryStatusUpdated(
|
||||||
|
messageID: messageID,
|
||||||
|
status: delivered
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateMessages(for: deliveryPeer).first?.deliveryStatus
|
||||||
|
== delivered
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.didReceiveTransportEvent(.bluetoothStateUpdated(.poweredOff))
|
||||||
|
#expect(viewModel.bluetoothState == .poweredOff)
|
||||||
|
#expect(viewModel.showBluetoothAlert)
|
||||||
|
|
||||||
|
// Snapshot events belong to TransportPeerEventsDelegate and are
|
||||||
|
// intentionally ignored at this typed sink.
|
||||||
|
viewModel.didReceiveTransportEvent(.peerSnapshotsUpdated([]))
|
||||||
|
#expect(viewModel.bluetoothState == .poweredOff)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func didConnectToPeer_notifiesDelegate() async {
|
func didConnectToPeer_notifiesDelegate() async {
|
||||||
let (_, transport) = makeTestableViewModel()
|
let (_, transport) = makeTestableViewModel()
|
||||||
|
|||||||
@@ -682,13 +682,18 @@ struct PrivateMediaEndToEndTests {
|
|||||||
#expect(!identity.hasObservedPrivateMediaCapability(
|
#expect(!identity.hasObservedPrivateMediaCapability(
|
||||||
fingerprint: impostorKey.sha256Fingerprint()
|
fingerprint: impostorKey.sha256Fingerprint()
|
||||||
))
|
))
|
||||||
|
#expect(identity.hasObservedPrivateMediaCapability(
|
||||||
|
fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint()
|
||||||
|
))
|
||||||
alice._test_seedConnectedPeer(
|
alice._test_seedConnectedPeer(
|
||||||
bob.myPeerID,
|
bob.myPeerID,
|
||||||
nickname: "Bob",
|
nickname: "Bob",
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
noisePublicKey: impostorKey
|
noisePublicKey: impostorKey
|
||||||
)
|
)
|
||||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
// The exact live Noise identity remains authoritative over a later
|
||||||
|
// impostor registry rewrite.
|
||||||
|
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -866,7 +871,7 @@ struct PrivateMediaEndToEndTests {
|
|||||||
+ marker
|
+ marker
|
||||||
+ Data(repeating: 0x4A, count: 6 * 1024)
|
+ Data(repeating: 0x4A, count: 6 * 1024)
|
||||||
try await assertPrivateMediaRoundTrip(
|
try await assertPrivateMediaRoundTrip(
|
||||||
fileName: "private.jpg",
|
fileName: "img_20260725_120000_11111111-1111-1111-1111-111111111111.jpg",
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
content: content,
|
content: content,
|
||||||
marker: marker,
|
marker: marker,
|
||||||
@@ -1137,6 +1142,17 @@ struct PrivateMediaEndToEndTests {
|
|||||||
#expect(message.isPrivate)
|
#expect(message.isPrivate)
|
||||||
#expect(message.senderPeerID == alice.myPeerID)
|
#expect(message.senderPeerID == alice.myPeerID)
|
||||||
#expect(message.content.hasPrefix(expectedMessagePrefix))
|
#expect(message.content.hasPrefix(expectedMessagePrefix))
|
||||||
|
if let stableMessageID = PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: file,
|
||||||
|
senderPeerID: alice.myPeerID,
|
||||||
|
recipientPeerID: bob.myPeerID
|
||||||
|
) {
|
||||||
|
#expect(message.id == stableMessageID)
|
||||||
|
} else {
|
||||||
|
// Generic/legacy filenames retain random per-arrival IDs so two
|
||||||
|
// unrelated "photo.jpg" transfers are never deduplicated.
|
||||||
|
#expect(!message.id.hasPrefix("media-"))
|
||||||
|
}
|
||||||
|
|
||||||
let stored = recursivelyStoredFiles(under: bobRoot)
|
let stored = recursivelyStoredFiles(under: bobRoot)
|
||||||
#expect(stored.count == 1)
|
#expect(stored.count == 1)
|
||||||
@@ -1279,6 +1295,8 @@ struct PrivateMediaEndToEndTests {
|
|||||||
|
|
||||||
return enumerator.compactMap { item in
|
return enumerator.compactMap { item in
|
||||||
guard let url = item as? URL,
|
guard let url = item as? URL,
|
||||||
|
!url.pathComponents.contains(".private-media-receipts"),
|
||||||
|
url.lastPathComponent != ".private-media-receipts.json",
|
||||||
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@@ -73,4 +74,85 @@ final class BitchatFilePacketTests: XCTestCase {
|
|||||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||||
XCTAssertEqual(decoded.content, content)
|
XCTAssertEqual(decoded.content, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
|
||||||
|
let senderKey = Data(repeating: 0x11, count: 32)
|
||||||
|
let recipientKey = Data(repeating: 0x22, count: 32)
|
||||||
|
let senderStable = PeerID(hexData: senderKey)
|
||||||
|
let recipientStable = PeerID(hexData: recipientKey)
|
||||||
|
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
|
||||||
|
let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: senderStable.toShort(),
|
||||||
|
recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"),
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: senderStable,
|
||||||
|
recipientPeerID: recipientStable.toShort(),
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
|
XCTAssertEqual(senderID, receiverID)
|
||||||
|
XCTAssertTrue(senderID.hasPrefix("media-"))
|
||||||
|
XCTAssertEqual(senderID.count, 38)
|
||||||
|
XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID))
|
||||||
|
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))"))
|
||||||
|
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))"))
|
||||||
|
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws {
|
||||||
|
let alice = PeerID(str: "0011223344556677")
|
||||||
|
let bob = PeerID(str: "8899aabbccddeeff")
|
||||||
|
let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a"
|
||||||
|
let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a"
|
||||||
|
let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: firstName
|
||||||
|
))
|
||||||
|
|
||||||
|
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: bob,
|
||||||
|
recipientPeerID: alice,
|
||||||
|
fileName: firstName
|
||||||
|
))
|
||||||
|
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: secondName
|
||||||
|
))
|
||||||
|
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: nil
|
||||||
|
))
|
||||||
|
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: "photo.jpg"
|
||||||
|
))
|
||||||
|
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: "img_11111111-1111-1111-1111-111111111111.pdf"
|
||||||
|
))
|
||||||
|
XCTAssertNotNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: "voice_0011223344556677.m4a"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: PeerID(str: "0011223344556677"),
|
||||||
|
recipientPeerID: PeerID(str: "8899aabbccddeeff"),
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
),
|
||||||
|
"media-910bd42c65060ab76bb6406f220c4516"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,27 @@ struct BLEFileTransferHandlerTests {
|
|||||||
|
|
||||||
var signatureVerifyCount = 0
|
var signatureVerifyCount = 0
|
||||||
var signedNameQueries: [PeerID] = []
|
var signedNameQueries: [PeerID] = []
|
||||||
|
var blockedPeers: Set<PeerID> = []
|
||||||
var trackedPackets: [BitchatPacket] = []
|
var trackedPackets: [BitchatPacket] = []
|
||||||
var quotaReservations: [Int] = []
|
var quotaReservations: [Int] = []
|
||||||
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
||||||
|
var receiptStates: [String: BLEPrivateMediaReceiptState] = [:]
|
||||||
|
var receiptCommits: [(messageID: String, storedURL: URL)] = []
|
||||||
|
var receiptCommitSucceeds = true
|
||||||
|
var removedIncomingFiles: [URL] = []
|
||||||
var lastSeenUpdates: [PeerID] = []
|
var lastSeenUpdates: [PeerID] = []
|
||||||
|
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||||
var deliveredMessages: [BitchatMessage] = []
|
var deliveredMessages: [BitchatMessage] = []
|
||||||
|
var saveOverride: ((
|
||||||
|
_ data: Data,
|
||||||
|
_ preferredName: String?,
|
||||||
|
_ subdirectory: String,
|
||||||
|
_ fallbackExtension: String?,
|
||||||
|
_ defaultPrefix: String
|
||||||
|
) -> URL?)?
|
||||||
|
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
|
||||||
|
var receiptCommitOverride: ((String, URL) -> Bool)?
|
||||||
|
var removeIncomingFileOverride: ((URL) -> Void)?
|
||||||
}
|
}
|
||||||
|
|
||||||
private let localPeerID = PeerID(str: "0102030405060708")
|
private let localPeerID = PeerID(str: "0102030405060708")
|
||||||
@@ -46,13 +62,44 @@ struct BLEFileTransferHandlerTests {
|
|||||||
},
|
},
|
||||||
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||||||
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
|
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
|
||||||
|
if let saveOverride = recorder.saveOverride {
|
||||||
|
return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix)
|
||||||
|
}
|
||||||
return recorder.saveResult
|
return recorder.saveResult
|
||||||
},
|
},
|
||||||
|
privateMediaReceiptState: { messageID in
|
||||||
|
if let receiptStateOverride = recorder.receiptStateOverride {
|
||||||
|
return receiptStateOverride(messageID)
|
||||||
|
}
|
||||||
|
return recorder.receiptStates[messageID] ?? .absent
|
||||||
|
},
|
||||||
|
commitPrivateMediaFile: { messageID, storedURL in
|
||||||
|
recorder.receiptCommits.append((messageID, storedURL))
|
||||||
|
if let receiptCommitOverride = recorder.receiptCommitOverride {
|
||||||
|
return receiptCommitOverride(messageID, storedURL)
|
||||||
|
}
|
||||||
|
guard recorder.receiptCommitSucceeds else { return false }
|
||||||
|
recorder.receiptStates[messageID] = .accepted(storedURL)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
removeIncomingFile: { storedURL in
|
||||||
|
recorder.removedIncomingFiles.append(storedURL)
|
||||||
|
recorder.removeIncomingFileOverride?(storedURL)
|
||||||
|
},
|
||||||
|
isPrivateMediaSenderBlocked: { peerID in
|
||||||
|
recorder.blockedPeers.contains(peerID)
|
||||||
|
},
|
||||||
updatePeerLastSeen: { peerID in
|
updatePeerLastSeen: { peerID in
|
||||||
recorder.lastSeenUpdates.append(peerID)
|
recorder.lastSeenUpdates.append(peerID)
|
||||||
},
|
},
|
||||||
deliverMessage: { message in
|
acknowledgePrivateMedia: { messageID, peerID in
|
||||||
|
recorder.deliveryAcks.append((messageID, peerID))
|
||||||
|
},
|
||||||
|
deliverMessage: { message, shouldDeliver, completion in
|
||||||
|
guard shouldDeliver() else { return }
|
||||||
recorder.deliveredMessages.append(message)
|
recorder.deliveredMessages.append(message)
|
||||||
|
guard shouldDeliver() else { return }
|
||||||
|
completion()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return BLEFileTransferHandler(environment: environment)
|
return BLEFileTransferHandler(environment: environment)
|
||||||
@@ -284,6 +331,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||||
|
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||||
// Must be explicit: BitchatMessage defaults private messages to
|
// Must be explicit: BitchatMessage defaults private messages to
|
||||||
// .sending, which the media views render as an in-flight send
|
// .sending, which the media views render as an in-flight send
|
||||||
// (empty reveal mask, disabled reveal tap).
|
// (empty reveal mask, disabled reveal tap).
|
||||||
@@ -291,13 +339,14 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() 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)]
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
let file = BitchatFilePacket(
|
let file = BitchatFilePacket(
|
||||||
fileName: "secret.jpg",
|
fileName: fileName,
|
||||||
fileSize: UInt64(content.count),
|
fileSize: UInt64(content.count),
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
content: content
|
content: content
|
||||||
@@ -316,6 +365,357 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||||
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
||||||
|
#expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
#expect(recorder.receiptCommits.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: sampleSigningKey
|
||||||
|
)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content,
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(handler.handle(packet, from: remotePeerID))
|
||||||
|
#expect(recorder.receiptCommits.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() 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: "photo.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(recorder.deliveredMessages.count == 2)
|
||||||
|
#expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id)
|
||||||
|
#expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() 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 fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
let expectedID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
|
// First encrypted arrival may precede the sender's authenticated bit-9
|
||||||
|
// proof. It still uses the bit-8 stable ID/ACK contract.
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
// A later automatic retry after proof must resolve the same durable ID
|
||||||
|
// rather than create a legacy random-ID bubble.
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(recorder.quotaReservations == [content.count])
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
// The handler re-offers a durable duplicate so a relaunched UI can
|
||||||
|
// restore its bubble; the synchronous conversation sink deduplicates.
|
||||||
|
#expect(recorder.deliveredMessages.count == 2)
|
||||||
|
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
|
||||||
|
#expect(recorder.deliveryAcks.count == 2)
|
||||||
|
#expect(recorder.deliveryAcks.allSatisfy {
|
||||||
|
$0.messageID == expectedID && $0.peerID == remotePeerID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||||
|
"private-media-handler-relaunch-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
|
||||||
|
func configure(_ recorder: Recorder) {
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
recorder.saveOverride = {
|
||||||
|
data,
|
||||||
|
preferredName,
|
||||||
|
subdirectory,
|
||||||
|
fallbackExtension,
|
||||||
|
defaultPrefix in
|
||||||
|
store.save(
|
||||||
|
data: data,
|
||||||
|
preferredName: preferredName,
|
||||||
|
subdirectory: subdirectory,
|
||||||
|
fallbackExtension: fallbackExtension,
|
||||||
|
defaultPrefix: defaultPrefix
|
||||||
|
)
|
||||||
|
}
|
||||||
|
recorder.receiptStateOverride = {
|
||||||
|
store.privateMediaReceiptState(messageID: $0)
|
||||||
|
}
|
||||||
|
recorder.receiptCommitOverride = {
|
||||||
|
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
|
||||||
|
}
|
||||||
|
recorder.removeIncomingFileOverride = {
|
||||||
|
store.removeIncomingFile(at: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = Recorder()
|
||||||
|
configure(first)
|
||||||
|
#expect(makeHandler(recorder: first).handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
let originalMessage = try #require(first.deliveredMessages.first)
|
||||||
|
#expect(first.deliveryAcks.count == 1)
|
||||||
|
|
||||||
|
// A fresh handler models process relaunch: its in-memory reservation
|
||||||
|
// cache is empty, so only the durable receipt can suppress disk work.
|
||||||
|
let relaunched = Recorder()
|
||||||
|
configure(relaunched)
|
||||||
|
#expect(makeHandler(recorder: relaunched).handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(relaunched.quotaReservations.isEmpty)
|
||||||
|
#expect(relaunched.saveCalls.isEmpty)
|
||||||
|
#expect(relaunched.receiptCommits.isEmpty)
|
||||||
|
#expect(relaunched.deliveredMessages.count == 1)
|
||||||
|
#expect(relaunched.deliveredMessages.first?.id == originalMessage.id)
|
||||||
|
#expect(relaunched.deliveredMessages.first?.content == originalMessage.content)
|
||||||
|
#expect(relaunched.deliveryAcks.count == 1)
|
||||||
|
#expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
var handler: BLEFileTransferHandler!
|
||||||
|
var nestedResult: Bool?
|
||||||
|
var failFirstSave = true
|
||||||
|
recorder.saveOverride = { _, _, _, _, _ in
|
||||||
|
if failFirstSave {
|
||||||
|
failFirstSave = false
|
||||||
|
nestedResult = handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: self.remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return recorder.saveResult
|
||||||
|
}
|
||||||
|
handler = makeHandler(recorder: recorder)
|
||||||
|
|
||||||
|
// The nested arrival sees the first reservation as pending. It is
|
||||||
|
// coalesced without an ACK; then the first durable save fails.
|
||||||
|
#expect(!handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(nestedResult == true)
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
|
||||||
|
// Failure released the reservation, so the sender's later retry can
|
||||||
|
// persist and deliver normally.
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_236)
|
||||||
|
))
|
||||||
|
#expect(recorder.saveCalls.count == 2)
|
||||||
|
#expect(recorder.deliveryAcks.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
let fileName =
|
||||||
|
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
let messageID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
recorder.receiptStates[messageID] = .unavailable
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let payload = try #require(BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
).encode())
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
|
#expect(recorder.receiptCommits.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
recorder.receiptCommitSucceeds = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let payload = try #require(BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
).encode())
|
||||||
|
|
||||||
|
#expect(!handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
#expect(recorder.receiptCommits.count == 1)
|
||||||
|
#expect(recorder.removedIncomingFiles.count == 1)
|
||||||
|
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.blockedPeers = [remotePeerID]
|
||||||
|
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: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
|
||||||
|
#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.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
|
||||||
|
// Unblocking must allow a retry through; the blocked attempt cannot
|
||||||
|
// poison the stable-ID dedup reservation.
|
||||||
|
recorder.blockedPeers = []
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -337,6 +737,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.quotaReservations.isEmpty)
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
#expect(recorder.saveCalls.isEmpty)
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.isEmpty)
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +869,33 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-receipt-cache-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let messageID = "media-00112233445566778899aabbccddeeff"
|
||||||
|
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
|
||||||
|
#expect(seed.recordDeleted(messageID: messageID))
|
||||||
|
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
#expect(
|
||||||
|
store.privateMediaReceiptState(messageID: messageID)
|
||||||
|
== .tombstoned
|
||||||
|
)
|
||||||
|
|
||||||
|
try store.panicWipe()
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
store.privateMediaReceiptState(messageID: messageID)
|
||||||
|
== .absent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||||
enum MarkerFailure: Error { case unavailable }
|
enum MarkerFailure: Error { case unavailable }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
@@ -11,7 +12,11 @@ struct BLENoisePacketHandlerTests {
|
|||||||
var handshakeAuthenticated = false
|
var handshakeAuthenticated = false
|
||||||
var hasSession = false
|
var hasSession = false
|
||||||
let sessionGeneration = UUID()
|
let sessionGeneration = UUID()
|
||||||
|
var awaitingResponderHandshake = false
|
||||||
var decryptResult: Result<Data, Error> = .success(Data())
|
var decryptResult: Result<Data, Error> = .success(Data())
|
||||||
|
var currentDate = Date(timeIntervalSince1970: 1_000)
|
||||||
|
var transportGenerationReady = false
|
||||||
|
var forcedServiceDecryptError: Error?
|
||||||
|
|
||||||
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
||||||
var hasSessionQueries: [PeerID] = []
|
var hasSessionQueries: [PeerID] = []
|
||||||
@@ -34,11 +39,12 @@ struct BLENoisePacketHandlerTests {
|
|||||||
recorder: Recorder,
|
recorder: Recorder,
|
||||||
now: Date = Date(timeIntervalSince1970: 1_000)
|
now: Date = Date(timeIntervalSince1970: 1_000)
|
||||||
) -> BLENoisePacketHandler {
|
) -> BLENoisePacketHandler {
|
||||||
|
recorder.currentDate = now
|
||||||
let environment = BLENoisePacketHandlerEnvironment(
|
let environment = BLENoisePacketHandlerEnvironment(
|
||||||
localPeerID: { [localPeerID] in localPeerID },
|
localPeerID: { [localPeerID] in localPeerID },
|
||||||
localPeerIDData: { [localPeerIDData] in localPeerIDData },
|
localPeerIDData: { [localPeerIDData] in localPeerIDData },
|
||||||
messageTTL: TransportConfig.messageTTLDefault,
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
now: { now },
|
now: { recorder.currentDate },
|
||||||
processHandshakeMessage: { peerID, message in
|
processHandshakeMessage: { peerID, message in
|
||||||
recorder.processedHandshakes.append((peerID, message))
|
recorder.processedHandshakes.append((peerID, message))
|
||||||
return NoiseHandshakeProcessingResult(
|
return NoiseHandshakeProcessingResult(
|
||||||
@@ -51,6 +57,9 @@ struct BLENoisePacketHandlerTests {
|
|||||||
recorder.hasSessionQueries.append(peerID)
|
recorder.hasSessionQueries.append(peerID)
|
||||||
return recorder.hasSession
|
return recorder.hasSession
|
||||||
},
|
},
|
||||||
|
isAwaitingResponderHandshakeCompletion: { _ in
|
||||||
|
recorder.awaitingResponderHandshake
|
||||||
|
},
|
||||||
initiateHandshake: { peerID in
|
initiateHandshake: { peerID in
|
||||||
recorder.initiatedHandshakes.append(peerID)
|
recorder.initiatedHandshakes.append(peerID)
|
||||||
recorder.events.append("initiateHandshake")
|
recorder.events.append("initiateHandshake")
|
||||||
@@ -82,6 +91,120 @@ struct BLENoisePacketHandlerTests {
|
|||||||
return BLENoisePacketHandler(environment: environment)
|
return BLENoisePacketHandler(environment: environment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeServiceBackedHandler(
|
||||||
|
service: NoiseEncryptionService,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
recorder: Recorder,
|
||||||
|
transportGenerationIsReady:
|
||||||
|
@escaping (UUID) -> Bool
|
||||||
|
) -> BLENoisePacketHandler {
|
||||||
|
BLENoisePacketHandler(
|
||||||
|
environment: BLENoisePacketHandlerEnvironment(
|
||||||
|
localPeerID: { localPeerID },
|
||||||
|
localPeerIDData: {
|
||||||
|
Data(hexString: localPeerID.id) ?? Data()
|
||||||
|
},
|
||||||
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
|
now: { recorder.currentDate },
|
||||||
|
processHandshakeMessage: { peerID, message in
|
||||||
|
try service.processHandshakeMessageWithResult(
|
||||||
|
from: peerID,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
},
|
||||||
|
hasNoiseSession: { peerID in
|
||||||
|
service.hasSession(with: peerID)
|
||||||
|
},
|
||||||
|
isAwaitingResponderHandshakeCompletion: { peerID in
|
||||||
|
service.isAwaitingResponderHandshakeCompletion(
|
||||||
|
with: peerID
|
||||||
|
)
|
||||||
|
},
|
||||||
|
initiateHandshake: { peerID in
|
||||||
|
recorder.initiatedHandshakes.append(peerID)
|
||||||
|
},
|
||||||
|
broadcastPacket: { packet in
|
||||||
|
recorder.broadcastPackets.append(packet)
|
||||||
|
},
|
||||||
|
updatePeerLastSeen: { peerID in
|
||||||
|
recorder.lastSeenUpdates.append(peerID)
|
||||||
|
},
|
||||||
|
decrypt: { payload, peerID in
|
||||||
|
recorder.decryptCalls.append((payload, peerID))
|
||||||
|
if let error = recorder.forcedServiceDecryptError {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
let result =
|
||||||
|
try service.decryptWithSessionGeneration(
|
||||||
|
payload,
|
||||||
|
from: peerID,
|
||||||
|
establishedGenerationIsReady:
|
||||||
|
transportGenerationIsReady
|
||||||
|
)
|
||||||
|
return BLENoiseDecryptionResult(
|
||||||
|
plaintext: result.plaintext,
|
||||||
|
sessionGeneration: result.sessionGeneration
|
||||||
|
)
|
||||||
|
},
|
||||||
|
clearSession: { peerID in
|
||||||
|
recorder.clearedSessions.append(peerID)
|
||||||
|
service.clearSession(for: peerID)
|
||||||
|
},
|
||||||
|
handleAuthenticatedPeerState: {
|
||||||
|
peerID, payload, generation in
|
||||||
|
recorder.authenticatedPeerStates.append(
|
||||||
|
(peerID, payload, generation)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
deliverNoisePayload: {
|
||||||
|
peerID, type, payload, timestamp in
|
||||||
|
recorder.deliveries.append(
|
||||||
|
(peerID, type, payload, timestamp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func establishedServices() throws -> (
|
||||||
|
sender: NoiseEncryptionService,
|
||||||
|
receiver: NoiseEncryptionService,
|
||||||
|
senderPeerID: PeerID,
|
||||||
|
receiverPeerID: PeerID
|
||||||
|
) {
|
||||||
|
let sender = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let senderPeerID = PeerID(
|
||||||
|
publicKey: sender.getStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
let receiverPeerID = PeerID(
|
||||||
|
publicKey: receiver.getStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
let message1 = try sender.initiateHandshake(with: receiverPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(
|
||||||
|
from: senderPeerID,
|
||||||
|
message: message1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try sender.processHandshakeMessage(
|
||||||
|
from: receiverPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try receiver.processHandshakeMessage(
|
||||||
|
from: senderPeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
senderPeerID,
|
||||||
|
receiverPeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Handshake
|
// MARK: Handshake
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -364,6 +487,799 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.deliveries.isEmpty)
|
#expect(recorder.deliveries.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func earlyCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 1)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicResetDiscardsDeferredCiphertextBeforeFutureAuthentication() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let prePanicCiphertext = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
payload: Data(
|
||||||
|
count: NoiseSecurityConstants.maxMessageSize
|
||||||
|
+ NoiseSecurityConstants.transportCiphertextOverhead
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(prePanicCiphertext, from: remotePeerID)
|
||||||
|
#expect(recorder.decryptCalls.count == 1)
|
||||||
|
|
||||||
|
handler.resetForPanic()
|
||||||
|
|
||||||
|
// Three maximum-sized packets fit only when reset also zeroed the
|
||||||
|
// global byte accounting. They model ciphertext received under the
|
||||||
|
// replacement identity before that responder handshake completes.
|
||||||
|
for index in 0..<3 {
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
timestamp: UInt64(901_000 + index),
|
||||||
|
payload: Data(
|
||||||
|
count: NoiseSecurityConstants.maxMessageSize
|
||||||
|
+ NoiseSecurityConstants.transportCiphertextOverhead
|
||||||
|
)
|
||||||
|
),
|
||||||
|
from: remotePeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(recorder.decryptCalls.count == 4)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
// Only the three post-reset packets replay; the pre-panic packet does
|
||||||
|
// not survive into the replacement session.
|
||||||
|
#expect(recorder.decryptCalls.count == 7)
|
||||||
|
#expect(recorder.deliveries.count == 3)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce()
|
||||||
|
throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(
|
||||||
|
publicKey: alice.getStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: message1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(
|
||||||
|
from: bobPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let typedPayload = Data([
|
||||||
|
NoisePayloadType.privateMessage.rawValue,
|
||||||
|
0xCA, 0xFE
|
||||||
|
])
|
||||||
|
let ciphertext = try alice.encrypt(
|
||||||
|
typedPayload,
|
||||||
|
for: bobPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager promotion has completed, but the serialized BLE callback is
|
||||||
|
// deliberately still behind this ciphertext.
|
||||||
|
_ = try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = false
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: bob,
|
||||||
|
localPeerID: bobPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let packet = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: bobPeerID.id),
|
||||||
|
payload: ciphertext
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(packet, from: alicePeerID)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
// The exact ciphertext must still authenticate, proving the readiness
|
||||||
|
// rejection happened before the receive nonce was consumed.
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
handler.handleSessionAuthenticated(alicePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func ciphertextQueuedAheadOfRestoreCallbackDoesNotConsumeNonce()
|
||||||
|
throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(
|
||||||
|
publicKey: alice.getStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
let initial1 = try alice.initiateHandshake(with: bobPeerID)
|
||||||
|
let initial2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: initial1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let initial3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(
|
||||||
|
from: bobPeerID,
|
||||||
|
message: initial2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: initial3
|
||||||
|
)
|
||||||
|
let typedPayload = Data([
|
||||||
|
NoisePayloadType.privateMessage.rawValue,
|
||||||
|
0xBE, 0xEF
|
||||||
|
])
|
||||||
|
let delayedCiphertext = try alice.encrypt(
|
||||||
|
typedPayload,
|
||||||
|
for: bobPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
let forged1 = try mallory.initiateHandshake(with: bobPeerID)
|
||||||
|
let forged2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: forged1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let forged3 = try #require(
|
||||||
|
try mallory.processHandshakeMessage(
|
||||||
|
from: bobPeerID,
|
||||||
|
message: forged2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(throws: NoiseSessionError.peerIdentityMismatch) {
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: forged3
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
|
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = false
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: bob,
|
||||||
|
localPeerID: bobPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let packet = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: bobPeerID.id),
|
||||||
|
payload: delayedCiphertext
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager rollback is visible, while the BLE restore callback is
|
||||||
|
// deliberately still queued behind this ciphertext.
|
||||||
|
handler.handleEncrypted(packet, from: alicePeerID)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
handler.handleSessionAuthenticated(alicePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0xBE, 0xEF]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func oversizedCiphertextCannotEvictEstablishedTransport() throws {
|
||||||
|
let pair = try establishedServices()
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: pair.receiver,
|
||||||
|
localPeerID: pair.receiverPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: Data(
|
||||||
|
count:
|
||||||
|
NoiseSecurityConstants
|
||||||
|
.maxPrivateFileCiphertextSize + 1
|
||||||
|
)
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||||
|
)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
let valid = try pair.sender.encrypt(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0x01]),
|
||||||
|
for: pair.receiverPeerID
|
||||||
|
)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: valid
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0x01]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func forgedAuthenticationFailureCannotEvictEstablishedTransport()
|
||||||
|
throws {
|
||||||
|
let pair = try establishedServices()
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: pair.receiver,
|
||||||
|
localPeerID: pair.receiverPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let valid = try pair.sender.encrypt(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0x02]),
|
||||||
|
for: pair.receiverPeerID
|
||||||
|
)
|
||||||
|
var forged = valid
|
||||||
|
forged[forged.index(before: forged.endIndex)] ^= 0xFF
|
||||||
|
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: forged
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||||
|
)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
// Authentication failure leaves nonce state untouched, so the exact
|
||||||
|
// original ciphertext remains valid.
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: valid
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0x02]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func replayCannotEvictEstablishedTransportOrBlockNextNonce() throws {
|
||||||
|
let pair = try establishedServices()
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: pair.receiver,
|
||||||
|
localPeerID: pair.receiverPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let first = try pair.sender.encrypt(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0x03]),
|
||||||
|
for: pair.receiverPeerID
|
||||||
|
)
|
||||||
|
let firstPacket = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: first
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
|
||||||
|
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
|
||||||
|
#expect(
|
||||||
|
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||||
|
)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
let next = try pair.sender.encrypt(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0x04]),
|
||||||
|
for: pair.receiverPeerID
|
||||||
|
)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: next
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(recorder.deliveries.count == 2)
|
||||||
|
#expect(recorder.deliveries.map { $0.payload } == [
|
||||||
|
Data([0x03]), Data([0x04])
|
||||||
|
])
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rateLimitFailureCannotEvictEstablishedTransportOrConsumeNonce()
|
||||||
|
throws {
|
||||||
|
let pair = try establishedServices()
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
recorder.forcedServiceDecryptError =
|
||||||
|
NoiseSecurityError.rateLimitExceeded
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: pair.receiver,
|
||||||
|
localPeerID: pair.receiverPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let valid = try pair.sender.encrypt(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0x05]),
|
||||||
|
for: pair.receiverPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: Data(repeating: 0xA5, count: 20)
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||||
|
)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
recorder.forcedServiceDecryptError = nil
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: valid
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0x05]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func maximumPrivateFileCiphertextIsEligibleForDeferredRetry() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
payload: Data(
|
||||||
|
count: NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.privateFile.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.type == .privateFile)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func oversizedEarlyCiphertextIsNotDeferred() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
payload: Data(
|
||||||
|
count:
|
||||||
|
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 1)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func missingSessionCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
NoiseEncryptionError.sessionNotEstablished
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
#expect(recorder.decryptCalls.count == 1)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func lowNonceCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(NoiseError.replayDetected)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.readReceipt.rawValue, 0x02])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.type == .readReceipt)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func invalidDeferredCiphertextDoesNotClearAuthenticatedSession() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func nonCipherFailureDuringResponderHandshakeIsDroppedNotDeferred() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(TestError())
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
),
|
||||||
|
from: remotePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 1)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func earlyCiphertextBufferIsBoundedPerPeer() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
|
||||||
|
for index in 0..<5 {
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
timestamp: UInt64(900_000 + index)
|
||||||
|
),
|
||||||
|
from: remotePeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(recorder.decryptCalls.count == 5)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 9)
|
||||||
|
#expect(recorder.deliveries.count == 4)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func earlyCiphertextBufferIsBoundedGlobally() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let peers = (1...33).map {
|
||||||
|
PeerID(str: String(format: "%016llx", UInt64($0)))
|
||||||
|
}
|
||||||
|
let packet = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
for peerID in peers {
|
||||||
|
handler.handleEncrypted(packet, from: peerID)
|
||||||
|
}
|
||||||
|
#expect(recorder.decryptCalls.count == 33)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
for peerID in peers {
|
||||||
|
handler.handleSessionAuthenticated(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 65)
|
||||||
|
#expect(recorder.deliveries.count == 32)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func earlyCiphertextBufferKeepsPrivateFileRoomAndByteBound() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let peers = [
|
||||||
|
PeerID(str: "0000000000000001"),
|
||||||
|
PeerID(str: "0000000000000002"),
|
||||||
|
PeerID(str: "0000000000000003")
|
||||||
|
]
|
||||||
|
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
payload: Data(
|
||||||
|
count:
|
||||||
|
NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||||
|
)
|
||||||
|
),
|
||||||
|
from: peers[0]
|
||||||
|
)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
payload: Data(count: 256 * 1024)
|
||||||
|
),
|
||||||
|
from: peers[1]
|
||||||
|
)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
payload: Data([0x01])
|
||||||
|
),
|
||||||
|
from: peers[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
for peerID in peers {
|
||||||
|
handler.handleSessionAuthenticated(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 5)
|
||||||
|
#expect(recorder.deliveries.count == 2)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func expiredEarlyCiphertextIsNotRetried() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
),
|
||||||
|
from: remotePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
recorder.currentDate =
|
||||||
|
recorder.currentDate.addingTimeInterval(
|
||||||
|
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||||
|
+ 0.001
|
||||||
|
)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 1)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func earlyCiphertextSurvivesResponderHandshakeWindow() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
),
|
||||||
|
from: remotePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
recorder.currentDate =
|
||||||
|
recorder.currentDate.addingTimeInterval(
|
||||||
|
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||||
|
- 0.001
|
||||||
|
)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket {
|
private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket {
|
||||||
BitchatPacket(
|
BitchatPacket(
|
||||||
type: MessageType.noiseHandshake.rawValue,
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
@@ -378,14 +1294,15 @@ struct BLENoisePacketHandlerTests {
|
|||||||
|
|
||||||
private func makeEncryptedPacket(
|
private func makeEncryptedPacket(
|
||||||
recipientID: Data?,
|
recipientID: Data?,
|
||||||
timestamp: UInt64 = 900_000
|
timestamp: UInt64 = 900_000,
|
||||||
|
payload: Data = Data([0xC0, 0xFF, 0xEE])
|
||||||
) -> BitchatPacket {
|
) -> BitchatPacket {
|
||||||
BitchatPacket(
|
BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
payload: Data([0xC0, 0xFF, 0xEE]),
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: TransportConfig.messageTTLDefault
|
ttl: TransportConfig.messageTTLDefault
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -111,5 +111,8 @@ struct BLENoiseReconnectPolicyTests {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
||||||
|
#expect(
|
||||||
|
PeerCapabilities.localSupported.contains(.privateMediaReceipts)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct BLEPrivateMediaReceiptStoreTests {
|
||||||
|
private struct TestError: Error {}
|
||||||
|
|
||||||
|
private let messageID = "media-00112233445566778899aabbccddeeff"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func acceptedReceiptPersistsAcrossStoreInstances() throws {
|
||||||
|
let root = makeRoot("persist")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
|
||||||
|
let first = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(first.commitAccepted(messageID: messageID, storedURL: payload))
|
||||||
|
#expect(first.state(for: messageID) == .accepted(payload))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
|
||||||
|
let root = makeRoot("list-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
|
||||||
|
var shouldFail = true
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
directoryReader: { directory in
|
||||||
|
if shouldFail {
|
||||||
|
shouldFail = false
|
||||||
|
throw TestError()
|
||||||
|
}
|
||||||
|
return try FileManager.default.contentsOfDirectory(
|
||||||
|
at: directory,
|
||||||
|
includingPropertiesForKeys: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .unavailable)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func recordReadFailureIsUnavailableAndPreservesReceiptForRetry() throws {
|
||||||
|
let root = makeRoot("read-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
|
||||||
|
var shouldFail = true
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
dataReader: { url in
|
||||||
|
if shouldFail {
|
||||||
|
shouldFail = false
|
||||||
|
throw TestError()
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .unavailable)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func decodeFailureIsUnavailableAndDoesNotDeleteOrCachePastRepair() throws {
|
||||||
|
let root = makeRoot("decode-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
let durableBytes = try Data(contentsOf: record)
|
||||||
|
let corruptBytes = Data("{not-json".utf8)
|
||||||
|
try corruptBytes.write(to: record, options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.state(for: messageID) == .unavailable)
|
||||||
|
#expect(!store.commitAccepted(messageID: messageID, storedURL: payload))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
#expect(try Data(contentsOf: record) == corruptBytes)
|
||||||
|
|
||||||
|
try durableBytes.write(to: record, options: .atomic)
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unreadableTombstoneNeverBecomesAbsentOrGetsDeleted() throws {
|
||||||
|
let root = makeRoot("tombstone-decode")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(messageID: messageID, storedURL: payload))
|
||||||
|
#expect(seed.recordDeleted(messageID: messageID))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
let durableBytes = try Data(contentsOf: record)
|
||||||
|
try Data([0xFF, 0x00, 0x7B]).write(to: record, options: .atomic)
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .unavailable)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
|
||||||
|
try durableBytes.write(to: record, options: .atomic)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func failedTombstonePersistenceDoesNotPoisonVolatileState() throws {
|
||||||
|
let root = makeRoot("failed-tombstone-write")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
|
||||||
|
// Force the atomic record write itself to fail after the store has
|
||||||
|
// successfully loaded its empty index.
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: record,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
#expect(!store.recordDeleted(messageID: messageID))
|
||||||
|
try FileManager.default.removeItem(at: record)
|
||||||
|
|
||||||
|
// The UI must be able to report the deletion failure without a
|
||||||
|
// process-lifetime tombstone silently hiding a later retry.
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unreleasedAggregateLedgerIsIgnoredAndLeftUntouched() throws {
|
||||||
|
let root = makeRoot("no-legacy-migration")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let files = root.appendingPathComponent("files", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: files,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let legacy = files.appendingPathComponent(
|
||||||
|
".private-media-receipts.json",
|
||||||
|
isDirectory: false
|
||||||
|
)
|
||||||
|
let bytes = Data(
|
||||||
|
#"{"entries":{"media-00112233445566778899aabbccddeeff":{"relativePath":"images/incoming/old.jpg","acceptedAt":0}}}"#
|
||||||
|
.utf8
|
||||||
|
)
|
||||||
|
try bytes.write(to: legacy, options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
#expect(try Data(contentsOf: legacy) == bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRoot(_ label: String) -> URL {
|
||||||
|
FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||||
|
"private-media-receipt-\(label)-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePayload(in root: URL) throws -> URL {
|
||||||
|
let directory = root.appendingPathComponent(
|
||||||
|
"files/images/incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let payload = directory.appendingPathComponent("image.jpg")
|
||||||
|
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiptRecord(in root: URL) -> URL {
|
||||||
|
root
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/.private-media-receipts",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent(messageID)
|
||||||
|
.appendingPathExtension("json")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1344,6 +1344,89 @@ struct NoiseEncryptionServiceTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Responder completion state spans ordinary XX message three")
|
||||||
|
func responderCompletionStateTracksOrdinaryHandshake() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(
|
||||||
|
publicKey: alice.getStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||||
|
#expect(
|
||||||
|
!bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||||
|
)
|
||||||
|
|
||||||
|
let message2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: message1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||||
|
)
|
||||||
|
|
||||||
|
let message3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(
|
||||||
|
from: bobPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
!bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Transport readiness rejection spends no message budget")
|
||||||
|
func transportReadinessRejectionSpendsNoMessageBudget() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(
|
||||||
|
publicKey: alice.getStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
let plaintext = Data([
|
||||||
|
NoisePayloadType.privateMessage.rawValue,
|
||||||
|
0xAB, 0xCD
|
||||||
|
])
|
||||||
|
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
||||||
|
|
||||||
|
for _ in 0...NoiseSecurityConstants.maxMessagesPerSecond {
|
||||||
|
do {
|
||||||
|
_ = try bob.decryptWithSessionGeneration(
|
||||||
|
ciphertext,
|
||||||
|
from: alicePeerID,
|
||||||
|
establishedGenerationIsReady: { _ in false }
|
||||||
|
)
|
||||||
|
Issue.record(
|
||||||
|
"Expected transport generation readiness rejection"
|
||||||
|
)
|
||||||
|
} catch NoiseEncryptionError.transportGenerationNotReady {
|
||||||
|
// Expected: authorization and nonce mutation are both later.
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected readiness error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decrypted = try bob.decryptWithSessionGeneration(
|
||||||
|
ciphertext,
|
||||||
|
from: alicePeerID,
|
||||||
|
establishedGenerationIsReady: { _ in true }
|
||||||
|
)
|
||||||
|
#expect(decrypted.plaintext == plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("NoiseMessage JSON and binary encoding round-trip")
|
@Test("NoiseMessage JSON and binary encoding round-trip")
|
||||||
func noiseMessageRoundTrips() throws {
|
func noiseMessageRoundTrips() throws {
|
||||||
let message = NoiseMessage(
|
let message = NoiseMessage(
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ peer's Noise session before BLE fragmentation.
|
|||||||
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
||||||
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
||||||
peer. No timeout automatically sends raw bytes.
|
peer. No timeout automatically sends raw bytes.
|
||||||
|
- `PeerCapabilities.privateMediaReceipts` is bit 9. Its exact-session
|
||||||
|
authenticated proof enables bounded sender-side automatic retry; a public
|
||||||
|
announce never does. It does not replace bit 8: encrypted `0x20` media from
|
||||||
|
bit-8-only prior iOS clients keeps the same deterministic stable ID and
|
||||||
|
delivery ACK. Receivers durably commit that ID before UI delivery or ACK, so
|
||||||
|
a lost proof followed by a later bit-9 retry cannot create a second,
|
||||||
|
random-ID bubble.
|
||||||
- An unpinned peer with a stable Noise key but without that capability is
|
- An unpinned peer with a stable Noise key but without that capability is
|
||||||
eligible for one signed, directed
|
eligible for one signed, directed
|
||||||
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
|||||||
/// before outer BLE fragmentation. Peers that omit this bit require the
|
/// before outer BLE fragmentation. Peers that omit this bit require the
|
||||||
/// signed directed raw-file migration fallback.
|
/// signed directed raw-file migration fallback.
|
||||||
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
||||||
|
/// Stable private-media IDs are durably deduplicated by the receiver and
|
||||||
|
/// correlated delivery/read receipts permit bounded automatic resend.
|
||||||
|
///
|
||||||
|
/// Bit 8 remains the encrypted-media compatibility contract. Bit 9 only
|
||||||
|
/// enables sender-side automatic retry after exact-session proof.
|
||||||
|
public static let privateMediaReceipts =
|
||||||
|
PeerCapabilities(rawValue: 1 << 9)
|
||||||
/// Reserved for test builds that briefly advertised non-destructive Noise
|
/// Reserved for test builds that briefly advertised non-destructive Noise
|
||||||
/// replacement. Current clients intentionally do not advertise or act on
|
/// replacement. Current clients intentionally do not advertise or act on
|
||||||
/// this bit; keep it decodable so the wire assignment is never reused.
|
/// this bit; keep it decodable so the wire assignment is never reused.
|
||||||
|
|||||||
@@ -18,14 +18,30 @@ struct PeerCapabilitiesTests {
|
|||||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||||
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
||||||
|
|
||||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
#expect(
|
||||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
PeerCapabilities.privateMediaReceipts.encoded()
|
||||||
|
== Data([0x00, 0x02])
|
||||||
|
)
|
||||||
#expect(
|
#expect(
|
||||||
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
||||||
== Data([0x00, 0x04])
|
== Data([0x00, 0x04])
|
||||||
)
|
)
|
||||||
|
|
||||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
let high = PeerCapabilities(rawValue: 1 << 11)
|
||||||
|
#expect(high.encoded() == Data([0x00, 0x08]))
|
||||||
|
|
||||||
|
let all: PeerCapabilities = [
|
||||||
|
.prekeys,
|
||||||
|
.wifiBulk,
|
||||||
|
.gateway,
|
||||||
|
.groups,
|
||||||
|
.board,
|
||||||
|
.vouch,
|
||||||
|
.meshDiagnostics,
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts,
|
||||||
|
.nonDestructiveNoiseReplacement
|
||||||
|
]
|
||||||
#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